feat(nexus): add ModeBreadcrumb for layout overhaul (phase 8)

Uppercase slash-separated breadcrumb that derives from the current
pathname per docs/specs/2026-04-11-nexus-layout-overhaul.md §4.2.
Leaf segment in text-primary (volt), non-leaf segments in
text-muted-foreground (silver). Pure function deriveBreadcrumbSegments
is exported for unit testing and covers 16 route patterns plus the
catch-all HOME fallback.

The derivation intentionally collapses Phase 11's soon-to-be-demoted
routes (issues/agents/routines/goals/approvals/costs/activity/inbox/
execution-workspaces) under the PROJECTS umbrella segment. When
Phase 11 lands and those routes become /projects/:slug/<tab>, the
derivation will naturally produce PROJECTS / PROJECT-SLUG without
code changes.

Part of Phase 8 of the Nexus layout overhaul (task 2 of 7).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nexus Dev 2026-04-11 11:04:31 +00:00
parent 9c1525a0f9
commit bd4e7c5c5d
2 changed files with 209 additions and 0 deletions

View file

@ -0,0 +1,104 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MemoryRouter } from "@/lib/router";
import { ModeBreadcrumb, deriveBreadcrumbSegments } from "./ModeBreadcrumb";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
// Match the pattern from IconRail.test.tsx — @/lib/router Link calls
// useCompany() internally, so we stub CompanyContext for isolated testing.
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({
companies: [],
selectedCompany: null,
selectedCompanyId: null,
setSelectedCompanyId: () => {},
selectionSource: null,
loading: false,
}),
}));
describe("deriveBreadcrumbSegments", () => {
it.each([
["/NEX/assistant", ["ASSISTANT"]],
["/NEX/assistant/conv-abc", ["ASSISTANT"]],
["/NEX/content-studio", ["STUDIO"]],
["/NEX/content-studio/diagrams", ["STUDIO", "DIAGRAMS"]],
["/NEX/convert", ["STUDIO", "CONVERT"]],
["/NEX/convert/pdf/docx", ["STUDIO", "CONVERT"]],
["/NEX/projects", ["PROJECTS"]],
["/NEX/projects/nexus-design", ["PROJECTS", "NEXUS-DESIGN"]],
["/NEX/issues", ["PROJECTS"]],
["/NEX/issues/NEX-42", ["PROJECTS", "NEX-42"]],
["/NEX/agents", ["PROJECTS"]],
["/NEX/routines", ["PROJECTS"]],
["/instance/settings/general", ["SETTINGS"]],
["/instance/settings/integrations", ["SETTINGS", "INTEGRATIONS"]],
["/", ["HOME"]],
["/unknown/path", ["HOME"]],
])("maps %s to %o", (input, expected) => {
expect(deriveBreadcrumbSegments(input as string)).toEqual(expected);
});
});
describe("ModeBreadcrumb (rendered)", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot> | null = null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = null;
});
afterEach(() => {
if (root) {
act(() => {
root!.unmount();
});
root = null;
}
if (container.parentNode) container.remove();
});
function renderCrumb(pathname: string) {
root = createRoot(container);
act(() => {
root!.render(
<MemoryRouter initialEntries={[pathname]}>
<ModeBreadcrumb />
</MemoryRouter>,
);
});
return {
getRoot: () => container.querySelector("[data-testid='mode-breadcrumb']") as HTMLElement | null,
getSegments: () =>
Array.from(container.querySelectorAll("[data-testid='mode-breadcrumb-segment']")).map(
(el) => el.textContent?.trim() ?? "",
),
getSeparators: () =>
container.querySelectorAll("[data-testid='mode-breadcrumb-sep']").length,
};
}
it("renders a single ASSISTANT segment and no separator", () => {
const { getSegments, getSeparators } = renderCrumb("/NEX/assistant");
expect(getSegments()).toEqual(["ASSISTANT"]);
expect(getSeparators()).toBe(0);
});
it("renders two segments with one separator for /NEX/content-studio/diagrams", () => {
const { getSegments, getSeparators } = renderCrumb("/NEX/content-studio/diagrams");
expect(getSegments()).toEqual(["STUDIO", "DIAGRAMS"]);
expect(getSeparators()).toBe(1);
});
it("renders PROJECTS / NEX-42 for /NEX/issues/NEX-42", () => {
const { getSegments } = renderCrumb("/NEX/issues/NEX-42");
expect(getSegments()).toEqual(["PROJECTS", "NEX-42"]);
});
});

View file

@ -0,0 +1,105 @@
import { useLocation } from "@/lib/router";
import { cn } from "@/lib/utils";
/**
* Derive breadcrumb segments from a pathname.
*
* Exposed for unit testing the component itself just consumes this.
*
* Mapping rules:
* - `/:prefix/assistant*` `["ASSISTANT"]` (any suffix collapses)
* - `/:prefix/content-studio` `["STUDIO"]`
* - `/:prefix/content-studio/<sub>` `["STUDIO", "<SUB>"]`
* - `/:prefix/convert*` `["STUDIO", "CONVERT"]` (Phase 10 folds Convert into Studio)
* - `/:prefix/projects` `["PROJECTS"]`
* - `/:prefix/projects/<slug>*` `["PROJECTS", "<SLUG>"]`
* - `/:prefix/(issues|agents|routines|goals|approvals|costs|activity|inbox)` `["PROJECTS"]`
* - `/:prefix/issues/<slug>` `["PROJECTS", "<SLUG>"]`
* - `/instance/settings` `["SETTINGS"]`
* - `/instance/settings/<sub>` (non-general) `["SETTINGS", "<SUB>"]`
* - everything else `["HOME"]`
*/
export function deriveBreadcrumbSegments(pathname: string): string[] {
// /instance/settings/...
if (pathname.startsWith("/instance/settings")) {
const rest = pathname.replace(/^\/instance\/settings\/?/, "");
if (!rest) return ["SETTINGS"];
const leaf = rest.split("/")[0] ?? "";
if (!leaf || leaf === "general") return ["SETTINGS"];
return ["SETTINGS", leaf.toUpperCase()];
}
// Company-prefixed routes: /NEX/<rest>
const match = pathname.match(/^\/[^/]+\/(.*)$/);
if (!match) return ["HOME"];
const rest = match[1] ?? "";
if (!rest) return ["HOME"];
const segments = rest.split("/").filter(Boolean);
const head = segments[0];
const next = segments[1];
switch (head) {
case "assistant":
return ["ASSISTANT"];
case "content-studio":
case "studio":
return next ? ["STUDIO", next.toUpperCase()] : ["STUDIO"];
case "convert":
return ["STUDIO", "CONVERT"];
case "projects":
return next ? ["PROJECTS", next.toUpperCase()] : ["PROJECTS"];
case "issues":
case "agents":
case "routines":
case "goals":
case "approvals":
case "costs":
case "activity":
case "inbox":
case "execution-workspaces":
return next ? ["PROJECTS", next.toUpperCase()] : ["PROJECTS"];
default:
return ["HOME"];
}
}
export function ModeBreadcrumb() {
const { pathname } = useLocation();
const segments = deriveBreadcrumbSegments(pathname);
return (
<div
data-testid="mode-breadcrumb"
className="flex items-center gap-2 text-[14px] font-semibold uppercase tracking-[0.1em]"
>
{segments.map((segment, index) => {
const isLeaf = index === segments.length - 1;
return (
<span key={index} className="flex items-center gap-2">
{index > 0 && (
<span
data-testid="mode-breadcrumb-sep"
aria-hidden="true"
className="text-muted-foreground"
>
/
</span>
)}
<span
data-testid="mode-breadcrumb-segment"
className={cn(isLeaf ? "text-primary" : "text-muted-foreground")}
>
{segment}
</span>
</span>
);
})}
</div>
);
}