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
+339 -86
View File
@@ -1,10 +1,9 @@
import { useQuery } from "@tanstack/react-query"
import { Ban, ChevronDown, Plus, X } from "lucide-react"
import { Ban, ChevronDown, Plus, RotateCw, X } from "lucide-react"
import { useState } from "react"
import type { MessageInfo, SettingDef, WidgetDef } from "@/client"
import { DEFAULT_RANGE, RANGES } from "@/components/Common/RangePicker"
import { CHART_SLOTS, paletteOf } from "@/components/Common/UplotChart"
import {
PANEL_SECTION,
PanelTitle,
@@ -36,7 +35,6 @@ import {
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
import { MAX_SEGMENTS, type Segment, segmentsOf } from "./BarWidget"
import { MAX_SERIES, refreshFor } from "./ChartWidget"
import { COLOR_DTYPES, COLOR_FORMATS, colorFormatOf } from "./ColorWidget"
import {
@@ -49,12 +47,16 @@ import {
import { ICON_COLORS, ICON_NAMES, ICONS } from "./icons"
import { messageCatalogQueryOptions } from "./queries"
import {
LOOK_CHOICES,
lookOf,
SETTING_DTYPES,
type SettingName,
settingIssue,
settingOf,
THEME_CHOICES,
} from "./settings"
import { type BarRow, MAX_ROWS, rowsOf, showTitle } from "./ui/core/config"
import { parsePalette, roleLabel } from "./ui/core/theme"
import {
acceptsDtype,
INPUT_WIDGETS,
@@ -70,6 +72,10 @@ const config = (widget: WidgetDef) =>
const str = (value: unknown) => (value == null ? "" : String(value))
/** A number field left blank inherits rather than reading as zero. */
const numberOrNone = (raw: string) =>
raw.trim() === "" ? undefined : Number(raw)
/** Which messages this kind of widget may be pointed at. */
function choicesFor(kind: WidgetKind, catalog: MessageInfo[]): MessageInfo[] {
const input = INPUT_WIDGETS.has(kind)
@@ -273,14 +279,20 @@ export function WidgetPanel({
const series = seriesOf(widget)
const setSeries = (next: Series[]) => set({ series: next })
// A bar's nested readings. An empty row stands in for none, so an unnested
// bar still offers the picker rather than only a button.
const segments = segmentsOf(widget)
const rows: Segment[] = segments.length ? segments : [{}]
// Always written as a list; `inner_dtype` belonged to the single binding a
// bar carried before it stacked, and goes with it.
const setSegments = (next: Segment[]) =>
set({ inner: next, inner_dtype: undefined })
// A bar's readings. An empty row stands in for none, so a fresh bar offers
// the picker rather than only a button.
const bound = rowsOf(widget)
const rows: BarRow[] = bound.length ? bound : [{}]
/** Writing rows is also what retires the shape a bar was stored in before. */
const setRows = (next: BarRow[]) =>
set({
rows: next,
message: undefined,
dtype: undefined,
inner: undefined,
inner_dtype: undefined,
inner_label: undefined,
})
// The icon widget's mapping. Position is the row's identity, as with series.
const rules = (cfg.rules ?? []) as {
at?: unknown
@@ -337,6 +349,20 @@ export function WidgetPanel({
</p>
) : null}
{/* The title is still the widget's name — what a screen reader calls
its controls, and what a publish is labelled with. This is only
whether the panel draws it: a row of gauges under one heading
reads better without four repeated captions above them. */}
<div className="flex items-center justify-between gap-2 text-sm">
Show title
<Switch
checked={showTitle(widget)}
aria-label="Show title"
data-testid="widget-show-title"
onCheckedChange={(value) => set({ show_title: value })}
/>
</div>
{widget.type === "markdown" ? (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Text</Label>
@@ -446,8 +472,9 @@ export function WidgetPanel({
</div>
)}
</div>
) : // A clock reads the wall; a picker would bind a message nothing reads.
widget.type === "clock" ? null : (
) : // A clock reads the wall; a picker would bind a message nothing
// reads. A bar binds a row at a time, below.
widget.type === "clock" || widget.type === "bar" ? null : (
<MessagePicker
kind={widget.type}
value={str(cfg[isInput ? "target" : "message"])}
@@ -470,51 +497,130 @@ export function WidgetPanel({
</div>
{widget.type === "bar" ? (
<div className="grid gap-2">
{rows.map((segment, index) => (
<div className="grid gap-3">
{rows.map((row, index) => (
<div
// Position is the only identity a segment row has, as with series.
key={`segment-${index}`}
className="flex items-end gap-1.5"
// Position is the only identity a row has, as with series.
key={`row-${index}`}
className="grid gap-1.5"
>
<div className="min-w-0 flex-1">
<MessagePicker
kind="bar"
value={segment.message ?? ""}
label={index === 0 ? "Nested bar" : ""}
testId={index === 0 ? "widget-inner" : undefined}
onPick={(message, dtype) =>
setSegments(
<div className="flex items-end gap-1.5">
<div className="min-w-0 flex-1">
<MessagePicker
kind="bar"
value={row.message ?? ""}
label={index === 0 ? "Draws" : ""}
testId={index === 0 ? "widget-message" : undefined}
onPick={(message, dtype) =>
setRows(
rows.map((other, at) =>
at === index ? { ...other, message, dtype } : other,
),
)
}
/>
</div>
<Input
className="w-28"
value={row.label ?? ""}
placeholder="Label"
aria-label="Row label"
onChange={(event) =>
setRows(
rows.map((other, at) =>
at === index ? { ...other, message, dtype } : other,
at === index
? { ...other, label: event.target.value }
: other,
),
)
}
/>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Remove row"
onClick={() =>
setRows(rows.filter((_, at) => at !== index))
}
>
<X />
</Button>
</div>
{/* Blank inherits the widget's own scale below, which is what
a bar of comparable readings wants. A row that measures
something else — a percentage beside a load in kW — says
so here. */}
<div className="flex gap-1.5">
<Input
type="number"
className="w-20"
placeholder="Min"
aria-label="Row minimum"
value={row.min === undefined ? "" : String(row.min)}
onChange={(event) =>
setRows(
rows.map((other, at) =>
at === index
? {
...other,
min: numberOrNone(event.target.value),
}
: other,
),
)
}
/>
<Input
type="number"
className="w-20"
placeholder="Max"
aria-label="Row maximum"
value={row.max === undefined ? "" : String(row.max)}
onChange={(event) =>
setRows(
rows.map((other, at) =>
at === index
? {
...other,
max: numberOrNone(event.target.value),
}
: other,
),
)
}
/>
<Input
className="min-w-0 flex-1"
placeholder="Unit"
aria-label="Row unit"
value={row.unit ?? ""}
onChange={(event) =>
setRows(
rows.map((other, at) =>
at === index
? {
...other,
unit: event.target.value || undefined,
}
: other,
),
)
}
/>
</div>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Remove segment"
onClick={() =>
setSegments(rows.filter((_, at) => at !== index))
}
>
<X />
</Button>
</div>
))}
{rows.length < MAX_SEGMENTS ? (
{rows.length < MAX_ROWS ? (
<Button
variant="outline"
size="sm"
className="h-8 justify-self-start"
onClick={() => setSegments([...rows, {}])}
data-testid="add-segment"
onClick={() => setRows([...rows, {}])}
data-testid="add-row"
>
<Plus />
Add segment
Add row
</Button>
) : null}
</div>
@@ -1039,9 +1145,101 @@ function SettingBinding({
)
}
/**
* The dashboard's colours, pasted or typed.
*
* A link is the fastest way to a palette somebody already likes, so anything
* with hex in it is read — a coolors.co link, a colorhunt one, a comma list, a
* column of `#rrggbb`. What is kept is the order, because order is the role.
*
* The text is held locally so a half-typed link is not fought over while it is
* being typed; the parsed colours are written through on every keystroke, which
* is what makes pasting a link show the dashboard immediately.
*/
function PalettePicker({
palette,
onChange,
}: {
palette: string[]
onChange: (palette: string[]) => void
}) {
const [draft, setDraft] = useState(palette.join(" "))
const write = (text: string) => {
setDraft(text)
const next = parsePalette(text)
if (next.join(" ") !== palette.join(" ")) onChange(next)
}
return (
<div className="grid gap-2">
<Input
placeholder="Paste a coolors.co link, or hex colours"
aria-label="Palette"
data-testid="dashboard-palette-input"
value={draft}
onChange={(event) => write(event.target.value)}
/>
{palette.length === 0 ? (
<p className="text-sm text-muted-foreground">
No palette this dashboard keeps the app's own colours.
</p>
) : (
<div
className="flex flex-wrap items-end gap-2"
data-testid="dashboard-palette"
>
{palette.map((hex, index) => (
<span
// Position is the role, so it is also the identity: the same
// colour twice is two different jobs.
key={`${hex}-${index}`}
className="grid justify-items-center gap-1"
>
<span
className="size-8 rounded-full border border-border"
style={{ background: hex }}
aria-label={`${roleLabel(index)} ${hex}`}
role="img"
/>
<span className="text-xs text-muted-foreground">
{roleLabel(index)}
</span>
</span>
))}
{/* The roles are positional, and a palette rarely arrives in the
order a dashboard wants them. Turning it is quicker than
retyping five colours. */}
<Button
variant="ghost"
size="icon"
aria-label="Rotate palette"
data-testid="dashboard-palette-rotate"
disabled={palette.length < 2}
onClick={() => {
const next = [...palette.slice(1), palette[0]]
setDraft(next.join(" "))
onChange(next)
}}
>
<RotateCw />
</Button>
</div>
)}
</div>
)
}
/** How a setting's own value reads in the "nothing is driving it" line. */
function valueLabel(name: SettingName, value: unknown): string {
if (name === "locked") return value === true ? "read-only" : "editable"
if (name === "touch") return value === true ? "touch friendly" : "pointer"
if (name === "look") return lookOf(value)
if (name === "background") return value ? "that image" : "no image"
if (name === "palette") {
const count = parsePalette(value).length
return count ? `those ${count} colours` : "no palette"
}
const chosen = THEME_CHOICES.find(([option]) => option === value)
return (chosen?.[1] ?? "System").toLowerCase()
}
@@ -1072,8 +1270,11 @@ export function DashboardPanel({
const canvas = canvasOf(dashboard)
const theme = settingOf(dashboard, "theme")
const locked = settingOf(dashboard, "locked")
const look = settingOf(dashboard, "look")
const background = settingOf(dashboard, "background")
const touch = settingOf(dashboard, "touch")
const paletteSetting = settingOf(dashboard, "palette")
const palette = paletteOf(paletteSetting.value)
const palette = parsePalette(paletteSetting.value)
/** Settings are a map, so one of them changing rewrites the whole of it. */
const setSetting = (name: SettingName, setting: SettingDef) =>
@@ -1205,6 +1406,28 @@ export function DashboardPanel({
</p>
</div>
<div className="grid gap-2">
<span className={PANEL_SECTION}>Look</span>
<Segmented
value={lookOf(look.value)}
options={LOOK_CHOICES}
label="Dashboard look"
testId="dashboard-look"
onChange={(value) => setSetting("look", { ...look, value })}
/>
<SettingBinding
name="look"
setting={look}
onChange={(setting) => setSetting("look", setting)}
/>
<p className="text-sm text-muted-foreground">
How this dashboard is drawn. Glass floats translucent tiles over a
soft moving ground; Material lays flat tonal cards on a plain one.
The widgets are the same either way a look changes what they
look like and nothing about what they do.
</p>
</div>
<div className="grid gap-2">
<span className={PANEL_SECTION}>Theme</span>
<Segmented
@@ -1224,55 +1447,85 @@ export function DashboardPanel({
wall has nobody to set the device preference System otherwise
follows. Bind a message and a flow drives it instead: a node
publishing on a cron is what a schedule looks like here, and the
choice above stays the fallback.
choice above stays the fallback. A palette settles this for
itself: its first colour is the ground, so a dashboard that names
one is already light or dark and this is left idle.
</p>
</div>
<div className="grid gap-2">
<span className={PANEL_SECTION}>Palette</span>
<div
className="flex flex-wrap items-center gap-2"
data-testid="dashboard-palette"
>
{CHART_SLOTS.map((slot) => {
const picked = palette.includes(slot)
return (
<button
key={slot}
type="button"
aria-pressed={picked}
aria-label={`Colour ${slot}`}
onClick={() =>
setSetting("palette", {
...paletteSetting,
value: picked
? palette.filter((other) => other !== slot)
: [...palette, slot],
})
}
className={cn(
"size-11 rounded-full transition-shadow md:size-8",
picked &&
"ring-2 ring-ring ring-offset-2 ring-offset-card",
)}
style={{
// A token alpha rather than `opacity`, which would fade
// the ring with the swatch.
background: picked
? `var(--chart-${slot})`
: `color-mix(in srgb, var(--chart-${slot}) 30%, transparent)`,
}}
/>
)
})}
</div>
<PalettePicker
palette={palette}
onChange={(value: string[]) =>
setSetting("palette", { ...paletteSetting, value })
}
/>
<SettingBinding
name="palette"
setting={paletteSetting}
onChange={(setting) => setSetting("palette", setting)}
/>
<p className="text-sm text-muted-foreground">
The colours this dashboard's charts draw with, taken in the order
you pick them — a chart with more lines than colours starts over
at the first. Pick none and each chart spreads itself across the
range by how many lines it has, which is usually what you want.
Each colour is offered once: two lines sharing one could not be
told apart, and neighbouring ones are close enough already.
The colours this dashboard is drawn in, in order: the ground, the
surface a widget is, the primary, the accent, and the text. Leave
the later ones off and they are worked out from the ones you gave
three colours are a whole dashboard. Anything past the five is
another colour for a chart to draw a line in. Name none and the
dashboard keeps the app's own.
</p>
</div>
<div className="grid gap-2">
<span className={PANEL_SECTION}>Background</span>
<Input
type="url"
placeholder="https://…"
aria-label="Background image"
data-testid="dashboard-background"
value={str(background.value)}
onChange={(event) =>
setSetting("background", {
...background,
value: event.target.value,
})
}
/>
<SettingBinding
name="background"
setting={background}
onChange={(setting) => setSetting("background", setting)}
/>
<p className="text-sm text-muted-foreground">
An image drawn under the widgets, covering the canvas. It takes
the place of the ground the Glass look brings with it. Bind a
message and a flow decides the picture — one per season, or one
per time of day.
</p>
</div>
<div className="grid gap-2">
<span className={PANEL_SECTION}>Touch</span>
<div className="flex items-center justify-between gap-2 text-sm">
Touch friendly
<Switch
checked={touch.value === true}
aria-label="Touch friendly"
data-testid="dashboard-touch"
onCheckedChange={(value) =>
setSetting("touch", { ...touch, value })
}
/>
</div>
<SettingBinding
name="touch"
setting={touch}
onChange={(setting) => setSetting("touch", setting)}
/>
<p className="text-sm text-muted-foreground">
Bigger controls, and nothing that only happens on hover — for a
panel that is touched rather than pointed at. A phone gets this
anyway; a wall panel has no way to say so for itself.
</p>
</div>