Rework the dashboard into two looks over one behaviour
A dashboard is a wall panel somebody hangs in their own hallway, so it now wears what they choose: a look, and a palette of their own colours. Two complete component sets live under `Dashboard/ui/` — `glass` (translucent panes over a slowly moving ground) and `material` (Material 3 tonal cards) — behind one prop contract. Every control's state, keyboard and `aria-` live in `ui/core` and are shared, so the two sets are the same dashboard drawn twice rather than two products: a set only decides what a control looks like while doing it. Four settings join the channel, each drivable by a flow like any other: `look`, `palette`, `background` and `touch`. A palette is an ordered list of hex colours — background, surface, primary, accent, text, then more chart colours — pasted from a coolors.co link or typed, written onto the canvas as the token variables everything already reads. Trailing roles are derived, so three colours are a whole dashboard, and derived text is held to AA rather than trusted (`theme.check.ts` measures it). A palette also decides light or dark, since its first colour is the ground. Widgets are measured against their own tile with container queries rather than against the viewport, animate through `motion`, and can be drawn without their title. The three reworks: - a bar draws a row per reading, up to eight, each in the dashboard's own data colours and each able to carry its own scale — replacing readings nested in one fill, which could only ever share one colour and stop at three. Documents written the old way are read as rows. - a chart's range picker moved to a column down its right-hand edge, which gives the plot back a whole row of a short tile. - the colour wheel became a disc: hue is the angle and saturation the distance from the middle, so a colour is one gesture rather than three, with brightness on a slider beside it. `index.css` and `lib/motion.ts` are untouched — the dashboard overrides token *values* on its canvas, never the blocks the two repos share.
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* The dial's geometry.
|
||||
*
|
||||
* A 240° arc starting at the lower left, which is the shape a gauge is
|
||||
* expected to have. Both looks draw the same sweep; only the stroke differs.
|
||||
*/
|
||||
|
||||
export const GAUGE_SWEEP = 240
|
||||
export const GAUGE_START = 150
|
||||
const RADIUS = 42
|
||||
|
||||
const point = (angle: number) => {
|
||||
const radians = (angle * Math.PI) / 180
|
||||
return [50 + RADIUS * Math.cos(radians), 50 + RADIUS * Math.sin(radians)]
|
||||
}
|
||||
|
||||
/** One arc of the dial, as an SVG path in a `0 0 100 78` box. */
|
||||
export function arcPath(from: number, to: number): string {
|
||||
const [x1, y1] = point(from)
|
||||
const [x2, y2] = point(to)
|
||||
const large = Math.abs(to - from) > 180 ? 1 : 0
|
||||
return `M ${x1} ${y1} A ${RADIUS} ${RADIUS} 0 ${large} 1 ${x2} ${y2}`
|
||||
}
|
||||
|
||||
/** The whole track, which the reading is drawn over a fraction of. */
|
||||
export const GAUGE_TRACK = arcPath(GAUGE_START, GAUGE_START + GAUGE_SWEEP)
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* A colour, on the wire and on the wheel.
|
||||
*
|
||||
* The conversions a colour widget publishes through, kept below every renderer
|
||||
* so the disc's own maths (`disc.ts`) can use them without either set being in
|
||||
* the way. Pure: no React, no DOM.
|
||||
*/
|
||||
import type { WidgetDef } from "@/client"
|
||||
|
||||
/** Three numbers: a colour in whichever of the two triples is meant. */
|
||||
export type Triple = [number, number, number]
|
||||
|
||||
export type ColorFormat = "hsv" | "rgb" | "hex"
|
||||
|
||||
/**
|
||||
* What each format puts on the wire, by payload type.
|
||||
*
|
||||
* Mirrored on the server (`COLOR_DTYPES` in `app/flow/dashboards.py`), which
|
||||
* refuses a binding the format cannot carry.
|
||||
*/
|
||||
export const COLOR_DTYPES: Record<ColorFormat, string> = {
|
||||
hsv: "list",
|
||||
rgb: "list",
|
||||
hex: "str",
|
||||
}
|
||||
|
||||
/** The formats, as the editor offers them. */
|
||||
export const COLOR_FORMATS = [
|
||||
["hsv", "HSV"],
|
||||
["rgb", "RGB"],
|
||||
["hex", "Hex"],
|
||||
] as const
|
||||
|
||||
/** Which format this widget sends. Anything unrecorded is the default. */
|
||||
export function colorFormatOf(widget: WidgetDef): ColorFormat {
|
||||
const format = widget.config?.format
|
||||
return format === "rgb" || format === "hex" ? format : "hsv"
|
||||
}
|
||||
|
||||
/** Rounded into range, for a percentage or a channel. */
|
||||
export const clamp = (value: number, high: number) =>
|
||||
Math.min(high, Math.max(0, Math.round(value)))
|
||||
|
||||
/** A hue is an angle: 370 degrees is 10, and -10 is 350. */
|
||||
export const wrap = (hue: number) => ((Math.round(hue) % 360) + 360) % 360
|
||||
|
||||
/**
|
||||
* HSV to RGB — the same conversion the reference's DMX encoders do, so a
|
||||
* fixture wired to `rgb` gets what one wired to `hsv` works out for itself.
|
||||
*
|
||||
* Hue 0-360 degrees, saturation and value 0-100 percent in; three 0-255
|
||||
* channels out.
|
||||
*/
|
||||
export function hsvToRgb([hue, saturation, value]: Triple): Triple {
|
||||
const level = clamp(value, 100) / 100
|
||||
const chroma = level * (clamp(saturation, 100) / 100)
|
||||
const sector = (((hue % 360) + 360) % 360) / 60
|
||||
const second = chroma * (1 - Math.abs((sector % 2) - 1))
|
||||
const base = level - chroma
|
||||
const [red, green, blue] =
|
||||
sector < 1
|
||||
? [chroma, second, 0]
|
||||
: sector < 2
|
||||
? [second, chroma, 0]
|
||||
: sector < 3
|
||||
? [0, chroma, second]
|
||||
: sector < 4
|
||||
? [0, second, chroma]
|
||||
: sector < 5
|
||||
? [second, 0, chroma]
|
||||
: [chroma, 0, second]
|
||||
const channel = (part: number) => Math.round((part + base) * 255)
|
||||
return [channel(red), channel(green), channel(blue)]
|
||||
}
|
||||
|
||||
/** The way back, for a colour some flow set rather than this wheel. */
|
||||
export function rgbToHsv(rgb: Triple): Triple {
|
||||
const [red, green, blue] = rgb.map((channel) => clamp(channel, 255) / 255)
|
||||
const high = Math.max(red, green, blue)
|
||||
const spread = high - Math.min(red, green, blue)
|
||||
let hue = 0
|
||||
if (spread) {
|
||||
hue =
|
||||
high === red
|
||||
? ((green - blue) / spread) % 6
|
||||
: high === green
|
||||
? (blue - red) / spread + 2
|
||||
: (red - green) / spread + 4
|
||||
hue = (hue * 60 + 360) % 360
|
||||
}
|
||||
return [
|
||||
Math.round(hue),
|
||||
Math.round(high ? (spread / high) * 100 : 0),
|
||||
Math.round(high * 100),
|
||||
]
|
||||
}
|
||||
|
||||
const toHex = (rgb: Triple) =>
|
||||
`#${rgb.map((channel) => clamp(channel, 255).toString(16).padStart(2, "0")).join("")}`
|
||||
|
||||
const fromHex = (text: string): Triple | null => {
|
||||
const digits = /^#?([0-9a-f]{6})$/i.exec(text)?.[1]
|
||||
if (!digits) return null
|
||||
const at = (index: number) =>
|
||||
Number.parseInt(digits.slice(index * 2, index * 2 + 2), 16)
|
||||
return [at(0), at(1), at(2)]
|
||||
}
|
||||
|
||||
/** What this control publishes, in the format its config picked. */
|
||||
export function encodeColor(hsv: Triple, format: ColorFormat): unknown {
|
||||
if (format === "hsv") return hsv
|
||||
const rgb = hsvToRgb(hsv)
|
||||
return format === "rgb" ? rgb : toHex(rgb)
|
||||
}
|
||||
|
||||
/**
|
||||
* What came back over the socket, as the wheel's own three numbers.
|
||||
*
|
||||
* Null for anything that is not a colour in this format — nothing published
|
||||
* yet, or a flow that answered with something else.
|
||||
*/
|
||||
export function decodeColor(
|
||||
value: unknown,
|
||||
format: ColorFormat,
|
||||
): Triple | null {
|
||||
if (format === "hex") {
|
||||
const rgb = typeof value === "string" ? fromHex(value) : null
|
||||
return rgb && rgbToHsv(rgb)
|
||||
}
|
||||
if (!Array.isArray(value) || value.length < 3) return null
|
||||
const [first, second, third] = value.slice(0, 3).map(Number)
|
||||
if (![first, second, third].every(Number.isFinite)) return null
|
||||
if (format === "rgb") return rgbToHsv([first, second, third])
|
||||
return [wrap(first), clamp(second, 100), clamp(third, 100)]
|
||||
}
|
||||
|
||||
/** The colour a set of three makes, for a swatch or a handle. */
|
||||
export const cssOf = (hsv: Triple) => `rgb(${hsvToRgb(hsv).join(" ")})`
|
||||
|
||||
/** Nothing published yet: white at full brightness, which is a lamp that is on. */
|
||||
export const UNSET: Triple = [0, 0, 100]
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Reading a widget's document, checked.
|
||||
*
|
||||
* cd frontend && bun run src/components/Dashboard/ui/core/config.check.ts
|
||||
*
|
||||
* What matters here is that a bar written before rows existed still draws the
|
||||
* same picture: the shape on disk changed, and no stored dashboard may lose a
|
||||
* reading because of it.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict"
|
||||
|
||||
import type { WidgetDef } from "@/client"
|
||||
import { format, fractionOf, MAX_ROWS, rowsOf, showTitle } from "./config"
|
||||
|
||||
const bar = (config: Record<string, unknown>): WidgetDef =>
|
||||
({ id: "b", type: "bar", config }) as WidgetDef
|
||||
|
||||
// --- a bar's readings, in every shape a document carries them -------------
|
||||
|
||||
assert.deepEqual(
|
||||
rowsOf(bar({ rows: [{ message: "a.x", dtype: "float", label: "A" }] })),
|
||||
[{ message: "a.x", dtype: "float", label: "A" }],
|
||||
"rows are read as written",
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
rowsOf(
|
||||
bar({
|
||||
message: "a.load",
|
||||
dtype: "float",
|
||||
inner: "a.pv",
|
||||
inner_dtype: "float",
|
||||
inner_label: "Roof",
|
||||
}),
|
||||
),
|
||||
[
|
||||
{ message: "a.load", dtype: "float" },
|
||||
{ message: "a.pv", dtype: "float", label: "Roof" },
|
||||
],
|
||||
"a bar with one nested reading becomes two rows",
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
rowsOf(
|
||||
bar({
|
||||
message: "a.load",
|
||||
dtype: "float",
|
||||
inner: [
|
||||
{ message: "a.pv", dtype: "float" },
|
||||
{ message: "a.grid", dtype: "float" },
|
||||
],
|
||||
}),
|
||||
).length,
|
||||
3,
|
||||
"a stacked bar becomes the outer reading and its segments",
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
rowsOf(bar({ rows: Array.from({ length: 9 }, () => ({ message: "a.x" })) }))
|
||||
.length,
|
||||
MAX_ROWS,
|
||||
"a bar draws at most eight readings",
|
||||
)
|
||||
|
||||
assert.deepEqual(rowsOf(bar({})), [], "an unbound bar has no rows")
|
||||
|
||||
// --- the rest -------------------------------------------------------------
|
||||
|
||||
assert.equal(
|
||||
showTitle(bar({})),
|
||||
true,
|
||||
"a widget shows its title unless told not to",
|
||||
)
|
||||
assert.equal(showTitle(bar({ show_title: false })), false)
|
||||
assert.equal(showTitle(bar({ show_title: true })), true)
|
||||
|
||||
assert.equal(fractionOf(5, 0, 10), 0.5)
|
||||
assert.equal(
|
||||
fractionOf(-1, 0, 10),
|
||||
0,
|
||||
"a reading under the scale sits at its foot",
|
||||
)
|
||||
assert.equal(fractionOf(11, 0, 10), 1, "and one over it fills the track")
|
||||
assert.equal(fractionOf(null, 0, 10), 0)
|
||||
assert.equal(
|
||||
fractionOf(5, 0, 0),
|
||||
1,
|
||||
"a scale of no width fills rather than dividing by zero",
|
||||
)
|
||||
|
||||
assert.equal(format(1.234, 1), "1.2")
|
||||
assert.equal(format(true, null), "On")
|
||||
assert.equal(format(null, 1), "—")
|
||||
|
||||
console.log("config: ok")
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Reading a widget's document.
|
||||
*
|
||||
* The four helpers at the top used to be copied into every widget file:
|
||||
* `widgets.tsx` renders the others, so none of them could import it back
|
||||
* without closing the circle. A module below all of them costs one import and
|
||||
* ends the duplication — nothing in `ui/` may import a widget.
|
||||
*/
|
||||
import type { WidgetDef } from "@/client"
|
||||
|
||||
export const config = (widget: WidgetDef): Record<string, unknown> =>
|
||||
(widget.config ?? {}) as Record<string, unknown>
|
||||
|
||||
export const text = (value: unknown, fallback = ""): string =>
|
||||
value === null || value === undefined ? fallback : String(value)
|
||||
|
||||
export const num = (value: unknown, fallback: number): number => {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : fallback
|
||||
}
|
||||
|
||||
/** Formats a reading the way a panel across the room should read it. */
|
||||
export function format(value: unknown, precision: number | null): string {
|
||||
if (value === null || value === undefined) return "—"
|
||||
if (typeof value === "boolean") return value ? "On" : "Off"
|
||||
if (typeof value === "number") {
|
||||
return precision === null ? String(value) : value.toFixed(precision)
|
||||
}
|
||||
if (typeof value === "object") return JSON.stringify(value)
|
||||
return String(value)
|
||||
}
|
||||
|
||||
/** Where a reading sits on its scale, as 0..1. */
|
||||
export const fractionOf = (
|
||||
value: number | null,
|
||||
min: number,
|
||||
max: number,
|
||||
): number =>
|
||||
value === null
|
||||
? 0
|
||||
: Math.min(1, Math.max(0, (value - min) / (max - min || 1)))
|
||||
|
||||
/**
|
||||
* Whether the frame draws this widget's title.
|
||||
*
|
||||
* Absent means yes: a document written before the switch existed keeps the
|
||||
* header it has. The title is still the widget's accessible name and its
|
||||
* publish label either way — hiding it is a matter of what the panel shows,
|
||||
* not of what the widget is called.
|
||||
*/
|
||||
export const showTitle = (widget: WidgetDef): boolean =>
|
||||
config(widget).show_title !== false
|
||||
|
||||
/** Radix hands back a string; the message wants whatever was configured. */
|
||||
export function asOriginal(
|
||||
selected: string,
|
||||
options: { value?: unknown }[],
|
||||
): unknown {
|
||||
const match = options.find((option) => text(option.value) === selected)
|
||||
return match ? match.value : selected
|
||||
}
|
||||
|
||||
/**
|
||||
* How many readings one bar draws, and a hard ceiling.
|
||||
*
|
||||
* Mirrored server-side as `BAR_ROWS` (`app/flow/dashboards.py`). The old cap of
|
||||
* three was a contrast limit: every nested segment was drawn in the one token
|
||||
* that cleared 3:1 against the outer fill, so a fourth could not be told from
|
||||
* its neighbour. Rows are separate tracks in the dashboard's own data colours,
|
||||
* so the limit is now legibility of the stack itself.
|
||||
*/
|
||||
export const MAX_ROWS = 8
|
||||
|
||||
/** One reading a bar draws, as the document stores it. */
|
||||
export type BarRow = {
|
||||
message?: string
|
||||
dtype?: string
|
||||
label?: string
|
||||
/** Blank inherits the widget's own scale. */
|
||||
min?: number
|
||||
max?: number
|
||||
unit?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The readings a bar draws, in every shape a document may carry them.
|
||||
*
|
||||
* Current documents write `rows`. Before that a bar had one reading with up to
|
||||
* three nested inside it (`inner`, itself written either as one name or as a
|
||||
* list), which is read here as the outer reading followed by the nested ones —
|
||||
* the same picture, drawn as separate tracks. The editor writes `rows` on the
|
||||
* first save and drops the old keys.
|
||||
*/
|
||||
export function rowsOf(widget: WidgetDef): BarRow[] {
|
||||
const cfg = config(widget)
|
||||
if (Array.isArray(cfg.rows)) return (cfg.rows as BarRow[]).slice(0, MAX_ROWS)
|
||||
|
||||
const outer: BarRow[] = cfg.message
|
||||
? [{ message: text(cfg.message), dtype: text(cfg.dtype) }]
|
||||
: []
|
||||
const inner: BarRow[] = Array.isArray(cfg.inner)
|
||||
? (cfg.inner as BarRow[])
|
||||
: cfg.inner
|
||||
? [
|
||||
{
|
||||
message: text(cfg.inner),
|
||||
dtype: text(cfg.inner_dtype),
|
||||
label: text(cfg.inner_label) || undefined,
|
||||
},
|
||||
]
|
||||
: []
|
||||
return [...outer, ...inner].slice(0, MAX_ROWS)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* What a look has to draw.
|
||||
*
|
||||
* There are two component sets — `ui/glass` and `ui/material` — and this is
|
||||
* the whole of what they have in common. Each implements every entry below;
|
||||
* a widget asks for the set with `useUi()` and never learns which one it got.
|
||||
*
|
||||
* The split is deliberate: **behaviour lives under `ui/core`** (the hooks in
|
||||
* `controls.ts`, `values.ts` and `disc.ts` hold the state, the keyboard and
|
||||
* every `aria-`), and a renderer's only job is markup and motion. That is what
|
||||
* makes the two sets the same dashboard: a control cannot behave differently
|
||||
* in one look, because neither set implements the behaviour.
|
||||
*/
|
||||
import type { LinkProps } from "@tanstack/react-router"
|
||||
|
||||
import type { Triple } from "./color"
|
||||
|
||||
/** Testids the panels' tests pin. Both sets emit them, from these constants. */
|
||||
export const TESTID = {
|
||||
frame: "widget-frame",
|
||||
issue: "widget-issue",
|
||||
/** A class, not an id: the editor drags a widget by whatever carries it. */
|
||||
grip: "widget-grip",
|
||||
barRow: "bar-row",
|
||||
barFill: "bar-fill",
|
||||
disc: "color-wheel",
|
||||
swatch: "color-swatch",
|
||||
rail: "panel-rail",
|
||||
locked: "dashboard-locked",
|
||||
} as const
|
||||
|
||||
export type FrameProps = {
|
||||
/** Absent draws no header at all — see `showTitle`. */
|
||||
title?: string
|
||||
/** Mis-wired: the same red dot and tooltip a failing node carries. */
|
||||
issue?: string | null
|
||||
/** Make this widget draggable in the editor. */
|
||||
grip?: boolean
|
||||
selected?: boolean
|
||||
onClick?: React.MouseEventHandler<HTMLDivElement>
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export type ButtonProps = {
|
||||
variant?: "filled" | "tonal" | "text"
|
||||
pressed?: boolean
|
||||
disabled?: boolean
|
||||
label?: string
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export type SwitchProps = {
|
||||
checked: boolean
|
||||
disabled?: boolean
|
||||
label: string
|
||||
onChange: (on: boolean) => void
|
||||
}
|
||||
|
||||
export type SliderProps = {
|
||||
value: number
|
||||
min: number
|
||||
max: number
|
||||
step: number
|
||||
label: string
|
||||
unit?: string
|
||||
disabled?: boolean
|
||||
orientation?: "horizontal" | "vertical"
|
||||
/** The scale under the track. Horizontal only; a column has no room. */
|
||||
ticks?: boolean
|
||||
/** Only the release publishes: a drag would send a value per pixel. */
|
||||
onCommit: (value: number) => void
|
||||
}
|
||||
|
||||
export type SegmentedProps = {
|
||||
value: string
|
||||
options: readonly (readonly [string, string])[]
|
||||
label: string
|
||||
orientation?: "horizontal" | "vertical"
|
||||
disabled?: boolean
|
||||
testId?: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export type SelectProps = {
|
||||
value: string
|
||||
options: { label?: string; value?: unknown }[]
|
||||
label: string
|
||||
disabled?: boolean
|
||||
onChange: (value: unknown) => void
|
||||
}
|
||||
|
||||
export type InputProps = {
|
||||
value: string
|
||||
type: "text" | "number"
|
||||
label: string
|
||||
disabled?: boolean
|
||||
onChange: (value: string) => void
|
||||
onCommit: () => void
|
||||
}
|
||||
|
||||
export type ReadoutProps = {
|
||||
value: unknown
|
||||
precision: number | null
|
||||
unit?: string
|
||||
/** `hero` is the one number a tile is for; `inline` sits in a row. */
|
||||
size?: "hero" | "inline"
|
||||
}
|
||||
|
||||
export type GaugeProps = {
|
||||
value: number | null
|
||||
min: number
|
||||
max: number
|
||||
precision: number | null
|
||||
unit?: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/** One reading a bar draws, ready to be drawn. */
|
||||
export type BarReading = {
|
||||
label: string
|
||||
value: number | null
|
||||
fraction: number
|
||||
precision: number | null
|
||||
unit?: string
|
||||
/** A colour, or a slot of the app's ramp — whatever the palette named. */
|
||||
color: string
|
||||
}
|
||||
|
||||
export type BarProps = { label: string; rows: BarReading[] }
|
||||
|
||||
export type ColorDiskProps = {
|
||||
name: string
|
||||
hsv: Triple
|
||||
disabled?: boolean
|
||||
onChange: (hsv: Triple) => void
|
||||
onCommit: () => void
|
||||
}
|
||||
|
||||
export type RailProps = {
|
||||
entries: {
|
||||
name: string
|
||||
label: string
|
||||
icon?: string
|
||||
active: boolean
|
||||
link: LinkProps
|
||||
}[]
|
||||
}
|
||||
|
||||
export type NoticeProps = { children: React.ReactNode }
|
||||
|
||||
/** The ambient ground under the widgets, or the image that replaces it. */
|
||||
export type BackdropProps = { image: string }
|
||||
|
||||
export type ComponentSet = {
|
||||
Backdrop: (props: BackdropProps) => React.ReactNode
|
||||
Frame: (props: FrameProps) => React.ReactNode
|
||||
Button: (props: ButtonProps) => React.ReactNode
|
||||
Switch: (props: SwitchProps) => React.ReactNode
|
||||
Slider: (props: SliderProps) => React.ReactNode
|
||||
Segmented: (props: SegmentedProps) => React.ReactNode
|
||||
Select: (props: SelectProps) => React.ReactNode
|
||||
Input: (props: InputProps) => React.ReactNode
|
||||
Readout: (props: ReadoutProps) => React.ReactNode
|
||||
Gauge: (props: GaugeProps) => React.ReactNode
|
||||
Bar: (props: BarProps) => React.ReactNode
|
||||
ColorDisk: (props: ColorDiskProps) => React.ReactNode
|
||||
Rail: (props: RailProps) => React.ReactNode
|
||||
Notice: (props: NoticeProps) => React.ReactNode
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* What the controls do, with nothing about how they look.
|
||||
*
|
||||
* Both component sets call these, which is what makes a switch a switch in
|
||||
* either look: the state, the keyboard and every `aria-` live here, and a
|
||||
* renderer only decides what it looks like while doing it.
|
||||
*/
|
||||
import { useCallback, useId, useRef, useState } from "react"
|
||||
|
||||
import { fractionOf } from "./config"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Press
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A press, as the looks draw it: a ripple for one, a moving glow for the other. */
|
||||
export type Press = { id: number; x: number; y: number }
|
||||
|
||||
/**
|
||||
* Where a control was last pressed, in percent of its own box.
|
||||
*
|
||||
* Written onto the element as `--press-x` / `--press-y` for whatever the look
|
||||
* paints from them, and kept as a short list so a set that draws one ripple
|
||||
* per press (Material) can. A ripple removes itself when it finishes.
|
||||
*/
|
||||
export function usePress() {
|
||||
const [presses, setPresses] = useState<Press[]>([])
|
||||
const next = useRef(0)
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(event: React.PointerEvent<HTMLElement>) => {
|
||||
const box = event.currentTarget.getBoundingClientRect()
|
||||
const x = box.width ? ((event.clientX - box.left) / box.width) * 100 : 50
|
||||
const y = box.height ? ((event.clientY - box.top) / box.height) * 100 : 50
|
||||
event.currentTarget.style.setProperty("--press-x", `${x}%`)
|
||||
event.currentTarget.style.setProperty("--press-y", `${y}%`)
|
||||
next.current += 1
|
||||
const id = next.current
|
||||
setPresses((current) => [...current, { id, x, y }])
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const done = useCallback(
|
||||
(id: number) => setPresses((current) => current.filter((p) => p.id !== id)),
|
||||
[],
|
||||
)
|
||||
|
||||
return { presses, onPointerDown, done }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Switch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A latch.
|
||||
*
|
||||
* A `button` rather than a checkbox or a library primitive: `role="switch"`
|
||||
* plus `aria-checked` is the whole contract, and a button already answers
|
||||
* Space and Enter.
|
||||
*/
|
||||
export function useSwitch({
|
||||
checked,
|
||||
disabled,
|
||||
label,
|
||||
onChange,
|
||||
}: {
|
||||
checked: boolean
|
||||
disabled?: boolean
|
||||
label: string
|
||||
onChange: (on: boolean) => void
|
||||
}) {
|
||||
return {
|
||||
buttonProps: {
|
||||
type: "button" as const,
|
||||
role: "switch",
|
||||
"aria-checked": checked,
|
||||
"aria-label": label,
|
||||
"data-state": checked ? "on" : "off",
|
||||
disabled,
|
||||
onClick: () => onChange(!checked),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Segmented
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** One of N, every choice shown at once. */
|
||||
export function useSegmented({
|
||||
value,
|
||||
options,
|
||||
label,
|
||||
orientation = "horizontal",
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
value: string
|
||||
options: readonly (readonly [string, string])[]
|
||||
label: string
|
||||
orientation?: "horizontal" | "vertical"
|
||||
disabled?: boolean
|
||||
onChange: (value: string) => void
|
||||
}) {
|
||||
const chosen = options.findIndex((option) => option[0] === value)
|
||||
return {
|
||||
chosen,
|
||||
/** One per instance, so two segmented controls do not share a thumb. */
|
||||
thumbId: useId(),
|
||||
groupProps: {
|
||||
role: "group",
|
||||
"aria-label": label,
|
||||
"data-orientation": orientation,
|
||||
},
|
||||
itemProps: (index: number) => ({
|
||||
type: "button" as const,
|
||||
"aria-pressed": index === chosen,
|
||||
"data-active": index === chosen ? "" : undefined,
|
||||
disabled,
|
||||
onClick: () => onChange(options[index][0]),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* How many intervals the scale under a slider is cut into.
|
||||
*
|
||||
* A mark lands on a step wherever the range divides evenly, so a value aimed
|
||||
* at is one the slider can stop on. Five labels is what stays readable across a
|
||||
* room; a range of five steps or fewer is simply labelled in full.
|
||||
*/
|
||||
export function tickIntervals(steps: number): number {
|
||||
if (!Number.isFinite(steps) || steps <= 0) return 4
|
||||
if (steps <= 5) return Math.max(1, Math.round(steps))
|
||||
return [4, 3, 2].find((count) => Number.isInteger(steps / count)) ?? 4
|
||||
}
|
||||
|
||||
/**
|
||||
* A value set by dragging, published when the handle is let go.
|
||||
*
|
||||
* A drag would otherwise send a value per pixel and flood whatever is
|
||||
* listening, so the draft follows the finger and only the release publishes.
|
||||
* While there is no draft the handle follows the engine, which is what makes a
|
||||
* value set elsewhere show up here.
|
||||
*/
|
||||
export function useSliderDrag({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
label,
|
||||
unit,
|
||||
disabled,
|
||||
orientation = "horizontal",
|
||||
ticks = false,
|
||||
onCommit,
|
||||
}: {
|
||||
value: number
|
||||
min: number
|
||||
max: number
|
||||
step: number
|
||||
label: string
|
||||
unit?: string
|
||||
disabled?: boolean
|
||||
orientation?: "horizontal" | "vertical"
|
||||
ticks?: boolean
|
||||
onCommit: (value: number) => void
|
||||
}) {
|
||||
const [draft, setDraft] = useState<number | null>(null)
|
||||
const current = draft ?? value
|
||||
|
||||
const release = () => {
|
||||
if (draft === null) return
|
||||
onCommit(draft)
|
||||
setDraft(null)
|
||||
}
|
||||
|
||||
const span = max - min
|
||||
const intervals = tickIntervals(step > 0 ? span / step : 0)
|
||||
// Taken off the step, so 0–1 at 0.01 reads "0.25" and 0–100 at 1 reads "25"
|
||||
// without a precision setting of its own.
|
||||
const digits = (String(step).split(".")[1] ?? "").length
|
||||
|
||||
return {
|
||||
current,
|
||||
fraction: fractionOf(current, min, max),
|
||||
inputProps: {
|
||||
type: "range" as const,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
value: current,
|
||||
disabled,
|
||||
"aria-label": label,
|
||||
"aria-orientation": orientation,
|
||||
"aria-valuetext": unit ? `${current}${unit}` : undefined,
|
||||
onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setDraft(Number(event.target.value)),
|
||||
onPointerUp: release,
|
||||
onKeyUp: release,
|
||||
onBlur: release,
|
||||
},
|
||||
/** The scale under the track, drawn rather than declared: no browser
|
||||
* renders `<option label>` for a range, and a 20–22 °C setpoint is
|
||||
* unusable without numbers to aim at. */
|
||||
marks:
|
||||
ticks && orientation === "horizontal" && span > 0
|
||||
? Array.from({ length: intervals + 1 }, (_, index) => ({
|
||||
percent: (index / intervals) * 100,
|
||||
label: String(
|
||||
Number((min + (span * index) / intervals).toFixed(digits)),
|
||||
),
|
||||
}))
|
||||
: [],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
/*
|
||||
* Dashboard geometry, shared by both looks.
|
||||
*
|
||||
* Everything here is size and shape rather than paint: how big a control is,
|
||||
* how a number grows with the tile it is in, where the disc's colours sit.
|
||||
* Two looks that disagreed about a hit target would be two dashboards, so the
|
||||
* measurements live in one place and each set colours them in.
|
||||
*
|
||||
* Sizes come off three variables set on the frame. A widget is measured
|
||||
* against its own tile — `cqh`/`cqw` are the frame's body, which is the query
|
||||
* container — and never against the viewport: a wall panel's tiles are not
|
||||
* small because the screen is.
|
||||
*/
|
||||
|
||||
.dui-frame {
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
overflow: hidden;
|
||||
padding: 1rem;
|
||||
/* A pointer above `md`, a finger below it, and a panel that says it is
|
||||
touched gets the finger whatever its size. */
|
||||
--dui-control: 2rem;
|
||||
--dui-thumb: 1rem;
|
||||
--dui-text: 0.875rem;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.dui-frame {
|
||||
--dui-control: 2.75rem;
|
||||
--dui-thumb: 1.25rem;
|
||||
--dui-text: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
[data-touch] .dui-frame {
|
||||
--dui-control: 3rem;
|
||||
--dui-thumb: 1.5rem;
|
||||
--dui-text: 1rem;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.dui-frame-head {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.dui-frame-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--dui-text);
|
||||
}
|
||||
|
||||
/*
|
||||
* The body is the query container everything inside is measured against, and
|
||||
* the scroller for anything taller than the tile. Centring has to be `safe`:
|
||||
* plain `center` overflows both edges at once and puts the top of a long body
|
||||
* out of reach.
|
||||
*/
|
||||
.dui-frame-body {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
justify-content: safe center;
|
||||
overflow-y: auto;
|
||||
container-type: size;
|
||||
font-size: var(--dui-text);
|
||||
}
|
||||
|
||||
/* No header, so the fault still has somewhere to be seen. */
|
||||
.dui-frame-corner {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
top: 0.5rem;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Type tiers, all measured against the tile rather than the page. */
|
||||
.dui-hero {
|
||||
font-size: clamp(1.25rem, min(32cqh, 12cqw), 5rem);
|
||||
line-height: 1.1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.dui-hero-unit {
|
||||
font-size: 0.4em;
|
||||
}
|
||||
|
||||
.dui-clock {
|
||||
font-size: clamp(1.5rem, min(40cqh, 14cqw), 6rem);
|
||||
line-height: 1.05;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.dui-glyph {
|
||||
width: clamp(1.75rem, min(55cqh, 40cqw), 8rem);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.dui-glyph-sm {
|
||||
width: clamp(1.25rem, 25cqh, 3rem);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* A chart is mostly plot; its legend is the hover readout and goes first. */
|
||||
@container (max-height: 150px) {
|
||||
.dui-chart .u-legend {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* The colour disc.
|
||||
*
|
||||
* Hue is the angle and saturation the radius, so white sits in the middle and
|
||||
* the rim is fully saturated: the white-to-transparent radial over the hue
|
||||
* ring *is* `(1 - s)` white over the hue, which is what HSV means. A shade
|
||||
* layer at `1 - v` completes it. These are the only colours in the dashboard
|
||||
* that are not tokens, deliberately — a colour control paints the value it
|
||||
* publishes rather than the palette.
|
||||
*/
|
||||
.dui-disc {
|
||||
position: relative;
|
||||
width: min(100cqh, 100cqw - 3.5rem);
|
||||
max-width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
touch-action: none;
|
||||
outline: none;
|
||||
background:
|
||||
radial-gradient(circle closest-side, #fff, rgb(255 255 255 / 0)),
|
||||
conic-gradient(
|
||||
from 0deg,
|
||||
hsl(0 100% 50%),
|
||||
hsl(60 100% 50%),
|
||||
hsl(120 100% 50%),
|
||||
hsl(180 100% 50%),
|
||||
hsl(240 100% 50%),
|
||||
hsl(300 100% 50%),
|
||||
hsl(360 100% 50%)
|
||||
);
|
||||
}
|
||||
|
||||
.dui-disc-shade {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
background: #000;
|
||||
opacity: var(--dui-shade, 0);
|
||||
}
|
||||
|
||||
.dui-disc-thumb {
|
||||
position: absolute;
|
||||
width: max(var(--dui-thumb), 1.25rem);
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
translate: -50% -50%;
|
||||
left: calc(50% + var(--dui-sx) * (50% - max(var(--dui-thumb), 1.25rem) / 2));
|
||||
top: calc(50% + var(--dui-sy) * (50% - max(var(--dui-thumb), 1.25rem) / 2));
|
||||
}
|
||||
|
||||
/* A column of range input, which is the one shape CSS has to be told about. */
|
||||
.dui-slider input[data-orientation="vertical"] {
|
||||
writing-mode: vertical-lr;
|
||||
direction: rtl;
|
||||
appearance: slider-vertical;
|
||||
width: var(--dui-control);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/*
|
||||
* A publish in flight, drawn as a ring just inside the tile's own edge.
|
||||
*
|
||||
* The marker takes no box at all and the frame draws the ring, because the
|
||||
* body a control sits in is a query container — which is a containing block
|
||||
* for anything absolute inside it, and clips. Full opacity at rest, so a panel
|
||||
* that asks for no motion still gets the ring; the animation only breathes it.
|
||||
*/
|
||||
.widget-transmit {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dui-frame:has(.widget-transmit)::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
box-shadow: inset 0 0 0 2px var(--primary);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
/* One beat per second, which reads as "on its way" from across a room
|
||||
without becoming the loudest thing in a browser tab. */
|
||||
.dui-frame:has(.widget-transmit)::after {
|
||||
animation: dui-transmit var(--duration-pulse) var(--ease-standard) infinite
|
||||
alternate;
|
||||
}
|
||||
|
||||
@keyframes dui-transmit {
|
||||
from {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* A bar draws a row per reading: its name, how far along it is, and what it
|
||||
* says. Rows share the tile, so each is a track of its own in the dashboard's
|
||||
* data colours rather than a segment nested in the one above it.
|
||||
*/
|
||||
.dui-bar {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-auto-rows: minmax(0, 1fr);
|
||||
gap: 0.5rem;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.dui-bar-row {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.dui-bar-label {
|
||||
min-width: 0;
|
||||
max-width: 8rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.dui-bar-track {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: clamp(0.5rem, 10cqh, 1.25rem);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dui-bar-fill {
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.dui-bar-value {
|
||||
flex: none;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Too narrow for three columns: the name takes a line of its own. */
|
||||
@container (max-width: 260px) {
|
||||
.dui-bar-row {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.dui-bar-label {
|
||||
max-width: none;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* A colour set on a disc.
|
||||
*
|
||||
* Hue is the angle and saturation is the distance from the centre, so one
|
||||
* gesture sets both: white in the middle, fully saturated at the rim. The
|
||||
* third component, brightness, is a slider beside it — a disc can only carry
|
||||
* two, and brightness is the one a lamp is usually adjusted by on its own.
|
||||
*
|
||||
* The frame is the wheel's: zero degrees at twelve o'clock, running clockwise,
|
||||
* which is what the disc's own `conic-gradient(from 0deg, …)` paints.
|
||||
*/
|
||||
import { useCallback } from "react"
|
||||
|
||||
import { clamp, type Triple, wrap } from "./color"
|
||||
|
||||
/** How far one arrow key moves. A degree at a time would be 360 presses. */
|
||||
const HUE_STEP = 5
|
||||
const SAT_STEP = 5
|
||||
const SAT_PAGE = 20
|
||||
|
||||
/**
|
||||
* Where the pointer is, as hue and saturation.
|
||||
*
|
||||
* `dx` and `dy` are offsets from the centre in disc radii, so the rim is 1 and
|
||||
* anything past it saturates rather than running off the scale — a finger that
|
||||
* slides off the disc keeps setting the colour it was pointing at.
|
||||
*/
|
||||
export function discToHsv(dx: number, dy: number): [number, number] {
|
||||
return [
|
||||
wrap((Math.atan2(dy, dx) * 180) / Math.PI + 90),
|
||||
clamp(Math.hypot(dx, dy) * 100, 100),
|
||||
]
|
||||
}
|
||||
|
||||
/** The way back: where the handle rides, as offsets from the centre. */
|
||||
export function hsvToDisc(hue: number, saturation: number) {
|
||||
const radius = clamp(saturation, 100) / 100
|
||||
return {
|
||||
sx: radius * Math.sin((hue * Math.PI) / 180),
|
||||
sy: -radius * Math.cos((hue * Math.PI) / 180),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The disc as a control: pointer, keyboard, and what it announces.
|
||||
*
|
||||
* One `role="slider"` rather than two nested ones. A disc is one picture and
|
||||
* one gesture, so it is one focus stop; both numbers are in `aria-valuetext`,
|
||||
* which is what a screen reader actually reads out for a slider.
|
||||
*/
|
||||
export function useColorDisk({
|
||||
name,
|
||||
hsv,
|
||||
disabled,
|
||||
onChange,
|
||||
onCommit,
|
||||
}: {
|
||||
name: string
|
||||
hsv: Triple
|
||||
disabled?: boolean
|
||||
onChange: (hsv: Triple) => void
|
||||
onCommit: () => void
|
||||
}) {
|
||||
const [hue, saturation, brightness] = hsv
|
||||
|
||||
const aim = useCallback(
|
||||
(event: React.PointerEvent<HTMLElement>) => {
|
||||
if (disabled) return
|
||||
const box = event.currentTarget.getBoundingClientRect()
|
||||
const dx = (event.clientX - (box.left + box.width / 2)) / (box.width / 2)
|
||||
const dy = (event.clientY - (box.top + box.height / 2)) / (box.height / 2)
|
||||
const [nextHue, nextSat] = discToHsv(dx, dy)
|
||||
// Dead centre has no angle to read, so the hue stays where it was
|
||||
// rather than snapping to whatever `atan2` calls zero.
|
||||
onChange([nextSat === 0 ? hue : nextHue, nextSat, brightness])
|
||||
},
|
||||
[brightness, disabled, hue, onChange],
|
||||
)
|
||||
|
||||
return {
|
||||
thumb: hsvToDisc(hue, saturation),
|
||||
/** How much black is laid over the disc: the brightness, as a picture. */
|
||||
shade: Math.min(0.75, 1 - clamp(brightness, 100) / 100),
|
||||
discProps: {
|
||||
role: "slider",
|
||||
// Not a native control, so the state it is in is said rather than
|
||||
// inherited — and the disc keeps its colours, which are the reading,
|
||||
// while the handle stops answering.
|
||||
tabIndex: disabled ? -1 : 0,
|
||||
"aria-disabled": disabled || undefined,
|
||||
"aria-label": `${name} hue and saturation`,
|
||||
"aria-valuemin": 0,
|
||||
"aria-valuemax": 359,
|
||||
"aria-valuenow": hue,
|
||||
"aria-valuetext": `${hue} degrees, ${saturation}% saturated`,
|
||||
onPointerDown: (event: React.PointerEvent<HTMLElement>) => {
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
aim(event)
|
||||
},
|
||||
onPointerMove: (event: React.PointerEvent<HTMLElement>) => {
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) aim(event)
|
||||
},
|
||||
onPointerUp: onCommit,
|
||||
onKeyDown: (event: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (disabled) return
|
||||
const key = event.key
|
||||
const hueBy =
|
||||
key === "ArrowRight" ? HUE_STEP : key === "ArrowLeft" ? -HUE_STEP : 0
|
||||
const satBy =
|
||||
key === "ArrowUp"
|
||||
? SAT_STEP
|
||||
: key === "ArrowDown"
|
||||
? -SAT_STEP
|
||||
: key === "PageUp"
|
||||
? SAT_PAGE
|
||||
: key === "PageDown"
|
||||
? -SAT_PAGE
|
||||
: 0
|
||||
if (hueBy || satBy) {
|
||||
event.preventDefault()
|
||||
onChange([
|
||||
wrap(hue + hueBy),
|
||||
clamp(saturation + satBy, 100),
|
||||
brightness,
|
||||
])
|
||||
return
|
||||
}
|
||||
if (key === "Home" || key === "End") {
|
||||
event.preventDefault()
|
||||
onChange([key === "Home" ? 0 : 359, saturation, brightness])
|
||||
}
|
||||
},
|
||||
onKeyUp: onCommit,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Which look is being drawn, and what the canvas it is drawn on carries.
|
||||
*
|
||||
* A dashboard states its look, its colours and whether it is touched on one
|
||||
* element — the canvas root — and everything below reads them from there:
|
||||
* the tokens by inheritance, the look and the touch flag through this context.
|
||||
*
|
||||
* The style is carried in the context as well as on the element, because a
|
||||
* menu is portalled to `body` and lands outside the canvas. A surface that
|
||||
* floats out of the tree re-states the tokens rather than borrowing the app's.
|
||||
*/
|
||||
import { createContext, useContext } from "react"
|
||||
|
||||
import type { DashboardDef_Output } from "@/client"
|
||||
import { useTheme } from "@/components/theme-provider"
|
||||
import {
|
||||
type Look,
|
||||
useDashboardLook,
|
||||
useDashboardPalette,
|
||||
useDashboardTheme,
|
||||
useDashboardTouch,
|
||||
} from "../../settings"
|
||||
import { rolesOf, tokenStyle } from "./theme"
|
||||
|
||||
type Canvas = {
|
||||
look: Look
|
||||
/** Bigger controls and no hover states. */
|
||||
touch: boolean
|
||||
/** The palette's tokens, or `{}` when the dashboard names none. */
|
||||
style: React.CSSProperties
|
||||
}
|
||||
|
||||
const LookContext = createContext<Canvas>({
|
||||
look: "material",
|
||||
touch: false,
|
||||
style: {},
|
||||
})
|
||||
|
||||
/** The look everything under here is drawn in. */
|
||||
export const useLook = () => useContext(LookContext)
|
||||
|
||||
/**
|
||||
* What the canvas root carries: the theme class, the palette, and the two
|
||||
* attributes the looks' stylesheets key off.
|
||||
*
|
||||
* The class is always resolved to `light` or `dark`, rather than left empty
|
||||
* for a dashboard that follows the device: the sets state their own colours
|
||||
* per theme, and a chart canvas has to be told which one it is drawing in.
|
||||
* Restating the app's own resolved theme changes nothing when they agree.
|
||||
*/
|
||||
export function useCanvasRoot(dashboard: DashboardDef_Output | undefined) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const stated = useDashboardTheme(dashboard)
|
||||
const roles = rolesOf(useDashboardPalette(dashboard))
|
||||
const look = useDashboardLook(dashboard)
|
||||
const touch = useDashboardTouch(dashboard)
|
||||
return {
|
||||
className: stated || (resolvedTheme === "dark" ? "dark" : "light"),
|
||||
style: (roles ? tokenStyle(roles) : {}) as React.CSSProperties,
|
||||
"data-look": look,
|
||||
"data-touch": touch ? "" : undefined,
|
||||
"data-palette": roles ? "" : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounted around whatever the dashboard draws, beside `PaletteProvider`.
|
||||
*
|
||||
* The rail is a sibling of the canvas rather than a widget on it, so this has
|
||||
* to sit above both — a rail drawn in the app's own chrome beside a glass
|
||||
* dashboard is two designs on one screen.
|
||||
*/
|
||||
export function LookProvider({
|
||||
dashboard,
|
||||
children,
|
||||
}: {
|
||||
dashboard: DashboardDef_Output | undefined
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const root = useCanvasRoot(dashboard)
|
||||
return (
|
||||
<LookContext.Provider
|
||||
value={{
|
||||
look: root["data-look"],
|
||||
touch: root["data-touch"] !== undefined,
|
||||
style: root.style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</LookContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* How each look moves.
|
||||
*
|
||||
* `lib/motion.ts` is the app's own set and is duplicated verbatim in the
|
||||
* website repo (`make design-check` holds the two byte-identical), so a
|
||||
* dashboard that moves differently needs its own table rather than an entry
|
||||
* there. The two looks are meant to *feel* different: material settles quickly
|
||||
* and squarely, glass carries a little overshoot.
|
||||
*
|
||||
* Physics only. What animates is each set's business; this is how it moves
|
||||
* when it does, kept in one place so the two can be compared.
|
||||
*/
|
||||
import type { Transition, Variants } from "motion/react"
|
||||
|
||||
import type { Look } from "../../settings"
|
||||
|
||||
type LookMotion = {
|
||||
spring: Transition
|
||||
/** How a widget arrives on the canvas. */
|
||||
enter: Variants
|
||||
/** How a surface answers a pointer, if it does at all. */
|
||||
hover?: { y: number }
|
||||
/** Seconds for one drift of the ambient ground; 0 draws none. */
|
||||
drift: number
|
||||
}
|
||||
|
||||
export const LOOK: Record<Look, LookMotion> = {
|
||||
material: {
|
||||
spring: { type: "spring", stiffness: 550, damping: 38, mass: 1 },
|
||||
enter: {
|
||||
hidden: { opacity: 0, y: 8 },
|
||||
visible: { opacity: 1, y: 0 },
|
||||
},
|
||||
// No lift: Material answers a pointer with a state layer, not a move.
|
||||
hover: undefined,
|
||||
drift: 0,
|
||||
},
|
||||
glass: {
|
||||
spring: { type: "spring", stiffness: 320, damping: 22, mass: 0.8 },
|
||||
enter: {
|
||||
hidden: { opacity: 0, y: 12, scale: 0.96 },
|
||||
visible: { opacity: 1, y: 0, scale: 1 },
|
||||
},
|
||||
hover: { y: -2 },
|
||||
drift: 28,
|
||||
},
|
||||
}
|
||||
|
||||
/** Widgets arrive as a page rather than all at once. */
|
||||
export const gridStagger: Variants = {
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: 0.03 } },
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* A dashboard's palette, checked.
|
||||
*
|
||||
* ponytail: a script rather than a suite, like `palette.check.ts` and
|
||||
* `color.check.ts` beside it — reading a link and mixing a token needs no
|
||||
* browser:
|
||||
*
|
||||
* cd frontend && bun run src/components/Dashboard/ui/core/theme.check.ts
|
||||
*
|
||||
* Two things are held here. **Reading** a palette: what a link means, and that
|
||||
* a palette this build does not understand is no palette rather than a wrong
|
||||
* one. And **legibility**: whatever four colours somebody picked, the tokens
|
||||
* derived from them still clear the ratios the guidelines ask for, because a
|
||||
* dashboard nobody can read is not a look.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict"
|
||||
|
||||
import {
|
||||
contrast,
|
||||
isLight,
|
||||
luminance,
|
||||
MAX_ROLES,
|
||||
mix,
|
||||
onColor,
|
||||
parsePalette,
|
||||
roleLabel,
|
||||
rolesOf,
|
||||
tokenStyle,
|
||||
} from "./theme"
|
||||
|
||||
// --- reading a palette ----------------------------------------------------
|
||||
|
||||
const CHOSEN = ["#264653", "#2a9d8f", "#e9c46a", "#f4a261", "#e76f51"]
|
||||
|
||||
assert.deepEqual(
|
||||
parsePalette("https://coolors.co/palette/264653-2a9d8f-e9c46a-f4a261-e76f51"),
|
||||
CHOSEN,
|
||||
"a coolors link is the five colours it names",
|
||||
)
|
||||
assert.deepEqual(
|
||||
parsePalette("coolors.co/264653-2a9d8f-e9c46a-f4a261-e76f51"),
|
||||
CHOSEN,
|
||||
"the shorter coolors link says the same thing",
|
||||
)
|
||||
assert.deepEqual(
|
||||
parsePalette("#264653, #2A9D8F"),
|
||||
["#264653", "#2a9d8f"],
|
||||
"a typed list is read in the order it was typed, however it is cased",
|
||||
)
|
||||
assert.deepEqual(
|
||||
parsePalette("https://colorhunt.co/palette/222831393e4600adb5eeeeee"),
|
||||
["#222831", "#393e46", "#00adb5", "#eeeeee"],
|
||||
"a run with nothing between the colours is cut into sixes",
|
||||
)
|
||||
|
||||
for (const legacy of [
|
||||
["5", "3", "1"],
|
||||
[1, 2],
|
||||
"",
|
||||
null,
|
||||
undefined,
|
||||
"nonsense",
|
||||
]) {
|
||||
assert.deepEqual(
|
||||
parsePalette(legacy),
|
||||
[],
|
||||
`${JSON.stringify(legacy)} is no palette, not a palette of its own`,
|
||||
)
|
||||
}
|
||||
|
||||
assert.equal(roleLabel(0), "Background")
|
||||
assert.equal(roleLabel(4), "Text")
|
||||
assert.equal(
|
||||
roleLabel(5),
|
||||
"Colour 6",
|
||||
"past the roles a colour is just a colour",
|
||||
)
|
||||
|
||||
// --- luminance and contrast ----------------------------------------------
|
||||
|
||||
assert.equal(luminance("#ffffff"), 1)
|
||||
assert.equal(luminance("#000000"), 0)
|
||||
assert.equal(Math.round(contrast("#ffffff", "#000000")), 21)
|
||||
assert.equal(isLight("#264653"), false)
|
||||
assert.equal(isLight("#e9c46a"), true)
|
||||
assert.equal(onColor("#264653"), "#f5f5f5")
|
||||
assert.equal(mix("#000000", "#ffffff", 0.5), "#808080")
|
||||
|
||||
// --- roles by position ----------------------------------------------------
|
||||
|
||||
const named = rolesOf(CHOSEN)
|
||||
assert.ok(named)
|
||||
assert.equal(named.background, CHOSEN[0])
|
||||
assert.equal(named.surface, CHOSEN[1])
|
||||
assert.equal(named.primary, CHOSEN[2])
|
||||
assert.equal(named.accent, CHOSEN[3])
|
||||
assert.equal(named.foreground, CHOSEN[4])
|
||||
assert.deepEqual(
|
||||
named.series.slice(0, 2),
|
||||
[CHOSEN[2], CHOSEN[3]],
|
||||
"a chart draws with the primary first, then the accent",
|
||||
)
|
||||
|
||||
const sixth = rolesOf([...CHOSEN, "#8ab17d"])
|
||||
assert.equal(sixth?.series[2], "#8ab17d", "a sixth colour is the third series")
|
||||
|
||||
const dark = rolesOf(["#0f172a"])
|
||||
assert.ok(dark)
|
||||
assert.equal(dark.foreground, "#f5f5f5", "a dark ground derives light text")
|
||||
assert.ok(
|
||||
luminance(dark.surface) > luminance(dark.background),
|
||||
"a surface lifts off a dark ground",
|
||||
)
|
||||
|
||||
const light = rolesOf(["#f4f1ea"])
|
||||
assert.ok(light)
|
||||
assert.equal(light.foreground, "#0a0a0a", "a light ground derives dark text")
|
||||
assert.ok(
|
||||
luminance(light.surface) < luminance(light.background),
|
||||
"a surface dips into a light ground",
|
||||
)
|
||||
|
||||
assert.equal(rolesOf([]), null, "no colours is no palette")
|
||||
|
||||
for (const roles of [named, dark, light, sixth]) {
|
||||
assert.ok(roles)
|
||||
assert.ok(
|
||||
roles.series.length >= MAX_ROLES,
|
||||
"a chart of five lines never has to repeat a colour",
|
||||
)
|
||||
}
|
||||
|
||||
// --- what the tokens are worth -------------------------------------------
|
||||
|
||||
for (const [name, hexes] of [
|
||||
["the chosen palette", CHOSEN],
|
||||
["a dark ground alone", ["#0f172a"]],
|
||||
["a light ground alone", ["#f4f1ea"]],
|
||||
// Text nobody could read on the surface derived from it: the palette says
|
||||
// white, the surface is nearly white.
|
||||
[
|
||||
"an unreadable pairing",
|
||||
["#fefefe", "#fdfdfd", "#4a7189", "#de8f6e", "#ffffff"],
|
||||
],
|
||||
] as const) {
|
||||
const roles = rolesOf([...hexes])
|
||||
assert.ok(roles)
|
||||
const tokens = tokenStyle(roles)
|
||||
const ratio = (a: string, b: string) => contrast(tokens[a], tokens[b])
|
||||
|
||||
assert.ok(
|
||||
ratio("--foreground", "--background") >= 4.5,
|
||||
`${name}: body text measures ${ratio("--foreground", "--background").toFixed(2)}:1 on the page`,
|
||||
)
|
||||
assert.ok(
|
||||
ratio("--card-foreground", "--card") >= 4.5,
|
||||
`${name}: text on a widget measures ${ratio("--card-foreground", "--card").toFixed(2)}:1`,
|
||||
)
|
||||
assert.ok(
|
||||
ratio("--muted-foreground", "--card") >= 4.5,
|
||||
`${name}: secondary text measures ${ratio("--muted-foreground", "--card").toFixed(2)}:1`,
|
||||
)
|
||||
assert.ok(
|
||||
ratio("--primary-foreground", "--primary") >= 4.5,
|
||||
`${name}: a label on a fill measures ${ratio("--primary-foreground", "--primary").toFixed(2)}:1`,
|
||||
)
|
||||
assert.ok(
|
||||
ratio("--primary-nested", "--primary") >= 3,
|
||||
`${name}: a nested reading measures ${ratio("--primary-nested", "--primary").toFixed(2)}:1 on the fill it sits in`,
|
||||
)
|
||||
assert.ok(
|
||||
ratio("--accent-foreground", "--accent") >= 4.5,
|
||||
`${name}: a label on the accent measures ${ratio("--accent-foreground", "--accent").toFixed(2)}:1`,
|
||||
)
|
||||
for (const token of Object.values(tokens)) {
|
||||
assert.match(
|
||||
token,
|
||||
/^#[0-9a-f]{6}$/,
|
||||
"every token is a literal colour; a chart canvas cannot resolve anything else",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
console.log("theme: ok")
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* A dashboard's own colours.
|
||||
*
|
||||
* A palette is an ordered list of hex colours — pasted from a coolors.co link
|
||||
* or typed — and position is the role: background, surface, primary, accent,
|
||||
* text, then anything after that is another colour for a chart to draw with.
|
||||
* Trailing roles may simply be left off; what is missing is derived from what
|
||||
* is there, so three colours are a whole dashboard.
|
||||
*
|
||||
* The result is written onto the canvas as the plain token variables the app
|
||||
* already themes with (`--background`, `--card`, `--primary`, …), which is the
|
||||
* same mechanism `.light` and `.dark` use on a subtree. Nothing here touches
|
||||
* `index.css`: that file's token blocks are byte-identical with the website's
|
||||
* and `make design-check` holds them there.
|
||||
*
|
||||
* Every value written out is a literal hex. uPlot draws on a canvas, and a
|
||||
* canvas takes neither `color-mix()` nor an unresolved custom property, so a
|
||||
* derived token has to arrive already mixed.
|
||||
*/
|
||||
|
||||
/** What each position means, in order. Anything past these is a chart colour. */
|
||||
export const ROLE_LABELS = [
|
||||
"Background",
|
||||
"Surface",
|
||||
"Primary",
|
||||
"Accent",
|
||||
"Text",
|
||||
] as const
|
||||
|
||||
export const MAX_ROLES = ROLE_LABELS.length
|
||||
|
||||
/** What to call the colour at this position, in the settings panel. */
|
||||
export const roleLabel = (index: number): string =>
|
||||
ROLE_LABELS[index] ?? `Colour ${index + 1}`
|
||||
|
||||
/** The brand's own primary and secondary, per theme, when a palette omits them. */
|
||||
const BRAND = {
|
||||
light: { primary: "#4a7189", accent: "#de8f6e" },
|
||||
dark: { primary: "#7ba3b8", accent: "#e5a184" },
|
||||
} as const
|
||||
|
||||
/**
|
||||
* The hex colours in a piece of text, in the order they appear.
|
||||
*
|
||||
* Anything that is not a hex digit separates, so a coolors link, a comma list
|
||||
* and a column of `#rrggbb` all read the same. A run several colours long with
|
||||
* nothing between them — what colorhunt's links look like — is cut into sixes.
|
||||
*
|
||||
* A palette this build does not understand parses as none, which is what makes
|
||||
* the old ramp-slot palettes (`["1", "3", "5"]`) read as "no palette" rather
|
||||
* than as three colours nobody chose.
|
||||
*/
|
||||
export function parsePalette(input: unknown): string[] {
|
||||
const text = Array.isArray(input)
|
||||
? input.map((entry) => String(entry)).join(" ")
|
||||
: typeof input === "string"
|
||||
? input
|
||||
: ""
|
||||
const found: string[] = []
|
||||
for (const token of text.split(/[^0-9a-f]+/i)) {
|
||||
if (token.length < 6 || token.length % 6 !== 0) continue
|
||||
for (let at = 0; at < token.length; at += 6) {
|
||||
found.push(`#${token.slice(at, at + 6).toLowerCase()}`)
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
const channels = (hex: string): [number, number, number] => {
|
||||
const digits = /^#?([0-9a-f]{6})$/i.exec(hex.trim())?.[1] ?? "000000"
|
||||
return [0, 1, 2].map((index) =>
|
||||
Number.parseInt(digits.slice(index * 2, index * 2 + 2), 16),
|
||||
) as [number, number, number]
|
||||
}
|
||||
|
||||
const hexOf = (rgb: number[]): string =>
|
||||
`#${rgb
|
||||
.map((channel) =>
|
||||
Math.min(255, Math.max(0, Math.round(channel)))
|
||||
.toString(16)
|
||||
.padStart(2, "0"),
|
||||
)
|
||||
.join("")}`
|
||||
|
||||
/** WCAG relative luminance, 0 for black and 1 for white. */
|
||||
export function luminance(hex: string): number {
|
||||
const [red, green, blue] = channels(hex).map((channel) => {
|
||||
const part = channel / 255
|
||||
return part <= 0.03928 ? part / 12.92 : ((part + 0.055) / 1.055) ** 2.4
|
||||
})
|
||||
return 0.2126 * red + 0.7152 * green + 0.0722 * blue
|
||||
}
|
||||
|
||||
/** The WCAG ratio between two colours, 1 to 21. */
|
||||
export function contrast(first: string, second: string): number {
|
||||
const [dark, light] = [luminance(first), luminance(second)].sort(
|
||||
(a, b) => a - b,
|
||||
)
|
||||
return (light + 0.05) / (dark + 0.05)
|
||||
}
|
||||
|
||||
/** Whether a surface of this colour wants dark text on it. */
|
||||
export const isLight = (hex: string): boolean => luminance(hex) > 0.179
|
||||
|
||||
/** Near-black or near-white, whichever can be read on this colour. */
|
||||
export const onColor = (hex: string): string =>
|
||||
isLight(hex) ? "#0a0a0a" : "#f5f5f5"
|
||||
|
||||
/** Two colours, part of the way between them. `t` of 1 is all `b`. */
|
||||
export function mix(a: string, b: string, t: number): string {
|
||||
const from = channels(a)
|
||||
const to = channels(b)
|
||||
return hexOf(
|
||||
from.map((channel, index) => channel + (to[index] - channel) * t),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Text that can actually be read on this surface.
|
||||
*
|
||||
* A palette names one text colour, and it is used wherever it clears AA. Where
|
||||
* it does not — a light text colour over the pale surface derived from a light
|
||||
* background — black or white stands in for it there rather than the dashboard
|
||||
* shipping a line nobody can read.
|
||||
*/
|
||||
const legible = (fore: string, on: string): string =>
|
||||
contrast(fore, on) >= 4.5 ? fore : onColor(on)
|
||||
|
||||
/** The same text, receded as far toward the surface as AA still allows. */
|
||||
function recede(fore: string, on: string): string {
|
||||
for (const t of [0.35, 0.25, 0.15]) {
|
||||
const candidate = mix(fore, on, t)
|
||||
if (contrast(candidate, on) >= 4.5) return candidate
|
||||
}
|
||||
return fore
|
||||
}
|
||||
|
||||
/** A reading drawn inside a `--primary` fill, at the 3:1 the guideline asks. */
|
||||
function nested(primary: string): string {
|
||||
for (const t of [0.5, 0.62, 0.74]) {
|
||||
const candidate = mix(primary, onColor(primary), t)
|
||||
if (contrast(candidate, primary) >= 3) return candidate
|
||||
}
|
||||
return onColor(primary)
|
||||
}
|
||||
|
||||
export type Roles = {
|
||||
background: string
|
||||
surface: string
|
||||
primary: string
|
||||
accent: string
|
||||
foreground: string
|
||||
/** Data colours, in draw order. At least five, so a full chart never repeats. */
|
||||
series: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* A palette, read as roles.
|
||||
*
|
||||
* `null` when there is nothing to read, which is how a dashboard with no
|
||||
* palette keeps the app's own neutral tokens.
|
||||
*/
|
||||
export function rolesOf(hexes: string[]): Roles | null {
|
||||
const [background, surface, primary, accent, foreground, ...extra] = hexes
|
||||
if (!background) return null
|
||||
|
||||
const brand = BRAND[isLight(background) ? "light" : "dark"]
|
||||
const fore = foreground ?? onColor(background)
|
||||
const roles: Roles = {
|
||||
background,
|
||||
// A surface lifts off a dark ground and dips into a light one, which is
|
||||
// the direction the app's own tokens move between themes.
|
||||
surface:
|
||||
surface ??
|
||||
mix(background, isLight(background) ? "#000000" : "#ffffff", 0.06),
|
||||
primary: primary ?? brand.primary,
|
||||
accent: accent ?? brand.accent,
|
||||
foreground: fore,
|
||||
series: [],
|
||||
}
|
||||
roles.series = [roles.primary, roles.accent, ...extra]
|
||||
// Enough colours for a full chart, taken from the two the palette named
|
||||
// rather than from a ramp the dashboard never chose.
|
||||
for (const tint of [
|
||||
mix(roles.primary, fore, 0.35),
|
||||
mix(roles.accent, fore, 0.35),
|
||||
mix(roles.primary, roles.accent, 0.5),
|
||||
]) {
|
||||
if (roles.series.length >= MAX_ROLES) break
|
||||
roles.series.push(tint)
|
||||
}
|
||||
return roles
|
||||
}
|
||||
|
||||
/**
|
||||
* The roles as the tokens everything under the canvas already reads.
|
||||
*
|
||||
* State colours are deliberately absent: `--destructive` and `--status-success`
|
||||
* say that something is wrong or well, and a palette that repainted them could
|
||||
* make a failure the same colour as a reading. They follow the light or dark
|
||||
* class the canvas carries instead.
|
||||
*/
|
||||
export function tokenStyle(roles: Roles): Record<string, string> {
|
||||
const { background, surface, primary, accent } = roles
|
||||
const onBackground = legible(roles.foreground, background)
|
||||
const onSurface = legible(roles.foreground, surface)
|
||||
const series = roles.series
|
||||
return {
|
||||
"--background": background,
|
||||
"--foreground": onBackground,
|
||||
"--card": surface,
|
||||
"--card-foreground": onSurface,
|
||||
"--popover": surface,
|
||||
"--popover-foreground": onSurface,
|
||||
"--primary": primary,
|
||||
"--primary-foreground": onColor(primary),
|
||||
"--primary-nested": nested(primary),
|
||||
"--secondary": mix(surface, onSurface, 0.08),
|
||||
"--secondary-foreground": onSurface,
|
||||
"--muted": mix(surface, onSurface, 0.08),
|
||||
"--muted-foreground": recede(onSurface, surface),
|
||||
"--accent": accent,
|
||||
"--accent-foreground": onColor(accent),
|
||||
"--border": mix(surface, onSurface, 0.15),
|
||||
"--input": mix(surface, onSurface, 0.15),
|
||||
"--ring": primary,
|
||||
"--chart-1": series[0],
|
||||
"--chart-2": series[1],
|
||||
"--chart-3": series[2],
|
||||
"--chart-4": series[3],
|
||||
"--chart-5": series[4],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Readings, on their way to being drawn.
|
||||
*
|
||||
* A number that jumps is hard to read and a number that crawls is a distraction
|
||||
* — both sets tween theirs, at their own look's stiffness, through the motion
|
||||
* values here. `<MotionConfig reducedMotion="user">` at the app root switches
|
||||
* every one of them off when the device asks.
|
||||
*/
|
||||
import {
|
||||
type MotionValue,
|
||||
useMotionValue,
|
||||
useSpring,
|
||||
useTransform,
|
||||
} from "motion/react"
|
||||
import { useEffect } from "react"
|
||||
|
||||
import type { WidgetDef } from "@/client"
|
||||
import { CHART_SLOTS } from "@/components/Common/UplotChart"
|
||||
import { displayName, flowOf } from "@/components/Flow/deriveEdges"
|
||||
import type { LiveValue } from "@/components/Flow/liveStore"
|
||||
import { config, format, fractionOf, num, rowsOf, text } from "./config"
|
||||
import type { BarReading } from "./contract"
|
||||
import { useLook } from "./look"
|
||||
import { LOOK } from "./motion"
|
||||
|
||||
/** The reading itself, tweened, as a string a `motion` element can hold. */
|
||||
export function useAnimatedNumber(
|
||||
value: unknown,
|
||||
precision: number | null,
|
||||
): { label: MotionValue<string>; numeric: boolean } {
|
||||
const { look } = useLook()
|
||||
const numeric = typeof value === "number"
|
||||
const raw = useMotionValue(numeric ? value : 0)
|
||||
const settled = useSpring(raw, LOOK[look].spring)
|
||||
useEffect(() => {
|
||||
if (numeric) raw.set(value as number)
|
||||
}, [numeric, value, raw])
|
||||
return {
|
||||
label: useTransform(settled, (at) => format(at, precision)),
|
||||
numeric,
|
||||
}
|
||||
}
|
||||
|
||||
/** Where a reading sits on its scale, tweened, for a fill or an arc. */
|
||||
export function useAnimatedFraction(fraction: number): MotionValue<number> {
|
||||
const { look } = useLook()
|
||||
const raw = useMotionValue(fraction)
|
||||
useEffect(() => {
|
||||
raw.set(fraction)
|
||||
}, [fraction, raw])
|
||||
return useSpring(raw, LOOK[look].spring)
|
||||
}
|
||||
|
||||
/**
|
||||
* A palette entry as CSS.
|
||||
*
|
||||
* A dashboard names colours, and a slot of the app's own ramp is what a
|
||||
* document written before it could says instead — so one is used as it stands
|
||||
* and the other is looked up.
|
||||
*/
|
||||
const cssColor = (slot: string) =>
|
||||
CHART_SLOTS.includes(slot) ? `var(--chart-${slot})` : slot
|
||||
|
||||
/**
|
||||
* A bar's rows, ready to draw: what each reads, how far along it is, and the
|
||||
* colour that tells it from the row above.
|
||||
*
|
||||
* A row may carry its own scale and unit — a battery percentage beside a load
|
||||
* in kW is one bar, not two widgets — and falls back to the widget's own.
|
||||
*/
|
||||
export function barReadings(
|
||||
widget: WidgetDef,
|
||||
live: (LiveValue | undefined)[],
|
||||
colors: string[],
|
||||
): BarReading[] {
|
||||
const cfg = config(widget)
|
||||
const min = num(cfg.min, 0)
|
||||
const max = num(cfg.max, 100)
|
||||
const precision = cfg.precision === undefined ? 1 : num(cfg.precision, 1)
|
||||
const unit = cfg.unit ? text(cfg.unit) : ""
|
||||
|
||||
return rowsOf(widget)
|
||||
.filter((row) => row.message)
|
||||
.map((row, index) => {
|
||||
const name = row.message as string
|
||||
const value =
|
||||
typeof live[index]?.value === "number"
|
||||
? (live[index]?.value as number)
|
||||
: null
|
||||
const low = row.min === undefined ? min : num(row.min, min)
|
||||
const high = row.max === undefined ? max : num(row.max, max)
|
||||
return {
|
||||
// The panel already carries the widget's title, so a row names its
|
||||
// reading by its port rather than repeating the flow it comes from —
|
||||
// or by whatever the author called it, since a port name is chosen for
|
||||
// the graph and not for somebody reading it across a room.
|
||||
label: text(row.label) || displayName(flowOf(name), name),
|
||||
value,
|
||||
fraction: fractionOf(value, low, high),
|
||||
precision,
|
||||
unit: row.unit === undefined ? unit : text(row.unit),
|
||||
color: cssColor(colors[index % colors.length]),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Liquid glass: the controls.
|
||||
*
|
||||
* A press is answered by the light moving — a soft glow that follows the
|
||||
* pointer across the surface — and by the control giving very slightly. Every
|
||||
* one of these is a `ui/core` hook in a different coat.
|
||||
*/
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { asOriginal, text } from "../core/config"
|
||||
import type {
|
||||
ButtonProps,
|
||||
InputProps,
|
||||
SegmentedProps,
|
||||
SelectProps,
|
||||
SliderProps,
|
||||
SwitchProps,
|
||||
} from "../core/contract"
|
||||
import {
|
||||
usePress,
|
||||
useSegmented,
|
||||
useSliderDrag,
|
||||
useSwitch,
|
||||
} from "../core/controls"
|
||||
import { useLook } from "../core/look"
|
||||
import { LOOK } from "../core/motion"
|
||||
|
||||
export function Button({
|
||||
variant = "tonal",
|
||||
pressed,
|
||||
disabled,
|
||||
label,
|
||||
onClick,
|
||||
children,
|
||||
}: ButtonProps) {
|
||||
const { onPointerDown } = usePress()
|
||||
return (
|
||||
<motion.button
|
||||
type="button"
|
||||
className="gl-button gl-pressable w-full"
|
||||
data-variant={variant}
|
||||
aria-pressed={pressed}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
transition={LOOK.glass.spring}
|
||||
onPointerDown={onPointerDown}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="gl-glow" aria-hidden />
|
||||
<span className="min-w-0 truncate">{children}</span>
|
||||
</motion.button>
|
||||
)
|
||||
}
|
||||
|
||||
export function Switch(props: SwitchProps) {
|
||||
const { buttonProps } = useSwitch(props)
|
||||
const { onPointerDown } = usePress()
|
||||
return (
|
||||
<button
|
||||
{...buttonProps}
|
||||
className="gl-switch gl-pressable"
|
||||
onPointerDown={onPointerDown}
|
||||
>
|
||||
<span className="gl-glow" aria-hidden />
|
||||
<motion.span
|
||||
layout
|
||||
transition={LOOK.glass.spring}
|
||||
className="gl-switch-thumb"
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function Slider(props: SliderProps) {
|
||||
const { fraction, inputProps, marks } = useSliderDrag(props)
|
||||
const vertical = props.orientation === "vertical"
|
||||
return (
|
||||
<div
|
||||
className={cn("dui-slider gl-slider", vertical ? "h-full" : "w-full")}
|
||||
style={{ "--dui-fraction": fraction } as React.CSSProperties}
|
||||
>
|
||||
<div
|
||||
className={cn("flex min-w-0 flex-col", vertical ? "h-full" : "w-full")}
|
||||
>
|
||||
<input
|
||||
{...inputProps}
|
||||
data-orientation={props.orientation ?? "horizontal"}
|
||||
/>
|
||||
{marks.length > 0 ? (
|
||||
// Decoration: the input itself announces min, max and where it stands.
|
||||
<div aria-hidden className="gl-ticks">
|
||||
{marks.map((mark) => (
|
||||
<span
|
||||
key={mark.percent}
|
||||
className="absolute top-0"
|
||||
style={{
|
||||
left: `${mark.percent}%`,
|
||||
transform: `translateX(-${mark.percent}%)`,
|
||||
}}
|
||||
>
|
||||
{mark.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Segmented(props: SegmentedProps) {
|
||||
const { chosen, thumbId, groupProps, itemProps } = useSegmented(props)
|
||||
const vertical = props.orientation === "vertical"
|
||||
const { onPointerDown } = usePress()
|
||||
return (
|
||||
<div
|
||||
{...groupProps}
|
||||
data-testid={props.testId}
|
||||
className="gl-segmented"
|
||||
style={
|
||||
vertical
|
||||
? undefined
|
||||
: {
|
||||
gridTemplateColumns: `repeat(${props.options.length}, minmax(0, 1fr))`,
|
||||
}
|
||||
}
|
||||
>
|
||||
{props.options.map(([value, label], index) => (
|
||||
<button
|
||||
key={value}
|
||||
{...itemProps(index)}
|
||||
className="gl-segment gl-pressable"
|
||||
onPointerDown={onPointerDown}
|
||||
>
|
||||
{index === chosen ? (
|
||||
<motion.span
|
||||
aria-hidden
|
||||
layoutId={thumbId}
|
||||
transition={LOOK.glass.spring}
|
||||
className="gl-segment-thumb"
|
||||
/>
|
||||
) : null}
|
||||
<span className="gl-glow" aria-hidden />
|
||||
<span className="gl-segment-label">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Select({
|
||||
value,
|
||||
options,
|
||||
label,
|
||||
disabled,
|
||||
onChange,
|
||||
}: SelectProps) {
|
||||
// The menu is portalled to `body`, which is outside the canvas — so it
|
||||
// re-states the dashboard's own colours rather than borrowing the app's.
|
||||
const { style } = useLook()
|
||||
return (
|
||||
<SelectPrimitive.Root
|
||||
value={value}
|
||||
onValueChange={(selected) => onChange(asOriginal(selected, options))}
|
||||
>
|
||||
<SelectPrimitive.Trigger
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
className="gl-field gl-pressable justify-between"
|
||||
>
|
||||
<span className="gl-glow" aria-hidden />
|
||||
<span className="min-w-0 truncate">
|
||||
<SelectPrimitive.Value placeholder="Choose" />
|
||||
</span>
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="size-4 shrink-0 opacity-60" aria-hidden />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
position="popper"
|
||||
sideOffset={4}
|
||||
style={style}
|
||||
className="gl-menu z-50 max-h-64 min-w-[var(--radix-select-trigger-width)] overflow-y-auto p-1 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
|
||||
>
|
||||
<SelectPrimitive.Viewport>
|
||||
{options.map((option) => (
|
||||
<SelectPrimitive.Item
|
||||
key={text(option.value)}
|
||||
value={text(option.value)}
|
||||
className="flex cursor-pointer select-none items-center justify-between gap-2 rounded-xl px-3 py-2 text-sm outline-none data-[highlighted]:bg-white/15"
|
||||
>
|
||||
<SelectPrimitive.ItemText>
|
||||
{option.label ?? text(option.value)}
|
||||
</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="size-4" aria-hidden />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
))}
|
||||
</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
</SelectPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export function Input({
|
||||
value,
|
||||
type,
|
||||
label,
|
||||
disabled,
|
||||
onChange,
|
||||
onCommit,
|
||||
}: InputProps) {
|
||||
return (
|
||||
<input
|
||||
className="gl-field"
|
||||
value={value}
|
||||
type={type}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onBlur={onCommit}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") onCommit()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Liquid glass: the readings.
|
||||
*
|
||||
* Lit fills over translucent tracks, and every number written out beside the
|
||||
* picture it is drawn as — an angle or a length nobody can measure is not a
|
||||
* reading.
|
||||
*/
|
||||
import { motion, useTransform } from "motion/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { arcPath, GAUGE_START, GAUGE_SWEEP, GAUGE_TRACK } from "../core/arc"
|
||||
import { cssOf } from "../core/color"
|
||||
import { format } from "../core/config"
|
||||
import type {
|
||||
BarProps,
|
||||
ColorDiskProps,
|
||||
GaugeProps,
|
||||
ReadoutProps,
|
||||
} from "../core/contract"
|
||||
import { TESTID } from "../core/contract"
|
||||
import { useColorDisk } from "../core/disc"
|
||||
import { useAnimatedFraction, useAnimatedNumber } from "../core/values"
|
||||
import { Slider } from "./Controls"
|
||||
|
||||
export function Readout({
|
||||
value,
|
||||
precision,
|
||||
unit,
|
||||
size = "hero",
|
||||
}: ReadoutProps) {
|
||||
const { label, numeric } = useAnimatedNumber(value, precision)
|
||||
return (
|
||||
<span className="flex min-w-0 items-baseline gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate",
|
||||
size === "hero" ? "dui-hero" : "tabular-nums",
|
||||
)}
|
||||
>
|
||||
{numeric ? (
|
||||
<motion.span>{label}</motion.span>
|
||||
) : (
|
||||
format(value, precision)
|
||||
)}
|
||||
</span>
|
||||
{unit ? (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-muted-foreground",
|
||||
size === "hero" && "dui-hero-unit",
|
||||
)}
|
||||
>
|
||||
{unit}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function Gauge({ value, min, max, precision, unit, label }: GaugeProps) {
|
||||
const fraction = useAnimatedFraction(
|
||||
value === null
|
||||
? 0
|
||||
: Math.min(1, Math.max(0, (value - min) / (max - min || 1))),
|
||||
)
|
||||
const { label: reading, numeric } = useAnimatedNumber(value, precision)
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center">
|
||||
<svg
|
||||
viewBox="0 0 100 78"
|
||||
className="h-full max-h-full w-full"
|
||||
role="img"
|
||||
aria-label={`${label}: ${format(value, precision)}${unit ?? ""} of ${max}`}
|
||||
>
|
||||
<path
|
||||
d={GAUGE_TRACK}
|
||||
fill="none"
|
||||
stroke="var(--gl-track)"
|
||||
strokeWidth={9}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* The same full arc as the track, revealed rather than re-pathed: `d`
|
||||
is not animatable, so a reading that redrew the arc could only
|
||||
jump. `pathLength` normalises it, which makes the reveal the
|
||||
fraction itself. */}
|
||||
<motion.path
|
||||
d={arcPath(GAUGE_START, GAUGE_START + GAUGE_SWEEP)}
|
||||
// Glass lights what it draws: the arc carries the same glow the
|
||||
// fills and the active controls do.
|
||||
style={{
|
||||
pathLength: fraction,
|
||||
filter: "drop-shadow(0 0 4px var(--primary))",
|
||||
}}
|
||||
fill="none"
|
||||
stroke="var(--primary)"
|
||||
strokeWidth={9}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<text
|
||||
x={50}
|
||||
y={54}
|
||||
// User units of the viewBox, not the text scale: the readout has to
|
||||
// stay proportional to the dial at whatever size the tile is.
|
||||
fontSize={13}
|
||||
textAnchor="middle"
|
||||
className="fill-foreground tabular-nums"
|
||||
>
|
||||
{numeric ? (
|
||||
<motion.tspan>{reading}</motion.tspan>
|
||||
) : (
|
||||
format(value, precision)
|
||||
)}
|
||||
{unit ?? ""}
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ row }: { row: BarProps["rows"][number] }) {
|
||||
const fraction = useAnimatedFraction(row.fraction)
|
||||
const width = useTransform(fraction, (at) => `${at * 100}%`)
|
||||
return (
|
||||
<div
|
||||
data-testid={TESTID.barRow}
|
||||
role="img"
|
||||
aria-label={`${row.label} ${format(row.value, row.precision)}${row.unit ?? ""}`}
|
||||
className="dui-bar-row"
|
||||
style={{ "--dui-fill": row.color } as React.CSSProperties}
|
||||
>
|
||||
<span className="dui-bar-label">{row.label}</span>
|
||||
<span className="dui-bar-track gl-track">
|
||||
<motion.span
|
||||
data-testid={TESTID.barFill}
|
||||
className="dui-bar-fill gl-fill"
|
||||
style={{ width }}
|
||||
/>
|
||||
</span>
|
||||
{/* Beside the track rather than on it: a number written on a fill has to
|
||||
clear the fill it sits on and the track it slides onto, and one that
|
||||
reads across a room cannot do both. */}
|
||||
<span className="dui-bar-value">
|
||||
{format(row.value, row.precision)}
|
||||
{row.unit ?? ""}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Bar({ rows }: BarProps) {
|
||||
// No group role of its own: each row already announces what it reads and
|
||||
// what it is worth, and the tile's title is on the frame around them.
|
||||
return (
|
||||
<div className="dui-bar">
|
||||
{rows.map((row, index) => (
|
||||
// Two rows can read the same message under different labels; position
|
||||
// is the identity, as it is for chart series.
|
||||
<Row key={`row-${index}`} row={row} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ColorDisk({
|
||||
name,
|
||||
hsv,
|
||||
disabled,
|
||||
onChange,
|
||||
onCommit,
|
||||
}: ColorDiskProps) {
|
||||
const { discProps, thumb, shade } = useColorDisk({
|
||||
name,
|
||||
hsv,
|
||||
disabled,
|
||||
onChange,
|
||||
onCommit,
|
||||
})
|
||||
const [hue, saturation, brightness] = hsv
|
||||
return (
|
||||
<div className="grid h-full min-h-0 grid-cols-[minmax(0,1fr)_auto] items-center justify-items-center gap-3">
|
||||
<div
|
||||
{...discProps}
|
||||
data-testid={TESTID.disc}
|
||||
className="dui-disc"
|
||||
style={
|
||||
{
|
||||
"--dui-shade": shade,
|
||||
"--dui-sx": thumb.sx,
|
||||
"--dui-sy": thumb.sy,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<span className="dui-disc-shade" aria-hidden />
|
||||
<span
|
||||
aria-hidden
|
||||
data-testid={TESTID.swatch}
|
||||
className="dui-disc-thumb gl-disc-thumb"
|
||||
style={{ background: cssOf(hsv) }}
|
||||
/>
|
||||
</div>
|
||||
<Slider
|
||||
orientation="vertical"
|
||||
value={brightness}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
unit="%"
|
||||
label={`${name} brightness`}
|
||||
disabled={disabled}
|
||||
onCommit={(next) => {
|
||||
onChange([hue, saturation, next])
|
||||
onCommit()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Liquid glass: the surfaces.
|
||||
*
|
||||
* Glass needs something behind it, so this look brings its own ground: two or
|
||||
* three soft blobs of the dashboard's colours, drifting slowly enough to be
|
||||
* noticed only if you look. An image, when one is set, replaces them.
|
||||
*/
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { Lock } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ICONS } from "../../icons"
|
||||
import type {
|
||||
BackdropProps,
|
||||
FrameProps,
|
||||
NoticeProps,
|
||||
RailProps,
|
||||
} from "../core/contract"
|
||||
import { TESTID } from "../core/contract"
|
||||
import { LOOK } from "../core/motion"
|
||||
|
||||
/** Where each blob drifts to and back. Pixels on the unscaled canvas. */
|
||||
const DRIFTS = [
|
||||
{ x: [0, 80], y: [0, -60] },
|
||||
{ x: [0, -70], y: [0, 50] },
|
||||
{ x: [0, 50], y: [0, -40] },
|
||||
]
|
||||
|
||||
export function Backdrop({ image }: BackdropProps) {
|
||||
if (image) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
data-testid="canvas-ground"
|
||||
className="pointer-events-none absolute inset-0 bg-cover bg-center"
|
||||
style={{ backgroundImage: `url(${JSON.stringify(image)})` }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
data-testid="canvas-ground"
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
>
|
||||
<span className="gl-ground" />
|
||||
{DRIFTS.map((drift, index) => (
|
||||
<motion.span
|
||||
// Position is the identity: which blob this is decides its colour.
|
||||
key={`blob-${index}`}
|
||||
className="gl-blob"
|
||||
animate={drift}
|
||||
transition={{
|
||||
duration: LOOK.glass.drift,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
repeatType: "mirror",
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Issue({ issue }: { issue: string }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
role="img"
|
||||
className="block size-2 shrink-0 rounded-full bg-destructive"
|
||||
aria-label={issue}
|
||||
data-testid={TESTID.issue}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs break-words">{issue}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export function Frame({
|
||||
title,
|
||||
issue,
|
||||
grip,
|
||||
selected,
|
||||
onClick,
|
||||
children,
|
||||
}: FrameProps) {
|
||||
const { hover, spring } = LOOK.glass
|
||||
return (
|
||||
// A card is not a control: the click only picks it in edit mode, and every
|
||||
// interactive element inside keeps its own role and keyboard handling.
|
||||
<motion.div
|
||||
data-testid={TESTID.frame}
|
||||
data-selected={selected ? "" : undefined}
|
||||
className="dui-frame gl-surface gl-frame"
|
||||
whileHover={hover}
|
||||
transition={spring}
|
||||
onClick={onClick}
|
||||
>
|
||||
{title ? (
|
||||
<div
|
||||
className={cn(
|
||||
"dui-frame-head gl-frame-title",
|
||||
grip &&
|
||||
`${TESTID.grip} -m-1 cursor-grab p-1 active:cursor-grabbing`,
|
||||
)}
|
||||
>
|
||||
<span className="dui-frame-title">{title}</span>
|
||||
{issue ? <Issue issue={issue} /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="dui-frame-body">{children}</div>
|
||||
{/* No header to sit in, so the fault takes the corner instead. */}
|
||||
{!title && issue ? (
|
||||
<div className="dui-frame-corner">
|
||||
<Issue issue={issue} />
|
||||
</div>
|
||||
) : null}
|
||||
{!title && grip ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
TESTID.grip,
|
||||
"absolute left-1/2 top-1 z-[2] h-1 w-8 -translate-x-1/2 cursor-grab rounded-full bg-white/40 active:cursor-grabbing",
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Two letters off the title, so a rail of four reads as four different things. */
|
||||
function initials(label: string): string {
|
||||
const words = label.split(/[\s_-]+/).filter(Boolean)
|
||||
if (words.length === 0) return "?"
|
||||
if (words.length === 1) return words[0].slice(0, 2).toUpperCase()
|
||||
return (words[0][0] + words[1][0]).toUpperCase()
|
||||
}
|
||||
|
||||
export function Rail({ entries }: RailProps) {
|
||||
return (
|
||||
<nav
|
||||
aria-label="Dashboards on this panel"
|
||||
data-testid={TESTID.rail}
|
||||
className={cn(
|
||||
"gl-surface gl-rail pointer-events-auto absolute inset-y-4 left-4 z-10 flex w-14 flex-col items-center gap-1 p-2",
|
||||
"overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
)}
|
||||
>
|
||||
{entries.map((entry) => {
|
||||
const Glyph = ICONS[entry.icon ?? ""]
|
||||
return (
|
||||
<Tooltip key={entry.name}>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
{...entry.link}
|
||||
aria-label={entry.label}
|
||||
aria-current={entry.active ? "page" : undefined}
|
||||
data-testid={`panel-rail-${entry.name}`}
|
||||
className={cn(
|
||||
"gl-pressable relative flex size-10 shrink-0 items-center justify-center rounded-full text-xs font-medium",
|
||||
entry.active ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="gl-glow" aria-hidden />
|
||||
{entry.active ? (
|
||||
<motion.span
|
||||
aria-hidden
|
||||
layoutId="gl-rail-active"
|
||||
transition={LOOK.glass.spring}
|
||||
className="absolute inset-0 rounded-full border border-white/30 bg-white/20"
|
||||
/>
|
||||
) : null}
|
||||
<span className="relative z-[1]">
|
||||
{Glyph ? <Glyph className="size-5" /> : initials(entry.label)}
|
||||
</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{entry.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
export function Notice({ children }: NoticeProps) {
|
||||
return (
|
||||
<div
|
||||
// Announced rather than merely drawn: `locked` may be driven by a flow,
|
||||
// so the state can change under someone already looking at the page.
|
||||
aria-live="polite"
|
||||
data-testid={TESTID.locked}
|
||||
className="gl-surface gl-notice pointer-events-none absolute bottom-0 right-0 flex items-center gap-1.5 px-3 py-1.5 text-sm"
|
||||
>
|
||||
<Lock className="size-4" aria-hidden />
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
/*
|
||||
* Liquid glass.
|
||||
*
|
||||
* Every surface is the same recipe: a thin wash of white, the ground behind it
|
||||
* blurred, a hairline that catches the light, and a highlight falling from the
|
||||
* top edge. It only reads as glass when there is something behind it to
|
||||
* refract, which is why this look brings its own ground — two soft drifting
|
||||
* blobs of the dashboard's own colours, or the image it was given.
|
||||
*
|
||||
* Dark is the natural direction for it. In light the same wash would be white
|
||||
* on white, so the mixture goes the other way: more opaque, brighter edges,
|
||||
* and a shadow soft enough not to read as a border.
|
||||
*
|
||||
* Paint only; the measurements are in `../core/core.css`.
|
||||
*/
|
||||
|
||||
[data-look="glass"] {
|
||||
--gl-bg: rgb(255 255 255 / 0.1);
|
||||
--gl-bg-strong: rgb(255 255 255 / 0.18);
|
||||
--gl-border: rgb(255 255 255 / 0.2);
|
||||
--gl-highlight: rgb(255 255 255 / 0.22);
|
||||
--gl-inset: inset 0 1px 1px rgb(255 255 255 / 0.1);
|
||||
--gl-shadow: 0 8px 32px rgb(0 0 0 / 0.37);
|
||||
--gl-blur: 24px;
|
||||
--gl-glow: 0 0 22px color-mix(in srgb, var(--primary) 45%, transparent);
|
||||
--gl-radius: 1.25rem;
|
||||
--gl-track: rgb(255 255 255 / 0.16);
|
||||
--gl-blob-opacity: 0.5;
|
||||
}
|
||||
|
||||
[data-look="glass"].light {
|
||||
--gl-bg: rgb(255 255 255 / 0.5);
|
||||
--gl-bg-strong: rgb(255 255 255 / 0.7);
|
||||
--gl-border: rgb(255 255 255 / 0.75);
|
||||
--gl-highlight: rgb(255 255 255 / 0.6);
|
||||
--gl-shadow: 0 8px 32px rgb(0 0 0 / 0.12);
|
||||
--gl-track: rgb(0 0 0 / 0.1);
|
||||
/* Diluted by the white it mixes into, so it takes more of it to show. */
|
||||
--gl-blob-opacity: 0.6;
|
||||
}
|
||||
|
||||
/* --- the ground ---------------------------------------------------------- */
|
||||
|
||||
/*
|
||||
* Glass has nothing to be glass against on a flat white page: a translucent
|
||||
* white tile over a white ground is a shadow and nothing else. So the look
|
||||
* brings a ground of its own — a wash of the dashboard's primary, deepest at
|
||||
* the top where a room's light would come from — and the tiles then sit
|
||||
* *brighter* than what is behind them, which is what reads as a pane.
|
||||
*/
|
||||
.gl-ground {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(
|
||||
120% 90% at 50% 0%,
|
||||
color-mix(in srgb, var(--primary) 14%, var(--background)),
|
||||
var(--background) 70%
|
||||
);
|
||||
}
|
||||
|
||||
.gl-blob {
|
||||
position: absolute;
|
||||
width: 55%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
/* A gradient that falls off rather than a blurred circle: `filter: blur()`
|
||||
on something this size costs a full-screen pass every frame, and a panel
|
||||
on the wall is often the slowest machine in the house. */
|
||||
background: radial-gradient(
|
||||
circle closest-side,
|
||||
var(--gl-blob, var(--primary)),
|
||||
transparent 100%
|
||||
);
|
||||
opacity: var(--gl-blob-opacity, 0.55);
|
||||
}
|
||||
|
||||
.gl-blob:nth-child(1) {
|
||||
left: -15%;
|
||||
top: -25%;
|
||||
--gl-blob: var(--primary);
|
||||
}
|
||||
|
||||
.gl-blob:nth-child(2) {
|
||||
right: -20%;
|
||||
top: 5%;
|
||||
--gl-blob: var(--accent);
|
||||
}
|
||||
|
||||
.gl-blob:nth-child(3) {
|
||||
left: 25%;
|
||||
bottom: -35%;
|
||||
opacity: calc(var(--gl-blob-opacity, 0.55) * 0.65);
|
||||
--gl-blob: var(--primary);
|
||||
}
|
||||
|
||||
/* --- surfaces ------------------------------------------------------------ */
|
||||
|
||||
.gl-surface {
|
||||
position: relative;
|
||||
border: 1px solid var(--gl-border);
|
||||
border-radius: var(--gl-radius);
|
||||
background: var(--gl-bg);
|
||||
box-shadow: var(--gl-shadow), var(--gl-inset);
|
||||
backdrop-filter: blur(var(--gl-blur));
|
||||
-webkit-backdrop-filter: blur(var(--gl-blur));
|
||||
}
|
||||
|
||||
/*
|
||||
* The light on the pane: a bright line along the top edge where it catches,
|
||||
* and a short wash falling from it. Carrying the wash the whole way down reads
|
||||
* as a gradient fill rather than as glass — the edge is what does the work.
|
||||
*/
|
||||
.gl-surface::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(to bottom, var(--gl-highlight), transparent 30%);
|
||||
opacity: 0.55;
|
||||
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.28);
|
||||
}
|
||||
|
||||
.gl-frame > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.gl-frame[data-selected] {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.gl-frame-title {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.gl-rail {
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.gl-notice {
|
||||
border-radius: 9999px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* --- the glow a press leaves --------------------------------------------- */
|
||||
|
||||
.gl-pressable {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.gl-pressable > .gl-glow {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
background: radial-gradient(
|
||||
circle at var(--press-x, 50%) var(--press-y, 50%),
|
||||
rgb(255 255 255 / 0.35),
|
||||
transparent 60%
|
||||
);
|
||||
transition: opacity var(--duration-base) var(--ease-standard);
|
||||
}
|
||||
|
||||
.gl-pressable:hover > .gl-glow,
|
||||
.gl-pressable:focus-visible > .gl-glow {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.gl-pressable:active > .gl-glow {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-touch] .gl-pressable:hover > .gl-glow {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.gl-pressable > :not(.gl-glow) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* --- controls ------------------------------------------------------------ */
|
||||
|
||||
.gl-button {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
min-height: var(--dui-control);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid var(--gl-border);
|
||||
border-radius: 9999px;
|
||||
padding-inline: 1.25rem;
|
||||
font-size: var(--dui-text);
|
||||
font-weight: 500;
|
||||
background: var(--gl-bg);
|
||||
color: var(--foreground);
|
||||
box-shadow: var(--gl-inset);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.gl-button[data-variant="filled"],
|
||||
.gl-button[aria-pressed="true"] {
|
||||
background: color-mix(in srgb, var(--primary) 78%, transparent);
|
||||
border-color: color-mix(in srgb, var(--primary) 55%, white);
|
||||
color: var(--primary-foreground);
|
||||
box-shadow: var(--gl-glow), var(--gl-inset);
|
||||
}
|
||||
|
||||
.gl-button[data-variant="text"] {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.gl-button:disabled {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gl-switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
width: calc(var(--dui-control) * 1.75);
|
||||
height: var(--dui-control);
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
border: 1px solid var(--gl-border);
|
||||
border-radius: 9999px;
|
||||
padding: 0.1875rem;
|
||||
background: var(--gl-track);
|
||||
box-shadow: var(--gl-inset);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.gl-switch[data-state="on"] {
|
||||
justify-content: flex-end;
|
||||
background: color-mix(in srgb, var(--primary) 70%, transparent);
|
||||
box-shadow: var(--gl-glow), var(--gl-inset);
|
||||
}
|
||||
|
||||
.gl-switch-thumb {
|
||||
display: block;
|
||||
height: calc(var(--dui-control) - 0.5rem);
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 6px rgb(0 0 0 / 0.35);
|
||||
}
|
||||
|
||||
.gl-switch:disabled {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gl-slider {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gl-slider input {
|
||||
width: 100%;
|
||||
height: var(--dui-control);
|
||||
margin: 0;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.gl-slider input::-webkit-slider-runnable-track {
|
||||
height: 0.5rem;
|
||||
border-radius: 9999px;
|
||||
box-shadow: var(--gl-inset);
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
color-mix(in srgb, var(--primary) 85%, white)
|
||||
calc(var(--dui-fraction, 0) * 100%),
|
||||
var(--gl-track) calc(var(--dui-fraction, 0) * 100%)
|
||||
);
|
||||
}
|
||||
|
||||
.gl-slider input[data-orientation="vertical"]::-webkit-slider-runnable-track {
|
||||
width: 0.5rem;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
color-mix(in srgb, var(--primary) 85%, white)
|
||||
calc(var(--dui-fraction, 0) * 100%),
|
||||
var(--gl-track) calc(var(--dui-fraction, 0) * 100%)
|
||||
);
|
||||
}
|
||||
|
||||
.gl-slider input::-moz-range-track {
|
||||
height: 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--gl-track);
|
||||
}
|
||||
|
||||
.gl-slider input::-moz-range-progress {
|
||||
height: 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background: color-mix(in srgb, var(--primary) 85%, white);
|
||||
}
|
||||
|
||||
.gl-slider input::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: var(--dui-thumb);
|
||||
height: var(--dui-thumb);
|
||||
margin-top: calc((0.5rem - var(--dui-thumb)) / 2);
|
||||
border: 1px solid var(--gl-border);
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 0.35);
|
||||
}
|
||||
|
||||
.gl-slider input::-moz-range-thumb {
|
||||
width: var(--dui-thumb);
|
||||
height: var(--dui-thumb);
|
||||
border: 1px solid var(--gl-border);
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.gl-slider input:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.gl-ticks {
|
||||
position: relative;
|
||||
height: 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted-foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.gl-segmented {
|
||||
position: relative;
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
border: 1px solid var(--gl-border);
|
||||
border-radius: 9999px;
|
||||
padding: 0.25rem;
|
||||
background: var(--gl-bg);
|
||||
box-shadow: var(--gl-inset);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.gl-segmented[data-orientation="vertical"] {
|
||||
height: 100%;
|
||||
grid-auto-flow: row;
|
||||
grid-auto-rows: minmax(0, 1fr);
|
||||
border-radius: 1.25rem;
|
||||
}
|
||||
|
||||
.gl-segment {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: calc(var(--dui-control) - 0.5rem);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
padding-inline: 0.625rem;
|
||||
font-size: var(--dui-text);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.gl-segment[data-active] {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.gl-segment-thumb {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
border-radius: 9999px;
|
||||
background: var(--gl-bg-strong);
|
||||
box-shadow: var(--gl-inset);
|
||||
}
|
||||
|
||||
.gl-segment-label {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gl-field {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: var(--dui-control);
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid var(--gl-border);
|
||||
border-radius: 0.875rem;
|
||||
background: var(--gl-bg);
|
||||
padding-inline: 0.875rem;
|
||||
font-size: var(--dui-text);
|
||||
color: var(--foreground);
|
||||
text-align: left;
|
||||
box-shadow: var(--gl-inset);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.gl-field::placeholder {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.gl-field:disabled {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gl-menu {
|
||||
border: 1px solid var(--gl-border);
|
||||
border-radius: 0.875rem;
|
||||
background: color-mix(in srgb, var(--popover) 75%, transparent);
|
||||
color: var(--popover-foreground);
|
||||
box-shadow: var(--gl-shadow), var(--gl-inset);
|
||||
backdrop-filter: blur(var(--gl-blur));
|
||||
-webkit-backdrop-filter: blur(var(--gl-blur));
|
||||
}
|
||||
|
||||
/* --- readings ------------------------------------------------------------ */
|
||||
|
||||
.gl-track {
|
||||
border-radius: 9999px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--dui-fill, var(--primary)) 22%,
|
||||
transparent
|
||||
);
|
||||
box-shadow: var(--gl-inset);
|
||||
}
|
||||
|
||||
.gl-fill {
|
||||
border-radius: 9999px;
|
||||
background: var(--dui-fill, var(--primary));
|
||||
box-shadow: 0 0 14px
|
||||
color-mix(in srgb, var(--dui-fill, var(--primary)) 55%, transparent);
|
||||
}
|
||||
|
||||
.gl-disc-thumb {
|
||||
border: 2px solid rgb(255 255 255 / 0.85);
|
||||
box-shadow: 0 2px 10px rgb(0 0 0 / 0.4);
|
||||
}
|
||||
|
||||
/* --- focus --------------------------------------------------------------- */
|
||||
|
||||
[data-look="glass"]
|
||||
:is(.gl-button, .gl-switch, .gl-segment, .gl-field, .dui-disc):focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Liquid glass — one of the dashboard's two looks.
|
||||
*
|
||||
* Every entry of `ComponentSet` is implemented here; the behaviour comes from
|
||||
* `ui/core`, so this set cannot do anything the other one cannot.
|
||||
*/
|
||||
import "./glass.css"
|
||||
|
||||
import type { ComponentSet } from "../core/contract"
|
||||
import { Button, Input, Segmented, Select, Slider, Switch } from "./Controls"
|
||||
import { Bar, ColorDisk, Gauge, Readout } from "./Data"
|
||||
import { Backdrop, Frame, Notice, Rail } from "./Surfaces"
|
||||
|
||||
export const glass: ComponentSet = {
|
||||
Backdrop,
|
||||
Frame,
|
||||
Button,
|
||||
Switch,
|
||||
Slider,
|
||||
Segmented,
|
||||
Select,
|
||||
Input,
|
||||
Readout,
|
||||
Gauge,
|
||||
Bar,
|
||||
ColorDisk,
|
||||
Rail,
|
||||
Notice,
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* The dashboard's components, in whichever look this dashboard wears.
|
||||
*
|
||||
* A widget asks for the set and draws with it; it never learns which one it
|
||||
* got, which is what keeps the two looks the same dashboard. Behaviour lives
|
||||
* under `core/` and is shared, so there is nothing for a set to disagree
|
||||
* about beyond markup and motion.
|
||||
*/
|
||||
import "./core/core.css"
|
||||
|
||||
import type { Look } from "../settings"
|
||||
import type { ComponentSet } from "./core/contract"
|
||||
import { useLook } from "./core/look"
|
||||
import { glass } from "./glass"
|
||||
import { material } from "./material"
|
||||
|
||||
const SETS: Record<Look, ComponentSet> = { glass, material }
|
||||
|
||||
/** The components this dashboard is drawn with. */
|
||||
export const useUi = (): ComponentSet => SETS[useLook().look]
|
||||
|
||||
export type { ComponentSet } from "./core/contract"
|
||||
export { TESTID } from "./core/contract"
|
||||
export { LookProvider, useCanvasRoot, useLook } from "./core/look"
|
||||
export { gridStagger, LOOK } from "./core/motion"
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Material 3: the controls.
|
||||
*
|
||||
* A press is answered by a state layer and a ripple from where it landed;
|
||||
* nothing lifts or scales. Every one of these is a `ui/core` hook in a
|
||||
* different coat — the state, the keyboard and the `aria-` come from there.
|
||||
*/
|
||||
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown } from "lucide-react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { asOriginal, text } from "../core/config"
|
||||
import type {
|
||||
ButtonProps,
|
||||
InputProps,
|
||||
SegmentedProps,
|
||||
SelectProps,
|
||||
SliderProps,
|
||||
SwitchProps,
|
||||
} from "../core/contract"
|
||||
import {
|
||||
type Press,
|
||||
usePress,
|
||||
useSegmented,
|
||||
useSliderDrag,
|
||||
useSwitch,
|
||||
} from "../core/controls"
|
||||
import { useLook } from "../core/look"
|
||||
import { LOOK } from "../core/motion"
|
||||
|
||||
/** The state layer plus whatever presses are still fading. */
|
||||
function Skin({
|
||||
presses,
|
||||
done,
|
||||
}: {
|
||||
presses: Press[]
|
||||
done: (id: number) => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<span className="m3-state" aria-hidden />
|
||||
<AnimatePresence>
|
||||
{presses.map((press) => (
|
||||
<motion.span
|
||||
key={press.id}
|
||||
aria-hidden
|
||||
className="m3-ripple"
|
||||
style={{ left: `${press.x}%`, top: `${press.y}%` }}
|
||||
initial={{ scale: 0, opacity: 0.3 }}
|
||||
animate={{ scale: 1, opacity: 0 }}
|
||||
transition={{ duration: 0.45, ease: [0.4, 0, 0.2, 1] }}
|
||||
onAnimationComplete={() => done(press.id)}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = "tonal",
|
||||
pressed,
|
||||
disabled,
|
||||
label,
|
||||
onClick,
|
||||
children,
|
||||
}: ButtonProps) {
|
||||
const { presses, onPointerDown, done } = usePress()
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="m3-button m3-pressable w-full"
|
||||
data-variant={variant}
|
||||
aria-pressed={pressed}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onPointerDown={onPointerDown}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Skin presses={presses} done={done} />
|
||||
<span className="min-w-0 truncate">{children}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function Switch(props: SwitchProps) {
|
||||
const { buttonProps } = useSwitch(props)
|
||||
const { presses, onPointerDown, done } = usePress()
|
||||
return (
|
||||
<button
|
||||
{...buttonProps}
|
||||
className="m3-switch m3-pressable"
|
||||
onPointerDown={onPointerDown}
|
||||
>
|
||||
<Skin presses={presses} done={done} />
|
||||
<motion.span
|
||||
layout
|
||||
transition={LOOK.material.spring}
|
||||
className="m3-switch-thumb"
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function Slider(props: SliderProps) {
|
||||
const { fraction, inputProps, marks } = useSliderDrag(props)
|
||||
const vertical = props.orientation === "vertical"
|
||||
return (
|
||||
<div
|
||||
className={cn("dui-slider m3-slider", vertical ? "h-full" : "w-full")}
|
||||
style={{ "--dui-fraction": fraction } as React.CSSProperties}
|
||||
>
|
||||
<div
|
||||
className={cn("flex min-w-0 flex-col", vertical ? "h-full" : "w-full")}
|
||||
>
|
||||
<input
|
||||
{...inputProps}
|
||||
data-orientation={props.orientation ?? "horizontal"}
|
||||
/>
|
||||
{marks.length > 0 ? (
|
||||
// Decoration: the input itself announces min, max and where it stands.
|
||||
<div aria-hidden className="m3-ticks">
|
||||
{marks.map((mark) => (
|
||||
<span
|
||||
key={mark.percent}
|
||||
className="absolute top-0"
|
||||
// Shifted by its own share of itself: the first label sits
|
||||
// flush left and the last flush right, so neither hangs off.
|
||||
style={{
|
||||
left: `${mark.percent}%`,
|
||||
transform: `translateX(-${mark.percent}%)`,
|
||||
}}
|
||||
>
|
||||
{mark.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Segmented(props: SegmentedProps) {
|
||||
const { chosen, thumbId, groupProps, itemProps } = useSegmented(props)
|
||||
const vertical = props.orientation === "vertical"
|
||||
return (
|
||||
<div
|
||||
{...groupProps}
|
||||
data-testid={props.testId}
|
||||
className="m3-segmented"
|
||||
style={
|
||||
vertical
|
||||
? undefined
|
||||
: {
|
||||
gridTemplateColumns: `repeat(${props.options.length}, minmax(0, 1fr))`,
|
||||
}
|
||||
}
|
||||
>
|
||||
{props.options.map(([value, label], index) => (
|
||||
<button
|
||||
key={value}
|
||||
{...itemProps(index)}
|
||||
className="m3-segment m3-pressable"
|
||||
>
|
||||
{index === chosen ? (
|
||||
<motion.span
|
||||
aria-hidden
|
||||
layoutId={thumbId}
|
||||
transition={LOOK.material.spring}
|
||||
className="m3-segment-thumb"
|
||||
/>
|
||||
) : null}
|
||||
<span className="m3-state" aria-hidden />
|
||||
<span className="m3-segment-label">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Select({
|
||||
value,
|
||||
options,
|
||||
label,
|
||||
disabled,
|
||||
onChange,
|
||||
}: SelectProps) {
|
||||
// The menu is portalled to `body`, which is outside the canvas — so it
|
||||
// re-states the dashboard's own colours rather than borrowing the app's.
|
||||
const { style } = useLook()
|
||||
return (
|
||||
<SelectPrimitive.Root
|
||||
value={value}
|
||||
onValueChange={(selected) => onChange(asOriginal(selected, options))}
|
||||
>
|
||||
<SelectPrimitive.Trigger
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
className="m3-field m3-pressable justify-between"
|
||||
>
|
||||
<span className="min-w-0 truncate">
|
||||
<SelectPrimitive.Value placeholder="Choose" />
|
||||
</span>
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="size-4 shrink-0 opacity-60" aria-hidden />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
position="popper"
|
||||
sideOffset={4}
|
||||
style={style}
|
||||
className="m3-menu z-50 max-h-64 min-w-[var(--radix-select-trigger-width)] overflow-y-auto p-1 data-[state=open]:animate-in data-[state=open]:zoom-in-95 data-[state=open]:fade-in-0"
|
||||
>
|
||||
<SelectPrimitive.Viewport>
|
||||
{options.map((option) => (
|
||||
<SelectPrimitive.Item
|
||||
key={text(option.value)}
|
||||
value={text(option.value)}
|
||||
className="m3-pressable flex cursor-pointer select-none items-center justify-between gap-2 rounded-lg px-3 py-2 text-sm outline-none"
|
||||
>
|
||||
<span className="m3-state" aria-hidden />
|
||||
<SelectPrimitive.ItemText>
|
||||
{option.label ?? text(option.value)}
|
||||
</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="size-4" aria-hidden />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
))}
|
||||
</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
</SelectPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export function Input({
|
||||
value,
|
||||
type,
|
||||
label,
|
||||
disabled,
|
||||
onChange,
|
||||
onCommit,
|
||||
}: InputProps) {
|
||||
return (
|
||||
<input
|
||||
className="m3-field"
|
||||
value={value}
|
||||
type={type}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onBlur={onCommit}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") onCommit()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Material 3: the readings.
|
||||
*
|
||||
* Flat fills on tonal tracks, and every number written out beside the picture
|
||||
* it is drawn as — an angle or a length nobody can measure is not a reading.
|
||||
*/
|
||||
import { motion, useTransform } from "motion/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { arcPath, GAUGE_START, GAUGE_SWEEP, GAUGE_TRACK } from "../core/arc"
|
||||
import { cssOf } from "../core/color"
|
||||
import { format } from "../core/config"
|
||||
import type {
|
||||
BarProps,
|
||||
ColorDiskProps,
|
||||
GaugeProps,
|
||||
ReadoutProps,
|
||||
} from "../core/contract"
|
||||
import { TESTID } from "../core/contract"
|
||||
import { useColorDisk } from "../core/disc"
|
||||
import { useAnimatedFraction, useAnimatedNumber } from "../core/values"
|
||||
import { Slider } from "./Controls"
|
||||
|
||||
export function Readout({
|
||||
value,
|
||||
precision,
|
||||
unit,
|
||||
size = "hero",
|
||||
}: ReadoutProps) {
|
||||
const { label, numeric } = useAnimatedNumber(value, precision)
|
||||
return (
|
||||
<span className="flex min-w-0 items-baseline gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate",
|
||||
size === "hero" ? "dui-hero" : "tabular-nums",
|
||||
)}
|
||||
>
|
||||
{numeric ? (
|
||||
<motion.span>{label}</motion.span>
|
||||
) : (
|
||||
format(value, precision)
|
||||
)}
|
||||
</span>
|
||||
{unit ? (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-muted-foreground",
|
||||
size === "hero" && "dui-hero-unit",
|
||||
)}
|
||||
>
|
||||
{unit}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function Gauge({ value, min, max, precision, unit, label }: GaugeProps) {
|
||||
const fraction = useAnimatedFraction(
|
||||
value === null
|
||||
? 0
|
||||
: Math.min(1, Math.max(0, (value - min) / (max - min || 1))),
|
||||
)
|
||||
const { label: reading, numeric } = useAnimatedNumber(value, precision)
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center">
|
||||
<svg
|
||||
viewBox="0 0 100 78"
|
||||
className="h-full max-h-full w-full"
|
||||
role="img"
|
||||
aria-label={`${label}: ${format(value, precision)}${unit ?? ""} of ${max}`}
|
||||
>
|
||||
<path
|
||||
d={GAUGE_TRACK}
|
||||
fill="none"
|
||||
stroke="var(--m3-sc)"
|
||||
strokeWidth={9}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* The same full arc as the track, revealed rather than re-pathed: `d`
|
||||
is not animatable, so a reading that redrew the arc could only
|
||||
jump. `pathLength` normalises it, which makes the reveal the
|
||||
fraction itself. */}
|
||||
<motion.path
|
||||
d={arcPath(GAUGE_START, GAUGE_START + GAUGE_SWEEP)}
|
||||
style={{ pathLength: fraction }}
|
||||
fill="none"
|
||||
stroke="var(--primary)"
|
||||
strokeWidth={9}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<text
|
||||
x={50}
|
||||
y={54}
|
||||
// User units of the viewBox, not the text scale: the readout has to
|
||||
// stay proportional to the dial at whatever size the tile is.
|
||||
fontSize={13}
|
||||
textAnchor="middle"
|
||||
className="fill-foreground tabular-nums"
|
||||
>
|
||||
{numeric ? (
|
||||
<motion.tspan>{reading}</motion.tspan>
|
||||
) : (
|
||||
format(value, precision)
|
||||
)}
|
||||
{unit ?? ""}
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ row }: { row: BarProps["rows"][number] }) {
|
||||
const fraction = useAnimatedFraction(row.fraction)
|
||||
const width = useTransform(fraction, (at) => `${at * 100}%`)
|
||||
return (
|
||||
<div
|
||||
data-testid={TESTID.barRow}
|
||||
role="img"
|
||||
aria-label={`${row.label} ${format(row.value, row.precision)}${row.unit ?? ""}`}
|
||||
className="dui-bar-row"
|
||||
style={{ "--dui-fill": row.color } as React.CSSProperties}
|
||||
>
|
||||
<span className="dui-bar-label">{row.label}</span>
|
||||
<span className="dui-bar-track m3-track">
|
||||
<motion.span
|
||||
data-testid={TESTID.barFill}
|
||||
className="dui-bar-fill m3-fill"
|
||||
style={{ width }}
|
||||
/>
|
||||
</span>
|
||||
{/* Beside the track rather than on it: a number written on a fill has to
|
||||
clear the fill it sits on and the track it slides onto, and one that
|
||||
reads across a room cannot do both. */}
|
||||
<span className="dui-bar-value">
|
||||
{format(row.value, row.precision)}
|
||||
{row.unit ?? ""}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Bar({ rows }: BarProps) {
|
||||
// No group role of its own: each row already announces what it reads and
|
||||
// what it is worth, and the tile's title is on the frame around them.
|
||||
return (
|
||||
<div className="dui-bar">
|
||||
{rows.map((row, index) => (
|
||||
// Two rows can read the same message under different labels; position
|
||||
// is the identity, as it is for chart series.
|
||||
<Row key={`row-${index}`} row={row} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ColorDisk({
|
||||
name,
|
||||
hsv,
|
||||
disabled,
|
||||
onChange,
|
||||
onCommit,
|
||||
}: ColorDiskProps) {
|
||||
const { discProps, thumb, shade } = useColorDisk({
|
||||
name,
|
||||
hsv,
|
||||
disabled,
|
||||
onChange,
|
||||
onCommit,
|
||||
})
|
||||
const [hue, saturation, brightness] = hsv
|
||||
return (
|
||||
<div className="grid h-full min-h-0 grid-cols-[minmax(0,1fr)_auto] items-center justify-items-center gap-3">
|
||||
<div
|
||||
{...discProps}
|
||||
data-testid={TESTID.disc}
|
||||
className="dui-disc"
|
||||
style={
|
||||
{
|
||||
"--dui-shade": shade,
|
||||
"--dui-sx": thumb.sx,
|
||||
"--dui-sy": thumb.sy,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<span className="dui-disc-shade" aria-hidden />
|
||||
<span
|
||||
aria-hidden
|
||||
data-testid={TESTID.swatch}
|
||||
className="dui-disc-thumb m3-disc-thumb"
|
||||
style={{ background: cssOf(hsv) }}
|
||||
/>
|
||||
</div>
|
||||
<Slider
|
||||
orientation="vertical"
|
||||
value={brightness}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
unit="%"
|
||||
label={`${name} brightness`}
|
||||
disabled={disabled}
|
||||
onCommit={(next) => {
|
||||
onChange([hue, saturation, next])
|
||||
onCommit()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Material 3: the surfaces.
|
||||
*
|
||||
* Tonal rather than outlined — a widget is told from the ground by being a
|
||||
* step further toward the primary, which is what lets a palette recolour the
|
||||
* whole look. Nothing here holds state: the behaviour is in `ui/core`.
|
||||
*/
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { Lock } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ICONS } from "../../icons"
|
||||
import type {
|
||||
BackdropProps,
|
||||
FrameProps,
|
||||
NoticeProps,
|
||||
RailProps,
|
||||
} from "../core/contract"
|
||||
import { TESTID } from "../core/contract"
|
||||
import { LOOK } from "../core/motion"
|
||||
|
||||
/** Material's ground is the surface colour itself; only an image is drawn. */
|
||||
export function Backdrop({ image }: BackdropProps) {
|
||||
if (!image) return null
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
data-testid="canvas-ground"
|
||||
className="pointer-events-none absolute inset-0 bg-cover bg-center"
|
||||
style={{ backgroundImage: `url(${JSON.stringify(image)})` }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Issue({ issue }: { issue: string }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
role="img"
|
||||
className="block size-2 shrink-0 rounded-full bg-destructive"
|
||||
aria-label={issue}
|
||||
data-testid={TESTID.issue}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs break-words">{issue}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export function Frame({
|
||||
title,
|
||||
issue,
|
||||
grip,
|
||||
selected,
|
||||
onClick,
|
||||
children,
|
||||
}: FrameProps) {
|
||||
return (
|
||||
// A card is not a control: the click only picks it in edit mode, and every
|
||||
// interactive element inside keeps its own role and keyboard handling.
|
||||
// biome-ignore lint/a11y/useKeyWithClickEvents: see above.
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: see above.
|
||||
<div
|
||||
data-testid={TESTID.frame}
|
||||
data-selected={selected ? "" : undefined}
|
||||
className="dui-frame m3-frame"
|
||||
onClick={onClick}
|
||||
>
|
||||
{title ? (
|
||||
<div
|
||||
className={cn(
|
||||
"dui-frame-head m3-frame-title",
|
||||
grip &&
|
||||
`${TESTID.grip} -m-1 cursor-grab p-1 active:cursor-grabbing`,
|
||||
)}
|
||||
>
|
||||
<span className="dui-frame-title">{title}</span>
|
||||
{issue ? <Issue issue={issue} /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="dui-frame-body">{children}</div>
|
||||
{/* No header to sit in, so the fault takes the corner instead — a widget
|
||||
drawn without its title still says when it is mis-wired. */}
|
||||
{!title && issue ? (
|
||||
<div className="dui-frame-corner">
|
||||
<Issue issue={issue} />
|
||||
</div>
|
||||
) : null}
|
||||
{/* And the editor still needs something to drag it by. */}
|
||||
{!title && grip ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
TESTID.grip,
|
||||
"absolute left-1/2 top-1 z-[2] h-1 w-8 -translate-x-1/2 cursor-grab rounded-full bg-current opacity-20 active:cursor-grabbing",
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Two letters off the title, so a rail of four reads as four different things. */
|
||||
function initials(label: string): string {
|
||||
const words = label.split(/[\s_-]+/).filter(Boolean)
|
||||
if (words.length === 0) return "?"
|
||||
if (words.length === 1) return words[0].slice(0, 2).toUpperCase()
|
||||
return (words[0][0] + words[1][0]).toUpperCase()
|
||||
}
|
||||
|
||||
export function Rail({ entries }: RailProps) {
|
||||
return (
|
||||
<nav
|
||||
aria-label="Dashboards on this panel"
|
||||
data-testid={TESTID.rail}
|
||||
className={cn(
|
||||
"m3-rail pointer-events-auto absolute inset-y-4 left-4 z-10 flex w-14 flex-col items-center gap-1 p-2",
|
||||
// More dashboards than the column is tall still scroll, but no bar is
|
||||
// ever drawn: a wall panel is swiped, and there is no room for one.
|
||||
"overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
)}
|
||||
>
|
||||
{entries.map((entry) => {
|
||||
const Glyph = ICONS[entry.icon ?? ""]
|
||||
return (
|
||||
<Tooltip key={entry.name}>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
{...entry.link}
|
||||
aria-label={entry.label}
|
||||
aria-current={entry.active ? "page" : undefined}
|
||||
data-testid={`panel-rail-${entry.name}`}
|
||||
className={cn(
|
||||
"m3-pressable relative flex size-10 shrink-0 items-center justify-center rounded-full text-xs font-medium",
|
||||
entry.active
|
||||
? "text-card-foreground"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="m3-state" aria-hidden />
|
||||
{entry.active ? (
|
||||
<motion.span
|
||||
aria-hidden
|
||||
layoutId="m3-rail-active"
|
||||
transition={LOOK.material.spring}
|
||||
className="absolute inset-0 rounded-full"
|
||||
style={{ background: "var(--m3-sc)" }}
|
||||
/>
|
||||
) : null}
|
||||
<span className="relative z-[1]">
|
||||
{Glyph ? <Glyph className="size-5" /> : initials(entry.label)}
|
||||
</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{entry.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
export function Notice({ children }: NoticeProps) {
|
||||
return (
|
||||
<div
|
||||
// Announced rather than merely drawn: `locked` may be driven by a flow,
|
||||
// so the state can change under someone already looking at the page.
|
||||
aria-live="polite"
|
||||
data-testid={TESTID.locked}
|
||||
className="m3-notice pointer-events-none absolute bottom-0 right-0 flex items-center gap-1.5 px-3 py-1.5 text-sm"
|
||||
>
|
||||
<Lock className="size-4" aria-hidden />
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Material 3, tonal — one of the dashboard's two looks.
|
||||
*
|
||||
* Every entry of `ComponentSet` is implemented here; the behaviour comes from
|
||||
* `ui/core`, so this set cannot do anything the other one cannot.
|
||||
*/
|
||||
import "./material.css"
|
||||
|
||||
import type { ComponentSet } from "../core/contract"
|
||||
import { Button, Input, Segmented, Select, Slider, Switch } from "./Controls"
|
||||
import { Bar, ColorDisk, Gauge, Readout } from "./Data"
|
||||
import { Backdrop, Frame, Notice, Rail } from "./Surfaces"
|
||||
|
||||
export const material: ComponentSet = {
|
||||
Backdrop,
|
||||
Frame,
|
||||
Button,
|
||||
Switch,
|
||||
Slider,
|
||||
Segmented,
|
||||
Select,
|
||||
Input,
|
||||
Readout,
|
||||
Gauge,
|
||||
Bar,
|
||||
ColorDisk,
|
||||
Rail,
|
||||
Notice,
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
/*
|
||||
* Material 3, tonal.
|
||||
*
|
||||
* Surfaces are told apart by tone rather than by a line: each container step
|
||||
* is the dashboard's primary mixed a little further into the surface colour,
|
||||
* which is what makes a palette recolour the whole look and not just the
|
||||
* accents. Interaction is a state layer — the control's own text colour laid
|
||||
* over it at 8% or 12% — plus a ripple from where it was pressed.
|
||||
*
|
||||
* Paint only; the measurements are in `../core/core.css`.
|
||||
*/
|
||||
|
||||
[data-look="material"] {
|
||||
--m3-c1: color-mix(in oklab, var(--primary) 5%, var(--card));
|
||||
--m3-c2: color-mix(in oklab, var(--primary) 8%, var(--card));
|
||||
--m3-c3: color-mix(in oklab, var(--primary) 11%, var(--card));
|
||||
--m3-c4: color-mix(in oklab, var(--primary) 14%, var(--card));
|
||||
/* What a selected segment, a tonal button and the rail's active pill wear. */
|
||||
--m3-sc: color-mix(in oklab, var(--primary) 22%, var(--card));
|
||||
--m3-outline: var(--border);
|
||||
--m3-radius: 1rem;
|
||||
}
|
||||
|
||||
/* --- surfaces ----------------------------------------------------------- */
|
||||
|
||||
/* A card is `surface-container-low`. Two tone steps off the page rather than
|
||||
one, because the page itself is the plain surface colour and a single step
|
||||
is not a separation anybody can see across a room. */
|
||||
.m3-frame {
|
||||
border-radius: var(--m3-radius);
|
||||
background: var(--m3-c2);
|
||||
color: var(--card-foreground);
|
||||
box-shadow: var(--shadow-e1);
|
||||
}
|
||||
|
||||
.m3-frame[data-selected] {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.m3-frame-title {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.m3-rail {
|
||||
border-radius: 1.75rem;
|
||||
background: var(--m3-c3);
|
||||
color: var(--card-foreground);
|
||||
box-shadow: var(--shadow-e2);
|
||||
}
|
||||
|
||||
.m3-notice {
|
||||
border-radius: 9999px;
|
||||
background: var(--m3-c3);
|
||||
color: var(--muted-foreground);
|
||||
box-shadow: var(--shadow-e2);
|
||||
}
|
||||
|
||||
/* --- state layer and ripple --------------------------------------------- */
|
||||
|
||||
.m3-pressable {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.m3-pressable > .m3-state {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
background: currentColor;
|
||||
opacity: 0;
|
||||
transition: opacity var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.m3-pressable:hover > .m3-state {
|
||||
opacity: 0.08;
|
||||
}
|
||||
|
||||
.m3-pressable:focus-visible > .m3-state,
|
||||
.m3-pressable:active > .m3-state {
|
||||
opacity: 0.12;
|
||||
}
|
||||
|
||||
/* A finger covers what it touches, so hover means nothing on a touch panel. */
|
||||
[data-touch] .m3-pressable:hover > .m3-state {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.m3-ripple {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
width: 250%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
translate: -50% -50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.m3-pressable > :not(.m3-state):not(.m3-ripple) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* --- controls ------------------------------------------------------------ */
|
||||
|
||||
.m3-button {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
min-height: var(--dui-control);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 9999px;
|
||||
padding-inline: 1.25rem;
|
||||
font-size: var(--dui-text);
|
||||
font-weight: 500;
|
||||
background: var(--m3-sc);
|
||||
color: var(--card-foreground);
|
||||
}
|
||||
|
||||
.m3-button[data-variant="filled"],
|
||||
.m3-button[aria-pressed="true"] {
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.m3-button[data-variant="text"] {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.m3-button:disabled {
|
||||
opacity: 0.38;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.m3-switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
width: calc(var(--dui-control) * 1.75);
|
||||
height: var(--dui-control);
|
||||
flex: none;
|
||||
align-items: center;
|
||||
border-radius: 9999px;
|
||||
padding: 0.25rem;
|
||||
background: var(--m3-c4);
|
||||
box-shadow: inset 0 0 0 2px var(--muted-foreground);
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.m3-switch[data-state="on"] {
|
||||
background: var(--primary);
|
||||
box-shadow: none;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.m3-switch-thumb {
|
||||
display: block;
|
||||
width: calc(var(--dui-control) * 0.45);
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
background: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.m3-switch[data-state="on"] .m3-switch-thumb {
|
||||
width: calc(var(--dui-control) * 0.7);
|
||||
background: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.m3-switch:disabled {
|
||||
opacity: 0.38;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.m3-slider {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.m3-slider input {
|
||||
width: 100%;
|
||||
height: var(--dui-control);
|
||||
margin: 0;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.m3-slider input::-webkit-slider-runnable-track {
|
||||
height: 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--primary) calc(var(--dui-fraction, 0) * 100%),
|
||||
var(--m3-sc) calc(var(--dui-fraction, 0) * 100%)
|
||||
);
|
||||
}
|
||||
|
||||
.m3-slider input::-moz-range-track {
|
||||
height: 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--m3-sc);
|
||||
}
|
||||
|
||||
.m3-slider input::-moz-range-progress {
|
||||
height: 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.m3-slider input::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: var(--dui-thumb);
|
||||
height: var(--dui-thumb);
|
||||
margin-top: calc((0.5rem - var(--dui-thumb)) / 2);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--primary) 12%, transparent);
|
||||
transition: box-shadow var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.m3-slider input::-moz-range-thumb {
|
||||
width: var(--dui-thumb);
|
||||
height: var(--dui-thumb);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.m3-slider input:hover::-webkit-slider-thumb,
|
||||
.m3-slider input:active::-webkit-slider-thumb,
|
||||
.m3-slider input:focus-visible::-webkit-slider-thumb {
|
||||
box-shadow: 0 0 0 0.625rem color-mix(in srgb, var(--primary) 12%, transparent);
|
||||
}
|
||||
|
||||
.m3-slider input[data-orientation="vertical"]::-webkit-slider-runnable-track {
|
||||
width: 0.5rem;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
var(--primary) calc(var(--dui-fraction, 0) * 100%),
|
||||
var(--m3-sc) calc(var(--dui-fraction, 0) * 100%)
|
||||
);
|
||||
}
|
||||
|
||||
.m3-slider input:disabled {
|
||||
opacity: 0.38;
|
||||
}
|
||||
|
||||
.m3-ticks {
|
||||
position: relative;
|
||||
height: 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted-foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.m3-segmented {
|
||||
position: relative;
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
border: 1px solid var(--m3-outline);
|
||||
border-radius: 9999px;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.m3-segmented[data-orientation="vertical"] {
|
||||
height: 100%;
|
||||
grid-auto-flow: row;
|
||||
grid-auto-rows: minmax(0, 1fr);
|
||||
border-radius: 1.25rem;
|
||||
}
|
||||
|
||||
.m3-segment {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: calc(var(--dui-control) - 0.5rem);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
padding-inline: 0.625rem;
|
||||
font-size: var(--dui-text);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.m3-segment[data-active] {
|
||||
color: var(--card-foreground);
|
||||
}
|
||||
|
||||
.m3-segment-thumb {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
border-radius: 9999px;
|
||||
background: var(--m3-sc);
|
||||
}
|
||||
|
||||
.m3-segment-label {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m3-field {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: var(--dui-control);
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid var(--m3-outline);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--m3-c2);
|
||||
padding-inline: 0.75rem;
|
||||
font-size: var(--dui-text);
|
||||
color: var(--card-foreground);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.m3-field:focus-visible,
|
||||
.m3-field[data-state="open"] {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: inset 0 0 0 1px var(--primary);
|
||||
}
|
||||
|
||||
.m3-field:disabled {
|
||||
opacity: 0.38;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.m3-menu {
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--m3-outline);
|
||||
background: var(--m3-c3);
|
||||
color: var(--card-foreground);
|
||||
box-shadow: var(--shadow-e2);
|
||||
}
|
||||
|
||||
/* --- readings ------------------------------------------------------------ */
|
||||
|
||||
.m3-track {
|
||||
border-radius: 9999px;
|
||||
background: color-mix(
|
||||
in oklab,
|
||||
var(--dui-fill, var(--primary)) 18%,
|
||||
var(--card)
|
||||
);
|
||||
}
|
||||
|
||||
.m3-fill {
|
||||
border-radius: 9999px;
|
||||
background: var(--dui-fill, var(--primary));
|
||||
}
|
||||
|
||||
.m3-disc-thumb {
|
||||
border: 3px solid var(--card);
|
||||
box-shadow: var(--shadow-e1);
|
||||
}
|
||||
|
||||
/* --- focus --------------------------------------------------------------- */
|
||||
|
||||
[data-look="material"]
|
||||
:is(.m3-button, .m3-switch, .m3-segment, .m3-field, .dui-disc):focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
Reference in New Issue
Block a user