chore(web): console onto @unom/ui 0.9.2 #142
+2
-2
@@ -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=="],
|
||||
|
||||
|
||||
+4
-4
@@ -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";
|
||||
|
||||
+73
-73
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-4 sm:p-6", className)}
|
||||
className={cn("flex flex-col space-y-1.5", INSET, className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -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) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(!flush && "p-4 pt-0 sm:p-6 sm:pt-0", className)}
|
||||
className={cn(!flush && INSET_AFTER_SIBLING, className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -91,7 +116,7 @@ const CardFooter = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-4 pt-0 sm:p-6 sm:pt-0", className)}
|
||||
className={cn("flex items-center", INSET_AFTER_SIBLING, className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -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. */}
|
||||
<Card>
|
||||
<CardContent className="flex flex-1 items-center justify-between p-4 sm:pt-6">
|
||||
<CardContent className="flex flex-1 items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{m.status_paired_count()}
|
||||
</span>
|
||||
@@ -72,7 +72,7 @@ export const DashboardView: FC<{
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex flex-1 items-center justify-between p-4 sm:pt-6">
|
||||
<CardContent className="flex flex-1 items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{m.status_pin_pending()}
|
||||
</span>
|
||||
@@ -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,
|
||||
}) => (
|
||||
<Card>
|
||||
<CardContent className="flex flex-1 items-center justify-between p-4 sm:pt-6">
|
||||
<CardContent className="flex flex-1 items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
{icon}
|
||||
{label}
|
||||
|
||||
@@ -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">{configuration}</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="live">
|
||||
<Card>
|
||||
<CardContent>{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
|
||||
|
||||
@@ -28,7 +28,7 @@ export const ConflictsCard: FC = () => {
|
||||
if (conflicts.length === 0) return null;
|
||||
return (
|
||||
<Card className="border-amber-600/40 dark:border-amber-500/40">
|
||||
<CardContent className="flex items-start gap-3 p-card pt-card sm:pt-card">
|
||||
<CardContent className="flex items-start gap-3">
|
||||
<AlertTriangle className="mt-0.5 size-5 shrink-0 text-amber-600 dark:text-amber-500" />
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<p className="text-sm font-medium text-amber-600 dark:text-amber-500">
|
||||
|
||||
@@ -238,13 +238,10 @@ export const LogsCard: FC<{
|
||||
|
||||
return (
|
||||
<Card>
|
||||
{/* 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.) */}
|
||||
<CardContent className="flex flex-col gap-3 pt-4 sm:pt-6">
|
||||
{/* 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. */}
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{LEVELS.map((l) => (
|
||||
|
||||
@@ -135,7 +135,7 @@ export const PairedDevices: FC<{
|
||||
<h2 className="text-lg font-medium">{m.pairing_native_devices()}</h2>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-6">
|
||||
<CardContent>
|
||||
<QueryState isLoading={isLoading} error={error} refetch={refetch}>
|
||||
{rows.length === 0 ? (
|
||||
m.pairing_native_empty()
|
||||
|
||||
@@ -55,7 +55,7 @@ export const JobProgressSection: FC<{
|
||||
if (!job.isError) return null;
|
||||
return (
|
||||
<Card className="ring-2 ring-destructive/60">
|
||||
<CardContent className="flex items-start gap-3 p-card pt-card sm:pt-card">
|
||||
<CardContent className="flex items-start gap-3">
|
||||
<XCircle className="mt-0.5 size-5 shrink-0 text-destructive" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{m.store_job_lost()}</p>
|
||||
@@ -92,7 +92,7 @@ export const JobProgressCard: FC<{
|
||||
className={failed ? "ring-2 ring-destructive/60" : undefined}
|
||||
aria-live="polite"
|
||||
>
|
||||
<CardContent className="space-y-3 p-card pt-card sm:pt-card">
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
{running ? (
|
||||
<Spinner className="mt-0.5 size-5 shrink-0" />
|
||||
|
||||
@@ -21,6 +21,42 @@ const meta = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/**
|
||||
* 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: () => (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>With a header</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
The body drops its top inset because the header above already supplied
|
||||
one.
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
No header, so the body keeps its own top inset — automatically, with
|
||||
nothing for the call site to remember.
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const HostCard: Story = {
|
||||
render: () => (
|
||||
<Card className="max-w-sm">
|
||||
|
||||
@@ -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 }));
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user