refactor(web): add ButtonLink/NavLink components, use typed links everywhere

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

- create ButtonLink via createLink() for type-safe button-styled links
  with preloading, replacing the <Button asChild><Link/></Button> pattern
- create NavLink via createLink() with activeProps/inactiveProps,
  replacing manual useMatchRoute() active state detection
- convert all string-interpolated Link paths to typed params form
- convert onClick navigate handlers to ButtonLink where possible
- remove unnecessary type assertions on useParams (router Register
  provides full type inference)

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

Change summary

webui2/src/components/layout/Header.tsx  | 46 ++++--------------
webui2/src/components/ui/button-link.tsx | 63 ++++++++++++++++++++++++++
webui2/src/pages/BugDetailPage.tsx       | 20 +++++--
webui2/src/pages/CodePage.tsx            | 14 ++++-
webui2/src/pages/ErrorPage.tsx           |  9 ++-
webui2/src/pages/NewBugPage.tsx          | 51 +++++++-------------
6 files changed, 121 insertions(+), 82 deletions(-)

Detailed changes

webui2/src/components/layout/Header.tsx πŸ”—

@@ -6,14 +6,14 @@
 // In external mode, shows a "Sign in" button when logged out and a sign-out
 // action when logged in.
 
-import { Link, useMatchRoute, useParams } from "@tanstack/react-router";
+import { Link, useParams } from "@tanstack/react-router";
 import { Bug, Plus, Sun, Moon, LogIn, LogOut } from "lucide-react";
 
 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
 import { Button } from "@/components/ui/button";
+import { ButtonLink, NavLink } from "@/components/ui/button-link";
 import { useAuth } from "@/lib/auth";
 import { useTheme } from "@/lib/theme";
-import { cn } from "@/lib/utils";
 
 // SignOutButton sends a POST to /auth/logout and reloads the page.
 // A full reload is the simplest way to reset all Apollo cache + React state.
@@ -41,14 +41,6 @@ export function Header() {
   // Don't show repo nav on the /auth/* pages.
   const effectiveRepo = repo === "auth" ? null : repo;
 
-  const matchRoute = useMatchRoute();
-  const isCodeActive = effectiveRepo
-    ? !!matchRoute({ to: "/$repo", params: { repo: effectiveRepo }, fuzzy: false })
-    : false;
-  const isIssuesActive = effectiveRepo
-    ? !!matchRoute({ to: "/$repo/issues", params: { repo: effectiveRepo }, fuzzy: true })
-    : false;
-
   return (
     <header className="border-border bg-background/95 sticky top-0 z-50 border-b backdrop-blur">
       <div className="mx-auto flex h-14 max-w-screen-xl items-center gap-6 px-4">
@@ -61,31 +53,17 @@ export function Header() {
         {/* Repo-scoped nav links β€” only shown when inside a repo */}
         {effectiveRepo && (
           <nav className="flex items-center gap-1">
-            <Link
+            <NavLink
               to="/$repo"
               params={{ repo: effectiveRepo }}
               search={{ ref: "", path: "", type: "tree" as const }}
-              className={cn(
-                "rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
-                isCodeActive
-                  ? "bg-accent text-accent-foreground"
-                  : "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
-              )}
+              activeOptions={{ exact: true }}
             >
               Code
-            </Link>
-            <Link
-              to="/$repo/issues"
-              params={{ repo: effectiveRepo }}
-              className={cn(
-                "rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
-                isIssuesActive
-                  ? "bg-accent text-accent-foreground"
-                  : "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
-              )}
-            >
+            </NavLink>
+            <NavLink to="/$repo/issues" params={{ repo: effectiveRepo }}>
               Issues
-            </Link>
+            </NavLink>
           </nav>
         )}
 
@@ -110,12 +88,10 @@ export function Header() {
 
           {user && effectiveRepo && (
             <>
-              <Button asChild size="sm">
-                <Link to="/$repo/issues/new" params={{ repo: effectiveRepo }}>
-                  <Plus className="size-4" />
-                  New issue
-                </Link>
-              </Button>
+              <ButtonLink to="/$repo/issues/new" params={{ repo: effectiveRepo }} size="sm">
+                <Plus className="size-4" />
+                New issue
+              </ButtonLink>
               <Link to="/$repo/user/$id" params={{ repo: effectiveRepo, id: user.humanId }}>
                 <Avatar className="size-7">
                   <AvatarImage src={user.avatarUrl ?? undefined} alt={user.displayName} />

webui2/src/components/ui/button-link.tsx πŸ”—

@@ -0,0 +1,63 @@
+import { createLink, type LinkComponent } from "@tanstack/react-router";
+import type { VariantProps } from "class-variance-authority";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+import { buttonVariants } from "./button";
+
+// A proper TanStack Router link that looks like a Button.
+// Replaces the `<Button asChild><Link …/></Button>` pattern,
+// giving us preloading, typed routes, and active link support.
+interface ButtonLinkProps extends VariantProps<typeof buttonVariants> {
+  className?: string;
+  children?: React.ReactNode;
+}
+
+const ButtonLinkComponent = React.forwardRef<
+  HTMLAnchorElement,
+  ButtonLinkProps & React.AnchorHTMLAttributes<HTMLAnchorElement>
+>(({ className, variant, size, children, ...props }, ref) => {
+  return (
+    <a ref={ref} className={cn(buttonVariants({ variant, size, className }))} {...props}>
+      {children}
+    </a>
+  );
+});
+ButtonLinkComponent.displayName = "ButtonLinkComponent";
+
+const CreatedButtonLink = createLink(ButtonLinkComponent);
+
+export const ButtonLink: LinkComponent<typeof ButtonLinkComponent> = (props) => {
+  return <CreatedButtonLink preload="intent" {...props} />;
+};
+
+// A nav link that uses activeProps/inactiveProps for styling.
+// Replaces the manual useMatchRoute() pattern in the header.
+const NavLinkComponent = React.forwardRef<
+  HTMLAnchorElement,
+  React.AnchorHTMLAttributes<HTMLAnchorElement>
+>(({ children, ...props }, ref) => {
+  return (
+    <a ref={ref} {...props}>
+      {children}
+    </a>
+  );
+});
+NavLinkComponent.displayName = "NavLinkComponent";
+
+const CreatedNavLink = createLink(NavLinkComponent);
+
+export const NavLink: LinkComponent<typeof NavLinkComponent> = (props) => {
+  return (
+    <CreatedNavLink
+      preload="intent"
+      className="text-muted-foreground hover:bg-accent hover:text-accent-foreground rounded-md px-3 py-1.5 text-sm font-medium transition-colors"
+      activeProps={{ className: "bg-accent text-accent-foreground" }}
+      inactiveProps={{
+        className: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
+      }}
+      {...props}
+    />
+  );
+};

webui2/src/pages/BugDetailPage.tsx πŸ”—

@@ -39,13 +39,11 @@ export function BugDetailPage() {
     return <div className="text-muted-foreground py-16 text-center text-sm">Issue not found.</div>;
   }
 
-  const issuesHref = repo ? `/${repo}/issues` : "/issues";
-  const authorHref = repo ? `/${repo}/user/${bug.author.humanId}` : `/user/${bug.author.humanId}`;
-
   return (
     <div>
       <Link
-        to={issuesHref}
+        to="/$repo/issues"
+        params={{ repo: repo! }}
         className="text-muted-foreground hover:text-foreground mb-4 flex items-center gap-1.5 text-sm"
       >
         <ArrowLeft className="size-3.5" />
@@ -60,7 +58,11 @@ export function BugDetailPage() {
       <div className="text-muted-foreground mb-6 flex flex-wrap items-center gap-3 text-sm">
         <StatusBadge status={bug.status} />
         <span>
-          <Link to={authorHref} className="text-foreground font-medium hover:underline">
+          <Link
+            to="/$repo/user/$id"
+            params={{ repo: repo!, id: bug.author.humanId }}
+            className="text-foreground font-medium hover:underline"
+          >
             {bug.author.displayName}
           </Link>{" "}
           opened this issue {formatDistanceToNow(new Date(bug.createdAt), { addSuffix: true })}
@@ -88,9 +90,13 @@ export function BugDetailPage() {
             </h3>
             <div className="flex flex-wrap gap-1.5">
               {bug.participants.nodes.map((p) => {
-                const participantHref = repo ? `/${repo}/user/${p.humanId}` : `/user/${p.humanId}`;
                 return (
-                  <Link key={p.id} to={participantHref} title={p.displayName}>
+                  <Link
+                    key={p.id}
+                    to="/$repo/user/$id"
+                    params={{ repo: repo!, id: p.humanId }}
+                    title={p.displayName}
+                  >
                     <Avatar className="size-6">
                       <AvatarImage src={p.avatarUrl ?? undefined} alt={p.displayName} />
                       <AvatarFallback className="text-[10px]">

webui2/src/pages/CodePage.tsx πŸ”—

@@ -15,7 +15,7 @@ import type { TreeEntryWithCommit } from "@/components/code/FileTree";
 import { FileViewer } from "@/components/code/FileViewer";
 import { RefSelector } from "@/components/code/RefSelector";
 import { Markdown } from "@/components/content/Markdown";
-import { Button } from "@/components/ui/button";
+import { ButtonLink } from "@/components/ui/button-link";
 import { Skeleton } from "@/components/ui/skeleton";
 import { useRepo } from "@/lib/repo";
 
@@ -222,14 +222,20 @@ export function CodePage() {
         )}
         <div className="flex items-center gap-2">
           {!refsLoading && (
-            <Button
+            <ButtonLink
+              to="/$repo"
+              params={{ repo: repo! }}
+              search={{
+                ref: currentRef,
+                path: currentPath,
+                type: viewMode === "commits" ? "tree" : "commits",
+              }}
               variant={viewMode === "commits" ? "secondary" : "outline"}
               size="sm"
-              onClick={() => navigateTo(currentPath, viewMode === "commits" ? "tree" : "commits")}
             >
               <GitCommit className="size-3.5" />
               History
-            </Button>
+            </ButtonLink>
           )}
           {refsLoading ? (
             <Skeleton className="h-8 w-28" />

webui2/src/pages/ErrorPage.tsx πŸ”—

@@ -1,9 +1,10 @@
 // Global error boundary page. Rendered by TanStack Router when a route throws.
 
-import { Link, useRouter } from "@tanstack/react-router";
+import { useRouter } from "@tanstack/react-router";
 import { AlertTriangle } from "lucide-react";
 
 import { Button } from "@/components/ui/button";
+import { ButtonLink } from "@/components/ui/button-link";
 
 export function ErrorPage({ error }: { error?: Error }) {
   const router = useRouter();
@@ -24,9 +25,9 @@ export function ErrorPage({ error }: { error?: Error }) {
         >
           Try again
         </Button>
-        <Button variant="outline" size="sm" asChild>
-          <Link to="/">Go home</Link>
-        </Button>
+        <ButtonLink to="/" variant="outline" size="sm">
+          Go home
+        </ButtonLink>
       </div>
     </div>
   );

webui2/src/pages/NewBugPage.tsx πŸ”—

@@ -1,10 +1,11 @@
-import { useNavigate, Link } from "@tanstack/react-router";
+import { Link, useNavigate } from "@tanstack/react-router";
 import { ArrowLeft } from "lucide-react";
 import { useState } from "react";
 
 import { useBugCreateMutation } from "@/__generated__/graphql";
 import { Markdown } from "@/components/content/Markdown";
 import { Button } from "@/components/ui/button";
+import { ButtonLink } from "@/components/ui/button-link";
 import { Input } from "@/components/ui/input";
 import { Textarea } from "@/components/ui/textarea";
 import { useRepo } from "@/lib/repo";
@@ -16,27 +17,27 @@ export function NewBugPage() {
   const [title, setTitle] = useState("");
   const [message, setMessage] = useState("");
   const [preview, setPreview] = useState(false);
-
   const [createBug, { loading, error }] = useBugCreateMutation();
 
   async function handleSubmit(e: React.FormEvent) {
     e.preventDefault();
-    if (!title.trim()) return;
     const result = await createBug({
       variables: { input: { title: title.trim(), message: message.trim() } },
     });
     const humanId = result.data?.bugCreate.bug.humanId;
     if (humanId) {
-      void navigate({ to: repo ? `/${repo}/issues/${humanId}` : `/issues/${humanId}` });
+      void navigate({
+        to: "/$repo/issues/$id",
+        params: { repo: repo!, id: humanId },
+      });
     }
   }
 
-  const issuesHref = repo ? `/${repo}/issues` : "/issues";
-
   return (
     <div className="mx-auto max-w-3xl">
       <Link
-        to={issuesHref}
+        to="/$repo/issues"
+        params={{ repo: repo! }}
         className="text-muted-foreground hover:text-foreground mb-6 flex items-center gap-1.5 text-sm"
       >
         <ArrowLeft className="size-3.5" />
@@ -51,24 +52,17 @@ export function NewBugPage() {
         }}
         className="space-y-4"
       >
-        <div>
-          <label htmlFor="title" className="mb-1.5 block text-sm font-medium">
-            Title
-          </label>
-          <Input
-            id="title"
-            placeholder="Brief description of the issue"
-            value={title}
-            onChange={(e) => setTitle(e.target.value)}
-            required
-            disabled={loading}
-          />
-        </div>
+        <Input
+          placeholder="Title"
+          value={title}
+          onChange={(e) => setTitle(e.target.value)}
+          disabled={loading}
+          autoFocus
+        />
 
         <div>
-          <div className="mb-1.5 flex items-center justify-between">
-            <label className="text-sm font-medium">Description</label>
-            <div className="flex gap-1 text-sm">
+          <div className="mb-2">
+            <div className="flex gap-2">
               <button
                 type="button"
                 onClick={() => setPreview(false)}
@@ -111,16 +105,9 @@ export function NewBugPage() {
         )}
 
         <div className="flex justify-end gap-2">
-          <Button
-            type="button"
-            variant="ghost"
-            onClick={() => {
-              void navigate({ to: issuesHref });
-            }}
-            disabled={loading}
-          >
+          <ButtonLink to="/$repo/issues" params={{ repo: repo! }} variant="ghost">
             Cancel
-          </Button>
+          </ButtonLink>
           <Button type="submit" disabled={!title.trim() || loading}>
             {loading ? "Creating…" : "Submit new issue"}
           </Button>