Docs / docs (push) Successful in 54s
Playwright Tests / test-playwright (1, 2) (push) Failing after 1m10s
Playwright Tests / test-playwright (2, 2) (push) Failing after 29s
pre-commit / pre-commit (push) Failing after 2m35s
Test Backend / test-backend (push) Failing after 47s
Compose Smoke Test / test-compose (push) Failing after 21s
Playwright Tests / merge-reports (push) Failing after 1m3s
- 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.
218 lines
6.0 KiB
TypeScript
218 lines
6.0 KiB
TypeScript
/**
|
|
* Liquid glass: the readings.
|
|
*
|
|
* Lit fills over translucent tracks, and every number written out beside the
|
|
* picture it is drawn as — an angle or a length nobody can measure is not a
|
|
* reading.
|
|
*/
|
|
import { motion, useTransform } from "motion/react"
|
|
|
|
import { cn } from "@/lib/utils"
|
|
import { arcPath, GAUGE_START, GAUGE_SWEEP, GAUGE_TRACK } from "../core/arc"
|
|
import { cssOf } from "../core/color"
|
|
import { format } from "../core/config"
|
|
import type {
|
|
BarProps,
|
|
ColorDiskProps,
|
|
GaugeProps,
|
|
ReadoutProps,
|
|
} from "../core/contract"
|
|
import { TESTID } from "../core/contract"
|
|
import { useColorDisk } from "../core/disc"
|
|
import { useAnimatedFraction, useAnimatedNumber } from "../core/values"
|
|
import { Slider } from "./Controls"
|
|
|
|
export function Readout({
|
|
value,
|
|
precision,
|
|
unit,
|
|
size = "hero",
|
|
}: ReadoutProps) {
|
|
const { label, numeric } = useAnimatedNumber(value, precision)
|
|
return (
|
|
<span data-testid="readout" className="flex min-w-0 items-baseline gap-1.5">
|
|
<span
|
|
className={cn(
|
|
"min-w-0 truncate",
|
|
size === "hero" ? "dui-hero" : "tabular-nums",
|
|
)}
|
|
>
|
|
{numeric ? (
|
|
<motion.span>{label}</motion.span>
|
|
) : (
|
|
format(value, precision)
|
|
)}
|
|
</span>
|
|
{unit ? (
|
|
<span
|
|
className={cn(
|
|
"shrink-0 text-muted-foreground",
|
|
size === "hero" && "dui-hero-unit",
|
|
)}
|
|
>
|
|
{unit}
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
export function Gauge({ value, min, max, precision, unit, label }: GaugeProps) {
|
|
const fraction = useAnimatedFraction(
|
|
value === null
|
|
? 0
|
|
: Math.min(1, Math.max(0, (value - min) / (max - min || 1))),
|
|
)
|
|
const { label: reading, numeric } = useAnimatedNumber(value, precision)
|
|
return (
|
|
<div className="flex min-h-0 flex-1 items-center justify-center">
|
|
<svg
|
|
viewBox="0 0 100 78"
|
|
className="h-full max-h-full w-full"
|
|
role="img"
|
|
aria-label={`${label}: ${format(value, precision)}${unit ?? ""} of ${max}`}
|
|
>
|
|
<path
|
|
d={GAUGE_TRACK}
|
|
fill="none"
|
|
stroke="var(--gl-track)"
|
|
strokeWidth={9}
|
|
strokeLinecap="round"
|
|
/>
|
|
{/* The same full arc as the track, revealed rather than re-pathed: `d`
|
|
is not animatable, so a reading that redrew the arc could only
|
|
jump. `pathLength` normalises it, which makes the reveal the
|
|
fraction itself. */}
|
|
<motion.path
|
|
d={arcPath(GAUGE_START, GAUGE_START + GAUGE_SWEEP)}
|
|
// Glass lights what it draws: the arc carries the same glow the
|
|
// fills and the active controls do.
|
|
style={{
|
|
pathLength: fraction,
|
|
filter: "drop-shadow(0 0 4px var(--primary))",
|
|
}}
|
|
fill="none"
|
|
stroke="var(--primary)"
|
|
strokeWidth={9}
|
|
strokeLinecap="round"
|
|
/>
|
|
<text
|
|
x={50}
|
|
y={54}
|
|
// User units of the viewBox, not the text scale: the readout has to
|
|
// stay proportional to the dial at whatever size the tile is.
|
|
fontSize={13}
|
|
textAnchor="middle"
|
|
className="fill-foreground tabular-nums"
|
|
>
|
|
{numeric ? (
|
|
<motion.tspan>{reading}</motion.tspan>
|
|
) : (
|
|
format(value, precision)
|
|
)}
|
|
{unit ?? ""}
|
|
</text>
|
|
</svg>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Row({ row }: { row: BarProps["rows"][number] }) {
|
|
const fraction = useAnimatedFraction(row.fraction)
|
|
const width = useTransform(fraction, (at) => `${at * 100}%`)
|
|
return (
|
|
<div
|
|
data-testid={TESTID.barRow}
|
|
role="img"
|
|
aria-label={`${row.label} ${format(row.value, row.precision)}${row.unit ?? ""}`}
|
|
className="dui-bar-row"
|
|
style={{ "--dui-fill": row.color } as React.CSSProperties}
|
|
>
|
|
<span className="dui-bar-label">{row.label}</span>
|
|
<span className="dui-bar-track gl-track">
|
|
<motion.span
|
|
data-testid={TESTID.barFill}
|
|
className="dui-bar-fill gl-fill"
|
|
style={{ width }}
|
|
/>
|
|
</span>
|
|
{/* Beside the track rather than on it: a number written on a fill has to
|
|
clear the fill it sits on and the track it slides onto, and one that
|
|
reads across a room cannot do both. */}
|
|
<span className="dui-bar-value">
|
|
{format(row.value, row.precision)}
|
|
{row.unit ?? ""}
|
|
</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function Bar({ rows }: BarProps) {
|
|
// No group role of its own: each row already announces what it reads and
|
|
// what it is worth, and the tile's title is on the frame around them.
|
|
return (
|
|
<div className="dui-bar">
|
|
{rows.map((row, index) => (
|
|
// Two rows can read the same message under different labels; position
|
|
// is the identity, as it is for chart series.
|
|
<Row key={`row-${index}`} row={row} />
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function ColorDisk({
|
|
name,
|
|
hsv,
|
|
disabled,
|
|
onChange,
|
|
onCommit,
|
|
}: ColorDiskProps) {
|
|
const { discProps, thumb, shade } = useColorDisk({
|
|
name,
|
|
hsv,
|
|
disabled,
|
|
onChange,
|
|
onCommit,
|
|
})
|
|
const [hue, saturation, brightness] = hsv
|
|
return (
|
|
<div className="dui-color">
|
|
<div
|
|
{...discProps}
|
|
data-testid={TESTID.disc}
|
|
className="dui-disc"
|
|
style={
|
|
{
|
|
"--dui-shade": shade,
|
|
"--dui-sx": thumb.sx,
|
|
"--dui-sy": thumb.sy,
|
|
} as React.CSSProperties
|
|
}
|
|
>
|
|
<span className="dui-disc-shade" aria-hidden />
|
|
<span
|
|
aria-hidden
|
|
data-testid={TESTID.swatch}
|
|
className="dui-disc-thumb gl-disc-thumb"
|
|
style={{ background: cssOf(hsv) }}
|
|
/>
|
|
</div>
|
|
<Slider
|
|
orientation="vertical"
|
|
value={brightness}
|
|
min={0}
|
|
max={100}
|
|
step={1}
|
|
unit="%"
|
|
label={`${name} brightness`}
|
|
disabled={disabled}
|
|
onCommit={(next) => {
|
|
onChange([hue, saturation, next])
|
|
onCommit()
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|