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 7c6e655e17
commit acae48ddb6
52 changed files with 5517 additions and 1568 deletions
+40 -1
View File
@@ -1,4 +1,4 @@
import { useCallback, useSyncExternalStore } from "react"
import { useCallback, useRef, useSyncExternalStore } from "react"
import { OpenAPI } from "@/client"
import { request } from "@/client/core/request"
@@ -446,6 +446,45 @@ export function useLatestTs(names: string[]): number {
)
}
/**
* Several messages at once, for a widget whose bindings are a list.
*
* `useLiveValue` is one hook per name, and hooks cannot be called in a loop —
* which is why a chart used to call it five times and a bar three, whatever
* the document actually bound. This subscribes to as many names as it is
* given, so the widget's shape follows its configuration instead of the other
* way round.
*
* The snapshot must keep its identity while nothing has changed:
* `useSyncExternalStore` compares by reference, and a fresh array every render
* would re-render for ever.
*/
export function useLiveValues(names: string[]): (LiveValue | undefined)[] {
const joined = names.join(SEP)
const last = useRef<(LiveValue | undefined)[]>([])
return useSyncExternalStore(
useCallback(
(listener: Listener) =>
subscribeAll(
parts(joined).map((name) => `value:${name}`),
listener,
),
[joined],
),
() => {
const next = parts(joined).map((name) =>
name ? values.get(name) : undefined,
)
const kept = last.current
if (next.length === kept.length && next.every((v, i) => v === kept[i])) {
return kept
}
last.current = next
return next
},
)
}
export function useLiveConnection(): boolean {
return useSyncExternalStore(
(listener) => {