Rework the dashboard into two looks over one behaviour

A dashboard is a wall panel somebody hangs in their own hallway, so it
now wears what they choose: a look, and a palette of their own colours.

Two complete component sets live under `Dashboard/ui/` — `glass`
(translucent panes over a slowly moving ground) and `material` (Material
3 tonal cards) — behind one prop contract. Every control's state,
keyboard and `aria-` live in `ui/core` and are shared, so the two sets
are the same dashboard drawn twice rather than two products: a set only
decides what a control looks like while doing it.

Four settings join the channel, each drivable by a flow like any other:
`look`, `palette`, `background` and `touch`. A palette is an ordered list
of hex colours — background, surface, primary, accent, text, then more
chart colours — pasted from a coolors.co link or typed, written onto the
canvas as the token variables everything already reads. Trailing roles
are derived, so three colours are a whole dashboard, and derived text is
held to AA rather than trusted (`theme.check.ts` measures it). A palette
also decides light or dark, since its first colour is the ground.

Widgets are measured against their own tile with container queries rather
than against the viewport, animate through `motion`, and can be drawn
without their title. The three reworks:

- a bar draws a row per reading, up to eight, each in the dashboard's own
  data colours and each able to carry its own scale — replacing readings
  nested in one fill, which could only ever share one colour and stop at
  three. Documents written the old way are read as rows.
- a chart's range picker moved to a column down its right-hand edge, which
  gives the plot back a whole row of a short tile.
- the colour wheel became a disc: hue is the angle and saturation the
  distance from the middle, so a colour is one gesture rather than three,
  with brightness on a slider beside it.

`index.css` and `lib/motion.ts` are untouched — the dashboard overrides
token *values* on its canvas, never the blocks the two repos share.
This commit is contained in:
2026-08-23 21:52:14 +02:00
parent d4c5af4d5a
commit 0b5ce4fcbb
52 changed files with 5517 additions and 1568 deletions
+138 -75
View File
@@ -14,7 +14,7 @@ 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. */
/** A panel of its own: a bar of three rows needs a tile to itself. */
const stackName = `${flowName}_stack`
/** A message of the flow under test, qualified the way the engine names it. */
@@ -220,93 +220,90 @@ test.afterAll(async ({ browser }) => {
])
})
/** The panel as a wall panel opens it, once the tiles are drawn. */
/** The panel as a wall panel opens it, once the tiles are drawn.
*
* Widgets arrive as a page rather than all at once, so "drawn" means settled:
* the first frame exists a moment before the last one has faded in, and a
* screenshot taken between the two shows half a dashboard.
*/
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,
await page.waitForFunction(
() =>
[...document.querySelectorAll(".widget-cell")].every(
(cell) => Number(getComputedStyle(cell).opacity) > 0.99,
),
undefined,
{ timeout: 15000 },
)
return (light + 0.05) / (dark + 0.05)
}
test("a nested bar is drawn inside its outer fill", async ({ page }) => {
test("a bar draws a row per reading", async ({ page }) => {
await openPanel(page)
const fill = page.getByTestId("bar-fill")
const inner = page.getByTestId("bar-inner")
await expect(inner).toBeVisible()
// The panel's bar is stored the way a bar was written before it had rows —
// one reading with a second nested in it — so drawing two rows is also what
// proves that shape is still read.
const rows = page.getByTestId("bar-row")
await expect(rows).toHaveCount(2)
const outerBox = await fill.boundingBox()
const innerBox = await inner.boundingBox()
expect(outerBox).not.toBeNull()
expect(innerBox).not.toBeNull()
const fills = page.getByTestId("bar-fill")
const outer = await fills.nth(0).boundingBox()
const inner = await fills.nth(1).boundingBox()
expect(outer).not.toBeNull()
expect(inner).not.toBeNull()
expect(
innerBox!.width,
"the nested share is not drawn inside the reading it is part of",
).toBeLessThan(outerBox!.width)
inner!.width,
"the smaller reading is not drawn as the shorter bar",
).toBeLessThan(outer!.width)
// Told apart by colour rather than by nesting, which is what lets a row
// carry a scale of its own.
const paint = (index: number) =>
fills.nth(index).evaluate((el) => getComputedStyle(el).backgroundColor)
expect(await paint(0), "two rows are drawn in the same colour").not.toEqual(
await paint(1),
)
})
test("a nested reading larger than the outer one is clamped to it", async ({
test("a reading over its scale fills the track and no more", 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()
await expect(page.getByText(/120\.00? kW/)).toBeVisible()
const outerBox = await page.getByTestId("bar-fill").boundingBox()
const innerBox = await page.getByTestId("bar-inner").boundingBox()
const fill = await page.getByTestId("bar-fill").nth(1).boundingBox()
const track = await page
.getByTestId("bar-row")
.nth(1)
.locator(".dui-bar-track")
.boundingBox()
expect(
innerBox!.width,
"a nested value over the reading spills onto the track",
).toBeLessThanOrEqual(outerBox!.width)
fill!.width,
"a value over the scale runs off the end of its track",
).toBeLessThanOrEqual(track!.width + 1)
await publish(page, w("pv"), 30)
})
test("a second nested reading starts where the first ends", async ({
page,
}) => {
test("rows are drawn in the order they were configured", 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()
const rows = page.getByTestId("bar-row")
await expect(rows).toHaveCount(3)
const first = await rows.nth(0).boundingBox()
const second = await rows.nth(1).boundingBox()
expect(second!.y, "the second row is not below the first").toBeGreaterThan(
first!.y,
)
// 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,
)
// Each row names what it reads, so a stack of three is legible without the
// widget's title having to list them.
await expect(rows.nth(1)).toContainText(/pv/i)
await expect(rows.nth(2)).toContainText(/grid/i)
})
test("the icon follows what the message says", async ({ page }) => {
@@ -464,27 +461,93 @@ test("a chart's cursor follows the pointer", async ({ page }) => {
).toBeLessThan(3)
})
/**
* What a dashboard was told to wear, as a wall panel would be told.
*
* The page has to be on the app first: the token these calls carry is read out
* of its local storage, and `about:blank` has none to read.
*/
async function setLook(
page: Page,
name: string,
settings: Record<string, unknown>,
) {
const current = await (
await api(page, `/dashboards/${name}?draft=true`)
).json()
const next = await (
await api(page, `/dashboards/${name}`, {
method: "PUT",
data: { ...current, settings: { ...current.settings, ...settings } },
})
).json()
await api(page, `/dashboards/${name}/publish`, {
method: "POST",
data: { version: next.version },
})
}
// Both looks, in both themes. A look is mostly colour and depth, so the four
// screenshots are the assertion that neither collapses — and the checks around
// them are that a look changes how the panel is drawn and nothing else.
for (const scheme of ["light", "dark"] as const) {
test.describe(`${scheme} theme`, () => {
test.use({ colorScheme: scheme })
test(`the panel reads in ${scheme}`, async ({ page }) => {
for (const look of ["material", "glass"] as const) {
test(`the panel reads in ${scheme} ${look}`, async ({ page }) => {
await openPanel(page)
await setLook(page, dashboardName, { look: { value: look } })
await openPanel(page)
await expect(page.getByTestId("canvas-surface")).toHaveAttribute(
"data-look",
look,
)
// Every widget still draws, whichever set is asked to draw it.
await expect(page.getByTestId("widget-frame")).toHaveCount(TILES)
await expect(page.getByTestId("bar-row")).toHaveCount(2)
await page.screenshot({
path: `screenshots/widgets/${scheme}-${look}.png`,
})
})
}
test(`a palette recolours the panel in ${scheme}`, async ({ page }) => {
await openPanel(page)
await setLook(page, dashboardName, {
look: { value: "glass" },
// Ground, surface, primary, accent — the roles, in order.
palette: {
value: ["#264653", "#2a9d8f", "#e9c46a", "#f4a261"],
},
})
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"))
const surface = page.getByTestId("canvas-surface")
// The ground decides light or dark, so a dark palette reads dark
// whatever the device asked for.
await expect(surface).toHaveClass(/dark/)
await expect(surface).toHaveAttribute("data-palette", "")
await expect(page.getByTestId("canvas-ground")).toBeVisible()
const primary = await page
.getByTestId("bar-fill")
.first()
.evaluate((el) => getComputedStyle(el).backgroundColor)
expect(
ratio,
`the nested fill measures ${ratio.toFixed(2)}:1 on the outer one`,
).toBeGreaterThanOrEqual(3)
primary,
"the first row is not drawn in the palette's primary",
).toBe("rgb(233, 196, 106)")
await page.screenshot({ path: `screenshots/widgets/${scheme}.png` })
await page.screenshot({
path: `screenshots/widgets/${scheme}-palette.png`,
})
await setLook(page, dashboardName, {
look: { value: "material" },
palette: { value: [] },
})
})
})
}