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,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;
}