chore(web): console onto @unom/ui 0.9.2 #142

Merged
enricobuehler merged 3 commits from worktree-console-unom-092 into main 2026-08-09 15:32:39 +00:00
12 changed files with 271 additions and 146 deletions
+2 -2
View File
@@ -10,7 +10,7 @@
"@tanstack/react-router": "^1.170.18",
"@tanstack/react-start": "^1.168.32",
"@unom/style": "^0.4.4",
"@unom/ui": "^0.8.16",
"@unom/ui": "^0.9.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.469.0",
@@ -982,7 +982,7 @@
"@unom/style": ["@unom/style@0.4.4", "https://git.unom.io/api/packages/unom/npm/%40unom%2Fstyle/-/0.4.4/style-0.4.4.tgz", { "peerDependencies": { "motion": "^12" } }, "sha512-M45nihK+LGyxwy2mmHYRKggaocTt+EKNVFNaMpTvTaIUpozi7bmKIkbM2/enMYS0/UYTaZrBSZs/a0nPXqkAKw=="],
"@unom/ui": ["@unom/ui@0.8.16", "https://git.unom.io/api/packages/unom/npm/%40unom%2Fui/-/0.8.16/ui-0.8.16.tgz", { "dependencies": { "@tanstack/react-router": "^1.170.11", "@tsdown/css": "^0.22.1", "clsx": "^2.1.1", "howler": "^2.2.4", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0" }, "peerDependencies": { "@payloadcms/richtext-lexical": "^3.85.0", "@tanstack/react-virtual": "^3.14.2", "@unom/style": "^0.4.4", "class-variance-authority": "^0.7.1", "embla-carousel-react": "^8.6.0", "lucide-react": "^1.17.0", "motion": "^12.40.0", "radix-ui": "^1.4.3", "react": "^19.2.7", "react-dom": "^19.2.7", "typescript": "^6.0.3", "zod": "^4.4.3" } }, "sha512-ZH7VOyaRDT81VY8nm1hmx8a4CeObykP8egZbnV4Nju6kE8rQ28wdpBo0X+Zsdu8WvTEmHZGwPR53NHWJULyciw=="],
"@unom/ui": ["@unom/ui@0.9.2", "https://git.unom.io/api/packages/unom/npm/%40unom%2Fui/-/0.9.2/ui-0.9.2.tgz", { "dependencies": { "@tanstack/react-router": "^1.170.11", "@tsdown/css": "^0.22.1", "clsx": "^2.1.1", "howler": "^2.2.4", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0" }, "peerDependencies": { "@payloadcms/richtext-lexical": "^3.85.0", "@tanstack/react-virtual": "^3.14.2", "@unom/style": "^0.4.4", "class-variance-authority": "^0.7.1", "embla-carousel-react": "^8.6.0", "lucide-react": "^1.17.0", "motion": "^12.40.0", "radix-ui": "^1.4.3", "react": "^19.2.7", "react-dom": "^19.2.7", "typescript": "^6.0.3", "zod": "^4.4.3" } }, "sha512-UbpNQEu6zRNMkAxsINRj6HvT53ty7+/QxN3TZv6WgQd/rLiLe767mjz4Zh765ASc3NY7EguHqVNuWX6L7V9TLA=="],
"@vercel/nft": ["@vercel/nft@1.10.2", "", { "dependencies": { "@mapbox/node-pre-gyp": "^2.0.0", "@rollup/pluginutils": "^5.1.3", "acorn": "^8.6.0", "acorn-import-attributes": "^1.9.5", "async-sema": "^3.1.1", "bindings": "^1.4.0", "estree-walker": "2.0.2", "glob": "^13.0.0", "graceful-fs": "^4.2.9", "node-gyp-build": "^4.2.2", "picomatch": "^4.0.2", "resolve-from": "^5.0.0" }, "bin": { "nft": "out/cli.js" } }, "sha512-w+WyX5Ulmj4dtTZrxaulqrjaLZHSbnPzx75SJsTNYmotKsqn1JlLnDJa+lz5hn90HJofhl/2MAtw0mCrgM3qYw=="],
+4 -4
View File
@@ -1902,10 +1902,10 @@
hash = "sha512-M45nihK+LGyxwy2mmHYRKggaocTt+EKNVFNaMpTvTaIUpozi7bmKIkbM2/enMYS0/UYTaZrBSZs/a0nPXqkAKw==";
name = "style-0.4.4.tgz";
};
"@unom/ui@0.8.16" = fetchurl {
url = "https://git.unom.io/api/packages/unom/npm/%40unom%2Fui/-/0.8.16/ui-0.8.16.tgz";
hash = "sha512-ZH7VOyaRDT81VY8nm1hmx8a4CeObykP8egZbnV4Nju6kE8rQ28wdpBo0X+Zsdu8WvTEmHZGwPR53NHWJULyciw==";
name = "ui-0.8.16.tgz";
"@unom/ui@0.9.2" = fetchurl {
url = "https://git.unom.io/api/packages/unom/npm/%40unom%2Fui/-/0.9.2/ui-0.9.2.tgz";
hash = "sha512-UbpNQEu6zRNMkAxsINRj6HvT53ty7+/QxN3TZv6WgQd/rLiLe767mjz4Zh765ASc3NY7EguHqVNuWX6L7V9TLA==";
name = "ui-0.9.2.tgz";
};
"@vercel/nft@1.10.2" = fetchurl {
url = "https://registry.npmjs.org/@vercel/nft/-/nft-1.10.2.tgz";
+73 -73
View File
@@ -1,75 +1,75 @@
{
"name": "punktfunk-web",
"private": true,
"type": "module",
"description": "punktfunk management console \u2014 TanStack Start + React Query (orval) + @unom/ui + Paraglide i18n",
"scripts": {
"prepare": "bun run codegen",
"postinstall": "bun2nix -o bun.nix",
"codegen": "orval --config orval.config.ts && paraglide-js compile --project ./project.inlang --outdir ./src/paraglide && node tools/check-i18n.mjs",
"predev": "orval --config orval.config.ts",
"dev": "vite dev --port 47992",
"prebuild": "orval --config orval.config.ts",
"build": "vite build",
"postbuild": "node tools/check-i18n.mjs",
"start": "bun run .output/server/index.mjs",
"api:gen": "orval --config orval.config.ts",
"lint": "tsc --noEmit",
"test": "bun test server/",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"screenshots": "node tools/screenshots.mjs",
"screenshots:build": "bun run build-storybook && node tools/screenshots.mjs"
},
"dependencies": {
"@fontsource-variable/geist": "^5.3.0",
"@tanstack/react-query": "^5.101.4",
"@tanstack/react-router": "^1.170.18",
"@tanstack/react-start": "^1.168.32",
"@unom/style": "^0.4.4",
"@unom/ui": "^0.8.16",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.469.0",
"motion": "^12.42.2",
"radix-ui": "^1.6.4",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"recharts": "^3.10.0",
"tailwind-merge": "^2.6.1",
"zod": "^4.4.3"
},
"devDependencies": {
"@biomejs/biome": "^2.5.5",
"@inlang/paraglide-js": "^2.22.0",
"@inlang/plugin-message-format": "^4.4.0",
"@storybook/react-vite": "^10.5.3",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/nitro-v2-vite-plugin": "^1.155.0",
"@types/node": "^22.20.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"bun2nix": "2.1.2",
"orval": "^8.22.0",
"playwright": "^1.61.1",
"storybook": "^10.5.3",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vite": "^7.3.6",
"vite-tsconfig-paths": "^5.1.4"
},
"overrides": {
"tar": "^7.5.21",
"dompurify": "^3.4.12",
"linkify-it": "^5.0.2",
"sharp": "^0.35.3",
"fast-uri": "^3.1.5",
"immutable": "^4.3.9",
"undici": "^7.29.0",
"postcss": "^8.5.25",
"js-yaml": "^4.3.0",
"brace-expansion": "^5.0.9"
}
"name": "punktfunk-web",
"private": true,
"type": "module",
"description": "punktfunk management console TanStack Start + React Query (orval) + @unom/ui + Paraglide i18n",
"scripts": {
"prepare": "bun run codegen",
"postinstall": "bun2nix -o bun.nix",
"codegen": "orval --config orval.config.ts && paraglide-js compile --project ./project.inlang --outdir ./src/paraglide && node tools/check-i18n.mjs",
"predev": "orval --config orval.config.ts",
"dev": "vite dev --port 47992",
"prebuild": "orval --config orval.config.ts",
"build": "vite build",
"postbuild": "node tools/check-i18n.mjs",
"start": "bun run .output/server/index.mjs",
"api:gen": "orval --config orval.config.ts",
"lint": "tsc --noEmit",
"test": "bun test server/",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"screenshots": "node tools/screenshots.mjs",
"screenshots:build": "bun run build-storybook && node tools/screenshots.mjs"
},
"dependencies": {
"@fontsource-variable/geist": "^5.3.0",
"@tanstack/react-query": "^5.101.4",
"@tanstack/react-router": "^1.170.18",
"@tanstack/react-start": "^1.168.32",
"@unom/style": "^0.4.4",
"@unom/ui": "^0.9.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.469.0",
"motion": "^12.42.2",
"radix-ui": "^1.6.4",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"recharts": "^3.10.0",
"tailwind-merge": "^2.6.1",
"zod": "^4.4.3"
},
"devDependencies": {
"@biomejs/biome": "^2.5.5",
"@inlang/paraglide-js": "^2.22.0",
"@inlang/plugin-message-format": "^4.4.0",
"@storybook/react-vite": "^10.5.3",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/nitro-v2-vite-plugin": "^1.155.0",
"@types/node": "^22.20.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"bun2nix": "2.1.2",
"orval": "^8.22.0",
"playwright": "^1.61.1",
"storybook": "^10.5.3",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vite": "^7.3.6",
"vite-tsconfig-paths": "^5.1.4"
},
"overrides": {
"tar": "^7.5.21",
"dompurify": "^3.4.12",
"linkify-it": "^5.0.2",
"sharp": "^0.35.3",
"fast-uri": "^3.1.5",
"immutable": "^4.3.9",
"undici": "^7.29.0",
"postcss": "^8.5.25",
"js-yaml": "^4.3.0",
"brace-expansion": "^5.0.9"
}
}
+33 -8
View File
@@ -27,13 +27,37 @@ const Card = ({
);
Card.displayName = "Card";
/**
* The card inset, as ONE utility.
*
* It used to be `p-4 sm:p-6`, and that responsive pair is what made every padding override in this
* codebase unreliable: tailwind-merge resolves conflicts only *within* a variant, so a call-site
* `pt-6` beat the base `pt-0` and lost to `sm:pt-0` — correct on mobile, zero on desktop. Seven call
* sites had grown their own compensation for that in five different dialects.
*
* A single-variant token cannot half-lose. `--spacing-padding-card` is also what @unom/ui's own
* `Card` uses, so nested cards finally agree on their inset.
*/
const INSET = "p-padding-card";
/**
* Body/footer padding, minus the top when something already sits above.
*
* The old code hard-coded `pt-0` because "a CardHeader supplies the top inset" — an assumption about
* a SIBLING that nothing enforced. Delete the header (exactly what tabbing a page does, since the
* tab label replaces the card title) and the top inset silently vanished at ≥640px. Asking the DOM
* instead of the author makes it self-correcting: first child keeps its inset, later children drop
* it.
*/
const INSET_AFTER_SIBLING = `${INSET} [&:not(:first-child)]:pt-0`;
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-4 sm:p-6", className)}
className={cn("flex flex-col space-y-1.5", INSET, className)}
{...props}
/>
));
@@ -67,11 +91,12 @@ CardDescription.displayName = "CardDescription";
* Card body. Pass `flush` for content that should meet the card's edges — a full-bleed table, most
* commonly — instead of trying to cancel the padding from the outside.
*
* `className="p-0"` does NOT work for that: tailwind-merge only resolves conflicts *within the same
* variant*, so `p-0` cancels `p-4` but leaves `sm:p-6` standing, and the padding silently returns at
* ≥640px. Every call site that tried it ended up with a doubled inset once a `CardHeader` (which
* brings its own `sm:p-6`) was nested inside — visible as one card whose title sits 24px further in
* than its neighbours'.
* Do NOT reach for `className="p-0"`: `flush` exists precisely so that intent is expressed as a prop
* the component honours, rather than as a utility that has to out-argue the one already there.
*
* Conversely, you no longer need to ADD top padding when there is no header — that is automatic now.
* If you find yourself writing `pt-*` on a CardContent, the layout is telling you something else is
* wrong.
*/
const CardContent = React.forwardRef<
HTMLDivElement,
@@ -79,7 +104,7 @@ const CardContent = React.forwardRef<
>(({ className, flush = false, ...props }, ref) => (
<div
ref={ref}
className={cn(!flush && "p-4 pt-0 sm:p-6 sm:pt-0", className)}
className={cn(!flush && INSET_AFTER_SIBLING, className)}
{...props}
/>
));
@@ -91,7 +116,7 @@ const CardFooter = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-4 pt-0 sm:p-6 sm:pt-0", className)}
className={cn("flex items-center", INSET_AFTER_SIBLING, className)}
{...props}
/>
));
+7 -4
View File
@@ -62,7 +62,7 @@ export const DashboardView: FC<{
only the GameStream certs read as "0 paired" on a host every
one of whose clients was in fact paired. */}
<Card>
<CardContent className="flex flex-1 items-center justify-between p-4 sm:pt-6">
<CardContent className="flex flex-1 items-center justify-between">
<span className="text-sm text-muted-foreground">
{m.status_paired_count()}
</span>
@@ -72,7 +72,7 @@ export const DashboardView: FC<{
</CardContent>
</Card>
<Card>
<CardContent className="flex flex-1 items-center justify-between p-4 sm:pt-6">
<CardContent className="flex flex-1 items-center justify-between">
<span className="text-sm text-muted-foreground">
{m.status_pin_pending()}
</span>
@@ -206,7 +206,10 @@ export const DashboardView: FC<{
* else except the host log.
*/
const AudioWiringCard: FC<{ audio: AudioWiring }> = ({ audio }) => {
const badge: { variant: "success" | "secondary" | "destructive"; text: string } =
const badge: {
variant: "success" | "secondary" | "destructive";
text: string;
} =
audio.readiness === "full"
? { variant: "success", text: m.audio_ready() }
: audio.readiness === "audio_only"
@@ -257,7 +260,7 @@ const StatCard: FC<{ icon: ReactNode; label: string; on: boolean }> = ({
on,
}) => (
<Card>
<CardContent className="flex flex-1 items-center justify-between p-4 sm:pt-6">
<CardContent className="flex flex-1 items-center justify-between">
<span className="flex items-center gap-2 text-sm text-muted-foreground">
{icon}
{label}
+62 -23
View File
@@ -41,9 +41,10 @@ import { QueryState } from "@/components/query-state";
import { Stagger } from "@/components/stagger";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import { InputNumber } from "@/components/ui/input-number";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { apiErrorMessage } from "@/lib/errors";
import { cn } from "@/lib/utils";
import { m } from "@/paraglide/messages";
@@ -177,17 +178,11 @@ export const DisplaySection: FC = () => {
});
return (
<div className="flex flex-col gap-card">
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-2">
<CardTitle>{m.display_config_title()}</CardTitle>
{/* Visible without scrolling to the save button — the card is taller than the
viewport, which is exactly how the pending edits went unnoticed. */}
{dirty && <Badge variant="warning">{m.display_unsaved()}</Badge>}
</div>
</CardHeader>
<CardContent className="space-y-4">
<DisplayTabs
dirty={dirty}
live={<LiveDisplays />}
configuration={
<>
<p className="max-w-prose text-sm text-muted-foreground">
{m.host_displays_help()}
</p>
@@ -226,20 +221,64 @@ export const DisplaySection: FC = () => {
/>
)}
</QueryState>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{m.display_live()}</CardTitle>
</CardHeader>
<CardContent>
<LiveDisplays />
</CardContent>
</Card>
</div>
</>
}
/>
);
};
/**
* The page's tab shell: **Configuration** and **Live displays** as the same pill strip the plugin
* UIs use, over a card per tab.
*
* Tabs rather than two stacked cards because the configuration card alone is taller than the
* viewport — which is how pending edits went unnoticed — and the live list sat below it, effectively
* off screen.
*
* Presentational on purpose, taking both panes as nodes: `DisplaySection` cannot be rendered in
* Storybook (it calls `useBlocker`, which needs a router), so putting the strip here is what keeps
* it reachable from a story. That matters more than usual on this page — `Displays.stories.tsx`
* exists to pin the MOTION NESTING of the preset grid, and inserting tabs changes that ancestor
* chain, so the story has to render the real one.
*/
export const DisplayTabs: FC<{
dirty: boolean;
configuration: ReactNode;
live: ReactNode;
}> = ({ dirty, configuration, live }) => (
<Tabs defaultValue="configuration" className="gap-card">
<TabsList>
<TabsTrigger value="configuration">
{m.display_config_title()}
{/* The dirty marker rides the TAB, not the card header. It used to sit inside a card
taller than the viewport; behind a tab it would vanish altogether while the Live
tab was open. On the trigger it survives both — and the Custom block keeps its
own inline badge, so nothing is lost when this tab IS open. */}
{dirty && (
<span
role="status"
aria-label={m.display_unsaved()}
className="ml-1.5 size-2 shrink-0 rounded-full bg-[var(--warning)]"
/>
)}
</TabsTrigger>
<TabsTrigger value="live">{m.display_live()}</TabsTrigger>
</TabsList>
<TabsContent value="configuration">
<Card>
<CardContent className="space-y-4">{configuration}</CardContent>
</Card>
</TabsContent>
<TabsContent value="live">
<Card>
<CardContent>{live}</CardContent>
</Card>
</TabsContent>
</Tabs>
);
/**
* The gate on anything that would throw unsaved Custom fields away — asked from three places (a
* preset click, applying a saved preset, and leaving the page), so it is written once. A function
+1 -1
View File
@@ -28,7 +28,7 @@ export const ConflictsCard: FC = () => {
if (conflicts.length === 0) return null;
return (
<Card className="border-amber-600/40 dark:border-amber-500/40">
<CardContent className="flex items-start gap-3 p-card pt-card sm:pt-card">
<CardContent className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 size-5 shrink-0 text-amber-600 dark:text-amber-500" />
<div className="min-w-0 flex-1 space-y-2">
<p className="text-sm font-medium text-amber-600 dark:text-amber-500">
+4 -7
View File
@@ -238,13 +238,10 @@ export const LogsCard: FC<{
return (
<Card>
{/* This card has no CardHeader, so it has to put the top padding back itself — and it
must do so at BOTH breakpoints. `CardContent` is `p-4 pt-0 sm:p-6 sm:pt-0`, and
tailwind-merge only resolves conflicts within the same variant: a bare `pt-6` cancels
`pt-0` but leaves `sm:pt-0` standing, so the padding was 24px on a phone and 0 on a
desktop, with the filter row touching the card's edge. (Same trap the `p-0` note in
components/ui/card.tsx describes, in the other direction.) */}
<CardContent className="flex flex-col gap-3 pt-4 sm:pt-6">
{/* No CardHeader here, and that no longer needs saying: CardContent keeps its top inset
unless something precedes it. This card used to restore it by hand at both
breakpoints. */}
<CardContent className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1">
{LEVELS.map((l) => (
+1 -1
View File
@@ -135,7 +135,7 @@ export const PairedDevices: FC<{
<h2 className="text-lg font-medium">{m.pairing_native_devices()}</h2>
</CardHeader>
<CardContent className="p-6">
<CardContent>
<QueryState isLoading={isLoading} error={error} refetch={refetch}>
{rows.length === 0 ? (
m.pairing_native_empty()
+2 -2
View File
@@ -55,7 +55,7 @@ export const JobProgressSection: FC<{
if (!job.isError) return null;
return (
<Card className="ring-2 ring-destructive/60">
<CardContent className="flex items-start gap-3 p-card pt-card sm:pt-card">
<CardContent className="flex items-start gap-3">
<XCircle className="mt-0.5 size-5 shrink-0 text-destructive" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{m.store_job_lost()}</p>
@@ -92,7 +92,7 @@ export const JobProgressCard: FC<{
className={failed ? "ring-2 ring-destructive/60" : undefined}
aria-live="polite"
>
<CardContent className="space-y-3 p-card pt-card sm:pt-card">
<CardContent className="space-y-3">
<div className="flex items-start gap-3">
{running ? (
<Spinner className="mt-0.5 size-5 shrink-0" />
+36
View File
@@ -21,6 +21,42 @@ const meta = {
export default meta;
type Story = StoryObj<typeof meta>;
/**
* The inset contract — the thing this card got wrong most often.
*
* `CardContent` drops its top padding only when something already sits above it. The pair below is
* the regression guard: both cards must show the same inset on every side, and the headerless one
* must not have its first line touching the top edge.
*
* It used to be wrong invisibly, and only on desktop. The padding was `p-4 pt-0 sm:p-6 sm:pt-0`, so
* a headerless card had to restore the top inset itself — and a call-site `pt-6` beat the base
* `pt-0` while losing to `sm:pt-0`, because tailwind-merge resolves conflicts only within a variant.
* Right on a phone, zero on a desktop. Seven call sites had grown their own workaround for it.
*
* ⚠ Check this at BOTH viewport widths. A single width cannot show that class of bug.
*/
export const InsetWithAndWithoutHeader: Story = {
render: () => (
<div className="grid gap-4 sm:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>With a header</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
The body drops its top inset because the header above already supplied
one.
</CardContent>
</Card>
<Card>
<CardContent className="text-sm text-muted-foreground">
No header, so the body keeps its own top inset automatically, with
nothing for the call site to remember.
</CardContent>
</Card>
</div>
),
};
export const HostCard: Story = {
render: () => (
<Card className="max-w-sm">
+46 -21
View File
@@ -1,9 +1,8 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useState } from "react";
import { userEvent, within } from "storybook/test";
import type { DisplayPolicy } from "@/api/gen/model/displayPolicy";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { m } from "@/paraglide/messages";
import { DisplayForm } from "@/sections/Displays/DisplayCard";
import { DisplayForm, DisplayTabs } from "@/sections/Displays/DisplayCard";
import {
displayCustomPresets,
displayEffective,
@@ -19,17 +18,29 @@ import {
* frame while every other grid in the console staggered. It is invisible in a diff and invisible to
* `tsc`; only a rendered page shows it.
*
* So the `<Card>` wrapper below is NOT decoration. It reproduces the page's motion nesting, which is
* the thing under test — dropping it would make the story pass for the wrong reason.
* So the wrapper below is NOT decoration. It reproduces the page's motion nesting, which is the
* thing under test — dropping it would make the story pass for the wrong reason. It renders the
* page's real `DisplayTabs` shell for exactly that reason: the tabs sit between the page `<Section>`
* and the card, so they are part of the ancestor chain this story exists to pin.
*/
const Harness = ({ seed }: { seed: DisplayPolicy }) => {
const Harness = ({
seed,
dirty = false,
}: {
seed: DisplayPolicy;
dirty?: boolean;
}) => {
const [draft, setDraft] = useState<DisplayPolicy>(seed);
return (
<Card>
<CardHeader>
<CardTitle>{m.display_config_title()}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<DisplayTabs
dirty={dirty}
live={
<p className="text-sm text-muted-foreground">
The live list reads `/display/state`, so it is not part of this story
see the tab strip and the Configuration pane.
</p>
}
configuration={
<DisplayForm
draft={draft}
setDraft={setDraft}
@@ -41,11 +52,11 @@ const Harness = ({ seed }: { seed: DisplayPolicy }) => {
applyAxis={(patch) => setDraft({ ...draft, ...patch })}
saveDraft={() => {}}
busy={false}
dirty={false}
dirty={dirty}
revert={() => {}}
/>
</CardContent>
</Card>
}
/>
);
};
@@ -70,11 +81,10 @@ export const CustomFields: Story = {
export const NoCustomPresets: Story = {
args: { seed: displayPolicy },
render: (args) => (
<Card>
<CardHeader>
<CardTitle>{m.display_config_title()}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<DisplayTabs
dirty={false}
live={null}
configuration={
<DisplayForm
draft={args.seed}
setDraft={() => {}}
@@ -89,7 +99,22 @@ export const NoCustomPresets: Story = {
dirty={false}
revert={() => {}}
/>
</CardContent>
</Card>
}
/>
),
};
/**
* Unsaved Custom edits, with the Configuration tab NOT open.
*
* The dirty marker has to survive being on the other tab — the whole reason it moved off the card
* header and onto the trigger. If this story ever shows a bare "Configuration" label, the warning
* has gone silent exactly when it matters most.
*/
export const UnsavedOnOtherTab: Story = {
args: { seed: { ...displayPolicy, preset: "custom" }, dirty: true },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(await canvas.findByRole("tab", { name: /Live/i }));
},
};