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:
@@ -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