CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

feat(nav): one canonical RepoNav — data-derived keys, insights sub-nav unified, lost pages find their way home #5550

MergedXSccantynz wants to mergefeat/repo-nav-coherencemainopened 6d ago
25 changed files+513−405
Modifiedsrc/__tests__/orphan-links-batch3.test.ts+8−5View fileUnifiedSplit
4949 // been unlocked and rebuilt with grouped AI menus, so the strip became a
5050 // second place to look for the same links — the exact ambiguity the nav
5151 // comment warns about — and was dropped in favour of the menu itself.
52 // The property under test is unchanged: all three stay reachable.
52 // The property under test is unchanged: all three stay reachable. The
53 // nav is now data-driven (REPO_NAV_AI_GROUPS), so assert against the
54 // canonical array rather than source-string literals.
5355 it("RepoNav's AI groups carry all three hrefs", async () => {
54 const src = await readSync("src/views/components.tsx");
55 expect(src).toContain("`${base}/migrate`");
56 expect(src).toContain("`${base}/docs/tracking`");
57 expect(src).toContain("`${base}/ai/changelog`");
56 const { REPO_NAV_AI_GROUPS } = await import("../views/components");
57 const paths = REPO_NAV_AI_GROUPS.flatMap((g) => g.items.map((i) => i.path));
58 expect(paths).toContain("/migrate");
59 expect(paths).toContain("/docs/tracking");
60 expect(paths).toContain("/ai/changelog");
5861 });
5962});
6063
Addedsrc/__tests__/repo-nav-coherence.test.ts+203−0View fileUnifiedSplit
1/**
2 * Repo-nav coherence guard — iterates DATA, not source strings.
3 *
4 * The predecessor (nav-tab-fixes.test.ts) asserted source-string literals
5 * and passed for weeks while the real defects persisted: RepoNav's `active`
6 * union carried two dead members ('changelog', 'semantic') no menu item
7 * rendered, was missing nine keys the menus DID render, seven insights
8 * pages hand-rolled mutually inconsistent sub-navs, and half a dozen
9 * repo pages rendered no repo nav (or identity) at all.
10 *
11 * This suite renders the actual components from the canonical exported
12 * arrays (REPO_NAV_* / INSIGHTS_SUBNAV_ITEMS in src/views/components.tsx),
13 * so a key that exists in data but doesn't render — or renders but can't be
14 * activated — fails here, no string-matching involved.
15 */
16
17import { describe, it, expect } from "bun:test";
18import {
19 RepoNav,
20 InsightsSubNav,
21 REPO_NAV_PRIMARY,
22 REPO_NAV_AI_GROUPS,
23 REPO_NAV_MORE,
24 REPO_NAV_SETTINGS,
25 INSIGHTS_SUBNAV_ITEMS,
26 type RepoNavKey,
27 type RepoNavItemDef,
28 type InsightsSubNavKey,
29} from "../views/components";
30
31// ─── canonical key inventory ────────────────────────────────────────────────
32
33const allNavItems: readonly RepoNavItemDef[] = [
34 ...REPO_NAV_PRIMARY,
35 ...REPO_NAV_AI_GROUPS.flatMap((g) => [...g.items]),
36 ...REPO_NAV_MORE,
37 REPO_NAV_SETTINGS,
38];
39const allNavKeys = allNavItems.map((it) => it.key) as RepoNavKey[];
40
41/** Render a (sync) hono/jsx component invocation to an HTML string. */
42function render(el: unknown): string {
43 const html = String(el);
44 if (html.includes("[object")) throw new Error("component did not render to HTML");
45 return html;
46}
47
48function renderRepoNav(active: RepoNavKey): string {
49 // currentUser === repoOwner so owner-gated items (Traffic) render too.
50 return render(
51 RepoNav({ owner: "o", repo: "r", active, currentUser: "o", repoOwner: "o" })
52 );
53}
54
55const countActive = (html: string) =>
56 (html.match(/aria-current="page"/g) ?? []).length;
57
58// ─── (a) active union ⇄ rendered menu/tab keys, both directions ─────────────
59
60describe("RepoNav key/menu coherence", () => {
61 it("keys are unique across primary, AI groups, More, and Settings", () => {
62 const seen = new Map<string, number>();
63 for (const k of allNavKeys) seen.set(k, (seen.get(k) ?? 0) + 1);
64 const dupes = [...seen.entries()].filter(([, n]) => n > 1);
65 expect(dupes).toEqual([]);
66 });
67
68 it("every canonical key renders exactly one aria-current match when active", () => {
69 // This is the two-way guarantee at runtime: the `active` union is
70 // derived from these same arrays (so no dead union members can exist),
71 // and here every key in the arrays must actually reach a rendered link.
72 for (const key of allNavKeys) {
73 const html = renderRepoNav(key);
74 expect(`${key}:${countActive(html)}`).toBe(`${key}:1`);
75 }
76 });
77
78 it("the active link's href is the item's canonical path", () => {
79 for (const it_ of allNavItems) {
80 const html = renderRepoNav(it_.key as RepoNavKey);
81 const href = `/o/r${it_.path}`.replace(/&/g, "&amp;");
82 // the marked link and the canonical href appear in the same anchor
83 const anchorRe = new RegExp(
84 `<a[^>]*href="${href.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"[^>]*aria-current="page"`
85 );
86 expect(anchorRe.test(html)).toBe(true);
87 }
88 });
89
90 it("the dead union members of the old hand-written union stay dead", () => {
91 // 'changelog' and 'semantic' matched no rendered menu key; they must
92 // not resurface in the canonical data (their real keys are
93 // 'ai-changelog' and 'ai-search').
94 expect(allNavKeys).not.toContain("changelog");
95 expect(allNavKeys).not.toContain("semantic");
96 });
97
98 it("owner-gated items disappear for non-owners without breaking others", () => {
99 const anon = render(RepoNav({ owner: "o", repo: "r", active: "code" }));
100 expect(anon).not.toContain("/o/r/traffic");
101 expect(countActive(anon)).toBe(1);
102 });
103});
104
105// ─── (b) InsightsSubNav covers all seven destinations ───────────────────────
106
107describe("InsightsSubNav coverage", () => {
108 const SEVEN: InsightsSubNavKey[] = [
109 "dora",
110 "velocity",
111 "health",
112 "hot-files",
113 "bus-factor",
114 "test-gaps",
115 "engineering",
116 ];
117
118 it("the canonical item list contains all seven insights destinations", () => {
119 const keys = INSIGHTS_SUBNAV_ITEMS.map((it_) => it_.key);
120 for (const k of SEVEN) expect(keys).toContain(k);
121 });
122
123 it("every item renders, and each key activates exactly one link", () => {
124 for (const it_ of INSIGHTS_SUBNAV_ITEMS) {
125 const html = render(
126 InsightsSubNav({ owner: "o", repo: "r", active: it_.key })
127 );
128 expect(countActive(html)).toBe(1);
129 expect(html).toContain(`href="/o/r${it_.path}"`);
130 expect(html).toContain(`aria-current="page"`);
131 }
132 });
133
134 it("keys are unique", () => {
135 const keys = INSIGHTS_SUBNAV_ITEMS.map((it_) => it_.key);
136 expect(new Set(keys).size).toBe(keys.length);
137 });
138});
139
140// ─── (c) restored pages actually mount the shared chrome ────────────────────
141
142describe("restored pages render RepoNav (and RepoHeader where identity was missing)", () => {
143 // Source-level assertions are acceptable here per the audit brief — but on
144 // the JSX call sites, not arbitrary strings.
145 const RENDERS_REPONAV = [
146 "src/routes/deployments.tsx",
147 "src/routes/wikis.tsx",
148 "src/routes/ask.tsx",
149 "src/routes/claude-web.tsx",
150 "src/routes/dev-env.tsx",
151 "src/routes/fork.tsx",
152 ];
153 // These four had NO repo identity at all — RepoHeader is the critical part.
154 const RENDERS_REPOHEADER = [
155 "src/routes/ask.tsx",
156 "src/routes/claude-web.tsx",
157 "src/routes/dev-env.tsx",
158 "src/routes/fork.tsx",
159 ];
160
161 for (const file of RENDERS_REPONAV) {
162 it(`${file} renders <RepoNav`, async () => {
163 const src = await Bun.file(file).text();
164 expect(src).toContain("<RepoNav ");
165 });
166 }
167
168 for (const file of RENDERS_REPOHEADER) {
169 it(`${file} renders <RepoHeader`, async () => {
170 const src = await Bun.file(file).text();
171 expect(src).toContain("<RepoHeader ");
172 });
173 }
174
175 it("deployments.tsx mounts RepoNav on BOTH the /deployments and /cloud-deployments pages", async () => {
176 const src = await Bun.file("src/routes/deployments.tsx").text();
177 const n = (src.match(/<RepoNav [^>]*active="deployments"/g) ?? []).length;
178 expect(n).toBeGreaterThanOrEqual(2);
179 });
180});
181
182// ─── the seven insights pages all use the ONE canonical sub-nav ─────────────
183
184describe("insights pages use InsightsSubNav, not hand-rolled strips", () => {
185 const PAGES = [
186 "src/routes/dora.tsx",
187 "src/routes/velocity.tsx",
188 "src/routes/health-score.tsx",
189 "src/routes/hot-files.tsx",
190 "src/routes/bus-factor.tsx",
191 "src/routes/test-gaps.tsx",
192 "src/routes/engineering-insights.tsx",
193 ];
194
195 for (const file of PAGES) {
196 it(`${file} renders <InsightsSubNav`, async () => {
197 const src = await Bun.file(file).text();
198 expect(src).toContain("<InsightsSubNav ");
199 // and no leftover per-page subnav class strips
200 expect(src).not.toMatch(/class="\w+-subnav"/);
201 });
202 }
203});
Modifiedsrc/routes/ai-changelog.tsx+3−4View fileUnifiedSplit
1919
2020import { Hono } from "hono";
2121import { Layout } from "../views/layout";
22import { RepoHeader } from "../views/components";
23import { IssueNav } from "./issues";
22import { RepoHeader, RepoNav } from "../views/components";
2423import {
2524 listBranches,
2625 listTags,
635634 c.html(
636635 <Layout title={`AI Changelog — ${owner}/${repo}`} user={user}>
637636 <RepoHeader owner={owner} repo={repo} />
638 <IssueNav owner={owner} repo={repo} active="code" />
637 <RepoNav owner={owner} repo={repo} active="ai-changelog" />
639638 <div class="ai-changelog-wrap">
640639 <ChangelogHero />
641640
813812 return c.html(
814813 <Layout title={`AI Changelog — ${owner}/${repo}`} user={user}>
815814 <RepoHeader owner={owner} repo={repo} />
816 <IssueNav owner={owner} repo={repo} active="code" />
815 <RepoNav owner={owner} repo={repo} active="ai-changelog" />
817816 <div class="ai-changelog-wrap">
818817 <ChangelogHero />
819818
Modifiedsrc/routes/ai-explain.tsx+3−4View fileUnifiedSplit
2828import { db } from "../db";
2929import { repositories, users } from "../db/schema";
3030import { Layout } from "../views/layout";
31import { RepoHeader } from "../views/components";
32import { IssueNav } from "./issues";
31import { RepoHeader, RepoNav } from "../views/components";
3332import { renderMarkdown } from "../lib/markdown";
3433import { softAuth, requireAuth } from "../middleware/auth";
3534import type { AuthEnv } from "../middleware/auth";
527526 return c.html(
528527 <Layout title={`Explain — ${owner}/${repo}`} user={user}>
529528 <RepoHeader owner={owner} repo={repo} />
530 <IssueNav owner={owner} repo={repo} active="code" />
529 <RepoNav owner={owner} repo={repo} active="explain" />
531530 <div class="ai-explain-wrap">
532531 <section class="ai-explain-hero">
533532 <div class="ai-explain-hero-orb" aria-hidden="true" />
590589 return c.html(
591590 <Layout title={`Explain — ${owner}/${repo}`} user={user}>
592591 <RepoHeader owner={owner} repo={repo} />
593 <IssueNav owner={owner} repo={repo} active="code" />
592 <RepoNav owner={owner} repo={repo} active="explain" />
594593 <div class="ai-explain-wrap">
595594 <section class="ai-explain-hero">
596595 <div class="ai-explain-hero-orb" aria-hidden="true" />
Modifiedsrc/routes/ai-tests.tsx+4−5View fileUnifiedSplit
2828import { db } from "../db";
2929import { repositories, users } from "../db/schema";
3030import { Layout } from "../views/layout";
31import { RepoHeader } from "../views/components";
32import { IssueNav } from "./issues";
31import { RepoHeader, RepoNav } from "../views/components";
3332import { softAuth, requireAuth } from "../middleware/auth";
3433import type { AuthEnv } from "../middleware/auth";
3534import {
741740 return c.html(
742741 <Layout title={`AI tests — ${owner}/${repo}`} user={user}>
743742 <RepoHeader owner={owner} repo={repo} />
744 <IssueNav owner={owner} repo={repo} active="code" />
743 <RepoNav owner={owner} repo={repo} active="tests" />
745744 <div class="ai-tests-wrap">
746745 <TestsHero />
747746
824823 return c.html(
825824 <Layout title={`AI tests — ${owner}/${repo}`} user={user}>
826825 <RepoHeader owner={owner} repo={repo} />
827 <IssueNav owner={owner} repo={repo} active="code" />
826 <RepoNav owner={owner} repo={repo} active="tests" />
828827 <div class="ai-tests-wrap">
829828 <TestsHero eyebrowExtra="error" />
830829 <div class="ai-tests-empty">
869868 return c.html(
870869 <Layout title={`AI tests — ${owner}/${repo}`} user={user}>
871870 <RepoHeader owner={owner} repo={repo} />
872 <IssueNav owner={owner} repo={repo} active="code" />
871 <RepoNav owner={owner} repo={repo} active="tests" />
873872 <div class="ai-tests-wrap">
874873 <TestsHero eyebrowExtra={path} />
875874
Modifiedsrc/routes/ask.tsx+15−0View fileUnifiedSplit
2626import { db } from "../db";
2727import { aiChats, repositories, users } from "../db/schema";
2828import { Layout } from "../views/layout";
29import { RepoHeader, RepoNav } from "../views/components";
2930import { requireAuth, softAuth } from "../middleware/auth";
3031import type { AuthEnv } from "../middleware/auth";
3132import { chat, explainFile } from "../lib/ai-chat";
6465 recentChats,
6566 user,
6667 unreadCount,
68 repoCtx,
6769 }: {
6870 messages: ChatMessage[];
6971 postUrl: string;
7375 recentChats?: Array<{ id: string; title: string | null; updatedAt: Date }>;
7476 user: any;
7577 unreadCount: number;
78 /** Repo-grounded chats render the repo identity + nav above the chat. */
79 repoCtx?: { owner: string; repo: string };
7680 }
7781) {
7882 return c.html(
7983 <Layout title={title} user={user} notificationCount={unreadCount}>
8084 <style dangerouslySetInnerHTML={{ __html: askCss }} />
85 {repoCtx && (
86 <>
87 <RepoHeader owner={repoCtx.owner} repo={repoCtx.repo} />
88 <RepoNav owner={repoCtx.owner} repo={repoCtx.repo} active="ask" />
89 </>
90 )}
8191 <div class="ask-page">
8292 {/* Hero (2026 polish: hairline + orb + grad headline) */}
8393 <header class="ask-hero">
739749 placeholder: "Continue the conversation...",
740750 user,
741751 unreadCount: unread,
752 repoCtx:
753 resumed.repoOwner && resumed.repoName
754 ? { owner: resumed.repoOwner, repo: resumed.repoName }
755 : undefined,
742756 });
743757});
744758
817831 recentChats: recent,
818832 user,
819833 unreadCount: unread,
834 repoCtx: { owner, repo },
820835 });
821836});
822837
Modifiedsrc/routes/bus-factor.tsx+2−34View fileUnifiedSplit
1717import { softAuth, requireAuth } from "../middleware/auth";
1818import { requireRepoAccess } from "../middleware/repo-access";
1919import { Layout } from "../views/layout";
20import { RepoHeader, RepoNav } from "../views/components";
20import { RepoHeader, RepoNav, InsightsSubNav } from "../views/components";
2121import { getUnreadCount } from "../lib/unread";
2222import {
2323 analyzeBusFactor,
3636 padding: var(--space-5) var(--space-4);
3737 }
3838
39 /* Insights sub-navigation */
40 .bf-subnav {
41 display: flex;
42 gap: 4px;
43 margin-bottom: var(--space-5);
44 border-bottom: 1px solid var(--border);
45 padding-bottom: 0;
46 }
47 .bf-subnav-link {
48 padding: 8px 14px;
49 font-size: 13px;
50 font-weight: 500;
51 color: var(--text-muted);
52 text-decoration: none;
53 border-bottom: 2px solid transparent;
54 margin-bottom: -1px;
55 transition: color 120ms ease, border-color 120ms ease;
56 border-radius: 4px 4px 0 0;
57 }
58 .bf-subnav-link:hover { color: var(--text); }
59 .bf-subnav-link.active {
60 color: var(--accent, var(--accent));
61 border-bottom-color: var(--accent, var(--accent));
62 }
63
6439 /* Hero */
6540 .bf-hero {
6641 position: relative;
432407 <RepoHeader owner={ownerName} repo={repoName} />
433408 <RepoNav owner={ownerName} repo={repoName} active="insights" />
434409
435 {/* Sub-navigation */}
436 <nav class="bf-subnav">
437 <a class="bf-subnav-link" href={`/${ownerName}/${repoName}/insights`}>Overview</a>
438 <a class="bf-subnav-link" href={`/${ownerName}/${repoName}/insights/health`}>Health</a>
439 <a class="bf-subnav-link" href={`/${ownerName}/${repoName}/insights/velocity`}>Velocity</a>
440 <a class="bf-subnav-link" href={`/${ownerName}/${repoName}/insights/hotfiles`}>Hot Files</a>
441 <a class="bf-subnav-link active" href={`/${ownerName}/${repoName}/insights/bus-factor`}>Bus Factor</a>
442 </nav>
410 <InsightsSubNav owner={ownerName} repo={repoName} active="bus-factor" />
443411
444412 {/* Hero */}
445413 <div class="bf-hero">
Modifiedsrc/routes/claude-web.tsx+3−5View fileUnifiedSplit
2323import { db } from "../db";
2424import { repositories, users } from "../db/schema";
2525import { Layout } from "../views/layout";
26import { RepoHeader, RepoNav } from "../views/components";
2627import { softAuth } from "../middleware/auth";
2728import type { AuthEnv } from "../middleware/auth";
2829import { resolveRepoAccess } from "../middleware/repo-access";
105106
106107 return c.html(
107108 <Layout title={`Claude — ${g.ownerName}/${g.repoName}`} user={user}>
109 <RepoHeader owner={g.ownerName} repo={g.repoName} />
110 <RepoNav owner={g.ownerName} repo={g.repoName} active="chat" />
108111 <main style={wrap}>
109 <p style="margin:0 0 6px">
110 <a href={`/${g.ownerName}/${g.repoName}`} style="color:#9ca3af;text-decoration:none">
111 ← {g.ownerName}/{g.repoName}
112 </a>
113 </p>
114112 <h1 style="margin:0 0 4px;font-size:22px">✨ Claude Code Sessions</h1>
115113 <p style="margin:0 0 20px;color:#9ca3af;font-size:14px">
116114 Browser-based Claude Code sessions on a live clone of this repo. Each session
Modifiedsrc/routes/contributors.tsx+1−1View fileUnifiedSplit
9898 return c.html(
9999 <Layout title={`Contributors — ${owner}/${repo}`} user={user}>
100100 <RepoHeader owner={owner} repo={repo} />
101 <RepoNav owner={owner} repo={repo} active="code" />
101 <RepoNav owner={owner} repo={repo} active="contributors" />
102102 <div class="contrib-wrap">
103103 <PageHeader
104104 eyebrow="Repository · Contributors"
Modifiedsrc/routes/deployments.tsx+3−1View fileUnifiedSplit
2828import type { AuthEnv } from "../middleware/auth";
2929import { softAuth, requireAuth } from "../middleware/auth";
3030import { Layout } from "../views/layout";
31import { RepoHeader } from "../views/components";
31import { RepoHeader, RepoNav } from "../views/components";
3232import { onDeployFailure } from "../lib/ai-incident";
3333import { encryptValue } from "../lib/server-targets-crypto";
3434
521521 return c.html(
522522 <Layout title={`${owner}/${repo} — deployments`} user={user}>
523523 <RepoHeader owner={owner} repo={repo} />
524 <RepoNav owner={owner} repo={repo} active="deployments" />
524525 <div class="dk-wrap">
525526 <header class="dk-head">
526527 <div class="dk-eyebrow">
12291230 return c.html(
12301231 <Layout title={`${owner}/${repo} — cloud deployments`} user={user}>
12311232 <RepoHeader owner={owner} repo={repo} />
1233 <RepoNav owner={owner} repo={repo} active="deployments" />
12321234 <div class="cds-wrap">
12331235 <header
12341236 class="cds-head"
Modifiedsrc/routes/dev-env.tsx+9−0View fileUnifiedSplit
2727 satisfiesAccess,
2828} from "../middleware/repo-access";
2929import { Layout } from "../views/layout";
30import { RepoHeader, RepoNav } from "../views/components";
3031import {
3132 buildDevEnvUrl,
3233 devEnvStatusLabel,
456457 __html: devEnvStyles + hostCapabilityNoticeCss,
457458 }}
458459 />
460 <RepoHeader owner={resolved.ownerName} repo={resolved.repoName} />
461 <RepoNav owner={resolved.ownerName} repo={resolved.repoName} active="dev" />
459462 <div class="dev-env-wrap">
460463 <div class="dev-env-card">
461464 <div class="dev-env-eyebrow">
506509 user={user ?? null}
507510 >
508511 <style dangerouslySetInnerHTML={{ __html: devEnvStyles }} />
512 <RepoHeader owner={resolved.ownerName} repo={resolved.repoName} />
513 <RepoNav owner={resolved.ownerName} repo={resolved.repoName} active="dev" />
509514 <div class="dev-env-wrap">
510515 <div class="dev-env-card">
511516 <div class="dev-env-eyebrow">
565570 user={user}
566571 >
567572 <style dangerouslySetInnerHTML={{ __html: devEnvStyles }} />
573 <RepoHeader owner={resolved.ownerName} repo={resolved.repoName} />
574 <RepoNav owner={resolved.ownerName} repo={resolved.repoName} active="dev" />
568575 <div class="dev-env-wrap">
569576 <div class="dev-env-card">
570577 <div class="dev-env-eyebrow">
737744 user={user}
738745 >
739746 <style dangerouslySetInnerHTML={{ __html: devEnvStyles }} />
747 <RepoHeader owner={resolved.ownerName} repo={resolved.repoName} />
748 <RepoNav owner={resolved.ownerName} repo={resolved.repoName} active="dev" />
740749 <div class="dev-env-wrap">
741750 <div class="dev-env-card">
742751 <div class="dev-env-eyebrow">
Modifiedsrc/routes/docs-tracking.tsx+1−1View fileUnifiedSplit
381381 forkCount={repoRow.forkCount}
382382 currentUser={user?.username}
383383 />
384 <RepoNav owner={owner} repo={repo} active="code" />
384 <RepoNav owner={owner} repo={repo} active="docs-tracking" />
385385
386386 <div class="doctrk-wrap">
387387 <section class="doctrk-head">
Modifiedsrc/routes/dora.tsx+6−1View fileUnifiedSplit
2626import { softAuth } from "../middleware/auth";
2727import { requireRepoAccess } from "../middleware/repo-access";
2828import { Layout } from "../views/layout";
29import { RepoHeader, RepoNav } from "../views/components";
29import { RepoHeader, RepoNav, InsightsSubNav } from "../views/components";
3030
3131const doraRoutes = new Hono<AuthEnv>();
3232
500500 <RepoHeader owner={owner} repo={repo} />
501501 <RepoNav owner={owner} repo={repo} active="insights" />
502502
503 {/* Insights sub-nav — DORA used to be the one insights page with
504 no strip at all: a terminal node you could enter but not
505 navigate out of. */}
506 <InsightsSubNav owner={owner} repo={repo} active="dora" />
507
503508 {/* Hero */}
504509 <div class="dora-hero">
505510 <div class="dora-eyebrow">DevOps Research &amp; Assessment</div>
Modifiedsrc/routes/engineering-insights.tsx+2−20View fileUnifiedSplit
4141 avg,
4242} from "drizzle-orm";
4343import { Layout } from "../views/layout";
44import { RepoHeader, RepoNav } from "../views/components";
44import { RepoHeader, RepoNav, InsightsSubNav } from "../views/components";
4545import { softAuth, requireAuth } from "../middleware/auth";
4646import type { AuthEnv } from "../middleware/auth";
4747import { getUnreadCount } from "../lib/unread";
16091609 </div>
16101610 </section>
16111611
1612 {/* Insights sub-nav */}
1613 <div style="display:flex;gap:4px;margin-bottom:var(--space-5);border-bottom:1px solid var(--border);padding-bottom:0">
1614 <a href={`/${owner}/${repo}/insights`} style="padding:8px 14px;font-size:13px;font-weight:500;color:var(--text-muted);text-decoration:none;border-bottom:2px solid transparent;margin-bottom:-1px">
1615 Code Intelligence
1616 </a>
1617 <a href={`/${owner}/${repo}/insights/velocity`} style="padding:8px 14px;font-size:13px;font-weight:500;color:var(--text-muted);text-decoration:none;border-bottom:2px solid transparent;margin-bottom:-1px">
1618 Velocity
1619 </a>
1620 <a href={`/${owner}/${repo}/insights/dora`} style="padding:8px 14px;font-size:13px;font-weight:500;color:var(--text-muted);text-decoration:none;border-bottom:2px solid transparent;margin-bottom:-1px">
1621 DORA
1622 </a>
1623 <a href={`/${owner}/${repo}/insights/health`} style="padding:8px 14px;font-size:13px;font-weight:500;color:var(--text-muted);text-decoration:none;border-bottom:2px solid transparent;margin-bottom:-1px">
1624 Health
1625 </a>
1626 <a href={`/${owner}/${repo}/insights/engineering`}
1627 style="padding:8px 14px;font-size:13px;font-weight:500;color:var(--accent,var(--accent));text-decoration:none;border-bottom:2px solid var(--accent,var(--accent));margin-bottom:-1px">
1628 Engineering
1629 </a>
1630 </div>
1612 <InsightsSubNav owner={owner} repo={repo} active="engineering" />
16311613
16321614 {/* KPI Row */}
16331615 <div class="ei-kpi-row">
Modifiedsrc/routes/fork.tsx+5−0View fileUnifiedSplit
2828import { config } from "../lib/config";
2929import { join } from "path";
3030import { Layout } from "../views/layout";
31import { RepoHeader, RepoNav } from "../views/components";
3132
3233const fork = new Hono<AuthEnv>();
3334
459460 user={user}
460461 >
461462 <style dangerouslySetInnerHTML={{ __html: forkStyles }} />
463 {/* Repo identity + nav for the SOURCE repo being forked — this page
464 previously had neither, so it floated free of any repo context. */}
465 <RepoHeader owner={ownerName} repo={repoName} />
466 <RepoNav owner={ownerName} repo={repoName} active="code" />
462467 <div class="fork-wrap">
463468 {/* ─── Hero ─── */}
464469 <div class="fork-hero">
Modifiedsrc/routes/health-score.tsx+2−56View fileUnifiedSplit
2020import { softAuth } from "../middleware/auth";
2121import { requireRepoAccess } from "../middleware/repo-access";
2222import { Layout } from "../views/layout";
23import { RepoHeader, RepoNav } from "../views/components";
23import { RepoHeader, RepoNav, InsightsSubNav } from "../views/components";
2424import { getUnreadCount } from "../lib/unread";
2525import { computeHealthScore } from "../lib/health-score";
2626
3535 padding: var(--space-5) var(--space-4);
3636 }
3737
38 /* Insights sub-navigation — mirrors .vel-subnav */
39 .hs-subnav {
40 display: flex;
41 gap: 4px;
42 margin-bottom: var(--space-5);
43 border-bottom: 1px solid var(--border);
44 padding-bottom: 0;
45 }
46 .hs-subnav-link {
47 padding: 8px 14px;
48 font-size: 13px;
49 font-weight: 500;
50 color: var(--text-muted);
51 text-decoration: none;
52 border-bottom: 2px solid transparent;
53 margin-bottom: -1px;
54 transition: color 120ms ease, border-color 120ms ease;
55 border-radius: 4px 4px 0 0;
56 }
57 .hs-subnav-link:hover { color: var(--text); }
58 .hs-subnav-link.active {
59 color: var(--accent, var(--accent));
60 border-bottom-color: var(--accent, var(--accent));
61 }
62
6338 /* Hero card */
6439 .hs-hero {
6540 position: relative;
320295 <RepoHeader owner={owner} repo={repo} />
321296 <RepoNav owner={owner} repo={repo} active="insights" />
322297
323 {/* Insights sub-nav */}
324 <div class="hs-subnav">
325 <a href={`/${owner}/${repo}/insights`} class="hs-subnav-link">
326 Insights
327 </a>
328 <a href={`/${owner}/${repo}/insights/dora`} class="hs-subnav-link">
329 DORA
330 </a>
331 <a
332 href={`/${owner}/${repo}/insights/velocity`}
333 class="hs-subnav-link"
334 >
335 Velocity
336 </a>
337 <a href={`/${owner}/${repo}/pulse`} class="hs-subnav-link">
338 Pulse
339 </a>
340 <a
341 href={`/${owner}/${repo}/insights/health`}
342 class="hs-subnav-link active"
343 >
344 Health
345 </a>
346 <a
347 href={`/${owner}/${repo}/insights/hotfiles`}
348 class="hs-subnav-link"
349 >
350 Hot Files
351 </a>
352 </div>
298 <InsightsSubNav owner={owner} repo={repo} active="health" />
353299
354300 {/* Hero — gauge + title + grade */}
355301 <div class="hs-hero">
Modifiedsrc/routes/hot-files.tsx+2−53View fileUnifiedSplit
1616import { softAuth } from "../middleware/auth";
1717import { requireRepoAccess } from "../middleware/repo-access";
1818import { Layout } from "../views/layout";
19import { RepoHeader, RepoNav } from "../views/components";
19import { RepoHeader, RepoNav, InsightsSubNav } from "../views/components";
2020import { getUnreadCount } from "../lib/unread";
2121import { getHotFiles } from "../lib/hot-files";
2222
3131 padding: var(--space-5) var(--space-4);
3232 }
3333
34 /* Insights sub-navigation */
35 .hf-subnav {
36 display: flex;
37 gap: 4px;
38 margin-bottom: var(--space-5);
39 border-bottom: 1px solid var(--border);
40 padding-bottom: 0;
41 }
42 .hf-subnav-link {
43 padding: 8px 14px;
44 font-size: 13px;
45 font-weight: 500;
46 color: var(--text-muted);
47 text-decoration: none;
48 border-bottom: 2px solid transparent;
49 margin-bottom: -1px;
50 transition: color 120ms ease, border-color 120ms ease;
51 border-radius: 4px 4px 0 0;
52 }
53 .hf-subnav-link:hover { color: var(--text); }
54 .hf-subnav-link.active {
55 color: var(--accent, var(--accent));
56 border-bottom-color: var(--accent, var(--accent));
57 }
58
5934 /* Hero */
6035 .hf-hero {
6136 position: relative;
323298 <RepoHeader owner={owner} repo={repo} />
324299 <RepoNav owner={owner} repo={repo} active="insights" />
325300
326 {/* Insights sub-nav */}
327 <div class="hf-subnav">
328 <a href={`/${owner}/${repo}/insights`} class="hf-subnav-link">
329 Insights
330 </a>
331 <a href={`/${owner}/${repo}/insights/dora`} class="hf-subnav-link">
332 DORA
333 </a>
334 <a
335 href={`/${owner}/${repo}/insights/velocity`}
336 class="hf-subnav-link"
337 >
338 Velocity
339 </a>
340 <a href={`/${owner}/${repo}/pulse`} class="hf-subnav-link">
341 Pulse
342 </a>
343 <a href={`/${owner}/${repo}/insights/health`} class="hf-subnav-link">
344 Health
345 </a>
346 <a
347 href={`/${owner}/${repo}/insights/hotfiles`}
348 class="hf-subnav-link active"
349 >
350 Hot Files
351 </a>
352 </div>
301 <InsightsSubNav owner={owner} repo={repo} active="hot-files" />
353302
354303 {/* Hero */}
355304 <div class="hf-hero">
Modifiedsrc/routes/issues.tsx+6−6View fileUnifiedSplit
22482248);
22492249
22502250// Shared nav component with issues tab
2251// Thin wrapper over the shared RepoNav so this page — and the ~7 other
2252// route files that import IssueNav (ai-explain, ai-tests, compare,
2253// nl-search, semantic-search, migration-assistant, ai-changelog) — all
2254// render the ONE canonical repo nav instead of a stripped 3-tab bar.
2255// Previously a deliberate minimal nav because RepoNav was treated as
2256// locked; owner-directed nav unification this session reverses that.
2251// Thin wrapper over the shared RepoNav so this page — and the route files
2252// that still import IssueNav (compare, migration-assistant) — render the
2253// ONE canonical repo nav instead of a stripped 3-tab bar. Pages that own a
2254// real RepoNav key (ai-explain, ai-tests, nl-search, semantic-search,
2255// ai-changelog) now call RepoNav directly with their own key instead of
2256// funnelling through this wrapper with active="code".
22572257const IssueNav = ({
22582258 owner,
22592259 repo,
Modifiedsrc/routes/nl-search.tsx+2−3View fileUnifiedSplit
1616import { db } from "../db";
1717import { repositories, users } from "../db/schema";
1818import { Layout } from "../views/layout";
19import { RepoHeader } from "../views/components";
20import { IssueNav } from "./issues";
19import { RepoHeader, RepoNav } from "../views/components";
2120import { softAuth } from "../middleware/auth";
2221import type { AuthEnv } from "../middleware/auth";
2322import { nlSearch } from "../lib/nl-search";
477476 return c.html(
478477 <Layout title={`NL Search — ${ownerName}/${repoName}`} user={user}>
479478 <RepoHeader owner={ownerName} repo={repoName} />
480 <IssueNav owner={ownerName} repo={repoName} active="code" />
479 <RepoNav owner={ownerName} repo={repoName} active="nl-search" />
481480
482481 <div class="nl-wrap">
483482 {/* ─── Header ─── */}
Modifiedsrc/routes/previews.tsx+1−1View fileUnifiedSplit
463463 forkCount={repoRow.forkCount}
464464 currentUser={user?.username}
465465 />
466 <RepoNav owner={owner} repo={repo} active="code" />
466 <RepoNav owner={owner} repo={repo} active="previews" />
467467
468468 <div class="preview-wrap">
469469 <section class="preview-head">
Modifiedsrc/routes/semantic-search.tsx+3−4View fileUnifiedSplit
99 * "Build index" CTA pointing at the reindex endpoint.
1010 *
1111 * 2026 polish:
12 * - Scoped `.ss-*` CSS — sits below RepoHeader + IssueNav.
12 * - Scoped `.ss-*` CSS — sits below RepoHeader + RepoNav.
1313 * - Eyebrow + display headline + 1-line subtitle.
1414 * - Prominent search input w/ focus ring + gradient submit button.
1515 * - Result cards show file:line in mono, snippet, and match score chip.
2323import { db } from "../db";
2424import { repositories, users, codeChunks } from "../db/schema";
2525import { Layout } from "../views/layout";
26import { RepoHeader } from "../views/components";
27import { IssueNav } from "./issues";
26import { RepoHeader, RepoNav } from "../views/components";
2827import { softAuth, requireAuth } from "../middleware/auth";
2928import type { AuthEnv } from "../middleware/auth";
3029import {
486485 return c.html(
487486 <Layout title={`Semantic search — ${ownerName}/${repoName}`} user={user}>
488487 <RepoHeader owner={ownerName} repo={repoName} />
489 <IssueNav owner={ownerName} repo={repoName} active="code" />
488 <RepoNav owner={ownerName} repo={repoName} active="ai-search" />
490489
491490 <div class="ss-wrap">
492491 <header class="ss-head">
Modifiedsrc/routes/test-gaps.tsx+2−35View fileUnifiedSplit
1616import { softAuth, requireAuth } from "../middleware/auth";
1717import { requireRepoAccess } from "../middleware/repo-access";
1818import { Layout } from "../views/layout";
19import { RepoHeader, RepoNav } from "../views/components";
19import { RepoHeader, RepoNav, InsightsSubNav } from "../views/components";
2020import { getUnreadCount } from "../lib/unread";
2121import { getTestGaps, getCachedTestGaps, ensureTestGapsAnalysis, clearTestGapsCache, type TestGap, type TestGapReport } from "../lib/test-gaps";
2222
3131 padding: var(--space-5) var(--space-4);
3232 }
3333
34 /* Insights sub-navigation */
35 .tg-subnav {
36 display: flex;
37 gap: 4px;
38 margin-bottom: var(--space-5);
39 border-bottom: 1px solid var(--border);
40 padding-bottom: 0;
41 }
42 .tg-subnav-link {
43 padding: 8px 14px;
44 font-size: 13px;
45 font-weight: 500;
46 color: var(--text-muted);
47 text-decoration: none;
48 border-bottom: 2px solid transparent;
49 margin-bottom: -1px;
50 transition: color 120ms ease, border-color 120ms ease;
51 border-radius: 4px 4px 0 0;
52 }
53 .tg-subnav-link:hover { color: var(--text); }
54 .tg-subnav-link.active {
55 color: var(--accent, var(--accent));
56 border-bottom-color: var(--accent, var(--accent));
57 }
58
5934 /* Hero */
6035 .tg-hero {
6136 position: relative;
349324 <RepoHeader owner={ownerName} repo={repoName} />
350325 <RepoNav owner={ownerName} repo={repoName} active="insights" />
351326
352 {/* Sub-navigation */}
353 <nav class="tg-subnav">
354 <a class="tg-subnav-link" href={`/${ownerName}/${repoName}/insights`}>Overview</a>
355 <a class="tg-subnav-link" href={`/${ownerName}/${repoName}/insights/health`}>Health</a>
356 <a class="tg-subnav-link" href={`/${ownerName}/${repoName}/insights/velocity`}>Velocity</a>
357 <a class="tg-subnav-link" href={`/${ownerName}/${repoName}/insights/hotfiles`}>Hot Files</a>
358 <a class="tg-subnav-link" href={`/${ownerName}/${repoName}/insights/bus-factor`}>Bus Factor</a>
359 <a class="tg-subnav-link active" href={`/${ownerName}/${repoName}/insights/test-gaps`}>Test Gaps</a>
360 </nav>
327 <InsightsSubNav owner={ownerName} repo={repoName} active="test-gaps" />
361328
362329 {/* Hero */}
363330 <div class="tg-hero">
Modifiedsrc/routes/velocity.tsx+2−41View fileUnifiedSplit
2929import { softAuth } from "../middleware/auth";
3030import { requireRepoAccess } from "../middleware/repo-access";
3131import { Layout } from "../views/layout";
32import { RepoHeader, RepoNav } from "../views/components";
32import { RepoHeader, RepoNav, InsightsSubNav } from "../views/components";
3333import { getUnreadCount } from "../lib/unread";
3434
3535const velocityRoutes = new Hono<AuthEnv>();
4343 padding: var(--space-5) var(--space-4);
4444 }
4545
46 /* Insights sub-navigation */
47 .vel-subnav {
48 display: flex;
49 gap: 4px;
50 margin-bottom: var(--space-5);
51 border-bottom: 1px solid var(--border);
52 padding-bottom: 0;
53 }
54 .vel-subnav-link {
55 padding: 8px 14px;
56 font-size: 13px;
57 font-weight: 500;
58 color: var(--text-muted);
59 text-decoration: none;
60 border-bottom: 2px solid transparent;
61 margin-bottom: -1px;
62 transition: color 120ms ease, border-color 120ms ease;
63 border-radius: 4px 4px 0 0;
64 }
65 .vel-subnav-link:hover { color: var(--text); }
66 .vel-subnav-link.active {
67 color: var(--accent, var(--accent));
68 border-bottom-color: var(--accent, var(--accent));
69 }
70
7146 /* Hero */
7247 .vel-hero {
7348 position: relative;
534509 <RepoHeader owner={owner} repo={repo} />
535510 <RepoNav owner={owner} repo={repo} active="insights" />
536511
537 {/* Insights sub-nav */}
538 <div class="vel-subnav">
539 <a
540 href={`/${owner}/${repo}/insights/dora`}
541 class="vel-subnav-link"
542 >
543 DORA
544 </a>
545 <a
546 href={`/${owner}/${repo}/insights/velocity`}
547 class="vel-subnav-link active"
548 >
549 Velocity
550 </a>
551 </div>
512 <InsightsSubNav owner={owner} repo={repo} active="velocity" />
552513
553514 {/* Hero */}
554515 <div class="vel-hero">
Modifiedsrc/routes/wikis.tsx+2−1View fileUnifiedSplit
2525 users,
2626} from "../db/schema";
2727import { Layout } from "../views/layout";
28import { RepoHeader } from "../views/components";
28import { RepoHeader, RepoNav } from "../views/components";
2929import { renderMarkdown } from "../lib/markdown";
3030import { formatRelative } from "../views/ui";
3131import { softAuth, requireAuth } from "../middleware/auth";
642642 return c.html(
643643 <Layout title={`Wiki — ${ownerName}/${repoName}`} user={user}>
644644 <RepoHeader owner={ownerName} repo={repoName} />
645 <RepoNav owner={ownerName} repo={repoName} active="wiki" />
645646 <div class="wiki-wrap">
646647 <header class="wiki-head">
647648 <div class="wiki-head-text">
Modifiedsrc/views/components.tsx+223−124View fileUnifiedSplit
150150 );
151151};
152152
153/**
154 * Canonical repo navigation data — the ONLY place the repo IA is defined,
155 * mirroring ADMIN_NAV (admin-shell.tsx) and SETTINGS_NAV_GROUPS below.
156 * `path` is relative to `/:owner/:repo` ("" = repo home). RepoNav's
157 * `active` union is DERIVED from these arrays, so a key that exists in the
158 * type but renders nowhere (or vice versa) is impossible by construction —
159 * enforced at runtime too by src/__tests__/repo-nav-coherence.test.ts.
160 */
161export interface RepoNavItemDef {
162 key: string;
163 /** Href path under the repo base — "" is the repo home page. */
164 path: string;
165 label: string;
166 /** Rendered only when the viewer is the repo owner (e.g. Traffic). */
167 ownerOnly?: boolean;
168}
169export interface RepoNavGroupDef {
170 heading: string;
171 items: readonly RepoNavItemDef[];
172}
173
174// Settings is rendered separately, LAST in the row — after the AI and
175// More menus — because settings-last is the convention every forge user
176// already knows, and the menus belong beside the content tabs they extend.
177export const REPO_NAV_PRIMARY = [
178 { key: "code", path: "", label: "Code" },
179 { key: "issues", path: "/issues", label: "Issues" },
180 { key: "pulls", path: "/pulls", label: "Pull Requests" },
181 { key: "actions", path: "/actions", label: "Actions" },
182 { key: "security", path: "/security/vulnerabilities", label: "Security" },
183 { key: "insights", path: "/insights", label: "Insights" },
184] as const satisfies readonly RepoNavItemDef[];
185
186// One AI menu, grouped by what you're trying to do.
187//
188// There were two. This dropdown had eight items, and the repo home page
189// rendered a second "AI surfaces" strip underneath the nav with six more
190// (Chat, Previews, Migrations, AI Search, AI release notes, Dev
191// environment). Its own comment explained why: "RepoNav is locked, so the
192// discovery row sits just below the nav as a slim CTA strip." The lock was
193// lifted on 2026-07-24; the workaround outlived it.
194//
195// The cost of two menus is not just clutter — it is that neither is
196// trustworthy. "Is AI Search in the AI menu or the AI strip?" has no
197// learnable answer, so you check both every time.
198export const REPO_NAV_AI_GROUPS = [
199 {
200 heading: "Understand",
201 items: [
202 { key: "explain", path: "/explain", label: "Explain codebase" },
203 { key: "ask", path: "/ask", label: "Ask AI" },
204 { key: "chat", path: "/chat", label: "Chat with repo" },
205 { key: "nl-search", path: "/search/nl", label: "NL search" },
206 { key: "ai-search", path: "/search?mode=semantic", label: "Semantic search" },
207 { key: "archaeology", path: "/archaeology", label: "Archaeology" },
208 { key: "debt-map", path: "/debt-map", label: "Debt map" },
209 { key: "docs-tracking", path: "/docs/tracking", label: "Docs tracking" },
210 ],
211 },
212 {
213 heading: "Build",
214 items: [
215 { key: "spec", path: "/spec", label: "Spec to PR" },
216 { key: "workspace", path: "/workspace", label: "Workspace" },
217 { key: "tests", path: "/ai/tests", label: "Generate tests" },
218 { key: "migrations", path: "/migrations/propose", label: "Propose migration" },
219 { key: "dev", path: "/dev", label: "Dev environment" },
220 { key: "migrate", path: "/migrate", label: "Codebase migrator" },
221 ],
222 },
223 {
224 heading: "Ship",
225 items: [
226 { key: "previews", path: "/previews", label: "Preview URLs" },
227 { key: "ai-release-notes", path: "/releases/new", label: "AI release notes" },
228 { key: "ai-changelog", path: "/ai/changelog", label: "AI changelog" },
229 ],
230 },
231] as const satisfies readonly RepoNavGroupDef[];
232
233export const REPO_NAV_MORE = [
234 { key: "commits", path: "/commits", label: "Commits" },
235 { key: "discussions", path: "/discussions", label: "Discussions" },
236 { key: "wiki", path: "/wiki", label: "Wiki" },
237 { key: "projects", path: "/projects", label: "Projects" },
238 { key: "releases", path: "/releases", label: "Releases" },
239 { key: "packages", path: "/packages", label: "Packages" },
240 { key: "contributors", path: "/contributors", label: "Contributors" },
241 { key: "pulse", path: "/pulse", label: "Pulse" },
242 { key: "gates", path: "/gates", label: "Gates" },
243 { key: "deployments", path: "/cloud-deployments", label: "Deployments" },
244 { key: "pipeline", path: "/pipeline", label: "Pipeline" },
245 { key: "agents", path: "/agents", label: "Agents" },
246 { key: "traffic", path: "/traffic", label: "Traffic", ownerOnly: true },
247] as const satisfies readonly RepoNavItemDef[];
248
249export const REPO_NAV_SETTINGS = {
250 key: "settings",
251 path: "/settings",
252 label: "Settings",
253} as const satisfies RepoNavItemDef;
254
255/**
256 * Every legal `active` value — derived from the canonical arrays above.
257 * (The old hand-written union had drifted both ways: it carried two dead
258 * members, 'changelog' and 'semantic', that no menu item rendered, and it
259 * was missing nine keys the menus DID render — ask, chat, ai-search, spec,
260 * tests, migrations, dev, previews, ai-release-notes.)
261 */
262export type RepoNavKey =
263 | (typeof REPO_NAV_PRIMARY)[number]["key"]
264 | (typeof REPO_NAV_AI_GROUPS)[number]["items"][number]["key"]
265 | (typeof REPO_NAV_MORE)[number]["key"]
266 | (typeof REPO_NAV_SETTINGS)["key"];
267
153268export const RepoNav: FC<{
154269 owner: string;
155270 repo: string;
156 active:
157 | "code"
158 | "commits"
159 | "issues"
160 | "pulls"
161 | "releases"
162 | "actions"
163 | "gates"
164 | "insights"
165 | "explain"
166 | "changelog"
167 | "semantic"
168 | "wiki"
169 | "projects"
170 | "agents"
171 | "discussions"
172 | "security"
173 | "settings"
174 | "debt-map"
175 | "migrate"
176 | "deployments"
177 | "nl-search"
178 | "contributors"
179 | "pulse"
180 | "traffic"
181 | "pipeline"
182 | "workspace"
183 | "archaeology"
184 | "docs-tracking"
185 | "migrate"
186 | "ai-changelog"
187 | "packages";
271 active: RepoNavKey;
188272 /** Current authenticated user — used for owner-only tab gating. */
189273 currentUser?: string | null;
190274 /** Repo owner username — used for owner-only tab gating. */
192276}> = ({ owner, repo, active, currentUser, repoOwner }) => {
193277 const base = `/${owner}/${repo}`;
194278 const isOwner = !!(currentUser && repoOwner && currentUser === repoOwner);
279 const href = (it: RepoNavItemDef) => `${base}${it.path}`;
195280
196 // key typed as `string` so comparisons against the (narrower) `active`
197 // union never trip TS2367 \u2014 every key below is a legitimate destination.
198 type NavItem = { key: string; href: string; label: string };
199
200 // Settings is rendered separately, LAST in the row — after the AI and
201 // More menus — because settings-last is the convention every forge user
202 // already knows, and the menus belong beside the content tabs they extend.
203 const primary: NavItem[] = [
204 { key: "code", href: base, label: "Code" },
205 { key: "issues", href: `${base}/issues`, label: "Issues" },
206 { key: "pulls", href: `${base}/pulls`, label: "Pull Requests" },
207 { key: "actions", href: `${base}/actions`, label: "Actions" },
208 { key: "security", href: `${base}/security/vulnerabilities`, label: "Security" },
209 { key: "insights", href: `${base}/insights`, label: "Insights" },
210 ];
211 const settingsTab: NavItem = { key: "settings", href: `${base}/settings`, label: "Settings" };
212
213 // One AI menu, grouped by what you're trying to do.
214 //
215 // There were two. This dropdown had eight items, and the repo home page
216 // rendered a second "AI surfaces" strip underneath the nav with six more
217 // (Chat, Previews, Migrations, AI Search, AI release notes, Dev
218 // environment). Its own comment explained why: "RepoNav is locked, so the
219 // discovery row sits just below the nav as a slim CTA strip." The lock was
220 // lifted on 2026-07-24; the workaround outlived it.
221 //
222 // The cost of two menus is not just clutter — it is that neither is
223 // trustworthy. "Is AI Search in the AI menu or the AI strip?" has no
224 // learnable answer, so you check both every time.
225 type NavGroup = { heading: string; items: NavItem[] };
226 const aiGroups: NavGroup[] = [
227 {
228 heading: "Understand",
229 items: [
230 { key: "explain", href: `${base}/explain`, label: "Explain codebase" },
231 { key: "ask", href: `${base}/ask`, label: "Ask AI" },
232 { key: "chat", href: `${base}/chat`, label: "Chat with repo" },
233 { key: "nl-search", href: `${base}/search/nl`, label: "NL search" },
234 { key: "ai-search", href: `${base}/search?mode=semantic`, label: "Semantic search" },
235 { key: "archaeology", href: `${base}/archaeology`, label: "Archaeology" },
236 { key: "debt-map", href: `${base}/debt-map`, label: "Debt map" },
237 { key: "docs-tracking", href: `${base}/docs/tracking`, label: "Docs tracking" },
238 ],
239 },
240 {
241 heading: "Build",
242 items: [
243 { key: "spec", href: `${base}/spec`, label: "Spec to PR" },
244 { key: "workspace", href: `${base}/workspace`, label: "Workspace" },
245 { key: "tests", href: `${base}/ai/tests`, label: "Generate tests" },
246 { key: "migrations", href: `${base}/migrations/propose`, label: "Propose migration" },
247 { key: "dev", href: `${base}/dev`, label: "Dev environment" },
248 { key: "migrate", href: `${base}/migrate`, label: "Codebase migrator" },
249 ],
250 },
251 {
252 heading: "Ship",
253 items: [
254 { key: "previews", href: `${base}/previews`, label: "Preview URLs" },
255 { key: "ai-release-notes", href: `${base}/releases/new`, label: "AI release notes" },
256 { key: "ai-changelog", href: `${base}/ai/changelog`, label: "AI changelog" },
257 ],
258 },
259 ];
260 const aiItems: NavItem[] = aiGroups.flatMap((g) => g.items);
261
262 const moreItems: NavItem[] = [
263 { key: "commits", href: `${base}/commits`, label: "Commits" },
264 { key: "discussions", href: `${base}/discussions`, label: "Discussions" },
265 { key: "wiki", href: `${base}/wiki`, label: "Wiki" },
266 { key: "projects", href: `${base}/projects`, label: "Projects" },
267 { key: "releases", href: `${base}/releases`, label: "Releases" },
268 { key: "packages", href: `${base}/packages`, label: "Packages" },
269 { key: "contributors", href: `${base}/contributors`, label: "Contributors" },
270 { key: "pulse", href: `${base}/pulse`, label: "Pulse" },
271 { key: "gates", href: `${base}/gates`, label: "Gates" },
272 { key: "deployments", href: `${base}/cloud-deployments`, label: "Deployments" },
273 { key: "pipeline", href: `${base}/pipeline`, label: "Pipeline" },
274 { key: "agents", href: `${base}/agents`, label: "Agents" },
275 ...(isOwner ? [{ key: "traffic", href: `${base}/traffic`, label: "Traffic" }] : []),
276 ];
277
281 // Widen the `as const` tuples back to the interface so ownerOnly (absent
282 // from most literals) is accessible and flatMap unifies across groups.
283 const moreItems = (REPO_NAV_MORE as readonly RepoNavItemDef[]).filter(
284 (it) => !it.ownerOnly || isOwner
285 );
286 const aiItems = (REPO_NAV_AI_GROUPS as readonly RepoNavGroupDef[]).flatMap(
287 (g) => g.items
288 );
278289 const aiActive = aiItems.some((i) => i.key === active);
279290 const moreActive = moreItems.some((i) => i.key === active);
280291
281292 return (
282293 <div class="repo-nav">
283 {primary.map((it) => (
284 <a href={it.href} class={active === it.key ? "active" : ""}>
294 {REPO_NAV_PRIMARY.map((it) => (
295 <a
296 href={href(it)}
297 class={active === it.key ? "active" : ""}
298 aria-current={active === it.key ? "page" : undefined}
299 >
285300 {it.label}
286301 </a>
287302 ))}
288 {/* One continuous row \u2014 no margin-left:auto. The AI and More menus
303 {/* One continuous row — no margin-left:auto. The AI and More menus
289304 used to be shoved to the far right edge, which split the nav into
290305 two clusters and left "More" orphaned a screen-width away from the
291306 tabs it extends. A nav should read left to right, once. */}
292307 <details
293308 class={`repo-nav-menu repo-nav-ai-menu${aiActive ? " is-active" : ""}`}
294309 >
295 <summary class="repo-nav-ai" title="AI-native features \u2014 review, chat, spec-to-PR, and more">
296 {"\u2728"} AI
310 <summary class="repo-nav-ai" title="AI-native features — review, chat, spec-to-PR, and more">
311 {"✨"} AI
297312 </summary>
298313 <div class="repo-nav-menu-panel repo-nav-menu-panel-grouped">
299 {aiGroups.map((g) => (
314 {REPO_NAV_AI_GROUPS.map((g) => (
300315 <div class="repo-nav-menu-group">
301316 <div class="repo-nav-menu-heading">{g.heading}</div>
302317 {g.items.map((it) => (
303 <a href={it.href} class={active === it.key ? "active" : ""}>
318 <a
319 href={href(it)}
320 class={active === it.key ? "active" : ""}
321 aria-current={active === it.key ? "page" : undefined}
322 >
304323 {it.label}
305324 </a>
306325 ))}
312331 <summary>More</summary>
313332 <div class="repo-nav-menu-panel">
314333 {moreItems.map((it) => (
315 <a href={it.href} class={active === it.key ? "active" : ""}>
334 <a
335 href={href(it)}
336 class={active === it.key ? "active" : ""}
337 aria-current={active === it.key ? "page" : undefined}
338 >
316339 {it.label}
317340 </a>
318341 ))}
319342 </div>
320343 </details>
321 <a href={settingsTab.href} class={active === settingsTab.key ? "active" : ""}>
322 {settingsTab.label}
344 <a
345 href={href(REPO_NAV_SETTINGS)}
346 class={active === REPO_NAV_SETTINGS.key ? "active" : ""}
347 aria-current={active === REPO_NAV_SETTINGS.key ? "page" : undefined}
348 >
349 {REPO_NAV_SETTINGS.label}
323350 </a>
324351 <script
325352 dangerouslySetInnerHTML={{
344371 );
345372};
346373
374/**
375 * Insights sub-nav — the ONE canonical strip for every /insights/* surface.
376 * Seven pages used to hand-roll mutually inconsistent versions of this
377 * (different item sets, orders and class prefixes; DORA had none at all,
378 * making it a dead end). The item list below is the only definition —
379 * see src/__tests__/repo-nav-coherence.test.ts.
380 */
381export const INSIGHTS_SUBNAV_ITEMS = [
382 { key: "overview", path: "/insights", label: "Overview" },
383 { key: "dora", path: "/insights/dora", label: "DORA" },
384 { key: "velocity", path: "/insights/velocity", label: "Velocity" },
385 { key: "health", path: "/insights/health", label: "Health" },
386 { key: "hot-files", path: "/insights/hotfiles", label: "Hot Files" },
387 { key: "bus-factor", path: "/insights/bus-factor", label: "Bus Factor" },
388 { key: "test-gaps", path: "/insights/test-gaps", label: "Test Gaps" },
389 { key: "engineering", path: "/insights/engineering", label: "Engineering" },
390] as const satisfies readonly { key: string; path: string; label: string }[];
391
392export type InsightsSubNavKey = (typeof INSIGHTS_SUBNAV_ITEMS)[number]["key"];
393
394// Style lifted verbatim from the hot-files/velocity strips (the canonical
395// look) — tokens only, no new colour literals. The component renders it
396// inline; duplicate <style> blocks with identical rules are harmless.
397const insightsSubNavStyles = `
398 .ins-subnav {
399 display: flex;
400 gap: 4px;
401 margin-bottom: var(--space-5);
402 border-bottom: 1px solid var(--border);
403 padding-bottom: 0;
404 overflow-x: auto;
405 }
406 .ins-subnav-link {
407 padding: 8px 14px;
408 font-size: 13px;
409 font-weight: 500;
410 color: var(--text-muted);
411 text-decoration: none;
412 border-bottom: 2px solid transparent;
413 margin-bottom: -1px;
414 transition: color 120ms ease, border-color 120ms ease;
415 border-radius: 4px 4px 0 0;
416 white-space: nowrap;
417 }
418 .ins-subnav-link:hover { color: var(--text); }
419 .ins-subnav-link.active {
420 color: var(--accent);
421 border-bottom-color: var(--accent);
422 }
423`;
424
425export const InsightsSubNav: FC<{
426 owner: string;
427 repo: string;
428 active: InsightsSubNavKey;
429}> = ({ owner, repo, active }) => (
430 <>
431 <style dangerouslySetInnerHTML={{ __html: insightsSubNavStyles }} />
432 <nav class="ins-subnav" aria-label="Insights sections">
433 {INSIGHTS_SUBNAV_ITEMS.map((it) => (
434 <a
435 href={`/${owner}/${repo}${it.path}`}
436 class={`ins-subnav-link${active === it.key ? " active" : ""}`}
437 aria-current={active === it.key ? "page" : undefined}
438 >
439 {it.label}
440 </a>
441 ))}
442 </nav>
443 </>
444);
445
347446export const BranchSwitcher: FC<{
348447 owner: string;
349448 repo: string;
350449
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts