Answer five things the panel got wrong

- 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.
This commit is contained in:
2026-08-23 23:28:47 +02:00
parent 6ccc6e9e26
commit 68fa5527b1
14 changed files with 403 additions and 236 deletions
@@ -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<string, unknown>): 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), "—")
@@ -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,
@@ -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;
}
}
@@ -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<Look, LookMotion> = {
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<Look, LookMotion> = {
hidden: { opacity: 0, y: 12, scale: 0.96 },
visible: { opacity: 1, y: 0, scale: 1 },
},
hover: { y: -2 },
drift: 28,
drift: 46,
},
}
@@ -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,
}
}
@@ -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 (
<div
className={cn("dui-slider gl-slider", vertical ? "h-full" : "w-full")}
className={cn(
"dui-slider gl-slider",
orientation === "horizontal" && "w-full",
)}
data-orientation={orientation}
style={{ "--dui-fraction": fraction } as React.CSSProperties}
>
<div
className={cn("flex min-w-0 flex-col", vertical ? "h-full" : "w-full")}
>
<input
{...inputProps}
data-orientation={props.orientation ?? "horizontal"}
/>
{marks.length > 0 ? (
// Decoration: the input itself announces min, max and where it stands.
<div aria-hidden className="gl-ticks">
{marks.map((mark) => (
<span
key={mark.percent}
className="absolute top-0"
style={{
left: `${mark.percent}%`,
transform: `translateX(-${mark.percent}%)`,
}}
>
{mark.label}
</span>
))}
</div>
) : null}
<div className="dui-slider-body">
<span className="dui-slider-rail" aria-hidden>
<span className="dui-slider-fill" />
</span>
{/* The control itself, laid transparent over what is drawn: it keeps
the keyboard, the pointer and every `aria-`, and owes the look
nothing. */}
<input {...inputProps} className="dui-slider-input" />
<span className="dui-slider-thumb" aria-hidden />
</div>
{marks.length > 0 ? (
// Decoration: the input itself announces min, max and where it stands.
<div aria-hidden className="dui-ticks">
{marks.map((mark) => (
<span
key={mark.percent}
className="absolute top-0"
// Shifted by its own share of itself: the first label sits flush
// left and the last flush right, so neither hangs off the tile.
style={{
left: `${mark.percent}%`,
transform: `translateX(-${mark.percent}%)`,
}}
>
{mark.label}
</span>
))}
</div>
) : null}
</div>
)
}
@@ -30,7 +30,7 @@ export function Readout({
}: ReadoutProps) {
const { label, numeric } = useAnimatedNumber(value, precision)
return (
<span className="flex min-w-0 items-baseline gap-1.5">
<span data-testid="readout" className="flex min-w-0 items-baseline gap-1.5">
<span
className={cn(
"min-w-0 truncate",
@@ -25,11 +25,18 @@ import type {
import { TESTID } from "../core/contract"
import { LOOK } from "../core/motion"
/** Where each blob drifts to and back. Pixels on the unscaled canvas. */
/**
* The path each blob wanders, in pixels on the unscaled canvas.
*
* A closed round rather than a line travelled back and forth: a blob that
* retraces its own path reads as a thing sliding, and one that comes round
* reads as light moving in a room. Each takes a different number of turns and
* a different length of time, so the three never fall into step.
*/
const DRIFTS = [
{ x: [0, 80], y: [0, -60] },
{ x: [0, -70], y: [0, 50] },
{ x: [0, 50], y: [0, -40] },
{ x: [0, 90, 130, 40, 0], y: [0, -70, 20, 60, 0], seconds: 1 },
{ x: [0, -80, -30, 60, 0], y: [0, 50, 110, 40, 0], seconds: 1.35 },
{ x: [0, 60, -40, -90, 0], y: [0, -50, -90, -20, 0], seconds: 1.7 },
]
export function Backdrop({ image }: BackdropProps) {
@@ -50,16 +57,15 @@ export function Backdrop({ image }: BackdropProps) {
className="pointer-events-none absolute inset-0 overflow-hidden"
>
<span className="gl-ground" />
{DRIFTS.map((drift, index) => (
{DRIFTS.map(({ seconds, ...path }, index) => (
<motion.span
// Position is the identity: which blob this is decides its colour.
key={`blob-${index}`}
className="gl-blob"
animate={drift}
animate={path}
transition={{
duration: LOOK.glass.drift,
duration: LOOK.glass.drift * seconds,
repeat: Number.POSITIVE_INFINITY,
repeatType: "mirror",
ease: "easeInOut",
}}
/>
@@ -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.
<motion.div
// biome-ignore lint/a11y/useKeyWithClickEvents: see above.
// biome-ignore lint/a11y/noStaticElementInteractions: see above.
<div
data-testid={TESTID.frame}
data-selected={selected ? "" : undefined}
className="dui-frame gl-surface gl-frame"
whileHover={hover}
transition={spring}
onClick={onClick}
>
{title ? (
@@ -132,7 +137,7 @@ export function Frame({
)}
/>
) : null}
</motion.div>
</div>
)
}
@@ -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;
@@ -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 (
<div
className={cn("dui-slider m3-slider", vertical ? "h-full" : "w-full")}
className={cn(
"dui-slider m3-slider",
orientation === "horizontal" && "w-full",
)}
data-orientation={orientation}
style={{ "--dui-fraction": fraction } as React.CSSProperties}
>
<div
className={cn("flex min-w-0 flex-col", vertical ? "h-full" : "w-full")}
>
<input
{...inputProps}
data-orientation={props.orientation ?? "horizontal"}
/>
{marks.length > 0 ? (
// Decoration: the input itself announces min, max and where it stands.
<div aria-hidden className="m3-ticks">
{marks.map((mark) => (
<span
key={mark.percent}
className="absolute top-0"
// Shifted by its own share of itself: the first label sits
// flush left and the last flush right, so neither hangs off.
style={{
left: `${mark.percent}%`,
transform: `translateX(-${mark.percent}%)`,
}}
>
{mark.label}
</span>
))}
</div>
) : null}
<div className="dui-slider-body">
<span className="dui-slider-rail" aria-hidden>
<span className="dui-slider-fill" />
</span>
{/* The control itself, laid transparent over what is drawn: it keeps
the keyboard, the pointer and every `aria-`, and owes the look
nothing. */}
<input {...inputProps} className="dui-slider-input" />
<span className="dui-slider-thumb" aria-hidden />
</div>
{marks.length > 0 ? (
// Decoration: the input itself announces min, max and where it stands.
<div aria-hidden className="dui-ticks">
{marks.map((mark) => (
<span
key={mark.percent}
className="absolute top-0"
// Shifted by its own share of itself: the first label sits flush
// left and the last flush right, so neither hangs off the tile.
style={{
left: `${mark.percent}%`,
transform: `translateX(-${mark.percent}%)`,
}}
>
{mark.label}
</span>
))}
</div>
) : null}
</div>
)
}
@@ -29,7 +29,7 @@ export function Readout({
}: ReadoutProps) {
const { label, numeric } = useAnimatedNumber(value, precision)
return (
<span className="flex min-w-0 items-baseline gap-1.5">
<span data-testid="readout" className="flex min-w-0 items-baseline gap-1.5">
<span
className={cn(
"min-w-0 truncate",
@@ -175,91 +175,30 @@
pointer-events: none;
}
.m3-slider {
position: relative;
display: flex;
min-width: 0;
align-items: center;
justify-content: center;
}
.m3-slider input {
width: 100%;
height: var(--dui-control);
margin: 0;
appearance: none;
background: transparent;
outline: none;
}
.m3-slider input::-webkit-slider-runnable-track {
height: 0.5rem;
border-radius: 9999px;
background: linear-gradient(
to right,
var(--primary) calc(var(--dui-fraction, 0) * 100%),
var(--m3-sc) calc(var(--dui-fraction, 0) * 100%)
);
}
.m3-slider input::-moz-range-track {
height: 0.5rem;
.m3-slider .dui-slider-rail {
border-radius: 9999px;
background: var(--m3-sc);
}
.m3-slider input::-moz-range-progress {
height: 0.5rem;
.m3-slider .dui-slider-fill {
border-radius: 9999px;
background: var(--primary);
}
.m3-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: none;
border-radius: 50%;
.m3-slider .dui-slider-thumb {
background: var(--primary);
box-shadow: 0 0 0 0 color-mix(in srgb, var(--primary) 12%, transparent);
transition: box-shadow var(--duration-fast) var(--ease-standard);
}
.m3-slider input::-moz-range-thumb {
width: var(--dui-thumb);
height: var(--dui-thumb);
border: none;
border-radius: 50%;
background: var(--primary);
}
.m3-slider input:hover::-webkit-slider-thumb,
.m3-slider input:active::-webkit-slider-thumb,
.m3-slider input:focus-visible::-webkit-slider-thumb {
.m3-slider:hover .dui-slider-thumb,
.m3-slider .dui-slider-input:active ~ .dui-slider-thumb,
.m3-slider .dui-slider-input:focus-visible ~ .dui-slider-thumb {
box-shadow: 0 0 0 0.625rem color-mix(in srgb, var(--primary) 12%, transparent);
}
.m3-slider input[data-orientation="vertical"]::-webkit-slider-runnable-track {
width: 0.5rem;
height: 100%;
background: linear-gradient(
to top,
var(--primary) calc(var(--dui-fraction, 0) * 100%),
var(--m3-sc) calc(var(--dui-fraction, 0) * 100%)
);
}
.m3-slider input:disabled {
opacity: 0.38;
}
.m3-ticks {
position: relative;
height: 1rem;
font-size: 0.75rem;
color: var(--muted-foreground);
font-variant-numeric: tabular-nums;
[data-touch] .m3-slider:hover .dui-slider-thumb {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--primary) 12%, transparent);
}
.m3-segmented {
+69 -1
View File
@@ -21,7 +21,7 @@ const stackName = `${flowName}_stack`
const w = (name: string) => `${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<string[]>((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,
}) => {