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.
184 lines
5.8 KiB
TypeScript
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>
|
|
)
|
|
}
|