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:
2026-08-23 21:52:14 +02:00
parent 7c6e655e17
commit acae48ddb6
52 changed files with 5517 additions and 1568 deletions
@@ -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;
}