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 }));
+ },
+};