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:
@@ -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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user