From 4f8e55d95b52180f1c5f5e9ce5968154fd14da28 Mon Sep 17 00:00:00 2001 From: stroblme Date: Sun, 23 Aug 2026 23:28:47 +0200 Subject: [PATCH] Answer five things the panel got wrong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A tile no longer lifts under the pointer. A finger does not move away afterwards the way a cursor does, so whatever hover raised stayed raised until something else was touched: a tile stuck, not answering. - The ground's blobs wander a closed path on their own clock instead of sliding back and forth along one line, which read as things moving rather than as light in a room. - A bar is one grid now, its rows borrowing its columns, so names of different lengths no longer start and end their tracks in different places — two bars that share no baseline cannot be compared, which is the one thing a stack of them is for. The names read rightward into their tracks, with room either side. - A reading on its way somewhere is written to as many decimals as the value it is heading for. Without that a slider stepping in halves passed through 22.37460937 on its way to 24: a number nobody asked for, a different width every frame. - The brightness column is the same control as the slider widget's, stood on its end and thicker, and exactly as tall as the disc beside it. Getting there meant drawing a slider's rail, fill and handle rather than styling `::-webkit-slider-*`: those need one set of rules per orientation, each with its own centring quirk, and the handle landed off its track when the writing mode turned. The native input stays, laid transparent over the top, so the keyboard, the pointer and every `aria-` are still its. --- NOTEPAD.md | 5 +- .../Dashboard/ui/core/config.check.ts | 18 +- .../components/Dashboard/ui/core/config.ts | 16 ++ .../src/components/Dashboard/ui/core/core.css | 183 ++++++++++++++++-- .../components/Dashboard/ui/core/motion.ts | 14 +- .../components/Dashboard/ui/core/values.ts | 22 ++- .../Dashboard/ui/glass/Controls.tsx | 60 +++--- .../components/Dashboard/ui/glass/Data.tsx | 2 +- .../Dashboard/ui/glass/Surfaces.tsx | 31 +-- .../components/Dashboard/ui/glass/glass.css | 77 +------- .../Dashboard/ui/material/Controls.tsx | 62 +++--- .../components/Dashboard/ui/material/Data.tsx | 2 +- .../Dashboard/ui/material/material.css | 77 +------- frontend/tests/widgets.spec.ts | 70 ++++++- 14 files changed, 403 insertions(+), 236 deletions(-) diff --git a/NOTEPAD.md b/NOTEPAD.md index 126ec04..8480e9d 100644 --- a/NOTEPAD.md +++ b/NOTEPAD.md @@ -25,8 +25,9 @@ Deferring because out of scope is fine, but don't mention deferring than. - CHORE/UI: a stacked dashboard on a phone gets no ground — no blobs, no background image, just the palette's own colour. The canvas is what carries it, and a stacked panel is not drawn on one. -- CHORE/UI: the Glass look's ground is three compositor-animated gradients. If - a low-end wall panel stutters, drop one or set `LOOK.glass.drift` to 0. +- CHORE/UI: the Glass look's ground is three compositor-animated gradients, each + wandering a closed path on its own clock. If a low-end wall panel stutters, + drop one or set `LOOK.glass.drift` to 0. - CHORE/UI: the app still has three copies of the segmented pill (`ui/segmented.tsx`, `Common/RangePicker.tsx`, and the flow screens). The dashboard no longer shares them — it has its own, one per look — so diff --git a/frontend/src/components/Dashboard/ui/core/config.check.ts b/frontend/src/components/Dashboard/ui/core/config.check.ts index 05bf787..69918b4 100644 --- a/frontend/src/components/Dashboard/ui/core/config.check.ts +++ b/frontend/src/components/Dashboard/ui/core/config.check.ts @@ -11,7 +11,14 @@ import assert from "node:assert/strict" import type { WidgetDef } from "@/client" -import { format, fractionOf, MAX_ROWS, rowsOf, showTitle } from "./config" +import { + decimalsOf, + format, + fractionOf, + MAX_ROWS, + rowsOf, + showTitle, +} from "./config" const bar = (config: Record): WidgetDef => ({ id: "b", type: "bar", config }) as WidgetDef @@ -89,6 +96,15 @@ assert.equal( "a scale of no width fills rather than dividing by zero", ) +// What a reading is worth decides how it is written while it is on its way +// there: a value tweening toward 21.5 must not pass through 21.37460937. +assert.equal(decimalsOf(21), 0) +assert.equal(decimalsOf(21.5), 1) +assert.equal(decimalsOf(21.25), 2) +assert.equal(decimalsOf(-0.125), 3) +assert.equal(decimalsOf(1e-9), 0, "an exponent is not a count of decimals") +assert.ok(decimalsOf(1 / 3) <= 6, "a repeating decimal is capped") + assert.equal(format(1.234, 1), "1.2") assert.equal(format(true, null), "On") assert.equal(format(null, 1), "—") diff --git a/frontend/src/components/Dashboard/ui/core/config.ts b/frontend/src/components/Dashboard/ui/core/config.ts index e2a8949..1642e03 100644 --- a/frontend/src/components/Dashboard/ui/core/config.ts +++ b/frontend/src/components/Dashboard/ui/core/config.ts @@ -30,6 +30,22 @@ export function format(value: unknown, precision: number | null): string { return String(value) } +/** + * How many decimals a number is written with. + * + * What a reading is *worth* decides how it is written when nothing configured + * a precision: 21.5 is one decimal, 21 is none. Without this a value tweening + * from 21 to 22 passes through 21.37460937 on its way, which is not a reading + * anybody asked for — and is a different number of characters every frame. + */ +export function decimalsOf(value: number): number { + const written = String(value) + // Anything in exponent form is far outside what a panel displays; treating + // it as a whole number is closer than counting the mantissa's digits. + if (written.includes("e") || written.includes("E")) return 0 + return Math.min(6, (written.split(".")[1] ?? "").length) +} + /** Where a reading sits on its scale, as 0..1. */ export const fractionOf = ( value: number | null, diff --git a/frontend/src/components/Dashboard/ui/core/core.css b/frontend/src/components/Dashboard/ui/core/core.css index 47f9eaf..c36a63c 100644 --- a/frontend/src/components/Dashboard/ui/core/core.css +++ b/frontend/src/components/Dashboard/ui/core/core.css @@ -135,10 +135,22 @@ align-items: center; justify-items: center; gap: 0.75rem; + /* How wide the brightness column is, and how thick its track. Brightness is + read at a glance from across a room, so it is a column rather than a wire + — the same weight the slider widget's own track has, stood on its end. */ + --dui-brightness: 2.5rem; + --dui-track-size: 1.25rem; + /* Whichever the tile runs out of first. Named here so the disc and the + column beside it are the same size rather than nearly. */ + --dui-disc: min(100cqh, 100cqw - var(--dui-brightness) - 0.75rem); } .dui-color > .dui-slider { - height: 100cqh; + height: var(--dui-disc); + width: var(--dui-brightness); + /* A handle that overhangs the column it rides, rather than one lost inside + a track thicker than itself. */ + --dui-thumb: 1.75rem; } /* @@ -153,7 +165,7 @@ */ .dui-disc { position: relative; - width: min(100cqh, 100cqw - 3.5rem); + width: var(--dui-disc, min(100cqh, 100cqw - 3.5rem)); max-width: 100%; max-height: 100cqh; aspect-ratio: 1; @@ -194,13 +206,147 @@ top: calc(50% + var(--dui-sy) * (50% - max(var(--dui-thumb), 1.25rem) / 2)); } -/* A column of range input, which is the one shape CSS has to be told about. */ -.dui-slider input[data-orientation="vertical"] { +/* + * A slider, drawn rather than styled. + * + * The native input is still the control — it has the keyboard, the pointer and + * every `aria-` for free — but it is laid transparent over a rail, a fill and a + * handle of our own. Styling `::-webkit-slider-*` instead means one set of + * rules for a row and another for a column, each with its own centring quirk, + * and a handle that lands off its track when the writing mode turns. Drawn + * parts are the same parts either way round. + */ +.dui-slider { + position: relative; + display: flex; + min-width: 0; + flex-direction: column; +} + +.dui-slider[data-orientation="vertical"] { + height: 100%; + width: var(--dui-control); +} + +.dui-slider-body { + position: relative; + width: 100%; + height: var(--dui-control); +} + +.dui-slider[data-orientation="vertical"] .dui-slider-body { + flex: 1; + min-height: 0; + height: auto; +} + +.dui-slider-input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + appearance: none; + background: none; + opacity: 0; + cursor: pointer; +} + +.dui-slider[data-orientation="vertical"] .dui-slider-input { writing-mode: vertical-lr; direction: rtl; - appearance: slider-vertical; - width: var(--dui-control); - height: 100%; +} + +.dui-slider-input:disabled { + cursor: default; +} + +.dui-slider-rail { + position: absolute; + overflow: hidden; + pointer-events: none; +} + +.dui-slider[data-orientation="horizontal"] .dui-slider-rail { + left: 0; + right: 0; + top: 50%; + height: var(--dui-track-size, 0.5rem); + translate: 0 -50%; +} + +.dui-slider[data-orientation="vertical"] .dui-slider-rail { + top: 0; + bottom: 0; + left: 50%; + width: var(--dui-track-size, 0.5rem); + translate: -50% 0; +} + +.dui-slider-fill { + position: absolute; +} + +.dui-slider[data-orientation="horizontal"] .dui-slider-fill { + inset-block: 0; + left: 0; + width: calc(var(--dui-fraction, 0) * 100%); +} + +.dui-slider[data-orientation="vertical"] .dui-slider-fill { + inset-inline: 0; + bottom: 0; + height: calc(var(--dui-fraction, 0) * 100%); +} + +/* The handle's travel is inset by half of itself, as a native one's is, so it + stops flush with each end rather than hanging over it. */ +.dui-slider-thumb { + position: absolute; + width: var(--dui-thumb); + height: var(--dui-thumb); + border-radius: 50%; + pointer-events: none; +} + +.dui-slider[data-orientation="horizontal"] .dui-slider-thumb { + top: 50%; + left: calc( + var(--dui-thumb) / + 2 + + var(--dui-fraction, 0) * + (100% - var(--dui-thumb)) + ); + translate: -50% -50%; +} + +.dui-slider[data-orientation="vertical"] .dui-slider-thumb { + left: 50%; + bottom: calc( + var(--dui-thumb) / + 2 + + var(--dui-fraction, 0) * + (100% - var(--dui-thumb)) + ); + translate: -50% 50%; +} + +.dui-slider-input:focus-visible ~ .dui-slider-thumb { + outline: 2px solid var(--ring); + outline-offset: 2px; +} + +.dui-slider-input:disabled ~ .dui-slider-thumb, +.dui-slider-input:disabled ~ .dui-slider-rail { + opacity: 0.45; +} + +.dui-ticks { + position: relative; + height: 1rem; + font-size: 0.75rem; + color: var(--muted-foreground); + font-variant-numeric: tabular-nums; } /* @@ -244,21 +390,27 @@ * A bar draws a row per reading: its name, how far along it is, and what it * says. Rows share the tile, so each is a track of its own in the dashboard's * data colours rather than a segment nested in the one above it. + * + * One grid for the whole bar, with each row borrowing its columns (`subgrid`). + * A grid per row would size its own name column, so rows whose names are + * different lengths would start and end their tracks in different places — and + * two bars that do not share a baseline cannot be compared, which is the one + * thing a stack of them is for. */ .dui-bar { display: grid; min-width: 0; + grid-template-columns: auto minmax(0, 1fr) auto; grid-auto-rows: minmax(0, 1fr); - gap: 0.5rem; align-content: center; + gap: 0.5rem 1rem; } .dui-bar-row { display: grid; - min-width: 0; - grid-template-columns: auto minmax(0, 1fr) auto; + grid-column: 1 / -1; + grid-template-columns: subgrid; align-items: center; - gap: 0.5rem; } .dui-bar-label { @@ -267,6 +419,9 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + /* Against the track it belongs to, so the names read as a column and the + tracks all begin at the same place. */ + text-align: right; color: var(--muted-foreground); } @@ -289,14 +444,16 @@ font-variant-numeric: tabular-nums; } -/* Too narrow for three columns: the name takes a line of its own. */ +/* Too narrow for three columns: the name takes a line of its own, and reads + from the left again now that it is not beside anything. */ @container (max-width: 260px) { - .dui-bar-row { + .dui-bar { grid-template-columns: minmax(0, 1fr) auto; } .dui-bar-label { max-width: none; grid-column: 1 / -1; + text-align: left; } } diff --git a/frontend/src/components/Dashboard/ui/core/motion.ts b/frontend/src/components/Dashboard/ui/core/motion.ts index ae38dd1..3a050ea 100644 --- a/frontend/src/components/Dashboard/ui/core/motion.ts +++ b/frontend/src/components/Dashboard/ui/core/motion.ts @@ -9,6 +9,11 @@ * * Physics only. What animates is each set's business; this is how it moves * when it does, kept in one place so the two can be compared. + * + * Neither look lifts a surface under the pointer. A wall panel is touched, and + * a finger does not move away afterwards the way a cursor does — so whatever + * hover raises stays raised until something else is touched, which reads as a + * tile stuck rather than as a tile answering. */ import type { Transition, Variants } from "motion/react" @@ -18,9 +23,7 @@ type LookMotion = { spring: Transition /** How a widget arrives on the canvas. */ enter: Variants - /** How a surface answers a pointer, if it does at all. */ - hover?: { y: number } - /** Seconds for one drift of the ambient ground; 0 draws none. */ + /** Seconds for one round of the ambient ground; 0 draws none. */ drift: number } @@ -31,8 +34,6 @@ export const LOOK: Record = { hidden: { opacity: 0, y: 8 }, visible: { opacity: 1, y: 0 }, }, - // No lift: Material answers a pointer with a state layer, not a move. - hover: undefined, drift: 0, }, glass: { @@ -41,8 +42,7 @@ export const LOOK: Record = { hidden: { opacity: 0, y: 12, scale: 0.96 }, visible: { opacity: 1, y: 0, scale: 1 }, }, - hover: { y: -2 }, - drift: 28, + drift: 46, }, } diff --git a/frontend/src/components/Dashboard/ui/core/values.ts b/frontend/src/components/Dashboard/ui/core/values.ts index ce8d7ea..649f417 100644 --- a/frontend/src/components/Dashboard/ui/core/values.ts +++ b/frontend/src/components/Dashboard/ui/core/values.ts @@ -18,12 +18,27 @@ import type { WidgetDef } from "@/client" import { CHART_SLOTS } from "@/components/Common/UplotChart" import { displayName, flowOf } from "@/components/Flow/deriveEdges" import type { LiveValue } from "@/components/Flow/liveStore" -import { config, format, fractionOf, num, rowsOf, text } from "./config" +import { + config, + decimalsOf, + format, + fractionOf, + num, + rowsOf, + text, +} from "./config" import type { BarReading } from "./contract" import { useLook } from "./look" import { LOOK } from "./motion" -/** The reading itself, tweened, as a string a `motion` element can hold. */ +/** + * The reading itself, tweened, as a string a `motion` element can hold. + * + * With no precision configured the tween is written to as many decimals as the + * value it is heading for: a slider stepping in halves reads 21.0, 21.5, 22.0 + * rather than every float in between, and the number stops changing width + * while it moves. + */ export function useAnimatedNumber( value: unknown, precision: number | null, @@ -32,11 +47,12 @@ export function useAnimatedNumber( const numeric = typeof value === "number" const raw = useMotionValue(numeric ? value : 0) const settled = useSpring(raw, LOOK[look].spring) + const written = precision ?? (numeric ? decimalsOf(value as number) : null) useEffect(() => { if (numeric) raw.set(value as number) }, [numeric, value, raw]) return { - label: useTransform(settled, (at) => format(at, precision)), + label: useTransform(settled, (at) => format(at, written)), numeric, } } diff --git a/frontend/src/components/Dashboard/ui/glass/Controls.tsx b/frontend/src/components/Dashboard/ui/glass/Controls.tsx index d9777d8..de3a38e 100644 --- a/frontend/src/components/Dashboard/ui/glass/Controls.tsx +++ b/frontend/src/components/Dashboard/ui/glass/Controls.tsx @@ -77,37 +77,45 @@ export function Switch(props: SwitchProps) { export function Slider(props: SliderProps) { const { fraction, inputProps, marks } = useSliderDrag(props) - const vertical = props.orientation === "vertical" + const orientation = props.orientation ?? "horizontal" return (
-
- - {marks.length > 0 ? ( - // Decoration: the input itself announces min, max and where it stands. -
- {marks.map((mark) => ( - - {mark.label} - - ))} -
- ) : null} +
+ + + + {/* The control itself, laid transparent over what is drawn: it keeps + the keyboard, the pointer and every `aria-`, and owes the look + nothing. */} + +
+ {marks.length > 0 ? ( + // Decoration: the input itself announces min, max and where it stands. +
+ {marks.map((mark) => ( + + {mark.label} + + ))} +
+ ) : null}
) } diff --git a/frontend/src/components/Dashboard/ui/glass/Data.tsx b/frontend/src/components/Dashboard/ui/glass/Data.tsx index 63a667a..c2a4114 100644 --- a/frontend/src/components/Dashboard/ui/glass/Data.tsx +++ b/frontend/src/components/Dashboard/ui/glass/Data.tsx @@ -30,7 +30,7 @@ export function Readout({ }: ReadoutProps) { const { label, numeric } = useAnimatedNumber(value, precision) return ( - + - {DRIFTS.map((drift, index) => ( + {DRIFTS.map(({ seconds, ...path }, index) => ( @@ -92,16 +98,15 @@ export function Frame({ onClick, children, }: FrameProps) { - const { hover, spring } = LOOK.glass return ( // A card is not a control: the click only picks it in edit mode, and every // interactive element inside keeps its own role and keyboard handling. - {title ? ( @@ -132,7 +137,7 @@ export function Frame({ )} /> ) : null} - +
) } diff --git a/frontend/src/components/Dashboard/ui/glass/glass.css b/frontend/src/components/Dashboard/ui/glass/glass.css index cc3fb99..56038dd 100644 --- a/frontend/src/components/Dashboard/ui/glass/glass.css +++ b/frontend/src/components/Dashboard/ui/glass/glass.css @@ -263,89 +263,24 @@ pointer-events: none; } -.gl-slider { - position: relative; - display: flex; - min-width: 0; - align-items: center; - justify-content: center; -} - -.gl-slider input { - width: 100%; - height: var(--dui-control); - margin: 0; - appearance: none; - background: transparent; - outline: none; -} - -.gl-slider input::-webkit-slider-runnable-track { - height: 0.5rem; - border-radius: 9999px; - box-shadow: var(--gl-inset); - background: linear-gradient( - to right, - color-mix(in srgb, var(--primary) 85%, white) - calc(var(--dui-fraction, 0) * 100%), - var(--gl-track) calc(var(--dui-fraction, 0) * 100%) - ); -} - -.gl-slider input[data-orientation="vertical"]::-webkit-slider-runnable-track { - width: 0.5rem; - height: 100%; - background: linear-gradient( - to top, - color-mix(in srgb, var(--primary) 85%, white) - calc(var(--dui-fraction, 0) * 100%), - var(--gl-track) calc(var(--dui-fraction, 0) * 100%) - ); -} - -.gl-slider input::-moz-range-track { - height: 0.5rem; +.gl-slider .dui-slider-rail { border-radius: 9999px; background: var(--gl-track); + box-shadow: var(--gl-inset); } -.gl-slider input::-moz-range-progress { - height: 0.5rem; +.gl-slider .dui-slider-fill { border-radius: 9999px; background: color-mix(in srgb, var(--primary) 85%, white); + box-shadow: var(--gl-glow); } -.gl-slider input::-webkit-slider-thumb { - appearance: none; - width: var(--dui-thumb); - height: var(--dui-thumb); - margin-top: calc((0.5rem - var(--dui-thumb)) / 2); - border: 1px solid var(--gl-border); - border-radius: 50%; +.gl-slider .dui-slider-thumb { background: #fff; + border: 1px solid var(--gl-border); box-shadow: 0 2px 8px rgb(0 0 0 / 0.35); } -.gl-slider input::-moz-range-thumb { - width: var(--dui-thumb); - height: var(--dui-thumb); - border: 1px solid var(--gl-border); - border-radius: 50%; - background: #fff; -} - -.gl-slider input:disabled { - opacity: 0.45; -} - -.gl-ticks { - position: relative; - height: 1rem; - font-size: 0.75rem; - color: var(--muted-foreground); - font-variant-numeric: tabular-nums; -} - .gl-segmented { position: relative; display: grid; diff --git a/frontend/src/components/Dashboard/ui/material/Controls.tsx b/frontend/src/components/Dashboard/ui/material/Controls.tsx index f593b8e..911ff26 100644 --- a/frontend/src/components/Dashboard/ui/material/Controls.tsx +++ b/frontend/src/components/Dashboard/ui/material/Controls.tsx @@ -106,39 +106,45 @@ export function Switch(props: SwitchProps) { export function Slider(props: SliderProps) { const { fraction, inputProps, marks } = useSliderDrag(props) - const vertical = props.orientation === "vertical" + const orientation = props.orientation ?? "horizontal" return (
-
- - {marks.length > 0 ? ( - // Decoration: the input itself announces min, max and where it stands. -
- {marks.map((mark) => ( - - {mark.label} - - ))} -
- ) : null} +
+ + + + {/* The control itself, laid transparent over what is drawn: it keeps + the keyboard, the pointer and every `aria-`, and owes the look + nothing. */} + +
+ {marks.length > 0 ? ( + // Decoration: the input itself announces min, max and where it stands. +
+ {marks.map((mark) => ( + + {mark.label} + + ))} +
+ ) : null}
) } diff --git a/frontend/src/components/Dashboard/ui/material/Data.tsx b/frontend/src/components/Dashboard/ui/material/Data.tsx index 5739645..ce1911d 100644 --- a/frontend/src/components/Dashboard/ui/material/Data.tsx +++ b/frontend/src/components/Dashboard/ui/material/Data.tsx @@ -29,7 +29,7 @@ export function Readout({ }: ReadoutProps) { const { label, numeric } = useAnimatedNumber(value, precision) return ( - + `${flowName}.${name}` /** Every tile the dashboard carries, including the deliberately unbound one. */ -const TILES = 8 +const TILES = 9 test.use({ storageState: "playwright/.auth/user.json" }) @@ -54,6 +54,7 @@ test.beforeAll(async ({ browser }) => { { name: "days", dtype: "list", item: "record" }, { name: "mode", dtype: "str" }, { name: "lamp", dtype: "bool" }, + { name: "setpoint", dtype: "float" }, ], }, ], @@ -71,6 +72,7 @@ test.beforeAll(async ({ browser }) => { await publish(page, w("pv"), 30) await publish(page, w("grid"), 20) await publish(page, w("condition"), "sun") + await publish(page, w("setpoint"), 21.5) await publish(page, w("days"), [ { label: "Mon", icon: "sun", value: "21°" }, { label: "Tue", icon: "cloudy", value: "18°" }, @@ -84,6 +86,9 @@ test.beforeAll(async ({ browser }) => { const dashboard = await ( await api(page, `/dashboards/${dashboardName}`, { method: "POST" }) ).json() + // Two rows taller than the default panel: the tiles below already fill it, + // and a widget past the last row is clipped rather than drawn. + dashboard.canvas_height = 1400 dashboard.pages[0].sections[0].widgets = [ { id: "load", @@ -159,6 +164,22 @@ test.beforeAll(async ({ browser }) => { layout: { lg: { x: 3, y: 4, w: 3, h: 2 } }, config: {}, }, + { + // No precision configured, so what it is worth is what decides how it is + // written — including on the way there. + id: "aim", + type: "slider", + title: "Setpoint", + layout: { lg: { x: 0, y: 6, w: 4, h: 2 } }, + config: { + target: w("setpoint"), + dtype: "float", + min: 16, + max: 24, + step: 0.5, + unit: "°C", + }, + }, { id: "trend", type: "chart", @@ -306,6 +327,53 @@ test("rows are drawn in the order they were configured", async ({ page }) => { await expect(rows.nth(2)).toContainText(/grid/i) }) +test("a reading is written to its own precision while it is moving", async ({ + page, +}) => { + await openPanel(page) + const readout = page + .getByTestId("widget-frame") + .filter({ hasText: "Setpoint" }) + .getByTestId("readout") + await expect(readout).toContainText("21.5") + + // A value tweening toward 24 must pass through 22.0 and 23.5, not + // 22.37460937: a reading nobody asked for, a different width every frame. + // Started before the publish, not awaited: the frames worth looking at are + // the ones between the old reading and the new one. + const seen = page.evaluate(async () => { + const cell = [ + ...document.querySelectorAll("[data-testid=widget-frame]"), + ].find((frame) => frame.textContent?.includes("Setpoint")) + const el = cell?.querySelector("[data-testid=readout]") + const samples: string[] = [] + const until = performance.now() + 600 + return new Promise((resolve) => { + const step = () => { + samples.push(el?.textContent ?? "") + if (performance.now() < until) requestAnimationFrame(step) + else resolve(samples) + } + requestAnimationFrame(step) + }) + }) + await publish(page, w("setpoint"), 24) + const frames = await seen + + for (const frame of frames) { + expect(frame, "a reading grew decimals on its way").toMatch( + /^-?\d+(\.\d)?\s*°C$/, + ) + } + // Otherwise the frames above are all the reading standing still, and every + // one of them would pass whatever the tween was doing. + expect( + new Set(frames).size, + "the reading never moved, so nothing was watched", + ).toBeGreaterThan(1) + await publish(page, w("setpoint"), 21.5) +}) + test("a widget is drawn inside its tile rather than scrolled", async ({ page, }) => {