feat(web): add stories for all presentational components

Quentin Gliech and Claude Opus 4.6 (1M context) created

Add Storybook stories for:
- UI primitives: Badge, Input, Textarea, Separator, Skeleton, Avatar
- Bug components: StatusBadge, LabelBadge, BugRow
- Code components: RefSelector, FileTree, FileViewer, CodeBreadcrumb
- Content: Markdown

Also add a TanStack Router decorator for components using <Link>.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Change summary

webui2/.storybook/decorators.tsx                      |  26 +++
webui2/src/components/bugs/BugRow.stories.tsx         |  74 ++++++++
webui2/src/components/bugs/LabelBadge.stories.tsx     |  54 ++++++
webui2/src/components/bugs/StatusBadge.stories.tsx    |  26 +++
webui2/src/components/code/CodeBreadcrumb.stories.tsx |  40 ++++
webui2/src/components/code/FileTree.stories.tsx       | 107 +++++++++++++
webui2/src/components/code/FileViewer.stories.tsx     |  72 ++++++++
webui2/src/components/code/RefSelector.stories.tsx    |  47 +++++
webui2/src/components/content/Markdown.stories.tsx    |  95 +++++++++++
webui2/src/components/ui/avatar.stories.tsx           |  45 +++++
webui2/src/components/ui/badge.stories.tsx            |  32 +++
webui2/src/components/ui/input.stories.tsx            |  26 +++
webui2/src/components/ui/separator.stories.tsx        |  42 +++++
webui2/src/components/ui/skeleton.stories.tsx         |  30 +++
webui2/src/components/ui/textarea.stories.tsx         |  22 ++
15 files changed, 738 insertions(+)

Detailed changes

webui2/.storybook/decorators.tsx πŸ”—

@@ -0,0 +1,26 @@
+import {
+  createMemoryHistory,
+  createRootRoute,
+  createRoute,
+  createRouter,
+  RouterProvider,
+} from "@tanstack/react-router";
+import type { Decorator } from "@storybook/react-vite";
+
+// Catch-all route so any <Link to="..."> resolves without errors.
+const rootRoute = createRootRoute();
+const catchAll = createRoute({
+  getParentRoute: () => rootRoute,
+  path: "$",
+});
+rootRoute.addChildren([catchAll]);
+
+const router = createRouter({
+  routeTree: rootRoute,
+  history: createMemoryHistory({ initialEntries: ["/"] }),
+});
+
+// Wraps a story in a TanStack Router context so components using <Link> render.
+export const withRouter: Decorator = (Story) => (
+  <RouterProvider router={router} defaultComponent={() => <Story />} />
+);

webui2/src/components/bugs/BugRow.stories.tsx πŸ”—

@@ -0,0 +1,74 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { Status } from "@/__generated__/graphql";
+import { withRouter } from "@/../.storybook/decorators";
+
+import { BugRow } from "./BugRow";
+
+const meta = {
+  component: BugRow,
+  decorators: [withRouter],
+} satisfies Meta<typeof BugRow>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+const baseArgs = {
+  id: "abc123",
+  humanId: "a1b2c3",
+  repo: "my-repo",
+  author: {
+    humanId: "user1",
+    displayName: "Jane Doe",
+    avatarUrl: null,
+  },
+  createdAt: new Date(Date.now() - 3600 * 1000).toISOString(),
+};
+
+export const OpenBug: Story = {
+  args: {
+    ...baseArgs,
+    status: Status.Open,
+    title: "Fix login page crash on empty email",
+    labels: [
+      { name: "bug", color: { R: 252, G: 41, B: 41 } },
+      { name: "priority", color: { R: 255, G: 152, B: 0 } },
+    ],
+    commentCount: 3,
+  },
+};
+
+export const ClosedBug: Story = {
+  args: {
+    ...baseArgs,
+    status: Status.Closed,
+    title: "Add dark mode support",
+    labels: [{ name: "enhancement", color: { R: 163, G: 230, B: 53 } }],
+    commentCount: 12,
+  },
+};
+
+export const NoLabels: Story = {
+  args: {
+    ...baseArgs,
+    status: Status.Open,
+    title: "Simple bug with no labels",
+    labels: [],
+    commentCount: 0,
+  },
+};
+
+export const LongTitle: Story = {
+  args: {
+    ...baseArgs,
+    status: Status.Open,
+    title:
+      "This is a very long bug title that should demonstrate how the component handles overflow and wrapping when the title extends beyond the available space",
+    labels: [
+      { name: "bug", color: { R: 252, G: 41, B: 41 } },
+      { name: "documentation", color: { R: 30, G: 80, B: 160 } },
+      { name: "help wanted", color: { R: 0, G: 150, B: 136 } },
+    ],
+    commentCount: 42,
+  },
+};

webui2/src/components/bugs/LabelBadge.stories.tsx πŸ”—

@@ -0,0 +1,54 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { fn } from "storybook/test";
+
+import { LabelBadge } from "./LabelBadge";
+
+const meta = {
+  component: LabelBadge,
+} satisfies Meta<typeof LabelBadge>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+export const Default: Story = {
+  args: {
+    name: "bug",
+    color: { R: 252, G: 41, B: 41 },
+  },
+};
+
+export const LightBackground: Story = {
+  args: {
+    name: "enhancement",
+    color: { R: 163, G: 230, B: 53 },
+  },
+};
+
+export const DarkBackground: Story = {
+  args: {
+    name: "documentation",
+    color: { R: 30, G: 80, B: 160 },
+  },
+};
+
+export const Clickable: Story = {
+  args: {
+    name: "feature",
+    color: { R: 100, G: 200, B: 150 },
+    onClick: fn(),
+  },
+};
+
+export const AllColors: Story = {
+  args: { name: "", color: { R: 0, G: 0, B: 0 } },
+  render: () => (
+    <div className="flex flex-wrap gap-2">
+      <LabelBadge name="bug" color={{ R: 252, G: 41, B: 41 }} />
+      <LabelBadge name="enhancement" color={{ R: 163, G: 230, B: 53 }} />
+      <LabelBadge name="documentation" color={{ R: 30, G: 80, B: 160 }} />
+      <LabelBadge name="help wanted" color={{ R: 0, G: 150, B: 136 }} />
+      <LabelBadge name="wontfix" color={{ R: 200, G: 200, B: 200 }} />
+      <LabelBadge name="priority" color={{ R: 255, G: 152, B: 0 }} />
+    </div>
+  ),
+};

webui2/src/components/bugs/StatusBadge.stories.tsx πŸ”—

@@ -0,0 +1,26 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { Status } from "@/__generated__/graphql";
+
+import { StatusBadge } from "./StatusBadge";
+
+const meta = {
+  component: StatusBadge,
+  argTypes: {
+    status: {
+      control: "select",
+      options: [Status.Open, Status.Closed],
+    },
+  },
+} satisfies Meta<typeof StatusBadge>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+export const Open: Story = {
+  args: { status: Status.Open },
+};
+
+export const Closed: Story = {
+  args: { status: Status.Closed },
+};

webui2/src/components/code/CodeBreadcrumb.stories.tsx πŸ”—

@@ -0,0 +1,40 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { withRouter } from "@/../.storybook/decorators";
+
+import { CodeBreadcrumb } from "./CodeBreadcrumb";
+
+const meta = {
+  component: CodeBreadcrumb,
+  decorators: [withRouter],
+} satisfies Meta<typeof CodeBreadcrumb>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+export const RootPath: Story = {
+  args: {
+    repoName: "git-bug",
+    currentRef: "main",
+    path: "",
+    repo: "git-bug",
+  },
+};
+
+export const FilePath: Story = {
+  args: {
+    repoName: "git-bug",
+    currentRef: "main",
+    path: "src/components/ui/button.tsx",
+    repo: "git-bug",
+  },
+};
+
+export const DeepPath: Story = {
+  args: {
+    repoName: "git-bug",
+    currentRef: "feature/auth",
+    path: "src/components/bugs/timeline/CommentItem.tsx",
+    repo: "git-bug",
+  },
+};

webui2/src/components/code/FileTree.stories.tsx πŸ”—

@@ -0,0 +1,107 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { GitObjectType } from "@/__generated__/graphql";
+import { withRouter } from "@/../.storybook/decorators";
+
+import { FileTree } from "./FileTree";
+
+const meta = {
+  component: FileTree,
+  decorators: [withRouter],
+} satisfies Meta<typeof FileTree>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+const entries = [
+  {
+    name: "src",
+    type: GitObjectType.Tree,
+    hash: "abc1",
+    lastCommit: {
+      hash: "def1",
+      shortHash: "def1",
+      message: "refactor: restructure source directory",
+      date: new Date(Date.now() - 86400 * 1000).toISOString(),
+    },
+  },
+  {
+    name: "docs",
+    type: GitObjectType.Tree,
+    hash: "abc2",
+    lastCommit: {
+      hash: "def2",
+      shortHash: "def2",
+      message: "docs: update getting started guide",
+      date: new Date(Date.now() - 172800 * 1000).toISOString(),
+    },
+  },
+  {
+    name: "README.md",
+    type: GitObjectType.Blob,
+    hash: "abc3",
+    lastCommit: {
+      hash: "def3",
+      shortHash: "def3",
+      message: "docs: add badges to README",
+      date: new Date(Date.now() - 3600 * 1000).toISOString(),
+    },
+  },
+  {
+    name: "package.json",
+    type: GitObjectType.Blob,
+    hash: "abc4",
+    lastCommit: {
+      hash: "def4",
+      shortHash: "def4",
+      message: "chore: bump dependencies",
+      date: new Date(Date.now() - 7200 * 1000).toISOString(),
+    },
+  },
+  {
+    name: ".gitignore",
+    type: GitObjectType.Blob,
+    hash: "abc5",
+  },
+];
+
+export const RootDirectory: Story = {
+  args: {
+    repo: "my-repo",
+    currentRef: "main",
+    currentPath: "",
+    entries,
+  },
+};
+
+export const SubDirectory: Story = {
+  args: {
+    repo: "my-repo",
+    currentRef: "main",
+    currentPath: "src",
+    entries: [
+      {
+        name: "components",
+        type: GitObjectType.Tree,
+        hash: "abc6",
+        lastCommit: {
+          hash: "def5",
+          shortHash: "def5",
+          message: "feat: add button component",
+          date: new Date(Date.now() - 3600 * 1000).toISOString(),
+        },
+      },
+      {
+        name: "index.ts",
+        type: GitObjectType.Blob,
+        hash: "abc7",
+        lastCommit: {
+          hash: "def6",
+          shortHash: "def6",
+          message: "fix: correct export paths",
+          date: new Date(Date.now() - 7200 * 1000).toISOString(),
+        },
+      },
+    ],
+  },
+};

webui2/src/components/code/FileViewer.stories.tsx πŸ”—

@@ -0,0 +1,72 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { FileViewer } from "./FileViewer";
+
+const meta = {
+  component: FileViewer,
+} satisfies Meta<typeof FileViewer>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+export const TypeScriptFile: Story = {
+  args: {
+    blob: {
+      text: `import { useState } from "react";
+
+interface CounterProps {
+  initial?: number;
+}
+
+export function Counter({ initial = 0 }: CounterProps) {
+  const [count, setCount] = useState(initial);
+
+  return (
+    <div>
+      <p>Count: {count}</p>
+      <button onClick={() => setCount(c => c + 1)}>
+        Increment
+      </button>
+    </div>
+  );
+}`,
+      hash: "abc123",
+      path: "src/Counter.tsx",
+      size: 312,
+      isBinary: false,
+      isTruncated: false,
+    },
+  },
+};
+
+export const BinaryFile: Story = {
+  args: {
+    blob: {
+      text: null,
+      hash: "def456",
+      path: "logo.png",
+      size: 24576,
+      isBinary: true,
+      isTruncated: false,
+    },
+  },
+};
+
+export const TruncatedFile: Story = {
+  args: {
+    blob: {
+      text: "line 1\nline 2\nline 3\n... (truncated)",
+      hash: "ghi789",
+      path: "large-file.log",
+      size: 1048576,
+      isBinary: false,
+      isTruncated: true,
+    },
+  },
+};
+
+export const Loading: Story = {
+  args: {
+    blob: null,
+  },
+};

webui2/src/components/code/RefSelector.stories.tsx πŸ”—

@@ -0,0 +1,47 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { fn } from "storybook/test";
+
+import { GitRefType } from "@/__generated__/graphql";
+
+import { RefSelector } from "./RefSelector";
+
+const meta = {
+  component: RefSelector,
+} satisfies Meta<typeof RefSelector>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+const sampleRefs = [
+  { name: "refs/heads/main", shortName: "main", type: GitRefType.Branch, hash: "abc1", isDefault: true },
+  { name: "refs/heads/develop", shortName: "develop", type: GitRefType.Branch, hash: "abc2", isDefault: false },
+  { name: "refs/heads/feature/auth", shortName: "feature/auth", type: GitRefType.Branch, hash: "abc3", isDefault: false },
+  { name: "refs/heads/fix/login", shortName: "fix/login", type: GitRefType.Branch, hash: "abc4", isDefault: false },
+  { name: "refs/tags/v1.0.0", shortName: "v1.0.0", type: GitRefType.Tag, hash: "abc5", isDefault: false },
+  { name: "refs/tags/v1.1.0", shortName: "v1.1.0", type: GitRefType.Tag, hash: "abc6", isDefault: false },
+  { name: "refs/tags/v2.0.0-rc1", shortName: "v2.0.0-rc1", type: GitRefType.Tag, hash: "abc7", isDefault: false },
+];
+
+export const Default: Story = {
+  args: {
+    refs: sampleRefs,
+    currentRef: "main",
+    onSelect: fn(),
+  },
+};
+
+export const OnTag: Story = {
+  args: {
+    refs: sampleRefs,
+    currentRef: "v1.1.0",
+    onSelect: fn(),
+  },
+};
+
+export const BranchesOnly: Story = {
+  args: {
+    refs: sampleRefs.filter((r) => r.type === GitRefType.Branch),
+    currentRef: "develop",
+    onSelect: fn(),
+  },
+};

webui2/src/components/content/Markdown.stories.tsx πŸ”—

@@ -0,0 +1,95 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { withRouter } from "@/../.storybook/decorators";
+
+import { Markdown } from "./Markdown";
+
+const meta = {
+  component: Markdown,
+  decorators: [withRouter],
+} satisfies Meta<typeof Markdown>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+export const BasicFormatting: Story = {
+  args: {
+    content: `# Heading 1
+## Heading 2
+### Heading 3
+
+This is a paragraph with **bold**, *italic*, and \`inline code\`.
+
+- Unordered list item 1
+- Unordered list item 2
+  - Nested item
+
+1. Ordered list item 1
+2. Ordered list item 2
+
+> This is a blockquote.
+
+---
+
+[A link](https://example.com)`,
+  },
+};
+
+export const CodeBlock: Story = {
+  args: {
+    content: `Here is a code block:
+
+\`\`\`typescript
+interface User {
+  id: string;
+  name: string;
+  email: string;
+}
+
+function greet(user: User): string {
+  return \`Hello, \${user.name}!\`;
+}
+\`\`\``,
+  },
+};
+
+export const GithubFlavored: Story = {
+  args: {
+    content: `## Task list
+
+- [x] Completed task
+- [ ] Incomplete task
+- [ ] Another task
+
+## Table
+
+| Feature | Status | Priority |
+|---------|--------|----------|
+| Auth    | Done   | High     |
+| Search  | WIP    | Medium   |
+| Export  | Todo   | Low      |
+
+## Strikethrough
+
+This is ~~deleted~~ text.
+
+## Emoji
+
+:rocket: :bug: :sparkles:`,
+  },
+};
+
+export const Comment: Story = {
+  args: {
+    content: `Fixed the issue by updating the query builder.
+
+The problem was that \`buildQuery()\` wasn't escaping special characters in label names containing spaces.
+
+\`\`\`diff
+- const q = \`label:\${name}\`;
++ const q = \`label:"\${name}"\`;
+\`\`\`
+
+Closes #42.`,
+  },
+};

webui2/src/components/ui/avatar.stories.tsx πŸ”—

@@ -0,0 +1,45 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { Avatar, AvatarFallback, AvatarImage } from "./avatar";
+
+const meta = {
+  component: Avatar,
+} satisfies Meta<typeof Avatar>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+export const WithImage: Story = {
+  render: () => (
+    <Avatar>
+      <AvatarImage src="https://github.com/shadcn.png" alt="User" />
+      <AvatarFallback>CN</AvatarFallback>
+    </Avatar>
+  ),
+};
+
+export const WithFallback: Story = {
+  render: () => (
+    <Avatar>
+      <AvatarImage src="/broken-url.png" alt="User" />
+      <AvatarFallback>JD</AvatarFallback>
+    </Avatar>
+  ),
+};
+
+export const Small: Story = {
+  render: () => (
+    <Avatar className="size-6">
+      <AvatarFallback className="text-[8px]">AB</AvatarFallback>
+    </Avatar>
+  ),
+};
+
+export const Large: Story = {
+  render: () => (
+    <Avatar className="size-16">
+      <AvatarImage src="https://github.com/shadcn.png" alt="User" />
+      <AvatarFallback>CN</AvatarFallback>
+    </Avatar>
+  ),
+};

webui2/src/components/ui/badge.stories.tsx πŸ”—

@@ -0,0 +1,32 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { Badge } from "./badge";
+
+const meta = {
+  component: Badge,
+  argTypes: {
+    variant: {
+      control: "select",
+      options: ["default", "secondary", "destructive", "outline"],
+    },
+  },
+} satisfies Meta<typeof Badge>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+export const Default: Story = {
+  args: { children: "Badge" },
+};
+
+export const Secondary: Story = {
+  args: { children: "Secondary", variant: "secondary" },
+};
+
+export const Destructive: Story = {
+  args: { children: "Destructive", variant: "destructive" },
+};
+
+export const Outline: Story = {
+  args: { children: "Outline", variant: "outline" },
+};

webui2/src/components/ui/input.stories.tsx πŸ”—

@@ -0,0 +1,26 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { Input } from "./input";
+
+const meta = {
+  component: Input,
+} satisfies Meta<typeof Input>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+export const Default: Story = {
+  args: { placeholder: "Type something…" },
+};
+
+export const WithValue: Story = {
+  args: { defaultValue: "Hello world" },
+};
+
+export const Disabled: Story = {
+  args: { placeholder: "Disabled", disabled: true },
+};
+
+export const Password: Story = {
+  args: { type: "password", placeholder: "Enter password" },
+};

webui2/src/components/ui/separator.stories.tsx πŸ”—

@@ -0,0 +1,42 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { Separator } from "./separator";
+
+const meta = {
+  component: Separator,
+  argTypes: {
+    orientation: {
+      control: "select",
+      options: ["horizontal", "vertical"],
+    },
+  },
+} satisfies Meta<typeof Separator>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+export const Horizontal: Story = {
+  args: { orientation: "horizontal" },
+  decorators: [
+    (Story) => (
+      <div className="w-64 space-y-2">
+        <p className="text-sm">Above</p>
+        <Story />
+        <p className="text-sm">Below</p>
+      </div>
+    ),
+  ],
+};
+
+export const Vertical: Story = {
+  args: { orientation: "vertical" },
+  decorators: [
+    (Story) => (
+      <div className="flex h-8 items-center gap-2">
+        <span className="text-sm">Left</span>
+        <Story />
+        <span className="text-sm">Right</span>
+      </div>
+    ),
+  ],
+};

webui2/src/components/ui/skeleton.stories.tsx πŸ”—

@@ -0,0 +1,30 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { Skeleton } from "./skeleton";
+
+const meta = {
+  component: Skeleton,
+} satisfies Meta<typeof Skeleton>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+export const Default: Story = {
+  args: { className: "h-4 w-48" },
+};
+
+export const Circle: Story = {
+  args: { className: "size-10 rounded-full" },
+};
+
+export const Card: Story = {
+  render: () => (
+    <div className="flex items-center gap-4">
+      <Skeleton className="size-12 rounded-full" />
+      <div className="space-y-2">
+        <Skeleton className="h-4 w-48" />
+        <Skeleton className="h-4 w-32" />
+      </div>
+    </div>
+  ),
+};

webui2/src/components/ui/textarea.stories.tsx πŸ”—

@@ -0,0 +1,22 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { Textarea } from "./textarea";
+
+const meta = {
+  component: Textarea,
+} satisfies Meta<typeof Textarea>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+export const Default: Story = {
+  args: { placeholder: "Write a comment…" },
+};
+
+export const WithValue: Story = {
+  args: { defaultValue: "This is some content in the textarea." },
+};
+
+export const Disabled: Story = {
+  args: { placeholder: "Disabled", disabled: true },
+};