diff --git a/web/bun.lock b/web/bun.lock index e1c2d0ff..40291304 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -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=="], diff --git a/web/bun.nix b/web/bun.nix index 12f9df09..5fa68eb2 100644 --- a/web/bun.nix +++ b/web/bun.nix @@ -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"; diff --git a/web/package.json b/web/package.json index 9d18da1b..e16fcd06 100644 --- a/web/package.json +++ b/web/package.json @@ -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" + } } diff --git a/web/src/components/ui/card.tsx b/web/src/components/ui/card.tsx index 257190a5..43a9b60f 100644 --- a/web/src/components/ui/card.tsx +++ b/web/src/components/ui/card.tsx @@ -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 >(({ className, ...props }, ref) => (
)); @@ -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) => (
)); @@ -91,7 +116,7 @@ const CardFooter = React.forwardRef< >(({ className, ...props }, ref) => (
)); diff --git a/web/src/sections/Dashboard/view.tsx b/web/src/sections/Dashboard/view.tsx index 792b3994..82da0cbc 100644 --- a/web/src/sections/Dashboard/view.tsx +++ b/web/src/sections/Dashboard/view.tsx @@ -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. */} - + {m.status_paired_count()} @@ -72,7 +72,7 @@ export const DashboardView: FC<{ - + {m.status_pin_pending()} @@ -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, }) => ( - + {icon} {label} diff --git a/web/src/sections/Displays/DisplayCard.tsx b/web/src/sections/Displays/DisplayCard.tsx index 8c28170b..cbd86c13 100644 --- a/web/src/sections/Displays/DisplayCard.tsx +++ b/web/src/sections/Displays/DisplayCard.tsx @@ -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 ( -
- - -
- {m.display_config_title()} - {/* Visible without scrolling to the save button — the card is taller than the - viewport, which is exactly how the pending edits went unnoticed. */} - {dirty && {m.display_unsaved()}} -
-
- + } + configuration={ + <>

{m.host_displays_help()}

@@ -226,20 +221,64 @@ export const DisplaySection: FC = () => { /> )} -
-
- - - {m.display_live()} - - - - - -
+ + } + /> ); }; +/** + * 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 }) => ( + + + + {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 && ( + + )} + + {m.display_live()} + + + + + {configuration} + + + + + + {live} + + + +); + /** * 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 diff --git a/web/src/sections/Host/ConflictsCard.tsx b/web/src/sections/Host/ConflictsCard.tsx index 12c845eb..5c47ee33 100644 --- a/web/src/sections/Host/ConflictsCard.tsx +++ b/web/src/sections/Host/ConflictsCard.tsx @@ -28,7 +28,7 @@ export const ConflictsCard: FC = () => { if (conflicts.length === 0) return null; return ( - +

diff --git a/web/src/sections/Logs/LogsCard.tsx b/web/src/sections/Logs/LogsCard.tsx index c0dc62b1..b4f177fb 100644 --- a/web/src/sections/Logs/LogsCard.tsx +++ b/web/src/sections/Logs/LogsCard.tsx @@ -238,13 +238,10 @@ export const LogsCard: FC<{ return ( - {/* 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.) */} - + {/* 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. */} +

{LEVELS.map((l) => ( diff --git a/web/src/sections/Pairing/PairedDevices.tsx b/web/src/sections/Pairing/PairedDevices.tsx index 90af41db..73fee68c 100644 --- a/web/src/sections/Pairing/PairedDevices.tsx +++ b/web/src/sections/Pairing/PairedDevices.tsx @@ -135,7 +135,7 @@ export const PairedDevices: FC<{

{m.pairing_native_devices()}

- + {rows.length === 0 ? ( m.pairing_native_empty() diff --git a/web/src/sections/Store/JobProgress.tsx b/web/src/sections/Store/JobProgress.tsx index 60c22e77..43ea7cc8 100644 --- a/web/src/sections/Store/JobProgress.tsx +++ b/web/src/sections/Store/JobProgress.tsx @@ -55,7 +55,7 @@ export const JobProgressSection: FC<{ if (!job.isError) return null; return ( - +

{m.store_job_lost()}

@@ -92,7 +92,7 @@ export const JobProgressCard: FC<{ className={failed ? "ring-2 ring-destructive/60" : undefined} aria-live="polite" > - +
{running ? ( diff --git a/web/src/stories/Card.stories.tsx b/web/src/stories/Card.stories.tsx index 21b79dbb..147a83f1 100644 --- a/web/src/stories/Card.stories.tsx +++ b/web/src/stories/Card.stories.tsx @@ -21,6 +21,42 @@ const meta = { export default meta; type Story = StoryObj; +/** + * 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: () => ( +
+ + + With a header + + + The body drops its top inset because the header above already supplied + one. + + + + + No header, so the body keeps its own top inset — automatically, with + nothing for the call site to remember. + + +
+ ), +}; + export const HostCard: Story = { render: () => ( diff --git a/web/src/stories/Displays.stories.tsx b/web/src/stories/Displays.stories.tsx index fc959ad7..f39c0845 100644 --- a/web/src/stories/Displays.stories.tsx +++ b/web/src/stories/Displays.stories.tsx @@ -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 `` 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 `
` + * 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(seed); return ( - - - {m.display_config_title()} - - + + The live list reads `/display/state`, so it is not part of this story + — see the tab strip and the Configuration pane. +

+ } + configuration={ { applyAxis={(patch) => setDraft({ ...draft, ...patch })} saveDraft={() => {}} busy={false} - dirty={false} + dirty={dirty} revert={() => {}} /> -
-
+ } + /> ); }; @@ -70,11 +81,10 @@ export const CustomFields: Story = { export const NoCustomPresets: Story = { args: { seed: displayPolicy }, render: (args) => ( - - - {m.display_config_title()} - - + {}} @@ -89,7 +99,22 @@ export const NoCustomPresets: Story = { dirty={false} revert={() => {}} /> - - + } + /> ), }; + +/** + * 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 })); + }, +};