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: () => (