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