diff --git a/NOTEPAD.md b/NOTEPAD.md index f4b96bf..642825e 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, }) => {