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:
+138
-75
@@ -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: [] },
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user