feat(web): add EmptyState, SectionHeading, Pagination, CommentCard components

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

Four new shared UI components with composition APIs:
- EmptyState: styled "no results" message
- SectionHeading: uppercase sidebar/section heading
- Pagination: createLink-wrapped Previous/Next with Info
- CommentCard: avatar + bordered card (Root/AuthorAvatar/Card/CardHeader/CardBody)

Each has stories and snapshot tests.

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

Change summary

webui2/src/components/code/__snapshots__/FileViewer.test.tsx.snap    |  40 
webui2/src/components/ui/__snapshots__/comment-card.test.tsx.snap    | 146 
webui2/src/components/ui/__snapshots__/empty-state.test.tsx.snap     |  21 
webui2/src/components/ui/__snapshots__/pagination.test.tsx.snap      | 206 
webui2/src/components/ui/__snapshots__/section-heading.test.tsx.snap |  21 
webui2/src/components/ui/comment-card.stories.tsx                    |  68 
webui2/src/components/ui/comment-card.test.tsx                       |  13 
webui2/src/components/ui/comment-card.tsx                            |  69 
webui2/src/components/ui/empty-state.stories.tsx                     |  18 
webui2/src/components/ui/empty-state.test.tsx                        |  13 
webui2/src/components/ui/empty-state.tsx                             |  14 
webui2/src/components/ui/pagination.stories.tsx                      |  49 
webui2/src/components/ui/pagination.test.tsx                         |  13 
webui2/src/components/ui/pagination.tsx                              |  82 
webui2/src/components/ui/section-heading.stories.tsx                 |  18 
webui2/src/components/ui/section-heading.test.tsx                    |  13 
webui2/src/components/ui/section-heading.tsx                         |  14 
17 files changed, 798 insertions(+), 20 deletions(-)

Detailed changes

webui2/src/components/code/__snapshots__/FileViewer.test.tsx.snap 🔗

@@ -261,83 +261,83 @@ exports[`FileViewer/TypeScriptFile matches snapshot 1`] = `
       >
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 70.27272538529849%;"
+          style="width: 38.89277215488839%;"
         />
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 84.39202253245477%;"
+          style="width: 30.59424948464735%;"
         />
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 36.70967368601532%;"
+          style="width: 88.8718618920598%;"
         />
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 87.50159030488214%;"
+          style="width: 76.71451982934077%;"
         />
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 51.86083821665096%;"
+          style="width: 83.74909292446992%;"
         />
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 46.479699116022175%;"
+          style="width: 30.204986115614368%;"
         />
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 36.75406000862486%;"
+          style="width: 43.630255749606825%;"
         />
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 60.27590888571913%;"
+          style="width: 65.07221950291687%;"
         />
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 38.67713014525041%;"
+          style="width: 52.442063845559616%;"
         />
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 86.91154246154659%;"
+          style="width: 65.54311299024468%;"
         />
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 77.40682922983703%;"
+          style="width: 82.36906592981144%;"
         />
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 82.74792087067682%;"
+          style="width: 83.59354501082503%;"
         />
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 61.364498248188944%;"
+          style="width: 48.94220651251563%;"
         />
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 48.028107813055215%;"
+          style="width: 59.74254935831376%;"
         />
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 61.27378677034177%;"
+          style="width: 58.093030580736645%;"
         />
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 50.634245170950734%;"
+          style="width: 40.43812857099223%;"
         />
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 40.67974048538118%;"
+          style="width: 78.25416747608861%;"
         />
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 87.04517503265906%;"
+          style="width: 57.060810833798755%;"
         />
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 31.598566849419893%;"
+          style="width: 56.43045048200637%;"
         />
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 51.4887047773234%;"
+          style="width: 73.65573925757732%;"
         />
       </div>
     </div>

webui2/src/components/ui/__snapshots__/comment-card.test.tsx.snap 🔗

@@ -0,0 +1,146 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`CommentCard/Default matches snapshot 1`] = `
+<div>
+  <div
+    class="flex gap-3"
+  >
+    <span
+      class="relative flex overflow-hidden rounded-full mt-1 size-8 shrink-0"
+    >
+      <span
+        class="flex h-full w-full items-center justify-center rounded-full bg-muted font-medium text-xs"
+      >
+        JA
+      </span>
+    </span>
+    <div
+      class="border-border min-w-0 flex-1 rounded-md border"
+    >
+      <div
+        class="border-border bg-muted/40 flex items-center gap-2 border-b px-4 py-2 text-sm"
+      >
+        <span
+          class="text-foreground font-medium"
+        >
+          Jane Doe
+        </span>
+        <span
+          class="text-muted-foreground"
+        >
+          2 hours ago
+        </span>
+      </div>
+      <div
+        class="px-4 py-3"
+      >
+        <p
+          class="text-sm"
+        >
+          This is a comment body with some text content.
+        </p>
+      </div>
+    </div>
+  </div>
+</div>
+`;
+
+exports[`CommentCard/EmptyBody matches snapshot 1`] = `
+<div>
+  <div
+    class="flex gap-3"
+  >
+    <span
+      class="relative flex overflow-hidden rounded-full mt-1 size-8 shrink-0"
+    >
+      <span
+        class="flex h-full w-full items-center justify-center rounded-full bg-muted font-medium text-xs"
+      >
+        AL
+      </span>
+    </span>
+    <div
+      class="border-border min-w-0 flex-1 rounded-md border"
+    >
+      <div
+        class="border-border bg-muted/40 flex items-center gap-2 border-b px-4 py-2 text-sm"
+      >
+        <span
+          class="text-foreground font-medium"
+        >
+          Alice Wu
+        </span>
+        <span
+          class="text-muted-foreground"
+        >
+          just now
+        </span>
+      </div>
+      <div
+        class="px-4 py-3"
+      >
+        <p
+          class="text-muted-foreground text-sm italic"
+        >
+          No description provided.
+        </p>
+      </div>
+    </div>
+  </div>
+</div>
+`;
+
+exports[`CommentCard/WithEditButton matches snapshot 1`] = `
+<div>
+  <div
+    class="flex gap-3"
+  >
+    <span
+      class="relative flex overflow-hidden rounded-full mt-1 size-8 shrink-0"
+    >
+      <span
+        class="flex h-full w-full items-center justify-center rounded-full bg-muted font-medium text-xs"
+      >
+        BO
+      </span>
+    </span>
+    <div
+      class="border-border min-w-0 flex-1 rounded-md border"
+    >
+      <div
+        class="border-border bg-muted/40 flex items-center gap-2 border-b px-4 py-2 text-sm"
+      >
+        <span
+          class="text-foreground font-medium"
+        >
+          Bob Smith
+        </span>
+        <span
+          class="text-muted-foreground"
+        >
+          1 day ago
+        </span>
+        <span
+          class="text-muted-foreground text-xs"
+        >
+          edited
+        </span>
+        <button
+          class="text-muted-foreground hover:bg-muted ml-auto rounded-sm px-1.5 py-0.5 text-xs"
+        >
+          Edit
+        </button>
+      </div>
+      <div
+        class="px-4 py-3"
+      >
+        <p
+          class="text-sm"
+        >
+          Updated the configuration to fix the build issue.
+        </p>
+      </div>
+    </div>
+  </div>
+</div>
+`;

webui2/src/components/ui/__snapshots__/empty-state.test.tsx.snap 🔗

@@ -0,0 +1,21 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`EmptyState/Default matches snapshot 1`] = `
+<div>
+  <p
+    class="text-muted-foreground px-4 py-8 text-center text-sm"
+  >
+    No open issues found.
+  </p>
+</div>
+`;
+
+exports[`EmptyState/NotFound matches snapshot 1`] = `
+<div>
+  <p
+    class="text-muted-foreground px-4 py-8 text-center text-sm"
+  >
+    Issue not found.
+  </p>
+</div>
+`;

webui2/src/components/ui/__snapshots__/pagination.test.tsx.snap 🔗

@@ -0,0 +1,206 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Pagination/Default matches snapshot 1`] = `
+<div>
+  <div
+    class="border-border rounded-md border"
+  >
+    <div
+      class="px-4 py-8 text-center text-sm"
+    >
+      Content above pagination
+    </div>
+    <div
+      class="border-border flex items-center justify-center gap-2 border-t px-4 py-2"
+    >
+      <a
+        aria-current="page"
+        aria-disabled="true"
+        class="inline-flex items-center justify-center whitespace-nowrap font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 hover:bg-accent hover:text-accent-foreground h-8 rounded-md px-3 text-xs text-muted-foreground gap-1 active"
+        data-status="active"
+        disabled=""
+        role="link"
+      >
+        <svg
+          aria-hidden="true"
+          class="lucide lucide-chevron-left size-4"
+          fill="none"
+          height="24"
+          stroke="currentColor"
+          stroke-linecap="round"
+          stroke-linejoin="round"
+          stroke-width="2"
+          viewBox="0 0 24 24"
+          width="24"
+          xmlns="http://www.w3.org/2000/svg"
+        >
+          <path
+            d="m15 18-6-6 6-6"
+          />
+        </svg>
+        Previous
+      </a>
+      <span
+        class="text-muted-foreground text-sm"
+      >
+        Page 1 of 5
+      </span>
+      <a
+        aria-current="page"
+        class="inline-flex items-center justify-center whitespace-nowrap font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 hover:bg-accent hover:text-accent-foreground h-8 rounded-md px-3 text-xs text-muted-foreground gap-1 active"
+        data-status="active"
+        href="/"
+      >
+        Next
+        <svg
+          aria-hidden="true"
+          class="lucide lucide-chevron-right size-4"
+          fill="none"
+          height="24"
+          stroke="currentColor"
+          stroke-linecap="round"
+          stroke-linejoin="round"
+          stroke-width="2"
+          viewBox="0 0 24 24"
+          width="24"
+          xmlns="http://www.w3.org/2000/svg"
+        >
+          <path
+            d="m9 18 6-6-6-6"
+          />
+        </svg>
+      </a>
+    </div>
+  </div>
+</div>
+`;
+
+exports[`Pagination/LastPage matches snapshot 1`] = `
+<div>
+  <div
+    class="border-border flex items-center justify-center gap-2 border-t px-4 py-2"
+  >
+    <a
+      aria-current="page"
+      class="inline-flex items-center justify-center whitespace-nowrap font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 hover:bg-accent hover:text-accent-foreground h-8 rounded-md px-3 text-xs text-muted-foreground gap-1 active"
+      data-status="active"
+      href="/"
+    >
+      <svg
+        aria-hidden="true"
+        class="lucide lucide-chevron-left size-4"
+        fill="none"
+        height="24"
+        stroke="currentColor"
+        stroke-linecap="round"
+        stroke-linejoin="round"
+        stroke-width="2"
+        viewBox="0 0 24 24"
+        width="24"
+        xmlns="http://www.w3.org/2000/svg"
+      >
+        <path
+          d="m15 18-6-6 6-6"
+        />
+      </svg>
+      Previous
+    </a>
+    <span
+      class="text-muted-foreground text-sm"
+    >
+      Page 5 of 5
+    </span>
+    <a
+      aria-current="page"
+      aria-disabled="true"
+      class="inline-flex items-center justify-center whitespace-nowrap font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 hover:bg-accent hover:text-accent-foreground h-8 rounded-md px-3 text-xs text-muted-foreground gap-1 active"
+      data-status="active"
+      disabled=""
+      role="link"
+    >
+      Next
+      <svg
+        aria-hidden="true"
+        class="lucide lucide-chevron-right size-4"
+        fill="none"
+        height="24"
+        stroke="currentColor"
+        stroke-linecap="round"
+        stroke-linejoin="round"
+        stroke-width="2"
+        viewBox="0 0 24 24"
+        width="24"
+        xmlns="http://www.w3.org/2000/svg"
+      >
+        <path
+          d="m9 18 6-6-6-6"
+        />
+      </svg>
+    </a>
+  </div>
+</div>
+`;
+
+exports[`Pagination/MiddlePage matches snapshot 1`] = `
+<div>
+  <div
+    class="border-border flex items-center justify-center gap-2 border-t px-4 py-2"
+  >
+    <a
+      aria-current="page"
+      class="inline-flex items-center justify-center whitespace-nowrap font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 hover:bg-accent hover:text-accent-foreground h-8 rounded-md px-3 text-xs text-muted-foreground gap-1 active"
+      data-status="active"
+      href="/"
+    >
+      <svg
+        aria-hidden="true"
+        class="lucide lucide-chevron-left size-4"
+        fill="none"
+        height="24"
+        stroke="currentColor"
+        stroke-linecap="round"
+        stroke-linejoin="round"
+        stroke-width="2"
+        viewBox="0 0 24 24"
+        width="24"
+        xmlns="http://www.w3.org/2000/svg"
+      >
+        <path
+          d="m15 18-6-6 6-6"
+        />
+      </svg>
+      Previous
+    </a>
+    <span
+      class="text-muted-foreground text-sm"
+    >
+      Page 3 of 5
+    </span>
+    <a
+      aria-current="page"
+      class="inline-flex items-center justify-center whitespace-nowrap font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 hover:bg-accent hover:text-accent-foreground h-8 rounded-md px-3 text-xs text-muted-foreground gap-1 active"
+      data-status="active"
+      href="/"
+    >
+      Next
+      <svg
+        aria-hidden="true"
+        class="lucide lucide-chevron-right size-4"
+        fill="none"
+        height="24"
+        stroke="currentColor"
+        stroke-linecap="round"
+        stroke-linejoin="round"
+        stroke-width="2"
+        viewBox="0 0 24 24"
+        width="24"
+        xmlns="http://www.w3.org/2000/svg"
+      >
+        <path
+          d="m9 18 6-6-6-6"
+        />
+      </svg>
+    </a>
+  </div>
+</div>
+`;

webui2/src/components/ui/__snapshots__/section-heading.test.tsx.snap 🔗

@@ -0,0 +1,21 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`SectionHeading/Default matches snapshot 1`] = `
+<div>
+  <h3
+    class="text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase"
+  >
+    Participants
+  </h3>
+</div>
+`;
+
+exports[`SectionHeading/Labels matches snapshot 1`] = `
+<div>
+  <h3
+    class="text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase"
+  >
+    Labels
+  </h3>
+</div>
+`;

webui2/src/components/ui/comment-card.stories.tsx 🔗

@@ -0,0 +1,68 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import * as CommentCard from "./comment-card";
+
+const meta = {
+  component: CommentCard.Root,
+} satisfies Meta<typeof CommentCard.Root>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+export const Default: Story = {
+  args: { children: null },
+  render: () => (
+    <CommentCard.Root>
+      <CommentCard.AuthorAvatar name="Jane Doe" />
+      <CommentCard.Card>
+        <CommentCard.CardHeader>
+          <span className="text-foreground font-medium">Jane Doe</span>
+          <span className="text-muted-foreground">2 hours ago</span>
+        </CommentCard.CardHeader>
+        <CommentCard.CardBody>
+          <p className="text-sm">This is a comment body with some text content.</p>
+        </CommentCard.CardBody>
+      </CommentCard.Card>
+    </CommentCard.Root>
+  ),
+};
+
+export const WithEditButton: Story = {
+  args: { children: null },
+  render: () => (
+    <CommentCard.Root>
+      <CommentCard.AuthorAvatar name="Bob Smith" src="https://github.com/shadcn.png" />
+      <CommentCard.Card>
+        <CommentCard.CardHeader>
+          <span className="text-foreground font-medium">Bob Smith</span>
+          <span className="text-muted-foreground">1 day ago</span>
+          <span className="text-muted-foreground text-xs">edited</span>
+          <button className="text-muted-foreground hover:bg-muted ml-auto rounded-sm px-1.5 py-0.5 text-xs">
+            Edit
+          </button>
+        </CommentCard.CardHeader>
+        <CommentCard.CardBody>
+          <p className="text-sm">Updated the configuration to fix the build issue.</p>
+        </CommentCard.CardBody>
+      </CommentCard.Card>
+    </CommentCard.Root>
+  ),
+};
+
+export const EmptyBody: Story = {
+  args: { children: null },
+  render: () => (
+    <CommentCard.Root>
+      <CommentCard.AuthorAvatar name="Alice Wu" />
+      <CommentCard.Card>
+        <CommentCard.CardHeader>
+          <span className="text-foreground font-medium">Alice Wu</span>
+          <span className="text-muted-foreground">just now</span>
+        </CommentCard.CardHeader>
+        <CommentCard.CardBody>
+          <p className="text-muted-foreground text-sm italic">No description provided.</p>
+        </CommentCard.CardBody>
+      </CommentCard.Card>
+    </CommentCard.Root>
+  ),
+};

webui2/src/components/ui/comment-card.test.tsx 🔗

@@ -0,0 +1,13 @@
+import { composeStories } from "@storybook/react-vite";
+import { expect, test } from "vitest";
+
+import * as stories from "./comment-card.stories";
+
+const composed = composeStories(stories);
+
+for (const [name, Story] of Object.entries(composed)) {
+  test(`CommentCard/${name} matches snapshot`, async () => {
+    await Story.run();
+    expect(document.body.firstChild).toMatchSnapshot();
+  });
+}

webui2/src/components/ui/comment-card.tsx 🔗

@@ -0,0 +1,69 @@
+import { cn } from "@/lib/utils";
+
+import { Avatar, AvatarFallback, AvatarImage } from "./avatar";
+
+interface RootProps {
+  children: React.ReactNode;
+  className?: string;
+}
+
+export function Root({ children, className }: RootProps) {
+  return <div className={cn("flex gap-3", className)}>{children}</div>;
+}
+
+interface AuthorAvatarProps {
+  src?: string | null;
+  name: string;
+  className?: string;
+}
+
+export function AuthorAvatar({ src, name, className }: AuthorAvatarProps) {
+  return (
+    <Avatar className={cn("mt-1 size-8 shrink-0", className)}>
+      <AvatarImage src={src ?? undefined} alt={name} />
+      <AvatarFallback className="text-xs">
+        {name.slice(0, 2).toUpperCase()}
+      </AvatarFallback>
+    </Avatar>
+  );
+}
+
+interface CardProps {
+  children: React.ReactNode;
+  className?: string;
+}
+
+export function Card({ children, className }: CardProps) {
+  return (
+    <div className={cn("border-border min-w-0 flex-1 rounded-md border", className)}>
+      {children}
+    </div>
+  );
+}
+
+interface CardHeaderProps {
+  children: React.ReactNode;
+  className?: string;
+}
+
+export function CardHeader({ children, className }: CardHeaderProps) {
+  return (
+    <div
+      className={cn(
+        "border-border bg-muted/40 flex items-center gap-2 border-b px-4 py-2 text-sm",
+        className,
+      )}
+    >
+      {children}
+    </div>
+  );
+}
+
+interface CardBodyProps {
+  children: React.ReactNode;
+  className?: string;
+}
+
+export function CardBody({ children, className }: CardBodyProps) {
+  return <div className={cn("px-4 py-3", className)}>{children}</div>;
+}

webui2/src/components/ui/empty-state.stories.tsx 🔗

@@ -0,0 +1,18 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { EmptyState } from "./empty-state";
+
+const meta = {
+  component: EmptyState,
+} satisfies Meta<typeof EmptyState>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+export const Default: Story = {
+  args: { children: "No open issues found." },
+};
+
+export const NotFound: Story = {
+  args: { children: "Issue not found." },
+};

webui2/src/components/ui/empty-state.test.tsx 🔗

@@ -0,0 +1,13 @@
+import { composeStories } from "@storybook/react-vite";
+import { expect, test } from "vitest";
+
+import * as stories from "./empty-state.stories";
+
+const composed = composeStories(stories);
+
+for (const [name, Story] of Object.entries(composed)) {
+  test(`EmptyState/${name} matches snapshot`, async () => {
+    await Story.run();
+    expect(document.body.firstChild).toMatchSnapshot();
+  });
+}

webui2/src/components/ui/empty-state.tsx 🔗

@@ -0,0 +1,14 @@
+import { cn } from "@/lib/utils";
+
+interface EmptyStateProps {
+  children: React.ReactNode;
+  className?: string;
+}
+
+export function EmptyState({ children, className }: EmptyStateProps) {
+  return (
+    <p className={cn("text-muted-foreground px-4 py-8 text-center text-sm", className)}>
+      {children}
+    </p>
+  );
+}

webui2/src/components/ui/pagination.stories.tsx 🔗

@@ -0,0 +1,49 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { withRouter } from "@/../.storybook/decorators";
+
+import * as Pagination from "./pagination";
+
+const meta = {
+  component: Pagination.Root,
+  decorators: [withRouter],
+} satisfies Meta<typeof Pagination.Root>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+export const Default: Story = {
+  args: { children: null },
+  render: () => (
+    <div className="border-border rounded-md border">
+      <div className="px-4 py-8 text-center text-sm">Content above pagination</div>
+      <Pagination.Root>
+        <Pagination.Previous to="/" disabled />
+        <Pagination.Info>Page 1 of 5</Pagination.Info>
+        <Pagination.Next to="/" />
+      </Pagination.Root>
+    </div>
+  ),
+};
+
+export const MiddlePage: Story = {
+  args: { children: null },
+  render: () => (
+    <Pagination.Root>
+      <Pagination.Previous to="/" />
+      <Pagination.Info>Page 3 of 5</Pagination.Info>
+      <Pagination.Next to="/" />
+    </Pagination.Root>
+  ),
+};
+
+export const LastPage: Story = {
+  args: { children: null },
+  render: () => (
+    <Pagination.Root>
+      <Pagination.Previous to="/" />
+      <Pagination.Info>Page 5 of 5</Pagination.Info>
+      <Pagination.Next to="/" disabled />
+    </Pagination.Root>
+  ),
+};

webui2/src/components/ui/pagination.test.tsx 🔗

@@ -0,0 +1,13 @@
+import { composeStories } from "@storybook/react-vite";
+import { expect, test } from "vitest";
+
+import * as stories from "./pagination.stories";
+
+const composed = composeStories(stories);
+
+for (const [name, Story] of Object.entries(composed)) {
+  test(`Pagination/${name} matches snapshot`, async () => {
+    await Story.run();
+    expect(document.body.firstChild).toMatchSnapshot();
+  });
+}

webui2/src/components/ui/pagination.tsx 🔗

@@ -0,0 +1,82 @@
+import { createLink, type LinkComponent } from "@tanstack/react-router";
+import { ChevronLeft, ChevronRight } from "lucide-react";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+import { buttonVariants } from "./button";
+
+interface RootProps {
+  children: React.ReactNode;
+  className?: string;
+}
+
+export function Root({ children, className }: RootProps) {
+  return (
+    <div
+      className={cn(
+        "border-border flex items-center justify-center gap-2 border-t px-4 py-2",
+        className,
+      )}
+    >
+      {children}
+    </div>
+  );
+}
+
+interface InfoProps {
+  children: React.ReactNode;
+}
+
+export function Info({ children }: InfoProps) {
+  return <span className="text-muted-foreground text-sm">{children}</span>;
+}
+
+// Previous/Next are createLink-wrapped so they work as router links.
+const PreviousComponent = React.forwardRef<
+  HTMLAnchorElement,
+  { className?: string; children?: React.ReactNode; disabled?: boolean } & React.AnchorHTMLAttributes<HTMLAnchorElement>
+>(({ className, children, ...props }, ref) => (
+  <a
+    ref={ref}
+    className={cn(
+      buttonVariants({ variant: "ghost", size: "sm" }),
+      "text-muted-foreground gap-1",
+      className,
+    )}
+    {...props}
+  >
+    <ChevronLeft className="size-4" />
+    {children ?? "Previous"}
+  </a>
+));
+PreviousComponent.displayName = "PreviousComponent";
+
+const CreatedPrevious = createLink(PreviousComponent);
+export const Previous: LinkComponent<typeof PreviousComponent> = (props) => (
+  <CreatedPrevious preload="intent" {...props} />
+);
+
+const NextComponent = React.forwardRef<
+  HTMLAnchorElement,
+  { className?: string; children?: React.ReactNode; disabled?: boolean } & React.AnchorHTMLAttributes<HTMLAnchorElement>
+>(({ className, children, ...props }, ref) => (
+  <a
+    ref={ref}
+    className={cn(
+      buttonVariants({ variant: "ghost", size: "sm" }),
+      "text-muted-foreground gap-1",
+      className,
+    )}
+    {...props}
+  >
+    {children ?? "Next"}
+    <ChevronRight className="size-4" />
+  </a>
+));
+NextComponent.displayName = "NextComponent";
+
+const CreatedNext = createLink(NextComponent);
+export const Next: LinkComponent<typeof NextComponent> = (props) => (
+  <CreatedNext preload="intent" {...props} />
+);

webui2/src/components/ui/section-heading.stories.tsx 🔗

@@ -0,0 +1,18 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { SectionHeading } from "./section-heading";
+
+const meta = {
+  component: SectionHeading,
+} satisfies Meta<typeof SectionHeading>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+export const Default: Story = {
+  args: { children: "Participants" },
+};
+
+export const Labels: Story = {
+  args: { children: "Labels" },
+};

webui2/src/components/ui/section-heading.test.tsx 🔗

@@ -0,0 +1,13 @@
+import { composeStories } from "@storybook/react-vite";
+import { expect, test } from "vitest";
+
+import * as stories from "./section-heading.stories";
+
+const composed = composeStories(stories);
+
+for (const [name, Story] of Object.entries(composed)) {
+  test(`SectionHeading/${name} matches snapshot`, async () => {
+    await Story.run();
+    expect(document.body.firstChild).toMatchSnapshot();
+  });
+}

webui2/src/components/ui/section-heading.tsx 🔗

@@ -0,0 +1,14 @@
+import { cn } from "@/lib/utils";
+
+interface SectionHeadingProps {
+  children: React.ReactNode;
+  className?: string;
+}
+
+export function SectionHeading({ children, className }: SectionHeadingProps) {
+  return (
+    <h3 className={cn("text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase", className)}>
+      {children}
+    </h3>
+  );
+}