From 332ed47bc008b30dd0edc33354de4ac232d46348 Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Sat, 11 Apr 2026 10:52:18 +0000 Subject: [PATCH] feat(nexus): add icon rail component for layout overhaul phase 8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the 56px left icon rail specified in docs/specs/2026-04-11-nexus-layout-overhaul.md §4.1. Four primary destinations (Assistant, Studio, Projects, Settings) rendered as Lucide icons with silver default + volt active state and a 2px volt bar on the right edge of the active item. Destinations are company-prefixed except Settings, which points at the global /instance/settings/general route. The Studio icon also highlights on /convert because Phase 10 folds ConvertPage into Studio as a workshop. The Projects icon is the umbrella for all Phase 11 per-project-tab routes (issues, agents, routines, goals, approvals, costs, activity, inbox, execution-workspaces). The rail is not yet mounted in Layout.tsx — that happens in task 6. Part of the Nexus v1.7 structural overhaul (Phase 8 of MIGRATION-PLAN.md §8b). Companion tests cover all 4 destinations, active-state derivation, and aria-current semantics. Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/components/frame/IconRail.test.tsx | 118 +++++++++++++++ ui/src/components/frame/IconRail.tsx | 177 ++++++++++++++++++++++ ui/src/lib/router.tsx | 4 + 3 files changed, 299 insertions(+) create mode 100644 ui/src/components/frame/IconRail.test.tsx create mode 100644 ui/src/components/frame/IconRail.tsx diff --git a/ui/src/components/frame/IconRail.test.tsx b/ui/src/components/frame/IconRail.test.tsx new file mode 100644 index 00000000..eb0ab014 --- /dev/null +++ b/ui/src/components/frame/IconRail.test.tsx @@ -0,0 +1,118 @@ +// @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 { IconRail } from "./IconRail"; + +// The real @/lib/router Link wrapper calls useCompany(), which throws +// outside a CompanyProvider. The IconRail doesn't depend on the company +// context for building URLs (it receives companyPrefix as a prop), so we +// stub useCompany() to return a minimal value so Link can render in tests. +vi.mock("@/context/CompanyContext", () => ({ + useCompany: () => ({ + companies: [], + selectedCompanyId: null, + selectedCompany: null, + selectionSource: "bootstrap" as const, + loading: false, + error: null, + setSelectedCompanyId: () => {}, + reloadCompanies: async () => {}, + createCompany: async () => { + throw new Error("not implemented in test stub"); + }, + }), +})); + +// Tell React this environment uses act() for event flushing. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +describe("IconRail", () => { + let container: HTMLDivElement; + let root: ReturnType | 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 renderRail(initialPath: string) { + root = createRoot(container); + act(() => { + root!.render( + + + , + ); + }); + return { + getNav: () => container.querySelector("nav[aria-label='Primary']") as HTMLElement, + getLinks: () => + Array.from(container.querySelectorAll("nav[aria-label='Primary'] a")) as HTMLAnchorElement[], + getLinkByLabel: (label: string) => + container.querySelector(`nav[aria-label='Primary'] a[aria-label='${label}']`) as HTMLAnchorElement | null, + }; + } + + it("renders a Primary nav with the four destination links", () => { + const { getNav, getLinks } = renderRail("/NEX/assistant"); + expect(getNav()).not.toBeNull(); + const links = getLinks(); + // The rail also renders a "Nexus home" link for the mark at the top; + // we filter to just the four destinations below. + const labels = links + .map((a) => a.getAttribute("aria-label")) + .filter((label) => label !== "Nexus home"); + expect(labels).toEqual(["Assistant", "Studio", "Projects", "Settings"]); + }); + + it("builds company-prefixed URLs for the first three destinations", () => { + const { getLinkByLabel } = renderRail("/NEX/assistant"); + expect(getLinkByLabel("Assistant")?.getAttribute("href")).toBe("/NEX/assistant"); + expect(getLinkByLabel("Studio")?.getAttribute("href")).toBe("/NEX/content-studio"); + expect(getLinkByLabel("Projects")?.getAttribute("href")).toBe("/NEX/projects"); + }); + + it("points Settings at the global /instance/settings/general route", () => { + const { getLinkByLabel } = renderRail("/NEX/assistant"); + expect(getLinkByLabel("Settings")?.getAttribute("href")).toBe("/instance/settings/general"); + }); + + it("marks the Assistant link as current on /NEX/assistant", () => { + const { getLinkByLabel } = renderRail("/NEX/assistant"); + expect(getLinkByLabel("Assistant")?.getAttribute("aria-current")).toBe("page"); + expect(getLinkByLabel("Projects")?.getAttribute("aria-current")).toBeNull(); + }); + + it("marks the Projects link as current on /NEX/issues", () => { + const { getLinkByLabel } = renderRail("/NEX/issues"); + expect(getLinkByLabel("Projects")?.getAttribute("aria-current")).toBe("page"); + expect(getLinkByLabel("Assistant")?.getAttribute("aria-current")).toBeNull(); + }); + + it("marks the Studio link as current on /NEX/convert (Convert is folded into Studio)", () => { + const { getLinkByLabel } = renderRail("/NEX/convert"); + expect(getLinkByLabel("Studio")?.getAttribute("aria-current")).toBe("page"); + }); + + it("marks the Settings link as current on /instance/settings/general", () => { + const { getLinkByLabel } = renderRail("/instance/settings/general"); + expect(getLinkByLabel("Settings")?.getAttribute("aria-current")).toBe("page"); + }); +}); diff --git a/ui/src/components/frame/IconRail.tsx b/ui/src/components/frame/IconRail.tsx new file mode 100644 index 00000000..9eaf15cc --- /dev/null +++ b/ui/src/components/frame/IconRail.tsx @@ -0,0 +1,177 @@ +import { MessageCircle, Sparkles, FolderKanban, Settings } from "lucide-react"; +import { Link, useLocation } from "@/lib/router"; +import { cn } from "@/lib/utils"; + +interface IconRailProps { + /** + * The currently active company prefix (e.g. "NEX"). Used to build + * company-prefixed destination URLs. When null, the rail still renders + * but the first three destinations navigate to "/". + */ + companyPrefix: string | null; +} + +type DestinationKey = "assistant" | "studio" | "projects" | "settings"; + +interface Destination { + key: DestinationKey; + label: string; + /** + * Given a company prefix (possibly null), return the destination URL. + */ + href: (prefix: string | null) => string; + /** + * Return true if the destination should be marked active for the given pathname. + */ + isActive: (pathname: string) => boolean; + icon: typeof MessageCircle; +} + +const DESTINATIONS: Destination[] = [ + { + key: "assistant", + label: "Assistant", + href: (prefix) => (prefix ? `/${prefix}/assistant` : "/"), + isActive: (pathname) => /\/assistant(\/|$)/.test(pathname), + icon: MessageCircle, + }, + { + key: "studio", + label: "Studio", + // Studio is a future unified route (Phase 10). For Phase 8 we route it + // to the existing content-studio page. The Studio icon also highlights + // when the user is on /convert because Convert folds into Studio in Phase 10. + href: (prefix) => (prefix ? `/${prefix}/content-studio` : "/"), + isActive: (pathname) => + /\/content-studio(\/|$)/.test(pathname) || + /\/studio(\/|$)/.test(pathname) || + /\/convert(\/|$)/.test(pathname), + icon: Sparkles, + }, + { + key: "projects", + label: "Projects", + href: (prefix) => (prefix ? `/${prefix}/projects` : "/"), + // The Projects icon acts as the umbrella for every route that Phase 11 + // will eventually demote to a per-project tab. + isActive: (pathname) => + /\/projects(\/|$)/.test(pathname) || + /\/issues(\/|$)/.test(pathname) || + /\/agents(\/|$)/.test(pathname) || + /\/routines(\/|$)/.test(pathname) || + /\/goals(\/|$)/.test(pathname) || + /\/approvals(\/|$)/.test(pathname) || + /\/costs(\/|$)/.test(pathname) || + /\/activity(\/|$)/.test(pathname) || + /\/inbox(\/|$)/.test(pathname) || + /\/execution-workspaces(\/|$)/.test(pathname), + icon: FolderKanban, + }, + { + key: "settings", + label: "Settings", + // Instance settings is a global (non-company-prefixed) route in Paperclip. + // Phase 13 may change this; for Phase 8 we preserve the current URL. + href: () => "/instance/settings/general", + isActive: (pathname) => pathname.startsWith("/instance/settings"), + icon: Settings, + }, +]; + +export function IconRail({ companyPrefix }: IconRailProps) { + const { pathname } = useLocation(); + + return ( + + ); +} + +function DestinationLink({ + destination, + companyPrefix, + pathname, +}: { + destination: Destination; + companyPrefix: string | null; + pathname: string; +}) { + const Icon = destination.icon; + const active = destination.isActive(pathname); + const href = destination.href(companyPrefix); + + return ( +
  • + + + + {/* Active bar on the right edge — DESIGN.md §4.1 */} + {active && ( +
  • + ); +} + +function NexusMark({ className }: { className?: string }) { + // Geometric Nexus mark — a hexagonal nucleus with a single volt stroke. + // Phase 16 may replace with a finalized brand mark; this is a placeholder + // that matches the ClickHouse-cockpit aesthetic (sharp, minimal, volt accent). + return ( + + + + + + ); +} diff --git a/ui/src/lib/router.tsx b/ui/src/lib/router.tsx index 5cf81c8d..a0243a18 100644 --- a/ui/src/lib/router.tsx +++ b/ui/src/lib/router.tsx @@ -40,6 +40,10 @@ function useActiveCompanyPrefix(): string | null { export * from "react-router-dom"; +// Re-exported for tests only. Production code should not need MemoryRouter — +// the application wires BrowserRouter in main.tsx. +export { MemoryRouter } from "react-router-dom"; + export const Link = React.forwardRef>( function CompanyLink({ to, ...props }, ref) { const companyPrefix = useActiveCompanyPrefix();