Files
app/frontend/src/components/Dashboard/ui/material/Surfaces.tsx
T
stroblme 0b5ce4fcbb 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.
2026-08-23 21:52:14 +02:00

184 lines
5.8 KiB
TypeScript

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