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
+29 -168
View File
@@ -1,181 +1,42 @@
import { displayName, flowOf } from "@/components/Flow/deriveEdges"
import { useLiveValue } from "@/components/Flow/liveStore"
import { cn } from "@/lib/utils"
import { slotsFor } from "@/components/Common/UplotChart"
import { useLiveValues } from "@/components/Flow/liveStore"
import { usePalette } from "./settings"
import { useUi } from "./ui"
import { rowsOf } from "./ui/core/config"
import { barReadings } from "./ui/core/values"
import type { WidgetProps } from "./widgets"
// Local copies: `widgets.tsx` imports this module, so its helpers cannot be
// imported back without closing the cycle (`ChartWidget.tsx` does the same).
const config = (widget: WidgetProps["widget"]) =>
(widget.config ?? {}) as Record<string, unknown>
const text = (value: unknown) => (value == null ? "" : String(value))
const num = (value: unknown, fallback: number) => {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : fallback
}
/** Below this the reading no longer fits on the fill and moves off its end. */
const FITS = 0.3
/**
* How many readings a bar nests, and a hard ceiling.
* Several readings on one tile, a row each.
*
* The limit is contrast rather than layout. No slot of the chart ramp reaches
* 3:1 against `--primary`, so every segment is drawn in the one token that
* does — `--primary-nested` — and neighbours are told apart by the gutter of
* outer fill left between them. Within the ramp only `--chart-1` against
* `--chart-5` clears 3:1 (3.28 light / 3.37 dark) and only slots 1-3 clear it
* against the `--muted` track, so a fourth segment could not be told from its
* neighbour without breaking the very guideline this stacking exists to fix.
*/
export const MAX_SEGMENTS = 3
/** Outer fill left around a segment, as `inset-y-1` leaves it above and below. */
const GUTTER = "2px"
export type Segment = { message?: string; dtype?: string; label?: string }
/** A segment as drawn: where it runs on the fill, and what it reads. */
type Band = { start: number; end: number; level: number; name: string }
/**
* The nested readings, in either shape a document may carry them: one binding,
* as a bar was written before it stacked, or an ordered list of them.
*/
export const segmentsOf = (widget: WidgetProps["widget"]): Segment[] => {
const cfg = config(widget)
if (Array.isArray(cfg.inner))
return (cfg.inner as Segment[]).slice(0, MAX_SEGMENTS)
return cfg.inner
? [
{
message: text(cfg.inner),
dtype: text(cfg.inner_dtype),
label: text(cfg.inner_label),
},
]
: []
}
/**
* A level, drawn as a horizontal bar with its reading written on it.
* It used to be one reading with up to three nested inside its own fill — the
* PV share of an inverter's input drawn within the input. That said one thing
* well, containment, and everything else badly: the nested readings all had to
* share a single colour to stay legible against the fill, three was the
* ceiling, and none of them could carry a scale of its own.
*
* Further messages can be nested inside the first — the PV share of an
* inverter's input — and are drawn on the fill on the same scale, stacked end
* to end so that they partition the reading rather than cover one another.
* None of them leaves the fill, so containment is still what the picture
* shows rather than something to work out.
* A row each says the same thing where it is true — two rows on one scale
* still read as a share, because the shorter bar *is* the smaller part — and
* says the things the old shape could not: a battery percentage beside a load
* in kW, told apart by the dashboard's own data colours rather than by a
* gutter. Documents written the old way are read as rows (`rowsOf`).
*/
export function BarWidget({ widget }: WidgetProps) {
const cfg = config(widget)
const message = text(cfg.message)
const segments = segmentsOf(widget)
const outer = useLiveValue(message || undefined)
// One hook per slot rather than one per segment, so the number of hooks
// React sees never moves with the configuration. MAX_SEGMENTS is its length.
const nested = [
useLiveValue(segments[0]?.message || undefined),
useLiveValue(segments[1]?.message || undefined),
useLiveValue(segments[2]?.message || undefined),
]
if (!message)
return <p className="text-sm text-muted-foreground">Pick a message.</p>
const { Bar } = useUi()
const rows = rowsOf(widget).filter((row) => row.message)
const live = useLiveValues(rows.map((row) => row.message as string))
// The dashboard's own data colours, in the order a chart would take them, so
// a bar and a chart of the same readings agree about which line is which.
const colors = slotsFor(rows.length, usePalette())
const min = num(cfg.min, 0)
const max = num(cfg.max, 100)
const span = max - min || 1
const precision = cfg.precision === undefined ? 1 : num(cfg.precision, 1)
const unit = cfg.unit ? text(cfg.unit) : ""
const reading = (live: unknown) => (typeof live === "number" ? live : null)
const fractionOf = (value: number | null) =>
value === null ? 0 : Math.min(1, Math.max(0, (value - min) / span))
const write = (value: number | null) =>
value === null ? "—" : `${value.toFixed(precision)}${unit}`
const value = reading(outer?.value)
const fraction = fractionOf(value)
const fits = fraction >= FITS
// Each segment starts where the one before it ended, and the run is clamped
// to the outer fill: a nested value larger than the reading it is part of
// used to spill onto the track and read as more than the whole.
const drawn: Band[] = []
let cursor = 0
for (const [index, segment] of segments.entries()) {
const name = segment.message ?? ""
const level = name ? reading(nested[index]?.value) : null
if (level === null) continue
const start = cursor
cursor = Math.min(fraction, start + fractionOf(level))
drawn.push({
start,
end: cursor,
level,
// The panel already carries the widget's title, so the caption names the
// reading by its port rather than repeating the flow it comes from — or
// by whatever the author called it, since a port name is chosen for the
// graph and not for somebody reading it across a room.
name: text(segment.label) || displayName(flowOf(name), name),
})
if (rows.length === 0) {
return <p className="text-muted-foreground">Pick a message.</p>
}
const detail = drawn
.map((band) => `${write(band.level)} of it from ${band.name}`)
.join(", ")
return (
<div
className="grid gap-1.5"
role="img"
aria-label={
detail
? `${write(value)} of ${max}${unit}, ${detail}`
: `${write(value)} of ${max}${unit}`
}
>
<div className="relative h-8 w-full overflow-hidden rounded-full bg-muted">
<div
data-testid="bar-fill"
className="absolute inset-y-0 left-0 rounded-full bg-primary motion-safe:transition-[width] motion-safe:duration-200 motion-safe:ease-[var(--ease-standard)]"
style={{ width: `${fraction * 100}%` }}
/>
{drawn.map((band, index) => (
<div
// Position is the only identity a segment has, as with chart series.
key={`segment-${index}`}
data-testid="bar-inner"
className="absolute inset-y-1 rounded-full bg-primary-nested motion-safe:transition-[left,width] motion-safe:duration-200 motion-safe:ease-[var(--ease-standard)]"
style={{
left: `${band.start * 100}%`,
// Segments share a fill, so the gutter is what tells one from the
// next: what shows between them is the outer fill they sit on.
width: `calc(${(band.end - band.start) * 100}% - ${GUTTER})`,
}}
/>
))}
{/* Always anchored to the end of the fill by `left`, so the whole
travel is one interpolating property. Which side of that anchor the
reading sits on is the translate: pulled back onto the fill while
there is room for it, left where it is once there is not. */}
<span
className={cn(
"absolute inset-y-0 flex items-center px-2 text-sm tabular-nums motion-safe:transition-[left,transform] motion-safe:duration-200 motion-safe:ease-[var(--ease-standard)]",
fits ? "text-primary-foreground" : "text-foreground",
)}
style={{
left: `${fraction * 100}%`,
transform: fits ? "translateX(-100%)" : "translateX(0)",
}}
>
{write(value)}
</span>
</div>
{drawn.length === 0 ? null : (
<p className="truncate text-xs text-muted-foreground">
{drawn.map((band) => `${band.name} ${write(band.level)}`).join(" · ")}
</p>
)}
</div>
<Bar
label={widget.title || "Readings"}
rows={barReadings(widget, live, colors)}
/>
)
}