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:
@@ -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",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user