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:
2026-08-23 21:52:14 +02:00
parent d4c5af4d5a
commit 0b5ce4fcbb
52 changed files with 5517 additions and 1568 deletions
@@ -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 01 at 0.01 reads "0.25" and 0100 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 2022 °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;
}