diff --git a/web/src/sections/Displays/DisplayCard.tsx b/web/src/sections/Displays/DisplayCard.tsx index 8c28170b..5f430c4b 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/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 })); + }, +};