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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user