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 d375ea97cc
commit fe1ba46d4e
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.
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
#: shadows the builtin for any annotation written inside the class.
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."""
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
def messages(self) -> list[str]:
"""Every message name this widget reads."""
@@ -132,8 +155,9 @@ class WidgetDef(BaseModel):
if series.get("message")
]
name = self.config.get("message")
inner = self.config.get("inner") # only a bar nests a second reading
return [str(value) for value in (name, inner) if value]
# Only a bar nests further readings inside the one it draws.
nested = [s.get("message") for s in self.inner_bindings]
return [str(value) for value in (name, *nested) if value]
@property
def target(self) -> str:
@@ -170,7 +194,10 @@ class WidgetDef(BaseModel):
str(series.get("dtype") 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")
def _check_binding(self) -> WidgetDef:
@@ -184,6 +211,10 @@ class WidgetDef(BaseModel):
)
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)
if not allowed:
return self
@@ -510,6 +541,7 @@ def default_dashboard(name: str) -> DashboardDef:
__all__ = [
"BAR_SEGMENTS",
"DASHBOARD_DIR",
"HISTORY_CAP",
"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"
/**
@@ -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>
+13
View File
@@ -48,6 +48,7 @@
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-primary-nested: var(--primary-nested);
--color-brand-secondary: var(--brand-secondary);
--color-brand-secondary-foreground: var(--brand-secondary-foreground);
--color-secondary: var(--secondary);
@@ -86,6 +87,16 @@
* 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
* 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 {
--background: #ffffff;
@@ -96,6 +107,7 @@
--popover-foreground: #333232;
--primary: #4a7189;
--primary-foreground: #ffffff;
--primary-nested: #152128; /* on --primary 3.14:1, on --muted 14.6:1 */
--brand-secondary: #de8f6e;
--brand-secondary-foreground: #333232;
--secondary: #f2f2f2;
@@ -133,6 +145,7 @@
--popover-foreground: #f5f5f5;
--primary: #7ba3b8;
--primary-foreground: #0a0a0a;
--primary-nested: #345160; /* on --primary 3.12:1, on --muted 1.86:1 */
--brand-secondary: #e5a184;
--brand-secondary-foreground: #0a0a0a;
--secondary: #232323;
+126
View File
@@ -40,6 +40,40 @@ async function expectFits(page: Page, where: string) {
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 }) => {
const page = await apiPage(browser)
@@ -91,6 +125,10 @@ test.beforeAll(async ({ browser }) => {
{ name: "level", dtype: "float" },
{ name: "pv", dtype: "float" },
{ 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",
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`, {
method: "POST",
data: {
@@ -167,6 +213,52 @@ test.beforeAll(async ({ browser }) => {
layout: { lg: { x: 0, y: 4, w: 6, h: 2 } },
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 (
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.waitForSelector("[data-testid=widget-frame]", { timeout: 15000 })
await expectFits(page, "the dashboard editor")
await expectNoInnerScroll(page, "the dashboard editor")
// Side by side on a panel, one under the other here.
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 }) => {
await page.goto(`/view/${dashboardName}`)
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 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 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. */
const w = (name: string) => `${flowName}.${name}`
@@ -47,6 +49,7 @@ test.beforeAll(async ({ browser }) => {
provides: [
{ name: "level", dtype: "float" },
{ name: "pv", dtype: "float" },
{ name: "grid", dtype: "float" },
{ name: "condition", dtype: "str" },
{ name: "days", dtype: "list", item: "record" },
{ name: "mode", dtype: "str" },
@@ -66,6 +69,7 @@ test.beforeAll(async ({ browser }) => {
await publish(page, w("level"), 80)
await publish(page, w("pv"), 30)
await publish(page, w("grid"), 20)
await publish(page, w("condition"), "sun")
await publish(page, w("days"), [
{ label: "Mon", icon: "sun", value: "21°" },
@@ -174,22 +178,75 @@ test.beforeAll(async ({ browser }) => {
method: "POST",
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()
})
test.afterAll(async ({ browser }) => {
await deleteAll(browser, [
`/dashboards/${dashboardName}`,
`/dashboards/${stackName}`,
`/flows/${flowName}`,
])
})
/** The panel as a wall panel opens it, once the tiles are drawn. */
async function openPanel(page: Page) {
await page.goto(`/view/${dashboardName}`)
async function openPanel(page: Page, name = dashboardName) {
await page.goto(`/view/${name}`)
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 }) => {
await openPanel(page)
@@ -207,6 +264,51 @@ test("a nested bar is drawn inside its outer fill", async ({ page }) => {
).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 }) => {
await openPanel(page)
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 }) => {
await openPanel(page)
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` })
})
})