storybook: primitive coverage stories + deterministic-render fixes

- Add minimal static stories for the 10 ui/ primitives no existing story
  rendered (alert-dialog, avatar, breadcrumb, collapsible, command,
  dropdown-menu, radio-card, scroll-area, sheet, skeleton) so the visual
  snapshot baseline covers every shared primitive.
- user-secrets: drop the story-level nested MemoryRouter (preview already
  provides one; nesting intermittently threw 'Router inside another Router').
- navigation-layout: provide PluginLauncherProvider at the meta level;
  BoardChromeMatrix mounts the real Sidebar whose PluginLauncherOutlet
  raced company selection and intermittently threw.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
scotttong 2026-07-06 13:48:17 -07:00
parent 1aea0c8a7e
commit 29dadead61
3 changed files with 339 additions and 1 deletions

View File

@ -335,6 +335,17 @@ function NavigationLayoutStories() {
const meta = {
title: "Product/Navigation & Layout",
component: NavigationLayoutStories,
// Sidebar mounts PluginLauncherOutlet unconditionally once a company is
// selected; without this provider the story raced company selection and
// intermittently threw "usePluginLauncherRuntime must be used within
// PluginLauncherProvider".
decorators: [
(Story) => (
<PluginLauncherProvider>
<Story />
</PluginLauncherProvider>
),
],
parameters: {
docs: {
description: {

View File

@ -0,0 +1,324 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { FileText, FolderOpen, Settings, Users } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Avatar,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
} from "@/components/ui/avatar";
import {
Breadcrumb,
BreadcrumbEllipsis,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@/components/ui/command";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { RadioCard, RadioCardGroup } from "@/components/ui/radio-card";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
/**
* Minimal coverage stories for the shared primitives in ui/src/components/ui/
* that no existing story renders directly (or that existing stories only
* reach behind a user interaction). Each story renders the primitive in a
* static, deterministic state so the visual snapshot suite baselines it.
*/
const meta = {
title: "Foundations/Primitive Coverage",
parameters: {
docs: {
description: {
component:
"Static snapshot coverage for shared ui/ primitives not exercised by existing stories: alert-dialog, avatar, breadcrumb, collapsible, command, dropdown-menu, radio-card, scroll-area, sheet, skeleton.",
},
},
},
} satisfies Meta;
export default meta;
type Story = StoryObj<typeof meta>;
function StoryFrame({ children }: { children: React.ReactNode }) {
return (
<div className="paperclip-story">
<main className="paperclip-story__inner">
<section className="paperclip-story__frame p-6">{children}</section>
</main>
</div>
);
}
export const AvatarStates: Story = {
render: () => (
<StoryFrame>
<div className="flex items-center gap-6">
<Avatar>
<AvatarFallback>BO</AvatarFallback>
</Avatar>
<Avatar>
<AvatarFallback>
<Users />
</AvatarFallback>
</Avatar>
<AvatarGroup>
<Avatar>
<AvatarFallback>PL</AvatarFallback>
</Avatar>
<Avatar>
<AvatarFallback>QA</AvatarFallback>
</Avatar>
<AvatarGroupCount>+3</AvatarGroupCount>
</AvatarGroup>
</div>
</StoryFrame>
),
};
export const BreadcrumbTrail: Story = {
render: () => (
<StoryFrame>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#">Paperclip</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbEllipsis />
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="#">Projects</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Control plane</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</StoryFrame>
),
};
export const CollapsibleOpenClosed: Story = {
render: () => (
<StoryFrame>
<div className="space-y-6">
<Collapsible open className="rounded-md border border-border p-3">
<CollapsibleTrigger className="text-sm font-medium">
Open section
</CollapsibleTrigger>
<CollapsibleContent className="pt-2 text-sm text-muted-foreground">
Visible collapsible content used for snapshot coverage.
</CollapsibleContent>
</Collapsible>
<Collapsible className="rounded-md border border-border p-3">
<CollapsibleTrigger className="text-sm font-medium">
Closed section
</CollapsibleTrigger>
<CollapsibleContent className="pt-2 text-sm text-muted-foreground">
Hidden content.
</CollapsibleContent>
</Collapsible>
</div>
</StoryFrame>
),
};
export const CommandPaletteInline: Story = {
render: () => (
<StoryFrame>
<Command className="max-w-md rounded-lg border border-border shadow-md">
<CommandInput placeholder="Type a command or search…" />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Navigate">
<CommandItem>
<FolderOpen />
<span>Open project</span>
<CommandShortcut>O</CommandShortcut>
</CommandItem>
<CommandItem>
<FileText />
<span>View tasks</span>
<CommandShortcut>T</CommandShortcut>
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Settings">
<CommandItem>
<Settings />
<span>Instance settings</span>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</StoryFrame>
),
};
export const RadioCards: Story = {
render: () => (
<StoryFrame>
<div className="max-w-md space-y-6">
<RadioCardGroup
ariaLabel="Delivery mode"
value="draft"
onValueChange={() => {}}
options={[
{
value: "draft",
title: "Draft for review",
description: "The agent proposes; you approve before it ships.",
},
{
value: "auto",
title: "Deliver automatically",
description: "Finished work is delivered without a review gate.",
},
]}
/>
<RadioCard selected={false} title="Standalone card" description="Unselected state." />
</div>
</StoryFrame>
),
};
export const ScrollAreaList: Story = {
render: () => (
<StoryFrame>
<ScrollArea className="h-40 w-64 rounded-md border border-border">
<div className="p-3">
{Array.from({ length: 12 }, (_, i) => (
<div key={i} className="py-1.5 text-sm">
Heartbeat run #{1200 + i}
</div>
))}
</div>
</ScrollArea>
</StoryFrame>
),
};
export const SkeletonLoading: Story = {
render: () => (
<StoryFrame>
<div className="max-w-sm space-y-3">
<Skeleton className="h-10 w-10 rounded-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-24 w-full" />
</div>
</StoryFrame>
),
};
export const AlertDialogOpen: Story = {
render: () => (
<StoryFrame>
<AlertDialog open>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Cancel this run?</AlertDialogTitle>
<AlertDialogDescription>
The agent will stop immediately and the task returns to the queue.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep running</AlertDialogCancel>
<AlertDialogAction>Cancel run</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</StoryFrame>
),
};
export const DropdownMenuOpen: Story = {
render: () => (
<StoryFrame>
<div className="flex min-h-72 items-start">
<DropdownMenu open>
<DropdownMenuTrigger asChild>
<Button variant="outline">Task actions</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem>Assign agent</DropdownMenuItem>
<DropdownMenuItem>Move to project</DropdownMenuItem>
<DropdownMenuCheckboxItem checked>Watch updates</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">Delete task</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</StoryFrame>
),
};
export const SheetOpen: Story = {
render: () => (
<StoryFrame>
<Sheet open>
<SheetContent side="right">
<SheetHeader>
<SheetTitle>Run details</SheetTitle>
<SheetDescription>Inspect the run without leaving the board.</SheetDescription>
</SheetHeader>
<div className="px-4 text-sm text-muted-foreground">
Sheet body content for snapshot coverage.
</div>
<SheetFooter>
<Button>Done</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</StoryFrame>
),
};

View File

@ -124,7 +124,10 @@ function SeedFixtures({ children }: { children: ReactNode }) {
}, [selectedCompanyId, setSelectedCompanyId]);
if (selectedCompanyId !== COMPANY_ID) return null;
return <MemoryRouter>{children}</MemoryRouter>;
// The preview decorator already provides a MemoryRouter; nesting a second
// react-router Router here raced it and intermittently threw
// "You cannot render a <Router> inside another <Router>".
return <>{children}</>;
}
function Section({ title, children }: { title: string; children: ReactNode }) {