feat(web): add IssueRow, StatusTabs, and WritePreview composition components

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

IssueRow: compound component (Root/StatusIcon/TitleArea/Meta/CommentCount)
replacing the flat prop-heavy BugRow.

StatusTabs: createLink-wrapped tab with OpenIndicator/ClosedIndicator/Count
sub-components for the open/closed toggle pattern.

WritePreview: context-based write/preview editor with controlled and
uncontrolled modes, replacing duplicated tab logic.

Also refactor LabelBadge: remove onClick prop, add LabelBadgeLink via
createLink for navigation.

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

Change summary

webui2/src/components/bugs/IssueRow.stories.tsx    | 123 ++++++++++++++++
webui2/src/components/bugs/IssueRow.tsx            |  71 +++++++++
webui2/src/components/bugs/LabelBadge.tsx          |  72 +++++---
webui2/src/components/ui/status-tabs.stories.tsx   |  28 +++
webui2/src/components/ui/status-tabs.tsx           |  78 ++++++++++
webui2/src/components/ui/write-preview.stories.tsx |  82 ++++++++++
webui2/src/components/ui/write-preview.tsx         |  94 ++++++++++++
7 files changed, 521 insertions(+), 27 deletions(-)

Detailed changes

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

@@ -0,0 +1,123 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { formatDistanceToNow } from "date-fns";
+
+import { Status } from "@/__generated__/graphql";
+import { withRouter } from "@/../.storybook/decorators";
+
+import * as IssueRow from "./IssueRow";
+import { LabelBadge } from "./LabelBadge";
+
+const meta = {
+  component: IssueRow.Root,
+  decorators: [withRouter],
+} satisfies Meta<typeof IssueRow.Root>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+const ago = formatDistanceToNow(new Date(Date.now() - 3600 * 1000), { addSuffix: true });
+
+export const OpenIssue: Story = {
+  args: { children: null },
+  render: () => (
+    <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 }} />
+          <LabelBadge name="priority" color={{ R: 255, G: 152, B: 0 }} />
+        </IssueRow.TitleArea>
+        <IssueRow.Meta>
+          #a1b2c3 opened {ago} by <a href="#" className="hover:underline">Jane Doe</a>
+        </IssueRow.Meta>
+      </div>
+      <IssueRow.CommentCount count={3} />
+    </IssueRow.Root>
+  ),
+};
+
+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>
+  ),
+};
+
+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>
+  ),
+};
+
+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>
+    </div>
+  ),
+};

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

@@ -0,0 +1,71 @@
+import { CircleDot, CircleCheck, MessageSquare } from "lucide-react";
+
+import { Status } from "@/__generated__/graphql";
+import { cn } from "@/lib/utils";
+
+interface RootProps {
+  className?: string;
+  children: React.ReactNode;
+}
+
+export function Root({ className, children }: RootProps) {
+  return (
+    <div
+      className={cn(
+        "border-border flex items-start gap-3 border-b px-4 py-3 last:border-0",
+        className,
+      )}
+    >
+      {children}
+    </div>
+  );
+}
+
+interface StatusIconProps {
+  status: Status;
+}
+
+export function StatusIcon({ status }: StatusIconProps) {
+  const isOpen = status === Status.Open;
+  const Icon = isOpen ? CircleDot : CircleCheck;
+  return (
+    <Icon
+      className={cn(
+        "mt-0.5 size-4 shrink-0",
+        isOpen
+          ? "text-green-600 dark:text-green-400"
+          : "text-purple-600 dark:text-purple-400",
+      )}
+    />
+  );
+}
+
+interface TitleAreaProps {
+  children: React.ReactNode;
+}
+
+export function TitleArea({ children }: TitleAreaProps) {
+  return <div className="flex flex-wrap items-baseline gap-2">{children}</div>;
+}
+
+interface MetaProps {
+  children: React.ReactNode;
+}
+
+export function Meta({ children }: MetaProps) {
+  return <p className="text-muted-foreground mt-0.5 text-xs">{children}</p>;
+}
+
+interface CommentCountProps {
+  count: number;
+}
+
+export function CommentCount({ count }: CommentCountProps) {
+  if (count <= 0) return null;
+  return (
+    <div className="text-muted-foreground flex shrink-0 items-center gap-1 text-xs">
+      <MessageSquare className="size-3.5" />
+      {count}
+    </div>
+  );
+}

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

@@ -1,7 +1,10 @@
+import { createLink, type LinkComponent } from "@tanstack/react-router";
+import * as React from "react";
+
 interface LabelBadgeProps {
   name: string;
   color: { R: number; G: number; B: number };
-  onClick?: (name: string) => void;
+  className?: string;
 }
 
 function contrastColor(r: number, g: number, b: number): string {
@@ -10,35 +13,50 @@ function contrastColor(r: number, g: number, b: number): string {
   return luminance > 0.55 ? "rgba(0,0,0,0.75)" : "rgba(255,255,255,0.9)";
 }
 
-// Coloured label pill. Renders as a <button> when onClick is provided,
-// used in BugRow and UserProfilePage to filter issues by label.
-export function LabelBadge({ name, color, onClick }: LabelBadgeProps) {
-  const bg = `rgb(${color.R},${color.G},${color.B})`;
-  const text = contrastColor(color.R, color.G, color.B);
+// 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 bg = `rgb(${color.R},${color.G},${color.B})`;
+    const text = contrastColor(color.R, color.G, color.B);
 
-  if (onClick) {
     return (
-      <button
-        type="button"
-        className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium hover:opacity-80"
+      <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 }}
-        onClick={(e) => {
-          e.preventDefault();
-          e.stopPropagation();
-          onClick(name);
-        }}
+        {...props}
       >
         {name}
-      </button>
+      </span>
     );
-  }
-
-  return (
-    <span
-      className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
-      style={{ backgroundColor: bg, color: text }}
-    >
-      {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 (
+        <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) => {
+  return <CreatedLabelBadgeLink preload="intent" {...props} />;
+};
+
+export { LabelBadge, LabelBadgeLink };

webui2/src/components/ui/status-tabs.stories.tsx 🔗

@@ -0,0 +1,28 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import * as StatusTabs from "./status-tabs";
+
+const meta = {
+  component: StatusTabs.Root,
+} satisfies Meta<typeof StatusTabs.Root>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+export const Default: Story = {
+  args: { children: null },
+  render: () => (
+    <StatusTabs.Root>
+      <button className="bg-accent text-accent-foreground flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium">
+        <StatusTabs.OpenIndicator />
+        Open
+        <StatusTabs.Count>12</StatusTabs.Count>
+      </button>
+      <button className="text-muted-foreground hover:bg-accent/50 hover:text-foreground flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium">
+        <StatusTabs.ClosedIndicator />
+        Closed
+        <StatusTabs.Count>5</StatusTabs.Count>
+      </button>
+    </StatusTabs.Root>
+  ),
+};

webui2/src/components/ui/status-tabs.tsx 🔗

@@ -0,0 +1,78 @@
+import { createLink, type LinkComponent } from "@tanstack/react-router";
+import { CircleDot, CircleCheck } from "lucide-react";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+interface RootProps {
+  children: React.ReactNode;
+  className?: string;
+}
+
+export function Root({ children, className }: RootProps) {
+  return <div className={cn("flex items-center gap-1", className)}>{children}</div>;
+}
+
+// Tab is a createLink-wrapped component — it IS the link.
+const TabComponent = React.forwardRef<
+  HTMLAnchorElement,
+  { className?: string; children?: React.ReactNode } & React.AnchorHTMLAttributes<HTMLAnchorElement>
+>(({ className, children, ...props }, ref) => {
+  return (
+    <a
+      ref={ref}
+      className={cn(
+        "text-muted-foreground hover:bg-accent/50 hover:text-foreground flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
+        className,
+      )}
+      {...props}
+    >
+      {children}
+    </a>
+  );
+});
+TabComponent.displayName = "TabComponent";
+
+const CreatedTab = createLink(TabComponent);
+
+export const Tab: LinkComponent<typeof TabComponent> = (props) => {
+  return (
+    <CreatedTab
+      preload="intent"
+      activeProps={{ className: "bg-accent text-accent-foreground" }}
+      {...props}
+    />
+  );
+};
+
+interface IndicatorProps {
+  className?: string;
+}
+
+export function OpenIndicator({ className }: IndicatorProps) {
+  return (
+    <CircleDot
+      className={cn("size-4 group-[.active]:text-green-600 dark:group-[.active]:text-green-400", className)}
+    />
+  );
+}
+
+export function ClosedIndicator({ className }: IndicatorProps) {
+  return (
+    <CircleCheck
+      className={cn("size-4 group-[.active]:text-purple-600 dark:group-[.active]:text-purple-400", className)}
+    />
+  );
+}
+
+interface CountProps {
+  children: React.ReactNode;
+}
+
+export function Count({ children }: CountProps) {
+  return (
+    <span className="bg-muted ml-0.5 rounded-full px-1.5 py-0.5 text-xs leading-none">
+      {children}
+    </span>
+  );
+}

webui2/src/components/ui/write-preview.stories.tsx 🔗

@@ -0,0 +1,82 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { useState } from "react";
+
+import { Markdown } from "@/components/content/Markdown";
+import { Textarea } from "@/components/ui/textarea";
+import { withRouter } from "@/../.storybook/decorators";
+
+import * as WritePreview from "./write-preview";
+
+const meta = {
+  component: WritePreview.Root,
+  decorators: [withRouter],
+} satisfies Meta<typeof WritePreview.Root>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+export const Uncontrolled: Story = {
+  args: { children: null },
+  render: () => {
+    const [message, setMessage] = useState("Hello **world**!");
+    return (
+      <WritePreview.Root hasContent={!!message.trim()}>
+        <WritePreview.Tabs className="mb-2" />
+        <WritePreview.WriteSlot>
+          <Textarea
+            placeholder="Describe the issue…"
+            className="min-h-[200px]"
+            value={message}
+            onChange={(e) => setMessage(e.target.value)}
+          />
+        </WritePreview.WriteSlot>
+        <WritePreview.PreviewSlot>
+          <div className="border-input min-h-[200px] rounded-md border px-3 py-2">
+            <Markdown content={message} />
+          </div>
+        </WritePreview.PreviewSlot>
+      </WritePreview.Root>
+    );
+  },
+};
+
+export const Controlled: Story = {
+  args: { children: null },
+  render: () => {
+    const [message, setMessage] = useState("");
+    const [preview, setPreview] = useState(false);
+    return (
+      <WritePreview.Root hasContent={!!message.trim()} preview={preview} onPreviewChange={setPreview}>
+        <WritePreview.Tabs className="mb-2" />
+        <WritePreview.WriteSlot>
+          <Textarea
+            placeholder="Leave a comment…"
+            className="min-h-[120px]"
+            value={message}
+            onChange={(e) => setMessage(e.target.value)}
+          />
+        </WritePreview.WriteSlot>
+        <WritePreview.PreviewSlot>
+          <div className="min-h-[120px] px-4 py-3">
+            <Markdown content={message} />
+          </div>
+        </WritePreview.PreviewSlot>
+      </WritePreview.Root>
+    );
+  },
+};
+
+export const Empty: Story = {
+  args: { children: null },
+  render: () => (
+    <WritePreview.Root hasContent={false}>
+      <WritePreview.Tabs className="mb-2" />
+      <WritePreview.WriteSlot>
+        <Textarea placeholder="Preview is disabled until you type something…" className="min-h-[120px]" />
+      </WritePreview.WriteSlot>
+      <WritePreview.PreviewSlot>
+        <div className="min-h-[120px] px-4 py-3">Nothing to preview</div>
+      </WritePreview.PreviewSlot>
+    </WritePreview.Root>
+  ),
+};

webui2/src/components/ui/write-preview.tsx 🔗

@@ -0,0 +1,94 @@
+import { createContext, useContext, useState } from "react";
+
+import { cn } from "@/lib/utils";
+
+interface WritePreviewContextValue {
+  preview: boolean;
+  setPreview: (v: boolean) => void;
+  hasContent: boolean;
+}
+
+const WritePreviewContext = createContext<WritePreviewContextValue | null>(null);
+
+function useWritePreview() {
+  const ctx = useContext(WritePreviewContext);
+  if (!ctx) throw new Error("WritePreview sub-components must be used within WritePreview.Root");
+  return ctx;
+}
+
+interface RootProps {
+  children: React.ReactNode;
+  className?: string;
+  hasContent?: boolean;
+  /** Controlled mode: current preview state. */
+  preview?: boolean;
+  /** Controlled mode: callback when preview state changes. */
+  onPreviewChange?: (v: boolean) => void;
+}
+
+export function Root({ children, className, hasContent = false, preview, onPreviewChange }: RootProps) {
+  const [internalPreview, setInternalPreview] = useState(false);
+  const isControlled = preview !== undefined;
+
+  const value: WritePreviewContextValue = {
+    preview: isControlled ? preview : internalPreview,
+    setPreview: isControlled ? (onPreviewChange ?? (() => {})) : setInternalPreview,
+    hasContent,
+  };
+
+  return (
+    <WritePreviewContext value={value}>
+      <div className={className}>{children}</div>
+    </WritePreviewContext>
+  );
+}
+
+interface TabsProps {
+  className?: string;
+}
+
+export function Tabs({ className }: TabsProps) {
+  const { preview, setPreview, hasContent } = useWritePreview();
+
+  return (
+    <div className={cn("flex gap-2", className)}>
+      <button
+        type="button"
+        onClick={() => setPreview(false)}
+        className={cn(
+          "rounded-sm px-2 py-0.5 text-sm font-medium transition-colors",
+          !preview ? "bg-muted font-medium" : "text-muted-foreground hover:text-foreground",
+        )}
+      >
+        Write
+      </button>
+      <button
+        type="button"
+        onClick={() => setPreview(true)}
+        disabled={!hasContent}
+        className={cn(
+          "rounded-sm px-2 py-0.5 text-sm font-medium transition-colors disabled:opacity-40",
+          preview ? "bg-muted font-medium" : "text-muted-foreground hover:text-foreground",
+        )}
+      >
+        Preview
+      </button>
+    </div>
+  );
+}
+
+interface SlotProps {
+  children: React.ReactNode;
+}
+
+export function WriteSlot({ children }: SlotProps) {
+  const { preview } = useWritePreview();
+  if (preview) return null;
+  return <>{children}</>;
+}
+
+export function PreviewSlot({ children }: SlotProps) {
+  const { preview } = useWritePreview();
+  if (!preview) return null;
+  return <>{children}</>;
+}