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
+27 -35
View File
@@ -33,28 +33,6 @@ export const CHART_SLOTS = Array.from({ length: MAX_SERIES }, (_, index) =>
/** No palette named; a chart left with this spreads itself. */
export const NO_PALETTE: string[] = []
/**
* A stored palette, made safe to draw with: a distinct subset, in draw order.
*
* Distinct on purpose, and worth keeping that way. Within the ramp only slot 1
* against slot 5 clears the 3:1 guideline for non-text — 3.28 light, 3.37 dark,
* measured in `BarWidget.tsx` — so two traces on neighbouring slots are already
* close, and two on the *same* slot could not be told apart at all. Free
* assignment with repeats is not an improvement on this; it is the bug.
*
* Empty is the answer for a dashboard that names none, and for anything
* unrecognised: no palette, so the chart spreads itself — see `slotsFor`.
*/
export function paletteOf(value: unknown): string[] {
if (!Array.isArray(value)) return NO_PALETTE
const slots = [...new Set(value.map(String))].filter((slot) =>
CHART_SLOTS.includes(slot),
)
// One shared array for the common answer, so a dashboard that names no
// palette does not hand its readers a new one on every render.
return slots.length > 0 ? slots : NO_PALETTE
}
/**
* What a chart of *n* lines draws with when no palette was named: the ramp
* spread, rather than its first *n* steps.
@@ -183,15 +161,29 @@ const PADDING: uPlot.Padding = [10, 12, 0, 0]
const canvasHeight = (element: HTMLElement) =>
Math.max(60, element.clientHeight || 180)
/** A token, resolved for the canvas — which cannot read CSS variables. */
function token(name: string): string {
return getComputedStyle(document.documentElement)
.getPropertyValue(name)
.trim()
/**
* A token, resolved for the canvas — which cannot read CSS variables.
*
* Read off the chart's own element rather than the document: a dashboard
* states its colours on the canvas it is drawn on, and custom properties
* inherit, so a tile inside one has to be asked where it stands. Asking the
* root would draw the shell's axes on the dashboard's chart.
*/
function token(name: string, from: Element): string {
return getComputedStyle(from).getPropertyValue(name).trim()
}
const seriesColor = (index: number, slots: string[]) =>
token(`--chart-${slots[index % slots.length]}`)
/**
* What one line is drawn in.
*
* A palette entry is either a slot of the app's own ramp — the shape a
* dashboard stored before it could name colours — or a colour, which is what a
* palette writes today. A colour is used as it stands.
*/
const seriesColor = (index: number, slots: string[], from: Element) => {
const slot = slots[index % slots.length]
return CHART_SLOTS.includes(slot) ? token(`--chart-${slot}`, from) : slot
}
/**
* Tick labels that stay distinct.
@@ -262,8 +254,8 @@ export function UplotChart({
/** A named y axis; the unit stays on the ticks. */
yLabel?: string
/** The ramp slots this chart's lines take, in order — the dashboard's own
* palette. Unset, the chart spreads itself across the ramp by how many
* lines it draws, which is what one outside a dashboard (Health, Home)
* palette — colours, or slots of the app's own ramp. Unset, the chart
* spreads itself across the ramp by how many lines it draws, which is what one outside a dashboard (Health, Home)
* always does. */
palette?: string[]
/** Draw the lines as a monotone cubic spline rather than straight segments.
@@ -304,9 +296,9 @@ export function UplotChart({
if (!element || labels.length === 0 || !ready) return
const axis = {
stroke: () => token("--muted-foreground"),
grid: { stroke: () => token("--border"), width: 1 },
ticks: { stroke: () => token("--border"), width: 1 },
stroke: () => token("--muted-foreground", element),
grid: { stroke: () => token("--border", element), width: 1 },
ticks: { stroke: () => token("--border", element), width: 1 },
font: `11px ${getComputedStyle(element).fontFamily}`,
}
/** The x value the page was last told about, so a move within one bucket
@@ -381,7 +373,7 @@ export function UplotChart({
width: 2,
// Read at draw time, so a theme toggle is a redraw rather than a
// rebuilt chart.
stroke: () => seriesColor(index, slots),
stroke: () => seriesColor(index, slots, element),
// The cursor readout is what decides how wide the legend gets, so
// it is shortened here; a named unit is short enough to keep. Four
// figures rather than three: this is the number someone is pointing