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
@@ -10,7 +10,6 @@
in the pitch its widgets actually snap to (`--dot-x` / `--dot-y`), and the
negative offset puts a dot centre on the grid's own origin. */
.dot-canvas {
background-color: var(--background);
background-image: radial-gradient(
circle at 1px 1px,
color-mix(in srgb, var(--muted-foreground) 30%, transparent) 1.5px,
@@ -75,91 +74,13 @@
}
/*
* A publish in flight, drawn as a ring just inside the tile's own edge.
*
* An overlay rather than anything the widget owns: it takes no layout box and
* no clicks, so a control being used never resizes its tile or moves what sits
* around it. Full opacity at rest, so a panel that asks for no motion still
* gets the ring — the animation only breathes it.
*/
.widget-transmit {
position: absolute;
inset: 0;
pointer-events: none;
border-radius: var(--radius-lg);
box-shadow: inset 0 0 0 2px var(--primary);
}
/*
* The colour wheel, sized to whatever tile it was put on.
*
* `min(100cqw, 100cqh)` is what keeps one square inside a box of any shape
* without measuring anything in JS — the box is the query container, so the
* wheel reads its height as well as its width. The ring itself is a conic
* gradient set on the element, so nothing here repaints per frame.
*/
.widget-wheel-box {
container-type: size;
}
.widget-wheel {
width: min(100cqw, 100cqh);
aspect-ratio: 1;
}
/* The tile is its own query container, so the widget can be laid out by the
shape it was given rather than by the viewport — a wall panel's rows are
often wider than they are tall, and a wheel stacked above two sliders in one
of those is a dot. A container cannot answer a query about itself, which is
what the inner `-body` is for. */
.widget-color {
container-type: size;
}
@container (min-aspect-ratio: 3 / 2) {
.widget-color-body {
flex-direction: row;
align-items: center;
}
/* Square by its height, taken from the tile: the wheel keeps whatever room
the row has and the components take the rest of the width. */
.widget-color-body > .widget-wheel-box {
flex: none;
width: 100cqh;
}
.widget-color-levels {
flex: 1;
min-width: 0;
}
}
/*
* Motion. A value settling is a neutral state change; a selection indicator
* moving is emphasized (Material). `<MotionConfig reducedMotion="user">` only
* covers `motion/react`, so CSS asks for itself.
* Motion. A selection indicator moving is emphasized (Material).
* `<MotionConfig reducedMotion="user">` only covers `motion/react`, so CSS
* asks for itself. The dashboard's own widgets are drawn by the component
* sets and animate through `motion`; what is left here is the app-side
* segmented shape, which the flow screens and the dashboard editor share.
*/
@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. */
.widget-transmit {
animation: widget-transmit var(--duration-pulse) var(--ease-standard)
infinite alternate;
}
@keyframes widget-transmit {
from {
opacity: 0.2;
}
}
/* The arc is the full 240 degrees and the dash hides the rest of it, so the
reading changes by animating one number rather than re-pathing. */
.widget-gauge-arc {
transition: stroke-dashoffset var(--duration-base) var(--ease-standard);
}
/* One indicator that slides between segments, rather than a fill that jumps
from cell to cell. */
.widget-segment-thumb {