feat(web): tab the Virtual displays page
ci / rust-arm64 (pull_request) Successful in 2m6s
ci / docs-site (pull_request) Successful in 3m59s
ci / bun-nix (pull_request) Successful in 4m39s
ci / web (pull_request) Successful in 5m1s
ci / rust (pull_request) Failing after 13m18s
nix / flake (pull_request) Failing after 23m11s

Same pill strip the plugin UIs use, via @unom/ui's Tabs: Configuration | Live displays.

The page was two stacked cards, and the configuration card ALONE is taller than the
viewport — the existing comment on the unsaved badge says as much, because that height
is how pending edits went unnoticed. The live-display list sat below all of it, so in
practice it was off screen.

Two details that are not cosmetic:

- The dirty marker moved from the card header onto the Configuration TRIGGER. Behind a
  tab the old badge would vanish entirely while Live was open — a strictly worse version
  of the problem it was added to solve. On the trigger it survives both tabs, and the
  Custom block keeps its own inline badge for when the tab IS open.
- The strip is extracted as a presentational `DisplayTabs` rather than inlined in
  `DisplaySection`. The container calls `useBlocker`, which needs a router, so it cannot
  render in Storybook — and this page's story exists specifically to pin the MOTION
  NESTING of the preset grid (a card sets no delayChildren, so tiles nested one level
  deeper stop staggering). Inserting tabs changes that ancestor chain, so the story has
  to render the real one or it passes for the wrong reason.

Adds Pages/Displays → "Unsaved on other tab", which switches to Live with a dirty draft:
if the marker ever goes silent there, the warning is gone exactly when it matters.

Verified: tsc clean, biome clean, `bun test server/` 9/9, vite build + i18n check clean,
Storybook builds, 32/32 screenshots.
This commit is contained in:
2026-08-09 16:58:46 +02:00
parent d13d253c2f
commit 31aef4b09f
2 changed files with 108 additions and 44 deletions
+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 pt-6">{configuration}</CardContent>
</Card>
</TabsContent>
<TabsContent value="live">
<Card>
<CardContent className="pt-6">{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
+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 }));
},
};