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
+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>
)
}