Rework the dashboard into two looks over one behaviour
A dashboard is a wall panel somebody hangs in their own hallway, so it now wears what they choose: a look, and a palette of their own colours. Two complete component sets live under `Dashboard/ui/` — `glass` (translucent panes over a slowly moving ground) and `material` (Material 3 tonal cards) — behind one prop contract. Every control's state, keyboard and `aria-` live in `ui/core` and are shared, so the two sets are the same dashboard drawn twice rather than two products: a set only decides what a control looks like while doing it. Four settings join the channel, each drivable by a flow like any other: `look`, `palette`, `background` and `touch`. A palette is an ordered list of hex colours — background, surface, primary, accent, text, then more chart colours — pasted from a coolors.co link or typed, written onto the canvas as the token variables everything already reads. Trailing roles are derived, so three colours are a whole dashboard, and derived text is held to AA rather than trusted (`theme.check.ts` measures it). A palette also decides light or dark, since its first colour is the ground. Widgets are measured against their own tile with container queries rather than against the viewport, animate through `motion`, and can be drawn without their title. The three reworks: - a bar draws a row per reading, up to eight, each in the dashboard's own data colours and each able to carry its own scale — replacing readings nested in one fill, which could only ever share one colour and stop at three. Documents written the old way are read as rows. - a chart's range picker moved to a column down its right-hand edge, which gives the plot back a whole row of a short tile. - the colour wheel became a disc: hue is the angle and saturation the distance from the middle, so a colour is one gesture rather than three, with brightness on a slider beside it. `index.css` and `lib/motion.ts` are untouched — the dashboard overrides token *values* on its canvas, never the blocks the two repos share.
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* 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 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="grid h-full min-h-0 grid-cols-[minmax(0,1fr)_auto] items-center justify-items-center gap-3">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user