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 9c149f9ed4
commit c69c9797d5
10 changed files with 589 additions and 85 deletions
+126
View File
@@ -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<HTMLElement>(
"[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)
})
+117 -2
View File
@@ -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` })
})
})