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,263 @@
/**
* Material 3: the controls.
*
* A press is answered by a state layer and a ripple from where it landed;
* nothing lifts or scales. Every one of these is a `ui/core` hook in a
* different coat — the state, the keyboard and the `aria-` come from there.
*/
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown } from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import { cn } from "@/lib/utils"
import { asOriginal, text } from "../core/config"
import type {
ButtonProps,
InputProps,
SegmentedProps,
SelectProps,
SliderProps,
SwitchProps,
} from "../core/contract"
import {
type Press,
usePress,
useSegmented,
useSliderDrag,
useSwitch,
} from "../core/controls"
import { useLook } from "../core/look"
import { LOOK } from "../core/motion"
/** The state layer plus whatever presses are still fading. */
function Skin({
presses,
done,
}: {
presses: Press[]
done: (id: number) => void
}) {
return (
<>
<span className="m3-state" aria-hidden />
<AnimatePresence>
{presses.map((press) => (
<motion.span
key={press.id}
aria-hidden
className="m3-ripple"
style={{ left: `${press.x}%`, top: `${press.y}%` }}
initial={{ scale: 0, opacity: 0.3 }}
animate={{ scale: 1, opacity: 0 }}
transition={{ duration: 0.45, ease: [0.4, 0, 0.2, 1] }}
onAnimationComplete={() => done(press.id)}
/>
))}
</AnimatePresence>
</>
)
}
export function Button({
variant = "tonal",
pressed,
disabled,
label,
onClick,
children,
}: ButtonProps) {
const { presses, onPointerDown, done } = usePress()
return (
<button
type="button"
className="m3-button m3-pressable w-full"
data-variant={variant}
aria-pressed={pressed}
aria-label={label}
disabled={disabled}
onPointerDown={onPointerDown}
onClick={onClick}
>
<Skin presses={presses} done={done} />
<span className="min-w-0 truncate">{children}</span>
</button>
)
}
export function Switch(props: SwitchProps) {
const { buttonProps } = useSwitch(props)
const { presses, onPointerDown, done } = usePress()
return (
<button
{...buttonProps}
className="m3-switch m3-pressable"
onPointerDown={onPointerDown}
>
<Skin presses={presses} done={done} />
<motion.span
layout
transition={LOOK.material.spring}
className="m3-switch-thumb"
/>
</button>
)
}
export function Slider(props: SliderProps) {
const { fraction, inputProps, marks } = useSliderDrag(props)
const vertical = props.orientation === "vertical"
return (
<div
className={cn("dui-slider m3-slider", vertical ? "h-full" : "w-full")}
style={{ "--dui-fraction": fraction } as React.CSSProperties}
>
<div
className={cn("flex min-w-0 flex-col", vertical ? "h-full" : "w-full")}
>
<input
{...inputProps}
data-orientation={props.orientation ?? "horizontal"}
/>
{marks.length > 0 ? (
// Decoration: the input itself announces min, max and where it stands.
<div aria-hidden className="m3-ticks">
{marks.map((mark) => (
<span
key={mark.percent}
className="absolute top-0"
// Shifted by its own share of itself: the first label sits
// flush left and the last flush right, so neither hangs off.
style={{
left: `${mark.percent}%`,
transform: `translateX(-${mark.percent}%)`,
}}
>
{mark.label}
</span>
))}
</div>
) : null}
</div>
</div>
)
}
export function Segmented(props: SegmentedProps) {
const { chosen, thumbId, groupProps, itemProps } = useSegmented(props)
const vertical = props.orientation === "vertical"
return (
<div
{...groupProps}
data-testid={props.testId}
className="m3-segmented"
style={
vertical
? undefined
: {
gridTemplateColumns: `repeat(${props.options.length}, minmax(0, 1fr))`,
}
}
>
{props.options.map(([value, label], index) => (
<button
key={value}
{...itemProps(index)}
className="m3-segment m3-pressable"
>
{index === chosen ? (
<motion.span
aria-hidden
layoutId={thumbId}
transition={LOOK.material.spring}
className="m3-segment-thumb"
/>
) : null}
<span className="m3-state" aria-hidden />
<span className="m3-segment-label">{label}</span>
</button>
))}
</div>
)
}
export function Select({
value,
options,
label,
disabled,
onChange,
}: SelectProps) {
// The menu is portalled to `body`, which is outside the canvas — so it
// re-states the dashboard's own colours rather than borrowing the app's.
const { style } = useLook()
return (
<SelectPrimitive.Root
value={value}
onValueChange={(selected) => onChange(asOriginal(selected, options))}
>
<SelectPrimitive.Trigger
aria-label={label}
disabled={disabled}
className="m3-field m3-pressable justify-between"
>
<span className="min-w-0 truncate">
<SelectPrimitive.Value placeholder="Choose" />
</span>
<SelectPrimitive.Icon asChild>
<ChevronDown className="size-4 shrink-0 opacity-60" aria-hidden />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
<SelectPrimitive.Portal>
<SelectPrimitive.Content
position="popper"
sideOffset={4}
style={style}
className="m3-menu z-50 max-h-64 min-w-[var(--radix-select-trigger-width)] overflow-y-auto p-1 data-[state=open]:animate-in data-[state=open]:zoom-in-95 data-[state=open]:fade-in-0"
>
<SelectPrimitive.Viewport>
{options.map((option) => (
<SelectPrimitive.Item
key={text(option.value)}
value={text(option.value)}
className="m3-pressable flex cursor-pointer select-none items-center justify-between gap-2 rounded-lg px-3 py-2 text-sm outline-none"
>
<span className="m3-state" aria-hidden />
<SelectPrimitive.ItemText>
{option.label ?? text(option.value)}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator>
<Check className="size-4" aria-hidden />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
))}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
</SelectPrimitive.Root>
)
}
export function Input({
value,
type,
label,
disabled,
onChange,
onCommit,
}: InputProps) {
return (
<input
className="m3-field"
value={value}
type={type}
aria-label={label}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
onBlur={onCommit}
onKeyDown={(event) => {
if (event.key === "Enter") onCommit()
}}
/>
)
}