From d13d253c2f58c45a160e15add44b359f1d494245 Mon Sep 17 00:00:00 2001
From: enricobuehler
Date: Sun, 9 Aug 2026 16:27:08 +0200
Subject: [PATCH 1/3] =?UTF-8?q?chore(web):=20@unom/ui=200.8.16=20=E2=86=92?=
=?UTF-8?q?=200.9.2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Brings the console onto the current design system. 0.9.x adds the Badge, Spinner,
Skeleton, Switch, Table, EmptyState and CodeBlock primitives, and 0.9.2 carries the
form fixes found while overhauling the rom-manager plugin UI:
- Select's border and focus ring resolved to `--main`, which is the FOREGROUND here
(`--main: var(--foreground)` in web/src/styles.css), so the trigger wore a near-white
border and a 3px near-white focus ring. Its chevron and placeholder were painted
`--secondary`, a SURFACE colour, and all but vanished. Now on `--input`/`--ring`, the
same tokens InputText already used.
- InputNumber declares a color-scheme, so the browser-drawn spinner arrows stop being
near-black on a near-black field.
Both defects were live in this console too — the console palette is what exposes them.
Verified: codegen + vite build clean, `tsc --noEmit` clean, `bun test server/` 9/9,
Storybook builds, 31/31 screenshots. A probe over all 61 stories reports ZERO page
errors, and the two stories containing a Select now render it at h-input-height with
`border: rgb(42, 33, 72)` (the input token) and a muted-foreground chevron.
Note: the console's components/ui/ wrapper layer is unchanged and still required —
@unom/ui's DialogContent remains a surface with no Portal or placement, which is
exactly what web/src/components/ui/dialog.tsx supplies.
---
web/bun.lock | 4 +-
web/bun.nix | 8 +--
web/package.json | 146 +++++++++++++++++++++++------------------------
3 files changed, 79 insertions(+), 79 deletions(-)
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"
+ }
}
From 31aef4b09fff8170d29f5b518157ff407df38b91 Mon Sep 17 00:00:00 2001
From: enricobuehler
Date: Sun, 9 Aug 2026 16:58:46 +0200
Subject: [PATCH 2/3] feat(web): tab the Virtual displays page
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
web/src/sections/Displays/DisplayCard.tsx | 85 +++++++++++++++++------
web/src/stories/Displays.stories.tsx | 67 ++++++++++++------
2 files changed, 108 insertions(+), 44 deletions(-)
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 }));
+ },
+};
From 0b252403cd668800f667b3c3cda73238c77a46e6 Mon Sep 17 00:00:00 2001
From: enricobuehler
Date: Sun, 9 Aug 2026 17:23:09 +0200
Subject: [PATCH 3/3] fix(web): fix the card inset at the root, not at the call
sites
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The broken inset on the Displays configuration card was the symptom. The cause is
structural, and it had already been diagnosed at least twice in-tree without being fixed.
Two faults, both in components/ui/card.tsx:
1. The padding was a RESPONSIVE COMPOUND: `p-4 pt-0 sm:p-6 sm:pt-0`. tailwind-merge
resolves conflicts only within a variant, so any call-site override won at the base
and lost at `sm:` — correct on a phone, wrong on every desktop. Measured on the
Displays card before this change: padding-top 24px at 500px, 0px at 1440px.
2. `pt-0` encoded an assumption about a SIBLING that nothing enforced — "a CardHeader is
above me and supplies the top inset". Delete the header, which is exactly what tabbing
a page does since the tab label replaces the card title, and the top inset silently
vanishes at ≥640px.
Fix:
- One single-variant utility, `p-padding-card` — the same `--spacing-padding-card` token
@unom/ui's own Card uses, so nested cards finally agree on their inset. A single
variant cannot half-lose an override.
- Top inset is now self-correcting: `[&:not(:first-child)]:pt-0`. Ask the DOM instead of
the author. A headerless CardContent keeps its inset with nothing to remember.
Seven call sites had grown their own compensation in five dialects — `p-6`,
`p-card pt-card sm:pt-card` (×3), `p-4 sm:pt-6` (×3), `pt-4 sm:pt-6`, and my own `pt-6`
from the tabs commit. All removed; they are the symptom-fixes this replaces. LogsCard
even carried a six-line comment correctly describing the trap and working around it
locally — that comment is now three lines saying it no longer needs saying.
`flush` stays: full-bleed content is a real intent, expressed as a prop the component
honours rather than a utility that has to out-argue the one already there.
Guarded by UI/Card → "Inset with and without header", a headered/headerless pair that has
to look identical on every side. It must be checked at BOTH widths — a single width
cannot show this class of bug, which is why it kept surviving.
Verified by measuring computed padding at 500px and 1440px: first child 20px on all four
sides, after-a-header 0px top and 20px elsewhere, identical at both widths. tsc clean,
biome clean on every touched file, 9/9 server tests, build + i18n clean, 32/32 screenshots.
---
web/src/components/ui/card.tsx | 41 +++++++++++++++++-----
web/src/sections/Dashboard/view.tsx | 11 +++---
web/src/sections/Displays/DisplayCard.tsx | 4 +--
web/src/sections/Host/ConflictsCard.tsx | 2 +-
web/src/sections/Logs/LogsCard.tsx | 11 +++---
web/src/sections/Pairing/PairedDevices.tsx | 2 +-
web/src/sections/Store/JobProgress.tsx | 4 +--
web/src/stories/Card.stories.tsx | 36 +++++++++++++++++++
8 files changed, 86 insertions(+), 25 deletions(-)
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 5f430c4b..cbd86c13 100644
--- a/web/src/sections/Displays/DisplayCard.tsx
+++ b/web/src/sections/Displays/DisplayCard.tsx
@@ -267,13 +267,13 @@ export const DisplayTabs: FC<{
- {configuration}
+ {configuration}
- {live}
+ {live}
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: () => (