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,263 @@
|
||||
/**
|
||||
* Material 3: the controls.
|
||||
*
|
||||
* A press is answered by a state layer and a ripple from where it landed;
|
||||
* nothing lifts or scales. Every one of these is a `ui/core` hook in a
|
||||
* different coat — the state, the keyboard and the `aria-` come from there.
|
||||
*/
|
||||
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown } from "lucide-react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { asOriginal, text } from "../core/config"
|
||||
import type {
|
||||
ButtonProps,
|
||||
InputProps,
|
||||
SegmentedProps,
|
||||
SelectProps,
|
||||
SliderProps,
|
||||
SwitchProps,
|
||||
} from "../core/contract"
|
||||
import {
|
||||
type Press,
|
||||
usePress,
|
||||
useSegmented,
|
||||
useSliderDrag,
|
||||
useSwitch,
|
||||
} from "../core/controls"
|
||||
import { useLook } from "../core/look"
|
||||
import { LOOK } from "../core/motion"
|
||||
|
||||
/** The state layer plus whatever presses are still fading. */
|
||||
function Skin({
|
||||
presses,
|
||||
done,
|
||||
}: {
|
||||
presses: Press[]
|
||||
done: (id: number) => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<span className="m3-state" aria-hidden />
|
||||
<AnimatePresence>
|
||||
{presses.map((press) => (
|
||||
<motion.span
|
||||
key={press.id}
|
||||
aria-hidden
|
||||
className="m3-ripple"
|
||||
style={{ left: `${press.x}%`, top: `${press.y}%` }}
|
||||
initial={{ scale: 0, opacity: 0.3 }}
|
||||
animate={{ scale: 1, opacity: 0 }}
|
||||
transition={{ duration: 0.45, ease: [0.4, 0, 0.2, 1] }}
|
||||
onAnimationComplete={() => done(press.id)}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = "tonal",
|
||||
pressed,
|
||||
disabled,
|
||||
label,
|
||||
onClick,
|
||||
children,
|
||||
}: ButtonProps) {
|
||||
const { presses, onPointerDown, done } = usePress()
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="m3-button m3-pressable w-full"
|
||||
data-variant={variant}
|
||||
aria-pressed={pressed}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onPointerDown={onPointerDown}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Skin presses={presses} done={done} />
|
||||
<span className="min-w-0 truncate">{children}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function Switch(props: SwitchProps) {
|
||||
const { buttonProps } = useSwitch(props)
|
||||
const { presses, onPointerDown, done } = usePress()
|
||||
return (
|
||||
<button
|
||||
{...buttonProps}
|
||||
className="m3-switch m3-pressable"
|
||||
onPointerDown={onPointerDown}
|
||||
>
|
||||
<Skin presses={presses} done={done} />
|
||||
<motion.span
|
||||
layout
|
||||
transition={LOOK.material.spring}
|
||||
className="m3-switch-thumb"
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function Slider(props: SliderProps) {
|
||||
const { fraction, inputProps, marks } = useSliderDrag(props)
|
||||
const vertical = props.orientation === "vertical"
|
||||
return (
|
||||
<div
|
||||
className={cn("dui-slider m3-slider", vertical ? "h-full" : "w-full")}
|
||||
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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Segmented(props: SegmentedProps) {
|
||||
const { chosen, thumbId, groupProps, itemProps } = useSegmented(props)
|
||||
const vertical = props.orientation === "vertical"
|
||||
return (
|
||||
<div
|
||||
{...groupProps}
|
||||
data-testid={props.testId}
|
||||
className="m3-segmented"
|
||||
style={
|
||||
vertical
|
||||
? undefined
|
||||
: {
|
||||
gridTemplateColumns: `repeat(${props.options.length}, minmax(0, 1fr))`,
|
||||
}
|
||||
}
|
||||
>
|
||||
{props.options.map(([value, label], index) => (
|
||||
<button
|
||||
key={value}
|
||||
{...itemProps(index)}
|
||||
className="m3-segment m3-pressable"
|
||||
>
|
||||
{index === chosen ? (
|
||||
<motion.span
|
||||
aria-hidden
|
||||
layoutId={thumbId}
|
||||
transition={LOOK.material.spring}
|
||||
className="m3-segment-thumb"
|
||||
/>
|
||||
) : null}
|
||||
<span className="m3-state" aria-hidden />
|
||||
<span className="m3-segment-label">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Select({
|
||||
value,
|
||||
options,
|
||||
label,
|
||||
disabled,
|
||||
onChange,
|
||||
}: SelectProps) {
|
||||
// The menu is portalled to `body`, which is outside the canvas — so it
|
||||
// re-states the dashboard's own colours rather than borrowing the app's.
|
||||
const { style } = useLook()
|
||||
return (
|
||||
<SelectPrimitive.Root
|
||||
value={value}
|
||||
onValueChange={(selected) => onChange(asOriginal(selected, options))}
|
||||
>
|
||||
<SelectPrimitive.Trigger
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
className="m3-field m3-pressable justify-between"
|
||||
>
|
||||
<span className="min-w-0 truncate">
|
||||
<SelectPrimitive.Value placeholder="Choose" />
|
||||
</span>
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="size-4 shrink-0 opacity-60" aria-hidden />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
position="popper"
|
||||
sideOffset={4}
|
||||
style={style}
|
||||
className="m3-menu z-50 max-h-64 min-w-[var(--radix-select-trigger-width)] overflow-y-auto p-1 data-[state=open]:animate-in data-[state=open]:zoom-in-95 data-[state=open]:fade-in-0"
|
||||
>
|
||||
<SelectPrimitive.Viewport>
|
||||
{options.map((option) => (
|
||||
<SelectPrimitive.Item
|
||||
key={text(option.value)}
|
||||
value={text(option.value)}
|
||||
className="m3-pressable flex cursor-pointer select-none items-center justify-between gap-2 rounded-lg px-3 py-2 text-sm outline-none"
|
||||
>
|
||||
<span className="m3-state" aria-hidden />
|
||||
<SelectPrimitive.ItemText>
|
||||
{option.label ?? text(option.value)}
|
||||
</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="size-4" aria-hidden />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
))}
|
||||
</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
</SelectPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export function Input({
|
||||
value,
|
||||
type,
|
||||
label,
|
||||
disabled,
|
||||
onChange,
|
||||
onCommit,
|
||||
}: InputProps) {
|
||||
return (
|
||||
<input
|
||||
className="m3-field"
|
||||
value={value}
|
||||
type={type}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onBlur={onCommit}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") onCommit()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Material 3: the readings.
|
||||
*
|
||||
* Flat fills on tonal 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(--m3-sc)"
|
||||
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)}
|
||||
style={{ pathLength: fraction }}
|
||||
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 m3-track">
|
||||
<motion.span
|
||||
data-testid={TESTID.barFill}
|
||||
className="dui-bar-fill m3-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 m3-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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Material 3: the surfaces.
|
||||
*
|
||||
* Tonal rather than outlined — a widget is told from the ground by being a
|
||||
* step further toward the primary, which is what lets a palette recolour the
|
||||
* whole look. Nothing here holds state: the behaviour is in `ui/core`.
|
||||
*/
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { Lock } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ICONS } from "../../icons"
|
||||
import type {
|
||||
BackdropProps,
|
||||
FrameProps,
|
||||
NoticeProps,
|
||||
RailProps,
|
||||
} from "../core/contract"
|
||||
import { TESTID } from "../core/contract"
|
||||
import { LOOK } from "../core/motion"
|
||||
|
||||
/** Material's ground is the surface colour itself; only an image is drawn. */
|
||||
export function Backdrop({ image }: BackdropProps) {
|
||||
if (!image) return null
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
data-testid="canvas-ground"
|
||||
className="pointer-events-none absolute inset-0 bg-cover bg-center"
|
||||
style={{ backgroundImage: `url(${JSON.stringify(image)})` }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Issue({ issue }: { issue: string }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
role="img"
|
||||
className="block size-2 shrink-0 rounded-full bg-destructive"
|
||||
aria-label={issue}
|
||||
data-testid={TESTID.issue}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs break-words">{issue}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export function Frame({
|
||||
title,
|
||||
issue,
|
||||
grip,
|
||||
selected,
|
||||
onClick,
|
||||
children,
|
||||
}: FrameProps) {
|
||||
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.
|
||||
// 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 m3-frame"
|
||||
onClick={onClick}
|
||||
>
|
||||
{title ? (
|
||||
<div
|
||||
className={cn(
|
||||
"dui-frame-head m3-frame-title",
|
||||
grip &&
|
||||
`${TESTID.grip} -m-1 cursor-grab p-1 active:cursor-grabbing`,
|
||||
)}
|
||||
>
|
||||
<span className="dui-frame-title">{title}</span>
|
||||
{issue ? <Issue issue={issue} /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="dui-frame-body">{children}</div>
|
||||
{/* No header to sit in, so the fault takes the corner instead — a widget
|
||||
drawn without its title still says when it is mis-wired. */}
|
||||
{!title && issue ? (
|
||||
<div className="dui-frame-corner">
|
||||
<Issue issue={issue} />
|
||||
</div>
|
||||
) : null}
|
||||
{/* And the editor still needs something to drag it by. */}
|
||||
{!title && grip ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
TESTID.grip,
|
||||
"absolute left-1/2 top-1 z-[2] h-1 w-8 -translate-x-1/2 cursor-grab rounded-full bg-current opacity-20 active:cursor-grabbing",
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Two letters off the title, so a rail of four reads as four different things. */
|
||||
function initials(label: string): string {
|
||||
const words = label.split(/[\s_-]+/).filter(Boolean)
|
||||
if (words.length === 0) return "?"
|
||||
if (words.length === 1) return words[0].slice(0, 2).toUpperCase()
|
||||
return (words[0][0] + words[1][0]).toUpperCase()
|
||||
}
|
||||
|
||||
export function Rail({ entries }: RailProps) {
|
||||
return (
|
||||
<nav
|
||||
aria-label="Dashboards on this panel"
|
||||
data-testid={TESTID.rail}
|
||||
className={cn(
|
||||
"m3-rail pointer-events-auto absolute inset-y-4 left-4 z-10 flex w-14 flex-col items-center gap-1 p-2",
|
||||
// More dashboards than the column is tall still scroll, but no bar is
|
||||
// ever drawn: a wall panel is swiped, and there is no room for one.
|
||||
"overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
)}
|
||||
>
|
||||
{entries.map((entry) => {
|
||||
const Glyph = ICONS[entry.icon ?? ""]
|
||||
return (
|
||||
<Tooltip key={entry.name}>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
{...entry.link}
|
||||
aria-label={entry.label}
|
||||
aria-current={entry.active ? "page" : undefined}
|
||||
data-testid={`panel-rail-${entry.name}`}
|
||||
className={cn(
|
||||
"m3-pressable relative flex size-10 shrink-0 items-center justify-center rounded-full text-xs font-medium",
|
||||
entry.active
|
||||
? "text-card-foreground"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="m3-state" aria-hidden />
|
||||
{entry.active ? (
|
||||
<motion.span
|
||||
aria-hidden
|
||||
layoutId="m3-rail-active"
|
||||
transition={LOOK.material.spring}
|
||||
className="absolute inset-0 rounded-full"
|
||||
style={{ background: "var(--m3-sc)" }}
|
||||
/>
|
||||
) : null}
|
||||
<span className="relative z-[1]">
|
||||
{Glyph ? <Glyph className="size-5" /> : initials(entry.label)}
|
||||
</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{entry.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
export function Notice({ children }: NoticeProps) {
|
||||
return (
|
||||
<div
|
||||
// Announced rather than merely drawn: `locked` may be driven by a flow,
|
||||
// so the state can change under someone already looking at the page.
|
||||
aria-live="polite"
|
||||
data-testid={TESTID.locked}
|
||||
className="m3-notice pointer-events-none absolute bottom-0 right-0 flex items-center gap-1.5 px-3 py-1.5 text-sm"
|
||||
>
|
||||
<Lock className="size-4" aria-hidden />
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Material 3, tonal — one of the dashboard's two looks.
|
||||
*
|
||||
* Every entry of `ComponentSet` is implemented here; the behaviour comes from
|
||||
* `ui/core`, so this set cannot do anything the other one cannot.
|
||||
*/
|
||||
import "./material.css"
|
||||
|
||||
import type { ComponentSet } from "../core/contract"
|
||||
import { Button, Input, Segmented, Select, Slider, Switch } from "./Controls"
|
||||
import { Bar, ColorDisk, Gauge, Readout } from "./Data"
|
||||
import { Backdrop, Frame, Notice, Rail } from "./Surfaces"
|
||||
|
||||
export const material: ComponentSet = {
|
||||
Backdrop,
|
||||
Frame,
|
||||
Button,
|
||||
Switch,
|
||||
Slider,
|
||||
Segmented,
|
||||
Select,
|
||||
Input,
|
||||
Readout,
|
||||
Gauge,
|
||||
Bar,
|
||||
ColorDisk,
|
||||
Rail,
|
||||
Notice,
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
/*
|
||||
* Material 3, tonal.
|
||||
*
|
||||
* Surfaces are told apart by tone rather than by a line: each container step
|
||||
* is the dashboard's primary mixed a little further into the surface colour,
|
||||
* which is what makes a palette recolour the whole look and not just the
|
||||
* accents. Interaction is a state layer — the control's own text colour laid
|
||||
* over it at 8% or 12% — plus a ripple from where it was pressed.
|
||||
*
|
||||
* Paint only; the measurements are in `../core/core.css`.
|
||||
*/
|
||||
|
||||
[data-look="material"] {
|
||||
--m3-c1: color-mix(in oklab, var(--primary) 5%, var(--card));
|
||||
--m3-c2: color-mix(in oklab, var(--primary) 8%, var(--card));
|
||||
--m3-c3: color-mix(in oklab, var(--primary) 11%, var(--card));
|
||||
--m3-c4: color-mix(in oklab, var(--primary) 14%, var(--card));
|
||||
/* What a selected segment, a tonal button and the rail's active pill wear. */
|
||||
--m3-sc: color-mix(in oklab, var(--primary) 22%, var(--card));
|
||||
--m3-outline: var(--border);
|
||||
--m3-radius: 1rem;
|
||||
}
|
||||
|
||||
/* --- surfaces ----------------------------------------------------------- */
|
||||
|
||||
/* A card is `surface-container-low`. Two tone steps off the page rather than
|
||||
one, because the page itself is the plain surface colour and a single step
|
||||
is not a separation anybody can see across a room. */
|
||||
.m3-frame {
|
||||
border-radius: var(--m3-radius);
|
||||
background: var(--m3-c2);
|
||||
color: var(--card-foreground);
|
||||
box-shadow: var(--shadow-e1);
|
||||
}
|
||||
|
||||
.m3-frame[data-selected] {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.m3-frame-title {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.m3-rail {
|
||||
border-radius: 1.75rem;
|
||||
background: var(--m3-c3);
|
||||
color: var(--card-foreground);
|
||||
box-shadow: var(--shadow-e2);
|
||||
}
|
||||
|
||||
.m3-notice {
|
||||
border-radius: 9999px;
|
||||
background: var(--m3-c3);
|
||||
color: var(--muted-foreground);
|
||||
box-shadow: var(--shadow-e2);
|
||||
}
|
||||
|
||||
/* --- state layer and ripple --------------------------------------------- */
|
||||
|
||||
.m3-pressable {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.m3-pressable > .m3-state {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
background: currentColor;
|
||||
opacity: 0;
|
||||
transition: opacity var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.m3-pressable:hover > .m3-state {
|
||||
opacity: 0.08;
|
||||
}
|
||||
|
||||
.m3-pressable:focus-visible > .m3-state,
|
||||
.m3-pressable:active > .m3-state {
|
||||
opacity: 0.12;
|
||||
}
|
||||
|
||||
/* A finger covers what it touches, so hover means nothing on a touch panel. */
|
||||
[data-touch] .m3-pressable:hover > .m3-state {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.m3-ripple {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
width: 250%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
translate: -50% -50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.m3-pressable > :not(.m3-state):not(.m3-ripple) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* --- controls ------------------------------------------------------------ */
|
||||
|
||||
.m3-button {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
min-height: var(--dui-control);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 9999px;
|
||||
padding-inline: 1.25rem;
|
||||
font-size: var(--dui-text);
|
||||
font-weight: 500;
|
||||
background: var(--m3-sc);
|
||||
color: var(--card-foreground);
|
||||
}
|
||||
|
||||
.m3-button[data-variant="filled"],
|
||||
.m3-button[aria-pressed="true"] {
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.m3-button[data-variant="text"] {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.m3-button:disabled {
|
||||
opacity: 0.38;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.m3-switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
width: calc(var(--dui-control) * 1.75);
|
||||
height: var(--dui-control);
|
||||
flex: none;
|
||||
align-items: center;
|
||||
border-radius: 9999px;
|
||||
padding: 0.25rem;
|
||||
background: var(--m3-c4);
|
||||
box-shadow: inset 0 0 0 2px var(--muted-foreground);
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.m3-switch[data-state="on"] {
|
||||
background: var(--primary);
|
||||
box-shadow: none;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.m3-switch-thumb {
|
||||
display: block;
|
||||
width: calc(var(--dui-control) * 0.45);
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
background: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.m3-switch[data-state="on"] .m3-switch-thumb {
|
||||
width: calc(var(--dui-control) * 0.7);
|
||||
background: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.m3-switch:disabled {
|
||||
opacity: 0.38;
|
||||
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;
|
||||
border-radius: 9999px;
|
||||
background: var(--m3-sc);
|
||||
}
|
||||
|
||||
.m3-slider input::-moz-range-progress {
|
||||
height: 0.5rem;
|
||||
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%;
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
.m3-segmented {
|
||||
position: relative;
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
border: 1px solid var(--m3-outline);
|
||||
border-radius: 9999px;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.m3-segmented[data-orientation="vertical"] {
|
||||
height: 100%;
|
||||
grid-auto-flow: row;
|
||||
grid-auto-rows: minmax(0, 1fr);
|
||||
border-radius: 1.25rem;
|
||||
}
|
||||
|
||||
.m3-segment {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: calc(var(--dui-control) - 0.5rem);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
padding-inline: 0.625rem;
|
||||
font-size: var(--dui-text);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.m3-segment[data-active] {
|
||||
color: var(--card-foreground);
|
||||
}
|
||||
|
||||
.m3-segment-thumb {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
border-radius: 9999px;
|
||||
background: var(--m3-sc);
|
||||
}
|
||||
|
||||
.m3-segment-label {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m3-field {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: var(--dui-control);
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid var(--m3-outline);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--m3-c2);
|
||||
padding-inline: 0.75rem;
|
||||
font-size: var(--dui-text);
|
||||
color: var(--card-foreground);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.m3-field:focus-visible,
|
||||
.m3-field[data-state="open"] {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: inset 0 0 0 1px var(--primary);
|
||||
}
|
||||
|
||||
.m3-field:disabled {
|
||||
opacity: 0.38;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.m3-menu {
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--m3-outline);
|
||||
background: var(--m3-c3);
|
||||
color: var(--card-foreground);
|
||||
box-shadow: var(--shadow-e2);
|
||||
}
|
||||
|
||||
/* --- readings ------------------------------------------------------------ */
|
||||
|
||||
.m3-track {
|
||||
border-radius: 9999px;
|
||||
background: color-mix(
|
||||
in oklab,
|
||||
var(--dui-fill, var(--primary)) 18%,
|
||||
var(--card)
|
||||
);
|
||||
}
|
||||
|
||||
.m3-fill {
|
||||
border-radius: 9999px;
|
||||
background: var(--dui-fill, var(--primary));
|
||||
}
|
||||
|
||||
.m3-disc-thumb {
|
||||
border: 3px solid var(--card);
|
||||
box-shadow: var(--shadow-e1);
|
||||
}
|
||||
|
||||
/* --- focus --------------------------------------------------------------- */
|
||||
|
||||
[data-look="material"]
|
||||
:is(.m3-button, .m3-switch, .m3-segment, .m3-field, .dui-disc):focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
Reference in New Issue
Block a user