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
+35 -3
View File
@@ -30,6 +30,12 @@ DASHBOARD_DIR = "_dashboards"
#: A chart cannot ask for an unbounded series; this is the ceiling. #: A chart cannot ask for an unbounded series; this is the ceiling.
HISTORY_CAP = 5000 HISTORY_CAP = 5000
#: How many readings a bar may nest inside its own. The limit is contrast, not
#: layout: the segments share one fill token, because no slot of the chart ramp
#: clears 3:1 against the outer one, and a fourth could not be told from its
#: neighbour. Mirrored in the client (``BarWidget.tsx``).
BAR_SEGMENTS = 3
#: Resolved out here on purpose: the store has a ``list`` method, which #: Resolved out here on purpose: the store has a ``list`` method, which
#: shadows the builtin for any annotation written inside the class. #: shadows the builtin for any annotation written inside the class.
Bindings = list[dict[str, Any]] Bindings = list[dict[str, Any]]
@@ -119,6 +125,23 @@ class WidgetDef(BaseModel):
"""A chart that asks a flow for its series instead of reading the ring.""" """A chart that asks a flow for its series instead of reading the ring."""
return self.type == "chart" and self.config.get("source") == "query" return self.type == "chart" and self.config.get("source") == "query"
@property
def inner_bindings(self) -> Bindings:
"""A bar's nested readings, in either shape a document may carry them.
One binding beside ``inner_dtype``, as a bar was written before it
stacked, or an ordered list of ``{message, dtype}`` — so an older
dashboard keeps drawing without being migrated first.
"""
inner = self.config.get("inner")
if isinstance(inner, list):
return [s for s in inner[:BAR_SEGMENTS] if isinstance(s, dict)]
dtype = self.config.get("inner_dtype")
# A recorded type with nothing bound is still a type to be held to.
if inner or dtype:
return [{"message": inner or "", "dtype": dtype}]
return []
@property @property
def messages(self) -> list[str]: def messages(self) -> list[str]:
"""Every message name this widget reads.""" """Every message name this widget reads."""
@@ -132,8 +155,9 @@ class WidgetDef(BaseModel):
if series.get("message") if series.get("message")
] ]
name = self.config.get("message") name = self.config.get("message")
inner = self.config.get("inner") # only a bar nests a second reading # Only a bar nests further readings inside the one it draws.
return [str(value) for value in (name, inner) if value] nested = [s.get("message") for s in self.inner_bindings]
return [str(value) for value in (name, *nested) if value]
@property @property
def target(self) -> str: def target(self) -> str:
@@ -170,7 +194,10 @@ class WidgetDef(BaseModel):
str(series.get("dtype") or "") str(series.get("dtype") or "")
for series in self.config.get("series") or [] for series in self.config.get("series") or []
] ]
return [str(self.config.get(key) or "") for key in ("dtype", "inner_dtype")] return [
str(self.config.get("dtype") or ""),
*(str(s.get("dtype") or "") for s in self.inner_bindings),
]
@model_validator(mode="after") @model_validator(mode="after")
def _check_binding(self) -> WidgetDef: def _check_binding(self) -> WidgetDef:
@@ -184,6 +211,10 @@ class WidgetDef(BaseModel):
) )
return self return self
inner = self.config.get("inner")
if isinstance(inner, list) and len(inner) > BAR_SEGMENTS:
raise ValueError(f"a bar nests at most {BAR_SEGMENTS} readings")
allowed = WIDGET_DTYPES.get(self.type) allowed = WIDGET_DTYPES.get(self.type)
if not allowed: if not allowed:
return self return self
@@ -510,6 +541,7 @@ def default_dashboard(name: str) -> DashboardDef:
__all__ = [ __all__ = [
"BAR_SEGMENTS",
"DASHBOARD_DIR", "DASHBOARD_DIR",
"HISTORY_CAP", "HISTORY_CAP",
"INPUT_WIDGETS", "INPUT_WIDGETS",
+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" import { cn } from "@/lib/utils"
/** /**
@@ -48,22 +52,41 @@ export function RangePicker({
value: Range value: Range
onChange: (range: Range) => void onChange: (range: Range) => void
}) { }) {
const chosen = RANGES.findIndex((range) => range.hours === value.hours)
return ( 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 <fieldset
data-testid="range-picker" 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> <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 <button
key={range.label} key={range.label}
type="button" type="button"
aria-pressed={range.hours === value.hours} aria-pressed={index === chosen}
onClick={() => onChange(range)} onClick={() => onChange(range)}
className={cn( className={cn(
"rounded-full px-2.5 py-1 text-xs transition-colors", "relative z-10 min-w-0 rounded-full px-2.5 py-1 text-xs transition-colors",
range.hours === value.hours index === chosen
? "bg-accent text-accent-foreground" ? "text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50", : "text-muted-foreground hover:bg-accent/50",
)} )}
> >
+11 -4
View File
@@ -239,11 +239,16 @@ export function UplotChart({
} }
}, [key, ready]) }, [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(() => { useEffect(() => {
if (!chart.current || plots.length === 0) return if (!chart.current || plots.length === 0) return
chart.current.setData(table(plots)) chart.current.setData(table(plots))
}, [points, key]) })
// The canvas cannot follow a CSS variable, so a theme swap is a redraw. The // 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 // paths are geometry and stay as they are — and leaving them alone is what
@@ -272,8 +277,10 @@ export function UplotChart({
) : null} ) : null}
</div> </div>
{/* Kept at the legend's resting height, so the plot does not resize {/* Kept at the legend's resting height, so the plot does not resize
under the pointer the first time a reading arrives. */} under the pointer the first time a reading arrives. uPlot mounts a
<div ref={legend} className="min-h-6 shrink-0" /> 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> </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. */ /** Below this the reading no longer fits on the fill and moves off its end. */
const FITS = 0.3 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 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 * Further messages 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 * inverter's input — and are drawn on the fill on the same scale, stacked end
* containment is what the picture shows rather than something to work out. * 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) { export function BarWidget({ widget }: WidgetProps) {
const cfg = config(widget) const cfg = config(widget)
const message = text(cfg.message) const message = text(cfg.message)
const nested = text(cfg.inner) const segments = segmentsOf(widget)
// 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 outer = useLiveValue(message || undefined) 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) if (!message)
return <p className="text-sm text-muted-foreground">Pick a message.</p> 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}` value === null ? "—" : `${value.toFixed(precision)}${unit}`
const value = reading(outer?.value) const value = reading(outer?.value)
const innerValue = nested ? reading(inner?.value) : null
const fraction = fractionOf(value) const fraction = fractionOf(value)
const fits = fraction >= FITS 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 ( return (
<div <div
className="grid gap-1.5" className="grid gap-1.5"
role="img" role="img"
aria-label={ aria-label={
nested detail
? `${write(value)} of ${max}${unit}, ${write(innerValue)} of it from ${innerName}` ? `${write(value)} of ${max}${unit}, ${detail}`
: `${write(value)} of ${max}${unit}` : `${write(value)} of ${max}${unit}`
} }
> >
<div className="relative h-8 w-full overflow-hidden rounded-full bg-muted"> <div className="relative h-8 w-full overflow-hidden rounded-full bg-muted">
<div <div
data-testid="bar-fill" 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}%` }} style={{ width: `${fraction * 100}%` }}
/> />
{innerValue === null ? null : ( {drawn.map((band, index) => (
<div <div
// Position is the only identity a segment has, as with chart series.
key={`segment-${index}`}
data-testid="bar-inner" data-testid="bar-inner"
className="absolute inset-y-1 left-0 rounded-full bg-chart-5" 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={{ width: `${fractionOf(innerValue) * 100}%` }} 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 <span
className={cn( 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", fits ? "text-primary-foreground" : "text-foreground",
)} )}
style={ style={
@@ -91,9 +160,9 @@ export function BarWidget({ widget }: WidgetProps) {
{write(value)} {write(value)}
</span> </span>
</div> </div>
{innerValue === null ? null : ( {drawn.length === 0 ? null : (
<p className="truncate text-xs text-muted-foreground"> <p className="truncate text-xs text-muted-foreground">
{innerName} {write(innerValue)} {drawn.map((band) => `${band.name} ${write(band.level)}`).join(" · ")}
</p> </p>
)} )}
</div> </div>
@@ -32,6 +32,12 @@
gap: 0.75rem; gap: 0.75rem;
grid-auto-rows: 5rem; grid-auto-rows: 5rem;
grid-template-columns: repeat(var(--widget-cols), minmax(0, 1fr)); 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 { .widget-cell {
@@ -66,6 +72,27 @@
height: calc(var(--h) * 5rem + (var(--h) - 1) * 0.75rem); 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 * 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 * well — the value each line carried at the cursor — so it is styled as chart
@@ -77,6 +104,12 @@
margin-top: 0.25rem; 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 { .u-legend .u-marker {
width: 0.5rem; width: 0.5rem;
height: 0.5rem; height: 0.5rem;
+57 -7
View File
@@ -28,6 +28,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { MAX_SEGMENTS, type Segment, segmentsOf } from "./BarWidget"
import { MAX_SERIES, refreshFor } from "./ChartWidget" import { MAX_SERIES, refreshFor } from "./ChartWidget"
import { import {
CANVAS_PRESETS, CANVAS_PRESETS,
@@ -227,6 +228,14 @@ export function WidgetPanel({
const series = seriesOf(widget) const series = seriesOf(widget)
const setSeries = (next: Series[]) => set({ series: next }) 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. // The icon widget's mapping. Position is the row's identity, as with series.
const rules = (cfg.rules ?? []) as { const rules = (cfg.rules ?? []) as {
at?: unknown at?: unknown
@@ -404,13 +413,54 @@ export function WidgetPanel({
</div> </div>
{widget.type === "bar" ? ( {widget.type === "bar" ? (
<MessagePicker <div className="grid gap-2">
kind="bar" {rows.map((segment, index) => (
value={str(cfg.inner)} <div
label="Nested bar" // Position is the only identity a segment row has, as with series.
testId="widget-inner" key={`segment-${index}`}
onPick={(inner, inner_dtype) => set({ inner, inner_dtype })} 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} ) : null}
{widget.type === "chart" && !querying ? ( {widget.type === "chart" && !querying ? (
+80 -44
View File
@@ -19,7 +19,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip" } from "@/components/ui/tooltip"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { BarWidget } from "./BarWidget" import { BarWidget, segmentsOf } from "./BarWidget"
import { ChartWidget } from "./ChartWidget" import { ChartWidget } from "./ChartWidget"
import { ClockWidget } from "./ClockWidget" import { ClockWidget } from "./ClockWidget"
import { ForecastWidget } from "./ForecastWidget" import { ForecastWidget } from "./ForecastWidget"
@@ -184,9 +184,13 @@ export function widgetIssue(widget: WidgetDef): string | null {
if (!acceptsDtype(widget.type, dtype)) { if (!acceptsDtype(widget.type, dtype)) {
return `${bound} is a ${dtype}; a ${WIDGET_LABELS[widget.type].toLowerCase()} cannot carry that.` 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. // Only a bar nests further readings, and an unrecorded type binds anything.
if (!acceptsDtype(widget.type, text(cfg.inner_dtype) || undefined)) { // Read through `segmentsOf` so a stacked bar is judged segment by segment
return `${text(cfg.inner)} is a ${text(cfg.inner_dtype)}; a bar nests numbers.` // 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) { if (widget.type === "icon" && !(cfg.rules as unknown[] | undefined)?.length) {
return "This icon has nothing mapped yet." return "This icon has nothing mapped yet."
@@ -235,7 +239,7 @@ export function WidgetFrame({
{title || actions || issue || grip ? ( {title || actions || issue || grip ? (
<div <div
className={cn( 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", grip && "widget-grip -m-1 cursor-grab p-1 active:cursor-grabbing",
)} )}
> >
@@ -266,7 +270,11 @@ export function WidgetFrame({
</span> </span>
</div> </div>
) : null} ) : 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} {children}
</div> </div>
</div> </div>
@@ -352,15 +360,21 @@ function GaugeWidget({ widget }: WidgetProps) {
strokeWidth={9} strokeWidth={9}
strokeLinecap="round" strokeLinecap="round"
/> />
{fraction > 0 ? ( {/* The same full arc as the track, revealed by the dash: `d` is not
<path transitionable, so a reading that re-paths the arc can only jump.
d={arc(start, start + sweep * fraction)} `pathLength` normalises it to 1, which makes the offset the
fill="none" fraction itself and saves measuring the geometry. */}
stroke="var(--primary)" <path
strokeWidth={9} className="widget-gauge-arc"
strokeLinecap="round" d={arc(start, start + sweep)}
/> pathLength={1}
) : null} strokeDasharray={1}
style={{ strokeDashoffset: 1 - fraction }}
fill="none"
stroke="var(--primary)"
strokeWidth={9}
strokeLinecap="round"
/>
<text <text
x={50} x={50}
y={54} y={54}
@@ -391,7 +405,7 @@ function MarkdownWidget({ widget }: WidgetProps) {
const content = text(config(widget).content) const content = text(config(widget).content)
const lines = content.split("\n") const lines = content.split("\n")
return ( return (
<div className="grid gap-1 text-sm"> <div className="grid gap-1 break-words text-sm">
{lines.map((line, index) => { {lines.map((line, index) => {
const heading = /^(#{1,3})\s+(.*)$/.exec(line) const heading = /^(#{1,3})\s+(.*)$/.exec(line)
const body = heading ? heading[2] : line.replace(/^[-*]\s+/, "") const body = heading ? heading[2] : line.replace(/^[-*]\s+/, "")
@@ -476,7 +490,7 @@ function AgendaWidget({ widget }: WidgetProps) {
<li <li
// Two entries can share a title and a time; position is the identity. // Two entries can share a title and a time; position is the identity.
key={`item-${index}`} 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"> <span className="shrink-0 text-muted-foreground tabular-nums">
{dayLabel(when, now)} {dayLabel(when, now)}
@@ -515,7 +529,7 @@ function NotificationWidget({ widget }: WidgetProps) {
} }
return ( return (
<div className="grid gap-1"> <div className="grid gap-1 break-words">
{title ? ( {title ? (
<p <p
className={cn( className={cn(
@@ -566,11 +580,13 @@ function ButtonWidget({ widget, dashboard }: WidgetProps) {
return ( return (
<Button <Button
variant="secondary" variant="secondary"
className="w-full" className="w-full min-w-0"
disabled={pending} disabled={pending}
onClick={() => send(cfg.value ?? true)} onClick={() => send(cfg.value ?? true)}
> >
{text(cfg.label, widget.title || "Send")} <span className="truncate">
{text(cfg.label, widget.title || "Send")}
</span>
</Button> </Button>
) )
} }
@@ -590,12 +606,12 @@ function SwitchWidget({ widget, dashboard }: WidgetProps) {
return cfg.style === "button" ? ( return cfg.style === "button" ? (
<Button <Button
variant={on ? "default" : "secondary"} variant={on ? "default" : "secondary"}
className="w-full" className="w-full min-w-0"
aria-pressed={on} aria-pressed={on}
aria-label={widget.title || target} aria-label={widget.title || target}
onClick={() => send(!on)} onClick={() => send(!on)}
> >
{on ? "On" : "Off"} <span className="truncate">{on ? "On" : "Off"}</span>
</Button> </Button>
) : ( ) : (
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
@@ -710,30 +726,50 @@ function DropdownWidget({ widget, dashboard }: WidgetProps) {
if (!target) return <Unbound /> if (!target) return <Unbound />
if (cfg.style === "segmented") { if (cfg.style === "segmented") {
const chosen = options.findIndex(
(option) => text(option.value) === text(live?.value),
)
return ( return (
// The one segmented shape: a single border pill, no dividers, // The one segmented shape: a single border pill, no dividers,
// transparent segments, bg-accent on the selected one. // transparent segments, bg-accent on the selected one — held by a thumb
<fieldset className="flex w-full items-center gap-1 rounded-full border border-border p-1"> // 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> <legend className="sr-only">{widget.title || target}</legend>
{options.map((option) => { {chosen >= 0 ? (
const selected = text(option.value) === text(live?.value) // Equal tracks and no gap, so a segment is exactly its share of the
return ( // padded box and the thumb needs no measuring.
<button <span
key={text(option.value)} aria-hidden
type="button" className="widget-segment-thumb pointer-events-none absolute inset-y-1 rounded-full bg-accent"
aria-pressed={selected} style={{
onClick={() => send(option.value)} left: `calc(0.25rem + ${chosen} * (100% - 0.5rem) / ${options.length})`,
className={cn( width: `calc((100% - 0.5rem) / ${options.length})`,
"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" ) : null}
: "text-muted-foreground hover:bg-accent/50", {options.map((option, index) => (
)} <button
> key={text(option.value)}
{option.label ?? text(option.value)} type="button"
</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> </fieldset>
) )
} }
@@ -745,7 +781,7 @@ function DropdownWidget({ widget, dashboard }: WidgetProps) {
value={text(live?.value)} value={text(live?.value)}
onValueChange={(value) => send(asOriginal(value, options))} onValueChange={(value) => send(asOriginal(value, options))}
> >
<SelectTrigger aria-label={widget.title || target}> <SelectTrigger className="w-full" aria-label={widget.title || target}>
<SelectValue placeholder="Choose" /> <SelectValue placeholder="Choose" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
+13
View File
@@ -48,6 +48,7 @@
--color-popover-foreground: var(--popover-foreground); --color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary); --color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground); --color-primary-foreground: var(--primary-foreground);
--color-primary-nested: var(--primary-nested);
--color-brand-secondary: var(--brand-secondary); --color-brand-secondary: var(--brand-secondary);
--color-brand-secondary-foreground: var(--brand-secondary-foreground); --color-brand-secondary-foreground: var(--brand-secondary-foreground);
--color-secondary: var(--secondary); --color-secondary: var(--secondary);
@@ -86,6 +87,16 @@
* 4.04:1 against white, below AA for the label sitting on a bg-primary fill. * 4.04:1 against white, below AA for the label sitting on a bg-primary fill.
* #59849b remains the wordmark colour (index/src/assets/fluksio-*.svg). See the root * #59849b remains the wordmark colour (index/src/assets/fluksio-*.svg). See the root
* DESIGN-GUIDELINES.md → Colour tokens. * DESIGN-GUIDELINES.md → Colour tokens.
*
* `--primary-nested` is that same hue and saturation a few steps deeper, for
* a reading drawn inside a `--primary` fill — the bar widget's stacked
* segments. No slot of the chart ramp clears the 3:1 non-text guideline
* against `--primary` (`--chart-5`, which the bar used, measures 2.53:1), so
* the nested fill needs a token of its own. It cannot clear 3:1 against
* `--muted` as well in dark: `--primary` and `--muted` are only 5.82:1 apart
* there, and a colour 3:1 from both would have to sit in a 9:1 gap. The
* segment is therefore drawn inside a gutter of the fill rather than ever
* bordering the track.
*/ */
:root { :root {
--background: #ffffff; --background: #ffffff;
@@ -96,6 +107,7 @@
--popover-foreground: #333232; --popover-foreground: #333232;
--primary: #4a7189; --primary: #4a7189;
--primary-foreground: #ffffff; --primary-foreground: #ffffff;
--primary-nested: #152128; /* on --primary 3.14:1, on --muted 14.6:1 */
--brand-secondary: #de8f6e; --brand-secondary: #de8f6e;
--brand-secondary-foreground: #333232; --brand-secondary-foreground: #333232;
--secondary: #f2f2f2; --secondary: #f2f2f2;
@@ -133,6 +145,7 @@
--popover-foreground: #f5f5f5; --popover-foreground: #f5f5f5;
--primary: #7ba3b8; --primary: #7ba3b8;
--primary-foreground: #0a0a0a; --primary-foreground: #0a0a0a;
--primary-nested: #345160; /* on --primary 3.12:1, on --muted 1.86:1 */
--brand-secondary: #e5a184; --brand-secondary: #e5a184;
--brand-secondary-foreground: #0a0a0a; --brand-secondary-foreground: #0a0a0a;
--secondary: #232323; --secondary: #232323;
+126
View File
@@ -40,6 +40,40 @@ async function expectFits(page: Page, where: string) {
expect(scroll, `${where} scrolls sideways`).toBeLessThanOrEqual(inner) expect(scroll, `${where} scrolls sideways`).toBeLessThanOrEqual(inner)
} }
/**
* The same rule, one level in.
*
* A widget body is a scroll container now — content taller than its card is
* reachable rather than painted over the title — and a scroller absorbs a
* sideways overflow before `document.scrollWidth` ever sees it. So the boxes
* are checked for themselves.
*
* Only a box the user can actually drag sideways counts: `truncate` is
* `overflow: hidden`, and hidden content reports a wider `scrollWidth` too
* without anyone being able to reach it. Of the ones that can, only a box that
* asked for it — `overflow-x-auto`, per DESIGN-GUIDELINES.md -> Responsive —
* is allowed to.
*/
async function expectNoInnerScroll(page: Page, where: string) {
const wide = await page.evaluate(() =>
[
...document.querySelectorAll<HTMLElement>(
"[data-testid=dashboard-canvas] *, main *",
),
]
.filter(
(el) =>
!el.classList.contains("overflow-x-auto") &&
["auto", "scroll"].includes(getComputedStyle(el).overflowX) &&
el.scrollWidth > el.clientWidth + 1,
)
.map((el) => `${el.tagName}.${el.className}`.slice(0, 120)),
)
expect(wide, `${where} has a sideways scroller: ${wide.join(" | ")}`).toEqual(
[],
)
}
test.beforeAll(async ({ browser }) => { test.beforeAll(async ({ browser }) => {
const page = await apiPage(browser) const page = await apiPage(browser)
@@ -91,6 +125,10 @@ test.beforeAll(async ({ browser }) => {
{ name: "level", dtype: "float" }, { name: "level", dtype: "float" },
{ name: "pv", dtype: "float" }, { name: "pv", dtype: "float" },
{ name: "days", dtype: "list", item: "record" }, { name: "days", dtype: "list", item: "record" },
// Long, and with nothing to break at: an unlabelled series puts
// this whole name in the chart's legend.
{ name: "climate_series_reading", dtype: "float" },
{ name: "mode", dtype: "str" },
], ],
}, },
], ],
@@ -112,6 +150,14 @@ test.beforeAll(async ({ browser }) => {
method: "POST", method: "POST",
data: { value: 24 }, data: { value: 24 },
}) })
// A chart only builds once it has a reading, and an unbuilt chart has no
// legend to overflow.
for (const value of [12, 14, 13]) {
await api(page, `/messages/${feedName}.climate_series_reading`, {
method: "POST",
data: { value },
})
}
await api(page, `/messages/${feedName}.days`, { await api(page, `/messages/${feedName}.days`, {
method: "POST", method: "POST",
data: { data: {
@@ -167,6 +213,52 @@ test.beforeAll(async ({ browser }) => {
layout: { lg: { x: 0, y: 4, w: 6, h: 2 } }, layout: { lg: { x: 0, y: 4, w: 6, h: 2 } },
config: { message: `${feedName}.days`, dtype: "list", count: 5 }, config: { message: `${feedName}.days`, dtype: "list", count: 5 },
}, },
{
// No label, so uPlot's legend carries the message name — a table cell
// holding one unbroken token.
id: "trend",
type: "chart",
title: "Trend",
layout: { lg: { x: 0, y: 6, w: 6, h: 4 } },
config: {
series: [
{ message: `${feedName}.climate_series_reading`, dtype: "float" },
],
},
},
{
// Five segments of prose in a pill that has to fit a phone.
id: "mode",
type: "dropdown",
title: "Mode",
layout: { lg: { x: 0, y: 10, w: 4, h: 2 } },
config: {
target: `${feedName}.mode`,
dtype: "str",
style: "segmented",
options: [
{ label: "Comfort heating", value: "comfort" },
{ label: "Economy overnight", value: "economy" },
{ label: "Away from home", value: "away" },
{ label: "Boost for an hour", value: "boost" },
{ label: "Frost protection only", value: "frost" },
],
},
},
{
// Far taller than the card it is given: the body has to scroll rather
// than run out under the title.
id: "notes",
type: "markdown",
title: "Notes",
layout: { lg: { x: 0, y: 12, w: 6, h: 2 } },
config: {
content: Array.from(
{ length: 40 },
(_, index) => `- Line ${index + 1}`,
).join("\n"),
},
},
] ]
const draft = await ( const draft = await (
await api(page, `/dashboards/${dashboardName}`, { await api(page, `/dashboards/${dashboardName}`, {
@@ -259,6 +351,7 @@ test("a dashboard stacks instead of shrinking", async ({ page }) => {
await page.goto(`/dashboards/${dashboardName}`) await page.goto(`/dashboards/${dashboardName}`)
await page.waitForSelector("[data-testid=widget-frame]", { timeout: 15000 }) await page.waitForSelector("[data-testid=widget-frame]", { timeout: 15000 })
await expectFits(page, "the dashboard editor") await expectFits(page, "the dashboard editor")
await expectNoInnerScroll(page, "the dashboard editor")
// Side by side on a panel, one under the other here. // Side by side on a panel, one under the other here.
const first = await page.getByTestId("widget-frame").first().boundingBox() const first = await page.getByTestId("widget-frame").first().boundingBox()
@@ -271,5 +364,38 @@ test("a dashboard stacks instead of shrinking", async ({ page }) => {
test("the panel view fits the viewport", async ({ page }) => { test("the panel view fits the viewport", async ({ page }) => {
await page.goto(`/view/${dashboardName}`) await page.goto(`/view/${dashboardName}`)
await page.waitForSelector("[data-testid=widget-frame]", { timeout: 15000 }) await page.waitForSelector("[data-testid=widget-frame]", { timeout: 15000 })
// uPlot's legend is the widest thing on the page and only exists once the
// chart has drawn, so there is nothing to measure until it does.
await page.locator(".u-legend").first().waitFor({ timeout: 15000 })
await expectFits(page, "the panel view") await expectFits(page, "the panel view")
await expectNoInnerScroll(page, "the panel view")
})
test("a widget scrolls rather than running out under its title", async ({
page,
}) => {
await page.goto(`/view/${dashboardName}`)
const notes = page
.getByTestId("widget-frame")
.filter({ hasText: "Notes" })
.first()
await notes.waitFor({ timeout: 15000 })
// The body, not the card: the card clips, and clipping is what used to let
// the lines paint over the header rather than scroll under it.
const scrolls = await notes.evaluate((frame) =>
[...frame.children].some(
(child) => child.scrollHeight > child.clientHeight + 1,
),
)
expect(scrolls, "forty lines fit a two-row card").toBe(true)
const title = await notes.getByText("Notes", { exact: true }).boundingBox()
const first = await notes.getByText("• Line 1", { exact: true }).boundingBox()
expect(title).not.toBeNull()
expect(first).not.toBeNull()
expect(
first!.y,
"the first line is drawn over the title",
).toBeGreaterThanOrEqual(title!.y + title!.height - 1)
}) })
+117 -2
View File
@@ -14,6 +14,8 @@ import { api, apiPage, deleteAll } from "./utils/api"
const flowName = `test_widgets_${Date.now().toString(36)}` const flowName = `test_widgets_${Date.now().toString(36)}`
const dashboardName = `${flowName}_panel` const dashboardName = `${flowName}_panel`
/** A panel of its own: a stacked bar needs the only `bar-inner` on the page. */
const stackName = `${flowName}_stack`
/** A message of the flow under test, qualified the way the engine names it. */ /** A message of the flow under test, qualified the way the engine names it. */
const w = (name: string) => `${flowName}.${name}` const w = (name: string) => `${flowName}.${name}`
@@ -47,6 +49,7 @@ test.beforeAll(async ({ browser }) => {
provides: [ provides: [
{ name: "level", dtype: "float" }, { name: "level", dtype: "float" },
{ name: "pv", dtype: "float" }, { name: "pv", dtype: "float" },
{ name: "grid", dtype: "float" },
{ name: "condition", dtype: "str" }, { name: "condition", dtype: "str" },
{ name: "days", dtype: "list", item: "record" }, { name: "days", dtype: "list", item: "record" },
{ name: "mode", dtype: "str" }, { name: "mode", dtype: "str" },
@@ -66,6 +69,7 @@ test.beforeAll(async ({ browser }) => {
await publish(page, w("level"), 80) await publish(page, w("level"), 80)
await publish(page, w("pv"), 30) await publish(page, w("pv"), 30)
await publish(page, w("grid"), 20)
await publish(page, w("condition"), "sun") await publish(page, w("condition"), "sun")
await publish(page, w("days"), [ await publish(page, w("days"), [
{ label: "Mon", icon: "sun", value: "21°" }, { label: "Mon", icon: "sun", value: "21°" },
@@ -174,22 +178,75 @@ test.beforeAll(async ({ browser }) => {
method: "POST", method: "POST",
data: { version: draft.version }, data: { version: draft.version },
}) })
await api(page, `/dashboards/${stackName}`, { method: "POST" })
const stack = await (await api(page, `/dashboards/${stackName}`)).json()
stack.pages[0].sections[0].widgets = [
{
id: "split",
type: "bar",
title: "Split",
layout: { lg: { x: 0, y: 0, w: 6, h: 2 } },
config: {
message: w("level"),
dtype: "float",
// The list shape. The panel above keeps the single binding a bar was
// written with, which is what proves both are still read.
inner: [
{ message: w("pv"), dtype: "float" },
{ message: w("grid"), dtype: "float" },
],
min: 0,
max: 100,
unit: " kW",
},
},
]
const stacked = await (
await api(page, `/dashboards/${stackName}`, { method: "PUT", data: stack })
).json()
await api(page, `/dashboards/${stackName}/publish`, {
method: "POST",
data: { version: stacked.version },
})
await page.close() await page.close()
}) })
test.afterAll(async ({ browser }) => { test.afterAll(async ({ browser }) => {
await deleteAll(browser, [ await deleteAll(browser, [
`/dashboards/${dashboardName}`, `/dashboards/${dashboardName}`,
`/dashboards/${stackName}`,
`/flows/${flowName}`, `/flows/${flowName}`,
]) ])
}) })
/** The panel as a wall panel opens it, once the tiles are drawn. */ /** The panel as a wall panel opens it, once the tiles are drawn. */
async function openPanel(page: Page) { async function openPanel(page: Page, name = dashboardName) {
await page.goto(`/view/${dashboardName}`) await page.goto(`/view/${name}`)
await page.waitForSelector("[data-testid=widget-frame]", { timeout: 15000 }) await page.waitForSelector("[data-testid=widget-frame]", { timeout: 15000 })
} }
/**
* WCAG contrast of two `rgb(...)` paints, so a fill can be held to the 3:1
* guideline for non-text rather than eyeballed on a screenshot.
*/
function contrast(first: string, second: string) {
const luminance = (paint: string) => {
const channel = (value: number) => {
const scaled = value / 255
return scaled <= 0.03928
? scaled / 12.92
: ((scaled + 0.055) / 1.055) ** 2.4
}
const [r, g, b] = (paint.match(/[\d.]+/g) ?? []).slice(0, 3).map(Number)
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
}
const [dark, light] = [luminance(first), luminance(second)].sort(
(a, b) => a - b,
)
return (light + 0.05) / (dark + 0.05)
}
test("a nested bar is drawn inside its outer fill", async ({ page }) => { test("a nested bar is drawn inside its outer fill", async ({ page }) => {
await openPanel(page) await openPanel(page)
@@ -207,6 +264,51 @@ test("a nested bar is drawn inside its outer fill", async ({ page }) => {
).toBeLessThan(outerBox!.width) ).toBeLessThan(outerBox!.width)
}) })
test("a nested reading larger than the outer one is clamped to it", async ({
page,
}) => {
await openPanel(page)
await publish(page, w("pv"), 120)
// Written from the same reading, so the caption says when it landed.
await expect(page.getByText(/120\.0 kW/)).toBeVisible()
const outerBox = await page.getByTestId("bar-fill").boundingBox()
const innerBox = await page.getByTestId("bar-inner").boundingBox()
expect(
innerBox!.width,
"a nested value over the reading spills onto the track",
).toBeLessThanOrEqual(outerBox!.width)
await publish(page, w("pv"), 30)
})
test("a second nested reading starts where the first ends", async ({
page,
}) => {
await openPanel(page, stackName)
const segments = page.getByTestId("bar-inner")
await expect(segments).toHaveCount(2)
const first = await segments.nth(0).boundingBox()
const second = await segments.nth(1).boundingBox()
const outerBox = await page.getByTestId("bar-fill").boundingBox()
// Stacked rather than drawn over one another: the only gap between them is
// the gutter that tells them apart, and neither leaves the fill.
const gap = second!.x - (first!.x + first!.width)
expect(gap, "the segments are drawn over one another").toBeGreaterThanOrEqual(
0,
)
expect(
gap,
"the second segment does not follow the first",
).toBeLessThanOrEqual(3)
expect(second!.x + second!.width).toBeLessThanOrEqual(
outerBox!.x + outerBox!.width + 1,
)
})
test("the icon follows what the message says", async ({ page }) => { test("the icon follows what the message says", async ({ page }) => {
await openPanel(page) await openPanel(page)
const glyph = page.getByTestId("icon-glyph") const glyph = page.getByTestId("icon-glyph")
@@ -306,6 +408,19 @@ for (const scheme of ["light", "dark"] as const) {
test(`the panel reads in ${scheme}`, async ({ page }) => { test(`the panel reads in ${scheme}`, async ({ page }) => {
await openPanel(page) await openPanel(page)
await expect(page.getByTestId("bar-inner")).toBeVisible() await expect(page.getByTestId("bar-inner")).toBeVisible()
// The nested fill is a picture, so it owes the 3:1 guideline for
// non-text against the fill it sits on — measured, not eyeballed.
const paint = (testId: string) =>
page
.getByTestId(testId)
.evaluate((el) => getComputedStyle(el).backgroundColor)
const ratio = contrast(await paint("bar-fill"), await paint("bar-inner"))
expect(
ratio,
`the nested fill measures ${ratio.toFixed(2)}:1 on the outer one`,
).toBeGreaterThanOrEqual(3)
await page.screenshot({ path: `screenshots/widgets/${scheme}.png` }) await page.screenshot({ path: `screenshots/widgets/${scheme}.png` })
}) })
}) })