Stack a bar's readings, and stop widgets taking the phone sideways

A bar drew its nested reading on top of the outer one in --chart-5, which
measures 2.53:1 against --primary and lost the 3:1 guideline for non-text. The
readings now partition the fill end to end, up to three of them, in a token of
their own: --primary-nested, the primary hue a few steps deeper, 3.14:1 light
and 3.12:1 dark. It cannot also clear 3:1 against --muted — in dark those two
are 5.82:1 apart and a colour 3:1 from both would need a 9:1 gap — so a segment
is drawn inside a gutter of outer fill rather than ever bordering the track,
which is what separates neighbours too, and what caps the count at three. A
nested value larger than its outer used to spill onto the track; it is clamped.
`inner` still reads as a single binding, so no dashboard needs migrating.

On a phone, .widget-grid took its width from the widest thing any widget held —
a truncating flex item still offers its whole unwrapped line as a min-content
contribution — and a handful of widgets had no floor of their own: the uPlot
legend is a table, a fieldset carries min-inline-size: min-content from the UA
sheet, and buttons are whitespace-nowrap. Each is capped now. A widget's body
scrolls rather than clipping, so long text stops painting over the title.

Gauges and bars move between readings instead of jumping, and a segmented
control slides one thumb rather than recolouring cells. The gauge arc is drawn
whole and revealed by its dash, because `d` cannot be transitioned.

UplotChart pushed new readings only when the point count changed, so once a
rolling window was full a refetch left the old values on screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uq8mtNb97A7praJLyeEYgs
This commit is contained in:
2026-08-21 10:11:44 +02:00
co-authored by Claude Opus 5
parent 9c149f9ed4
commit c69c9797d5
10 changed files with 589 additions and 85 deletions
+29 -6
View File
@@ -1,3 +1,7 @@
// The segmented shape's thumb transition lives beside the dashboard's own
// widgets, and CSS is chunked per entry — so the rule is pulled in wherever
// this picker is used, or the two copies of one shape would move differently.
import "@/components/Dashboard/dashboard.css"
import { cn } from "@/lib/utils"
/**
@@ -48,22 +52,41 @@ export function RangePicker({
value: Range
onChange: (range: Range) => void
}) {
const chosen = RANGES.findIndex((range) => range.hours === value.hours)
return (
// A `fieldset` carries `min-inline-size: min-content` from the UA sheet,
// which no width utility overrides. Equal tracks and no gap put the
// sliding thumb at its share of the padded box without measuring — a grid
// rather than a flex row because `flex-1` under `w-fit` sizes the segments
// to a share of the widest label instead of to the label itself.
<fieldset
data-testid="range-picker"
className="flex w-fit items-center gap-1 rounded-full border border-border p-1"
className="relative grid w-fit min-w-0 items-center rounded-full border border-border p-1"
style={{
gridTemplateColumns: `repeat(${RANGES.length}, minmax(0, 1fr))`,
}}
>
<legend className="sr-only">Time range</legend>
{RANGES.map((range) => (
{chosen >= 0 ? (
<span
aria-hidden
className="widget-segment-thumb pointer-events-none absolute inset-y-1 rounded-full bg-accent"
style={{
left: `calc(0.25rem + ${chosen} * (100% - 0.5rem) / ${RANGES.length})`,
width: `calc((100% - 0.5rem) / ${RANGES.length})`,
}}
/>
) : null}
{RANGES.map((range, index) => (
<button
key={range.label}
type="button"
aria-pressed={range.hours === value.hours}
aria-pressed={index === chosen}
onClick={() => onChange(range)}
className={cn(
"rounded-full px-2.5 py-1 text-xs transition-colors",
range.hours === value.hours
? "bg-accent text-accent-foreground"
"relative z-10 min-w-0 rounded-full px-2.5 py-1 text-xs transition-colors",
index === chosen
? "text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50",
)}
>
+11 -4
View File
@@ -239,11 +239,16 @@ export function UplotChart({
}
}, [key, ready])
// biome-ignore lint/correctness/useExhaustiveDependencies: rebuilding the joined table is what the point count stands for.
// Every render, deliberately. `plots` is rebuilt on every render in both
// consumers, so no memo can hit, and any signature short of hashing shares
// the blind spot a point count has: once a rolling window is full, a refetch
// carrying different readings leaves the count where it was and never fires.
// Safe to run this often because `setData` is idempotent and re-ranges the
// scales *from the data* — the opposite of the `redraw(false)` below.
useEffect(() => {
if (!chart.current || plots.length === 0) return
chart.current.setData(table(plots))
}, [points, key])
})
// The canvas cannot follow a CSS variable, so a theme swap is a redraw. The
// paths are geometry and stay as they are — and leaving them alone is what
@@ -272,8 +277,10 @@ export function UplotChart({
) : null}
</div>
{/* Kept at the legend's resting height, so the plot does not resize
under the pointer the first time a reading arrives. */}
<div ref={legend} className="min-h-6 shrink-0" />
under the pointer the first time a reading arrives. uPlot mounts a
table here and a table cannot lay out below its min-content width, so
the labels get their own scroller rather than widening the card. */}
<div ref={legend} className="min-h-6 min-w-0 shrink-0 overflow-x-auto" />
</div>
)
}
+88 -19
View File
@@ -18,22 +18,61 @@ const num = (value: unknown, fallback: number) => {
/** 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.
*
* 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 }
/** 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) }]
: []
}
/**
* A level, drawn as a horizontal bar with its reading written on it.
*
* A second message can be nested inside the first — the PV share of an
* inverter's input — and is drawn on top of the fill on the same scale, so
* containment is what the picture shows rather than something to work out.
* 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.
*/
export function BarWidget({ widget }: WidgetProps) {
const cfg = config(widget)
const message = text(cfg.message)
const nested = text(cfg.inner)
// 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.
const innerName = nested ? displayName(flowOf(nested), nested) : ""
const segments = segmentsOf(widget)
const outer = useLiveValue(message || undefined)
const inner = useLiveValue(nested || 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>
@@ -50,36 +89,66 @@ export function BarWidget({ widget }: WidgetProps) {
value === null ? "—" : `${value.toFixed(precision)}${unit}`
const value = reading(outer?.value)
const innerValue = nested ? reading(inner?.value) : null
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.
name: displayName(flowOf(name), name),
})
}
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={
nested
? `${write(value)} of ${max}${unit}, ${write(innerValue)} of it from ${innerName}`
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"
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}%` }}
/>
{innerValue === null ? null : (
{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 left-0 rounded-full bg-chart-5"
style={{ width: `${fractionOf(innerValue) * 100}%` }}
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})`,
}}
/>
)}
))}
<span
className={cn(
"absolute inset-y-0 flex items-center px-2 text-sm tabular-nums",
"absolute inset-y-0 flex items-center px-2 text-sm tabular-nums motion-safe:transition-[left,right] motion-safe:duration-200 motion-safe:ease-[var(--ease-standard)]",
fits ? "text-primary-foreground" : "text-foreground",
)}
style={
@@ -91,9 +160,9 @@ export function BarWidget({ widget }: WidgetProps) {
{write(value)}
</span>
</div>
{innerValue === null ? null : (
{drawn.length === 0 ? null : (
<p className="truncate text-xs text-muted-foreground">
{innerName} {write(innerValue)}
{drawn.map((band) => `${band.name} ${write(band.level)}`).join(" · ")}
</p>
)}
</div>
@@ -32,6 +32,12 @@
gap: 0.75rem;
grid-auto-rows: 5rem;
grid-template-columns: repeat(var(--widget-cols), minmax(0, 1fr));
/* Stacked, this is a flex item in an `auto` track, and its automatic minimum
size is the widest thing any widget holds — one long message name would
size the column and take the page sideways with it. The fixed-pixel canvas
the non-stacked path sits on already uses `minmax(0, 1fr)`, so this only
ever matters on a phone. See DESIGN-GUIDELINES.md -> Responsive. */
min-width: 0;
}
.widget-cell {
@@ -66,6 +72,27 @@
height: calc(var(--h) * 5rem + (var(--h) - 1) * 0.75rem);
}
/*
* Motion. A value settling is a neutral state change; a selection indicator
* moving is emphasized (Material). `<MotionConfig reducedMotion="user">` only
* covers `motion/react`, so CSS asks for itself.
*/
@media (prefers-reduced-motion: no-preference) {
/* The arc is the full 240 degrees and the dash hides the rest of it, so the
reading changes by animating one number rather than re-pathing. */
.widget-gauge-arc {
transition: stroke-dashoffset var(--duration-base) var(--ease-standard);
}
/* One indicator that slides between segments, rather than a fill that jumps
from cell to cell. */
.widget-segment-thumb {
transition:
left var(--duration-base) var(--ease-emphasized),
width var(--duration-base) var(--ease-emphasized);
}
}
/*
* uPlot, routed through the tokens. Its own legend is the hover readout as
* well — the value each line carried at the cursor — so it is styled as chart
@@ -77,6 +104,12 @@
margin-top: 0.25rem;
}
/* Series labels default to the message name, which has no spaces to break at
— and a table cannot lay out below its min-content width. */
.u-legend th {
overflow-wrap: anywhere;
}
.u-legend .u-marker {
width: 0.5rem;
height: 0.5rem;
+57 -7
View File
@@ -28,6 +28,7 @@ import {
SelectValue,
} from "@/components/ui/select"
import { cn } from "@/lib/utils"
import { MAX_SEGMENTS, type Segment, segmentsOf } from "./BarWidget"
import { MAX_SERIES, refreshFor } from "./ChartWidget"
import {
CANVAS_PRESETS,
@@ -227,6 +228,14 @@ 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 })
// The icon widget's mapping. Position is the row's identity, as with series.
const rules = (cfg.rules ?? []) as {
at?: unknown
@@ -404,13 +413,54 @@ export function WidgetPanel({
</div>
{widget.type === "bar" ? (
<MessagePicker
kind="bar"
value={str(cfg.inner)}
label="Nested bar"
testId="widget-inner"
onPick={(inner, inner_dtype) => set({ inner, inner_dtype })}
/>
<div className="grid gap-2">
{rows.map((segment, index) => (
<div
// Position is the only identity a segment row has, as with series.
key={`segment-${index}`}
className="flex items-end 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(
rows.map((other, at) =>
at === index ? { ...other, message, dtype } : 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 ? (
<Button
variant="outline"
size="sm"
className="h-8 justify-self-start"
onClick={() => setSegments([...rows, {}])}
data-testid="add-segment"
>
<Plus />
Add segment
</Button>
) : null}
</div>
) : null}
{widget.type === "chart" && !querying ? (
+80 -44
View File
@@ -19,7 +19,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { BarWidget } from "./BarWidget"
import { BarWidget, segmentsOf } from "./BarWidget"
import { ChartWidget } from "./ChartWidget"
import { ClockWidget } from "./ClockWidget"
import { ForecastWidget } from "./ForecastWidget"
@@ -184,9 +184,13 @@ export function widgetIssue(widget: WidgetDef): string | null {
if (!acceptsDtype(widget.type, dtype)) {
return `${bound} is a ${dtype}; a ${WIDGET_LABELS[widget.type].toLowerCase()} cannot carry that.`
}
// Only a bar nests a second reading, and an unrecorded type binds anything.
if (!acceptsDtype(widget.type, text(cfg.inner_dtype) || undefined)) {
return `${text(cfg.inner)} is a ${text(cfg.inner_dtype)}; a bar nests numbers.`
// Only a bar nests further readings, and an unrecorded type binds anything.
// Read through `segmentsOf` so a stacked bar is judged segment by segment
// rather than only in the one-reading shape it used to carry.
for (const segment of segmentsOf(widget)) {
if (!acceptsDtype(widget.type, segment.dtype || undefined)) {
return `${segment.message} is a ${segment.dtype}; a bar nests numbers.`
}
}
if (widget.type === "icon" && !(cfg.rules as unknown[] | undefined)?.length) {
return "This icon has nothing mapped yet."
@@ -235,7 +239,7 @@ export function WidgetFrame({
{title || actions || issue || grip ? (
<div
className={cn(
"flex items-start justify-between gap-2",
"flex min-w-0 items-start justify-between gap-2",
grip && "widget-grip -m-1 cursor-grab p-1 active:cursor-grabbing",
)}
>
@@ -266,7 +270,11 @@ export function WidgetFrame({
</span>
</div>
) : null}
<div className="flex min-h-0 flex-1 flex-col justify-center">
{/* A scroller, not a clip: the header is an earlier sibling, so anything
taller than the card would otherwise paint over the title instead of
being reachable. Centring has to be `safe` — plain `center` overflows
both edges at once and puts the top of a long body out of reach. */}
<div className="flex min-h-0 flex-1 flex-col justify-center-safe overflow-y-auto">
{children}
</div>
</div>
@@ -352,15 +360,21 @@ function GaugeWidget({ widget }: WidgetProps) {
strokeWidth={9}
strokeLinecap="round"
/>
{fraction > 0 ? (
<path
d={arc(start, start + sweep * fraction)}
fill="none"
stroke="var(--primary)"
strokeWidth={9}
strokeLinecap="round"
/>
) : null}
{/* The same full arc as the track, revealed by the dash: `d` is not
transitionable, so a reading that re-paths the arc can only jump.
`pathLength` normalises it to 1, which makes the offset the
fraction itself and saves measuring the geometry. */}
<path
className="widget-gauge-arc"
d={arc(start, start + sweep)}
pathLength={1}
strokeDasharray={1}
style={{ strokeDashoffset: 1 - fraction }}
fill="none"
stroke="var(--primary)"
strokeWidth={9}
strokeLinecap="round"
/>
<text
x={50}
y={54}
@@ -391,7 +405,7 @@ function MarkdownWidget({ widget }: WidgetProps) {
const content = text(config(widget).content)
const lines = content.split("\n")
return (
<div className="grid gap-1 text-sm">
<div className="grid gap-1 break-words text-sm">
{lines.map((line, index) => {
const heading = /^(#{1,3})\s+(.*)$/.exec(line)
const body = heading ? heading[2] : line.replace(/^[-*]\s+/, "")
@@ -476,7 +490,7 @@ function AgendaWidget({ widget }: WidgetProps) {
<li
// Two entries can share a title and a time; position is the identity.
key={`item-${index}`}
className="flex items-baseline gap-2"
className="flex min-w-0 items-baseline gap-2"
>
<span className="shrink-0 text-muted-foreground tabular-nums">
{dayLabel(when, now)}
@@ -515,7 +529,7 @@ function NotificationWidget({ widget }: WidgetProps) {
}
return (
<div className="grid gap-1">
<div className="grid gap-1 break-words">
{title ? (
<p
className={cn(
@@ -566,11 +580,13 @@ function ButtonWidget({ widget, dashboard }: WidgetProps) {
return (
<Button
variant="secondary"
className="w-full"
className="w-full min-w-0"
disabled={pending}
onClick={() => send(cfg.value ?? true)}
>
{text(cfg.label, widget.title || "Send")}
<span className="truncate">
{text(cfg.label, widget.title || "Send")}
</span>
</Button>
)
}
@@ -590,12 +606,12 @@ function SwitchWidget({ widget, dashboard }: WidgetProps) {
return cfg.style === "button" ? (
<Button
variant={on ? "default" : "secondary"}
className="w-full"
className="w-full min-w-0"
aria-pressed={on}
aria-label={widget.title || target}
onClick={() => send(!on)}
>
{on ? "On" : "Off"}
<span className="truncate">{on ? "On" : "Off"}</span>
</Button>
) : (
<div className="flex items-center justify-between gap-2">
@@ -710,30 +726,50 @@ function DropdownWidget({ widget, dashboard }: WidgetProps) {
if (!target) return <Unbound />
if (cfg.style === "segmented") {
const chosen = options.findIndex(
(option) => text(option.value) === text(live?.value),
)
return (
// The one segmented shape: a single border pill, no dividers,
// transparent segments, bg-accent on the selected one.
<fieldset className="flex w-full items-center gap-1 rounded-full border border-border p-1">
// transparent segments, bg-accent on the selected one — held by a thumb
// that slides rather than a fill that jumps from cell to cell. A
// `fieldset` carries `min-inline-size: min-content` from the UA sheet,
// which `w-full` does not override.
<fieldset
className="relative grid w-full min-w-0 items-center rounded-full border border-border p-1"
style={{
gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))`,
}}
>
<legend className="sr-only">{widget.title || target}</legend>
{options.map((option) => {
const selected = text(option.value) === text(live?.value)
return (
<button
key={text(option.value)}
type="button"
aria-pressed={selected}
onClick={() => send(option.value)}
className={cn(
"h-11 min-w-0 flex-1 truncate rounded-full px-2.5 text-sm transition-colors md:h-8",
selected
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50",
)}
>
{option.label ?? text(option.value)}
</button>
)
})}
{chosen >= 0 ? (
// Equal tracks and no gap, so a segment is exactly its share of the
// padded box and the thumb needs no measuring.
<span
aria-hidden
className="widget-segment-thumb pointer-events-none absolute inset-y-1 rounded-full bg-accent"
style={{
left: `calc(0.25rem + ${chosen} * (100% - 0.5rem) / ${options.length})`,
width: `calc((100% - 0.5rem) / ${options.length})`,
}}
/>
) : null}
{options.map((option, index) => (
<button
key={text(option.value)}
type="button"
aria-pressed={index === chosen}
onClick={() => send(option.value)}
className={cn(
"relative z-10 h-11 min-w-0 truncate rounded-full px-2.5 text-sm transition-colors md:h-8",
index === chosen
? "text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50",
)}
>
{option.label ?? text(option.value)}
</button>
))}
</fieldset>
)
}
@@ -745,7 +781,7 @@ function DropdownWidget({ widget, dashboard }: WidgetProps) {
value={text(live?.value)}
onValueChange={(value) => send(asOriginal(value, options))}
>
<SelectTrigger aria-label={widget.title || target}>
<SelectTrigger className="w-full" aria-label={widget.title || target}>
<SelectValue placeholder="Choose" />
</SelectTrigger>
<SelectContent>