From fe1ba46d4eeffcbf2b2a8b43f873c785ea7dc08d Mon Sep 17 00:00:00 2001 From: stroblme Date: Fri, 21 Aug 2026 10:11:44 +0200 Subject: [PATCH] Stack a bar's readings, and stop widgets taking the phone sideways MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01Uq8mtNb97A7praJLyeEYgs --- backend/app/flow/dashboards.py | 38 +++++- .../src/components/Common/RangePicker.tsx | 35 ++++- frontend/src/components/Common/UplotChart.tsx | 15 ++- .../src/components/Dashboard/BarWidget.tsx | 107 ++++++++++++--- .../src/components/Dashboard/dashboard.css | 33 +++++ frontend/src/components/Dashboard/panels.tsx | 64 ++++++++- frontend/src/components/Dashboard/widgets.tsx | 124 +++++++++++------ frontend/src/index.css | 13 ++ frontend/tests/mobile.spec.ts | 126 ++++++++++++++++++ frontend/tests/widgets.spec.ts | 119 ++++++++++++++++- 10 files changed, 589 insertions(+), 85 deletions(-) diff --git a/backend/app/flow/dashboards.py b/backend/app/flow/dashboards.py index cc9314d..a049e01 100644 --- a/backend/app/flow/dashboards.py +++ b/backend/app/flow/dashboards.py @@ -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", diff --git a/frontend/src/components/Common/RangePicker.tsx b/frontend/src/components/Common/RangePicker.tsx index 8985f28..8b90a7a 100644 --- a/frontend/src/components/Common/RangePicker.tsx +++ b/frontend/src/components/Common/RangePicker.tsx @@ -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.
Time range - {RANGES.map((range) => ( + {chosen >= 0 ? ( + + ) : null} + {RANGES.map((range, index) => ( + + ))} + {rows.length < MAX_SEGMENTS ? ( + + ) : null} + ) : null} {widget.type === "chart" && !querying ? ( diff --git a/frontend/src/components/Dashboard/widgets.tsx b/frontend/src/components/Dashboard/widgets.tsx index a611c69..edfec36 100644 --- a/frontend/src/components/Dashboard/widgets.tsx +++ b/frontend/src/components/Dashboard/widgets.tsx @@ -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 ? (
@@ -266,7 +270,11 @@ export function WidgetFrame({
) : null} -
+ {/* 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. */} +
{children}
@@ -352,15 +360,21 @@ function GaugeWidget({ widget }: WidgetProps) { strokeWidth={9} strokeLinecap="round" /> - {fraction > 0 ? ( - - ) : 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. */} + +
{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) {
  • {dayLabel(when, now)} @@ -515,7 +529,7 @@ function NotificationWidget({ widget }: WidgetProps) { } return ( -
    +
    {title ? (

    send(cfg.value ?? true)} > - {text(cfg.label, widget.title || "Send")} + + {text(cfg.label, widget.title || "Send")} + ) } @@ -590,12 +606,12 @@ function SwitchWidget({ widget, dashboard }: WidgetProps) { return cfg.style === "button" ? ( ) : (

    @@ -710,30 +726,50 @@ function DropdownWidget({ widget, dashboard }: WidgetProps) { if (!target) return 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. -
    + // 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. +
    {widget.title || target} - {options.map((option) => { - const selected = text(option.value) === text(live?.value) - return ( - - ) - })} + {chosen >= 0 ? ( + // Equal tracks and no gap, so a segment is exactly its share of the + // padded box and the thumb needs no measuring. + + ) : null} + {options.map((option, index) => ( + + ))}
    ) } @@ -745,7 +781,7 @@ function DropdownWidget({ widget, dashboard }: WidgetProps) { value={text(live?.value)} onValueChange={(value) => send(asOriginal(value, options))} > - + diff --git a/frontend/src/index.css b/frontend/src/index.css index cb3daaa..94cbca4 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; diff --git a/frontend/tests/mobile.spec.ts b/frontend/tests/mobile.spec.ts index fa37eb8..2be33c3 100644 --- a/frontend/tests/mobile.spec.ts +++ b/frontend/tests/mobile.spec.ts @@ -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( + "[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) }) diff --git a/frontend/tests/widgets.spec.ts b/frontend/tests/widgets.spec.ts index 84f6478..4c55168 100644 --- a/frontend/tests/widgets.spec.ts +++ b/frontend/tests/widgets.spec.ts @@ -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` }) }) })