refactor(web): type components against GraphQL fragment types

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

- LabelBadge: props typed as LabelFieldsFragment (spread fragment
  data directly onto the component)
- CommentCard.AuthorAvatar: props typed as Pick<IdentitySummaryFragment>
  with avatarUrl/displayName fields
- IssueRow stories: mock data typed as BugSummaryFragment
- CommentCard stories: mock data typed as IdentitySummaryFragment
- Update Timeline and CommentBox for new AuthorAvatar prop names

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

Change summary

webui2/src/components/bugs/CommentBox.tsx                         |   2 
webui2/src/components/bugs/IssueRow.stories.tsx                   | 139 
webui2/src/components/bugs/LabelBadge.stories.tsx                 |  44 
webui2/src/components/bugs/LabelBadge.tsx                         |  67 
webui2/src/components/bugs/Timeline.tsx                           |   2 
webui2/src/components/bugs/__snapshots__/IssueRow.test.tsx.snap   | 147 
webui2/src/components/bugs/__snapshots__/LabelBadge.test.tsx.snap |   4 
webui2/src/components/ui/comment-card.stories.tsx                 |  36 
webui2/src/components/ui/comment-card.tsx                         |  13 
9 files changed, 254 insertions(+), 200 deletions(-)

Detailed changes

webui2/src/components/bugs/CommentBox.tsx 🔗

@@ -74,7 +74,7 @@ export function CommentBox({ bugPrefix, bugStatus, ref_ }: CommentBoxProps) {
 
   return (
     <CommentCard.Root>
-      <CommentCard.AuthorAvatar src={user.avatarUrl} name={user.displayName} />
+      <CommentCard.AuthorAvatar avatarUrl={user.avatarUrl} displayName={user.displayName} />
       <CommentCard.Card>
         <WritePreview.Root hasContent={hasMessage} preview={preview} onPreviewChange={setPreview}>
           <WritePreview.Tabs className="border-border border-b px-4 py-2" />

webui2/src/components/bugs/IssueRow.stories.tsx 🔗

@@ -1,6 +1,7 @@
 import type { Meta, StoryObj } from "@storybook/react-vite";
 import { formatDistanceToNow } from "date-fns";
 
+import type { BugSummaryFragment } from "@/__generated__/graphql";
 import { Status } from "@/__generated__/graphql";
 import { withRouter } from "@/../.storybook/decorators";
 
@@ -15,109 +16,91 @@ const meta = {
 export default meta;
 type Story = StoryObj<typeof meta>;
 
-const ago = formatDistanceToNow(new Date(Date.now() - 3600 * 1000), { addSuffix: true });
+// Mock data shaped like BugSummaryFragment from GraphQL
+const openBug: BugSummaryFragment = {
+  id: "abc123",
+  humanId: "a1b2c3",
+  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 } },
+  ],
+  author: { id: "u1", humanId: "user1", displayName: "Jane Doe", avatarUrl: null },
+  createdAt: new Date(Date.now() - 3600 * 1000).toISOString(),
+  comments: { totalCount: 3 },
+};
 
-export const OpenIssue: Story = {
-  args: { children: null },
-  render: () => (
+const closedBug: BugSummaryFragment = {
+  id: "def456",
+  humanId: "d4e5f6",
+  status: Status.Closed,
+  title: "Add dark mode support",
+  labels: [{ name: "enhancement", color: { R: 163, G: 230, B: 53 } }],
+  author: { id: "u2", humanId: "user2", displayName: "Bob Smith", avatarUrl: null },
+  createdAt: new Date(Date.now() - 86400 * 1000).toISOString(),
+  comments: { totalCount: 12 },
+};
+
+const noLabelsBug: BugSummaryFragment = {
+  id: "ghi789",
+  humanId: "g7h8i9",
+  status: Status.Open,
+  title: "Simple issue with no labels",
+  labels: [],
+  author: { id: "u3", humanId: "user3", displayName: "Alice Wu", avatarUrl: null },
+  createdAt: new Date(Date.now() - 7200 * 1000).toISOString(),
+  comments: { totalCount: 0 },
+};
+
+function BugRow({ bug }: { bug: BugSummaryFragment }) {
+  const ago = formatDistanceToNow(new Date(bug.createdAt), { addSuffix: true });
+  return (
     <IssueRow.Root className="hover:bg-muted/30">
-      <IssueRow.StatusIcon status={Status.Open} />
+      <IssueRow.StatusIcon status={bug.status} />
       <div className="min-w-0 flex-1">
         <IssueRow.TitleArea>
           <a href="#" className="text-foreground hover:text-primary font-medium hover:underline">
-            Fix login page crash on empty email
+            {bug.title}
           </a>
-          <LabelBadge name="bug" color={{ R: 252, G: 41, B: 41 }} />
-          <LabelBadge name="priority" color={{ R: 255, G: 152, B: 0 }} />
+          {bug.labels.map((l) => (
+            <LabelBadge key={l.name} {...l} />
+          ))}
         </IssueRow.TitleArea>
         <IssueRow.Meta>
-          #a1b2c3 opened {ago} by <a href="#" className="hover:underline">Jane Doe</a>
+          #{bug.humanId} opened {ago} by{" "}
+          <a href="#" className="hover:underline">
+            {bug.author.displayName}
+          </a>
         </IssueRow.Meta>
       </div>
-      <IssueRow.CommentCount count={3} />
+      <IssueRow.CommentCount count={bug.comments.totalCount} />
     </IssueRow.Root>
-  ),
+  );
+}
+
+export const OpenIssue: Story = {
+  args: { children: null },
+  render: () => <BugRow bug={openBug} />,
 };
 
 export const ClosedIssue: Story = {
   args: { children: null },
-  render: () => (
-    <IssueRow.Root>
-      <IssueRow.StatusIcon status={Status.Closed} />
-      <div className="min-w-0 flex-1">
-        <IssueRow.TitleArea>
-          <a href="#" className="text-foreground hover:text-primary font-medium hover:underline">
-            Add dark mode support
-          </a>
-          <LabelBadge name="enhancement" color={{ R: 163, G: 230, B: 53 }} />
-        </IssueRow.TitleArea>
-        <IssueRow.Meta>#d4e5f6 opened {ago}</IssueRow.Meta>
-      </div>
-      <IssueRow.CommentCount count={12} />
-    </IssueRow.Root>
-  ),
+  render: () => <BugRow bug={closedBug} />,
 };
 
 export const NoLabelsNoComments: Story = {
   args: { children: null },
-  render: () => (
-    <IssueRow.Root>
-      <IssueRow.StatusIcon status={Status.Open} />
-      <div className="min-w-0 flex-1">
-        <IssueRow.TitleArea>
-          <a href="#" className="text-foreground hover:text-primary font-medium hover:underline">
-            Simple issue with no labels
-          </a>
-        </IssueRow.TitleArea>
-        <IssueRow.Meta>#abc123 opened {ago} by <a href="#" className="hover:underline">Bob</a></IssueRow.Meta>
-      </div>
-      <IssueRow.CommentCount count={0} />
-    </IssueRow.Root>
-  ),
+  render: () => <BugRow bug={noLabelsBug} />,
 };
 
 export const List: Story = {
   args: { children: null },
   render: () => (
     <div className="border-border rounded-md border">
-      <IssueRow.Root className="hover:bg-muted/30">
-        <IssueRow.StatusIcon status={Status.Open} />
-        <div className="min-w-0 flex-1">
-          <IssueRow.TitleArea>
-            <a href="#" className="text-foreground hover:text-primary font-medium hover:underline">
-              Fix login page crash on empty email
-            </a>
-            <LabelBadge name="bug" color={{ R: 252, G: 41, B: 41 }} />
-          </IssueRow.TitleArea>
-          <IssueRow.Meta>#a1b2c3 opened {ago} by Jane Doe</IssueRow.Meta>
-        </div>
-        <IssueRow.CommentCount count={3} />
-      </IssueRow.Root>
-      <IssueRow.Root className="hover:bg-muted/30">
-        <IssueRow.StatusIcon status={Status.Open} />
-        <div className="min-w-0 flex-1">
-          <IssueRow.TitleArea>
-            <a href="#" className="text-foreground hover:text-primary font-medium hover:underline">
-              Add dark mode support
-            </a>
-            <LabelBadge name="enhancement" color={{ R: 163, G: 230, B: 53 }} />
-          </IssueRow.TitleArea>
-          <IssueRow.Meta>#d4e5f6 opened {ago} by Bob</IssueRow.Meta>
-        </div>
-        <IssueRow.CommentCount count={0} />
-      </IssueRow.Root>
-      <IssueRow.Root className="hover:bg-muted/30">
-        <IssueRow.StatusIcon status={Status.Closed} />
-        <div className="min-w-0 flex-1">
-          <IssueRow.TitleArea>
-            <a href="#" className="text-foreground hover:text-primary font-medium hover:underline">
-              Update dependencies
-            </a>
-          </IssueRow.TitleArea>
-          <IssueRow.Meta>#g7h8i9 opened {ago} by Alice</IssueRow.Meta>
-        </div>
-        <IssueRow.CommentCount count={7} />
-      </IssueRow.Root>
+      <BugRow bug={openBug} />
+      <BugRow bug={closedBug} />
+      <BugRow bug={noLabelsBug} />
     </div>
   ),
 };

webui2/src/components/bugs/LabelBadge.stories.tsx 🔗

@@ -1,6 +1,8 @@
 import type { Meta, StoryObj } from "@storybook/react-vite";
 import { fn } from "storybook/test";
 
+import type { LabelFieldsFragment } from "@/__generated__/graphql";
+
 import { LabelBadge } from "./LabelBadge";
 
 const meta = {
@@ -10,45 +12,39 @@ const meta = {
 export default meta;
 type Story = StoryObj<typeof meta>;
 
+// Mock data shaped like LabelFieldsFragment from GraphQL
+const bug: LabelFieldsFragment = { name: "bug", color: { R: 252, G: 41, B: 41 } };
+const enhancement: LabelFieldsFragment = { name: "enhancement", color: { R: 163, G: 230, B: 53 } };
+const documentation: LabelFieldsFragment = { name: "documentation", color: { R: 30, G: 80, B: 160 } };
+const helpWanted: LabelFieldsFragment = { name: "help wanted", color: { R: 0, G: 150, B: 136 } };
+const wontfix: LabelFieldsFragment = { name: "wontfix", color: { R: 200, G: 200, B: 200 } };
+const priority: LabelFieldsFragment = { name: "priority", color: { R: 255, G: 152, B: 0 } };
+
+const allLabels = [bug, enhancement, documentation, helpWanted, wontfix, priority];
+
 export const Default: Story = {
-  args: {
-    name: "bug",
-    color: { R: 252, G: 41, B: 41 },
-  },
+  args: bug,
 };
 
 export const LightBackground: Story = {
-  args: {
-    name: "enhancement",
-    color: { R: 163, G: 230, B: 53 },
-  },
+  args: enhancement,
 };
 
 export const DarkBackground: Story = {
-  args: {
-    name: "documentation",
-    color: { R: 30, G: 80, B: 160 },
-  },
+  args: documentation,
 };
 
 export const Clickable: Story = {
-  args: {
-    name: "feature",
-    color: { R: 100, G: 200, B: 150 },
-    onClick: fn(),
-  },
+  args: { ...helpWanted, onClick: fn() },
 };
 
 export const AllColors: Story = {
-  args: { name: "", color: { R: 0, G: 0, B: 0 } },
+  args: bug,
   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 }} />
+      {allLabels.map((label) => (
+        <LabelBadge key={label.name} {...label} />
+      ))}
     </div>
   ),
 };

webui2/src/components/bugs/LabelBadge.tsx 🔗

@@ -1,11 +1,11 @@
 import { createLink, type LinkComponent } from "@tanstack/react-router";
 import * as React from "react";
 
-interface LabelBadgeProps {
-  name: string;
-  color: { R: number; G: number; B: number };
+import type { LabelFieldsFragment } from "@/__generated__/graphql";
+
+type LabelBadgeProps = LabelFieldsFragment & {
   className?: string;
-}
+};
 
 function contrastColor(r: number, g: number, b: number): string {
   // Perceived luminance — pick black or white text for readability
@@ -15,44 +15,46 @@ function contrastColor(r: number, g: number, b: number): string {
 
 // Coloured label pill. Always renders as a <span>.
 // Use LabelBadgeLink for a clickable variant that navigates.
-const LabelBadge = React.forwardRef<HTMLSpanElement, LabelBadgeProps & Omit<React.HTMLAttributes<HTMLSpanElement>, "color">>(
-  ({ name, color, className, ...props }, ref) => {
+const LabelBadge = React.forwardRef<
+  HTMLSpanElement,
+  LabelBadgeProps & Omit<React.HTMLAttributes<HTMLSpanElement>, "color">
+>(({ name, color, className, ...props }, ref) => {
+  const bg = `rgb(${color.R},${color.G},${color.B})`;
+  const text = contrastColor(color.R, color.G, color.B);
+
+  return (
+    <span
+      ref={ref}
+      className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${className ?? ""}`}
+      style={{ backgroundColor: bg, color: text }}
+      {...props}
+    >
+      {name}
+    </span>
+  );
+});
+LabelBadge.displayName = "LabelBadge";
+
+// LabelBadge as a TanStack Router link — renders as <a> with label styling.
+const CreatedLabelBadgeLink = createLink(
+  React.forwardRef<
+    HTMLAnchorElement,
+    LabelBadgeProps & Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "color">
+  >(({ name, color, className, ...props }, ref) => {
     const bg = `rgb(${color.R},${color.G},${color.B})`;
     const text = contrastColor(color.R, color.G, color.B);
 
     return (
-      <span
+      <a
         ref={ref}
-        className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${className ?? ""}`}
+        className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium hover:opacity-80 ${className ?? ""}`}
         style={{ backgroundColor: bg, color: text }}
         {...props}
       >
         {name}
-      </span>
+      </a>
     );
-  },
-);
-LabelBadge.displayName = "LabelBadge";
-
-// LabelBadge as a TanStack Router link — renders as <a> with label styling.
-const CreatedLabelBadgeLink = createLink(
-  React.forwardRef<HTMLAnchorElement, LabelBadgeProps & Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "color">>(
-    ({ name, color, className, ...props }, ref) => {
-      const bg = `rgb(${color.R},${color.G},${color.B})`;
-      const text = contrastColor(color.R, color.G, color.B);
-
-      return (
-        <a
-          ref={ref}
-          className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium hover:opacity-80 ${className ?? ""}`}
-          style={{ backgroundColor: bg, color: text }}
-          {...props}
-        >
-          {name}
-        </a>
-      );
-    },
-  ),
+  }),
 );
 
 const LabelBadgeLink: LinkComponent<typeof CreatedLabelBadgeLink> = (props) => {
@@ -60,3 +62,4 @@ const LabelBadgeLink: LinkComponent<typeof CreatedLabelBadgeLink> = (props) => {
 };
 
 export { LabelBadge, LabelBadgeLink };
+export type { LabelBadgeProps };

webui2/src/components/bugs/Timeline.tsx 🔗

@@ -95,7 +95,7 @@ function CommentItem({
 
   return (
     <CommentCard.Root>
-      <CommentCard.AuthorAvatar src={item.author.avatarUrl} name={item.author.displayName} />
+      <CommentCard.AuthorAvatar avatarUrl={item.author.avatarUrl} displayName={item.author.displayName} />
       <CommentCard.Card>
         <CommentCard.CardHeader>
           <Link

webui2/src/components/bugs/__snapshots__/IssueRow.test.tsx.snap 🔗

@@ -3,7 +3,7 @@
 exports[`IssueRow/ClosedIssue matches snapshot 1`] = `
 <div>
   <div
-    class="border-border flex items-start gap-3 border-b px-4 py-3 last:border-0"
+    class="border-border flex items-start gap-3 border-b px-4 py-3 last:border-0 hover:bg-muted/30"
   >
     <svg
       aria-hidden="true"
@@ -49,8 +49,18 @@ exports[`IssueRow/ClosedIssue matches snapshot 1`] = `
       <p
         class="text-muted-foreground mt-0.5 text-xs"
       >
-        #d4e5f6 opened 
-        about 1 hour ago
+        #
+        d4e5f6
+         opened 
+        1 day ago
+         by
+         
+        <a
+          class="hover:underline"
+          href="#"
+        >
+          Bob Smith
+        </a>
       </p>
     </div>
     <div
@@ -129,13 +139,28 @@ exports[`IssueRow/List matches snapshot 1`] = `
           >
             bug
           </span>
+          <span
+            class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium "
+            style="background-color: rgb(255, 152, 0); color: rgba(0, 0, 0, 0.75);"
+          >
+            priority
+          </span>
         </div>
         <p
           class="text-muted-foreground mt-0.5 text-xs"
         >
-          #a1b2c3 opened 
+          #
+          a1b2c3
+           opened 
           about 1 hour ago
-           by Jane Doe
+           by
+           
+          <a
+            class="hover:underline"
+            href="#"
+          >
+            Jane Doe
+          </a>
         </p>
       </div>
       <div
@@ -166,7 +191,7 @@ exports[`IssueRow/List matches snapshot 1`] = `
     >
       <svg
         aria-hidden="true"
-        class="lucide lucide-circle-dot mt-0.5 size-4 shrink-0 text-green-600 dark:text-green-400"
+        class="lucide lucide-circle-check mt-0.5 size-4 shrink-0 text-purple-600 dark:text-purple-400"
         fill="none"
         height="24"
         stroke="currentColor"
@@ -182,10 +207,8 @@ exports[`IssueRow/List matches snapshot 1`] = `
           cy="12"
           r="10"
         />
-        <circle
-          cx="12"
-          cy="12"
-          r="1"
+        <path
+          d="m9 12 2 2 4-4"
         />
       </svg>
       <div
@@ -210,18 +233,49 @@ exports[`IssueRow/List matches snapshot 1`] = `
         <p
           class="text-muted-foreground mt-0.5 text-xs"
         >
-          #d4e5f6 opened 
-          about 1 hour ago
-           by Bob
+          #
+          d4e5f6
+           opened 
+          1 day ago
+           by
+           
+          <a
+            class="hover:underline"
+            href="#"
+          >
+            Bob Smith
+          </a>
         </p>
       </div>
+      <div
+        class="text-muted-foreground flex shrink-0 items-center gap-1 text-xs"
+      >
+        <svg
+          aria-hidden="true"
+          class="lucide lucide-message-square size-3.5"
+          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="M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z"
+          />
+        </svg>
+        12
+      </div>
     </div>
     <div
       class="border-border flex items-start gap-3 border-b px-4 py-3 last:border-0 hover:bg-muted/30"
     >
       <svg
         aria-hidden="true"
-        class="lucide lucide-circle-check mt-0.5 size-4 shrink-0 text-purple-600 dark:text-purple-400"
+        class="lucide lucide-circle-dot mt-0.5 size-4 shrink-0 text-green-600 dark:text-green-400"
         fill="none"
         height="24"
         stroke="currentColor"
@@ -237,8 +291,10 @@ exports[`IssueRow/List matches snapshot 1`] = `
           cy="12"
           r="10"
         />
-        <path
-          d="m9 12 2 2 4-4"
+        <circle
+          cx="12"
+          cy="12"
+          r="1"
         />
       </svg>
       <div
@@ -251,39 +307,26 @@ exports[`IssueRow/List matches snapshot 1`] = `
             class="text-foreground hover:text-primary font-medium hover:underline"
             href="#"
           >
-            Update dependencies
+            Simple issue with no labels
           </a>
         </div>
         <p
           class="text-muted-foreground mt-0.5 text-xs"
         >
-          #g7h8i9 opened 
-          about 1 hour ago
-           by Alice
+          #
+          g7h8i9
+           opened 
+          about 2 hours ago
+           by
+           
+          <a
+            class="hover:underline"
+            href="#"
+          >
+            Alice Wu
+          </a>
         </p>
       </div>
-      <div
-        class="text-muted-foreground flex shrink-0 items-center gap-1 text-xs"
-      >
-        <svg
-          aria-hidden="true"
-          class="lucide lucide-message-square size-3.5"
-          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="M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z"
-          />
-        </svg>
-        7
-      </div>
     </div>
   </div>
 </div>
@@ -292,7 +335,7 @@ exports[`IssueRow/List matches snapshot 1`] = `
 exports[`IssueRow/NoLabelsNoComments matches snapshot 1`] = `
 <div>
   <div
-    class="border-border flex items-start gap-3 border-b px-4 py-3 last:border-0"
+    class="border-border flex items-start gap-3 border-b px-4 py-3 last:border-0 hover:bg-muted/30"
   >
     <svg
       aria-hidden="true"
@@ -334,14 +377,17 @@ exports[`IssueRow/NoLabelsNoComments matches snapshot 1`] = `
       <p
         class="text-muted-foreground mt-0.5 text-xs"
       >
-        #abc123 opened 
-        about 1 hour ago
-         by 
+        #
+        g7h8i9
+         opened 
+        about 2 hours ago
+         by
+         
         <a
           class="hover:underline"
           href="#"
         >
-          Bob
+          Alice Wu
         </a>
       </p>
     </div>
@@ -406,9 +452,12 @@ exports[`IssueRow/OpenIssue matches snapshot 1`] = `
       <p
         class="text-muted-foreground mt-0.5 text-xs"
       >
-        #a1b2c3 opened 
+        #
+        a1b2c3
+         opened 
         about 1 hour ago
-         by 
+         by
+         
         <a
           class="hover:underline"
           href="#"

webui2/src/components/bugs/__snapshots__/LabelBadge.test.tsx.snap 🔗

@@ -49,9 +49,9 @@ exports[`LabelBadge/Clickable matches snapshot 1`] = `
 <div>
   <span
     class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium "
-    style="background-color: rgb(100, 200, 150); color: rgba(0, 0, 0, 0.75);"
+    style="background-color: rgb(0, 150, 136); color: rgba(255, 255, 255, 0.9);"
   >
-    feature
+    help wanted
   </span>
 </div>
 `;

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

@@ -1,5 +1,7 @@
 import type { Meta, StoryObj } from "@storybook/react-vite";
 
+import type { IdentitySummaryFragment } from "@/__generated__/graphql";
+
 import * as CommentCard from "./comment-card";
 
 const meta = {
@@ -9,14 +11,36 @@ const meta = {
 export default meta;
 type Story = StoryObj<typeof meta>;
 
+// Mock data shaped like IdentitySummaryFragment
+const jane: IdentitySummaryFragment = {
+  id: "1",
+  humanId: "jane1",
+  displayName: "Jane Doe",
+  avatarUrl: null,
+};
+
+const bob: IdentitySummaryFragment = {
+  id: "2",
+  humanId: "bob1",
+  displayName: "Bob Smith",
+  avatarUrl: "https://github.com/shadcn.png",
+};
+
+const alice: IdentitySummaryFragment = {
+  id: "3",
+  humanId: "alice1",
+  displayName: "Alice Wu",
+  avatarUrl: null,
+};
+
 export const Default: Story = {
   args: { children: null },
   render: () => (
     <CommentCard.Root>
-      <CommentCard.AuthorAvatar name="Jane Doe" />
+      <CommentCard.AuthorAvatar displayName={jane.displayName} avatarUrl={jane.avatarUrl} />
       <CommentCard.Card>
         <CommentCard.CardHeader>
-          <span className="text-foreground font-medium">Jane Doe</span>
+          <span className="text-foreground font-medium">{jane.displayName}</span>
           <span className="text-muted-foreground">2 hours ago</span>
         </CommentCard.CardHeader>
         <CommentCard.CardBody>
@@ -31,10 +55,10 @@ export const WithEditButton: Story = {
   args: { children: null },
   render: () => (
     <CommentCard.Root>
-      <CommentCard.AuthorAvatar name="Bob Smith" src="https://github.com/shadcn.png" />
+      <CommentCard.AuthorAvatar displayName={bob.displayName} avatarUrl={bob.avatarUrl} />
       <CommentCard.Card>
         <CommentCard.CardHeader>
-          <span className="text-foreground font-medium">Bob Smith</span>
+          <span className="text-foreground font-medium">{bob.displayName}</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">
@@ -53,10 +77,10 @@ export const EmptyBody: Story = {
   args: { children: null },
   render: () => (
     <CommentCard.Root>
-      <CommentCard.AuthorAvatar name="Alice Wu" />
+      <CommentCard.AuthorAvatar displayName={alice.displayName} avatarUrl={alice.avatarUrl} />
       <CommentCard.Card>
         <CommentCard.CardHeader>
-          <span className="text-foreground font-medium">Alice Wu</span>
+          <span className="text-foreground font-medium">{alice.displayName}</span>
           <span className="text-muted-foreground">just now</span>
         </CommentCard.CardHeader>
         <CommentCard.CardBody>

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

@@ -1,3 +1,4 @@
+import type { IdentitySummaryFragment } from "@/__generated__/graphql";
 import { cn } from "@/lib/utils";
 
 import { Avatar, AvatarFallback, AvatarImage } from "./avatar";
@@ -11,18 +12,16 @@ export function Root({ children, className }: RootProps) {
   return <div className={cn("flex gap-3", className)}>{children}</div>;
 }
 
-interface AuthorAvatarProps {
-  src?: string | null;
-  name: string;
+type AuthorAvatarProps = Pick<IdentitySummaryFragment, "displayName" | "avatarUrl"> & {
   className?: string;
-}
+};
 
-export function AuthorAvatar({ src, name, className }: AuthorAvatarProps) {
+export function AuthorAvatar({ avatarUrl, displayName, className }: AuthorAvatarProps) {
   return (
     <Avatar className={cn("mt-1 size-8 shrink-0", className)}>
-      <AvatarImage src={src ?? undefined} alt={name} />
+      <AvatarImage src={avatarUrl ?? undefined} alt={displayName} />
       <AvatarFallback className="text-xs">
-        {name.slice(0, 2).toUpperCase()}
+        {displayName.slice(0, 2).toUpperCase()}
       </AvatarFallback>
     </Avatar>
   );