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