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
@@ -1,6 +1,7 @@
import { useLiveValue } from "@/components/Flow/liveStore"
import { cn } from "@/lib/utils"
import { ICON_COLORS, ICONS } from "./icons"
import { config } from "./ui/core/config"
import type { WidgetProps } from "./widgets"
/** One column of a forecast, as the `list` message declares it. */
@@ -11,9 +12,6 @@ type ForecastItem = {
color?: string
}
const config = (widget: WidgetProps["widget"]) =>
(widget.config ?? {}) as Record<string, unknown>
/**
* What comes next, as a strip of columns over a `list` message.
*
@@ -28,7 +26,7 @@ export function ForecastWidget({ widget }: WidgetProps) {
const message = cfg.message ? String(cfg.message) : ""
const live = useLiveValue(message || undefined)
if (!message) {
return <p className="text-sm text-muted-foreground">Pick a message.</p>
return <p className="text-muted-foreground">Pick a message.</p>
}
const count = Number(cfg.count)
@@ -37,7 +35,7 @@ export function ForecastWidget({ widget }: WidgetProps) {
.slice(0, Number.isFinite(count) ? count : 5)
if (items.length === 0) {
return <p className="text-sm text-muted-foreground">Nothing forecast.</p>
return <p className="text-muted-foreground">Nothing forecast.</p>
}
return (
@@ -56,18 +54,18 @@ export function ForecastWidget({ widget }: WidgetProps) {
// ramp out of an arbitrary Tailwind class.
style={{ opacity: Math.max(0.45, 1 - index * 0.14) }}
>
<span className="w-full truncate text-center text-xs text-muted-foreground">
<span className="w-full truncate text-center text-[0.85em] text-muted-foreground">
{item.label ?? ""}
</span>
{Glyph ? (
<Glyph
className={cn(
"size-5 shrink-0",
"dui-glyph-sm shrink-0",
ICON_COLORS[item.color ?? ""] ?? ICON_COLORS.default,
)}
/>
) : null}
<span className="w-full truncate text-center text-sm tabular-nums">
<span className="w-full truncate text-center tabular-nums">
{item.value === null || item.value === undefined
? ""
: String(item.value)}