Files
app/frontend/src/components/Dashboard/ui/glass/Data.tsx
T
stroblme 4f8e55d95b
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
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.
2026-08-23 23:28:47 +02:00

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>
)
}