feat(nexus): add icon rail component for layout overhaul phase 8
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) <noreply@anthropic.com>
This commit is contained in:
parent
2a42f4ab86
commit
332ed47bc0
3 changed files with 299 additions and 0 deletions
118
ui/src/components/frame/IconRail.test.tsx
Normal file
118
ui/src/components/frame/IconRail.test.tsx
Normal file
|
|
@ -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<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 renderRail(initialPath: string) {
|
||||||
|
root = createRoot(container);
|
||||||
|
act(() => {
|
||||||
|
root!.render(
|
||||||
|
<MemoryRouter initialEntries={[initialPath]}>
|
||||||
|
<IconRail companyPrefix="NEX" />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
177
ui/src/components/frame/IconRail.tsx
Normal file
177
ui/src/components/frame/IconRail.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<nav
|
||||||
|
aria-label="Primary"
|
||||||
|
className="hidden md:flex w-[56px] shrink-0 flex-col items-center bg-background py-2"
|
||||||
|
>
|
||||||
|
{/* Nexus mark — the only volt-filled element in the rail */}
|
||||||
|
<Link
|
||||||
|
to={companyPrefix ? `/${companyPrefix}/assistant` : "/"}
|
||||||
|
aria-label="Nexus home"
|
||||||
|
className="mb-4 flex h-8 w-8 items-center justify-center rounded-[4px] text-[#faff69]"
|
||||||
|
>
|
||||||
|
<NexusMark className="h-5 w-5" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<ul className="flex flex-1 flex-col items-center gap-2">
|
||||||
|
{DESTINATIONS.slice(0, 3).map((dest) => (
|
||||||
|
<DestinationLink
|
||||||
|
key={dest.key}
|
||||||
|
destination={dest}
|
||||||
|
companyPrefix={companyPrefix}
|
||||||
|
pathname={pathname}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<ul className="mt-auto flex flex-col items-center pb-1">
|
||||||
|
<DestinationLink
|
||||||
|
destination={DESTINATIONS[3]!}
|
||||||
|
companyPrefix={companyPrefix}
|
||||||
|
pathname={pathname}
|
||||||
|
/>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<li className="relative">
|
||||||
|
<Link
|
||||||
|
to={href}
|
||||||
|
aria-label={destination.label}
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
title={destination.label}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-10 items-center justify-center rounded-[4px]",
|
||||||
|
"transition-colors duration-100 ease-out",
|
||||||
|
active ? "text-[#faff69]" : "text-muted-foreground hover:text-[#faff69]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5" strokeWidth={1.5} />
|
||||||
|
</Link>
|
||||||
|
{/* Active bar on the right edge — DESIGN.md §4.1 */}
|
||||||
|
{active && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute right-0 top-1/2 h-5 w-[2px] -translate-y-1/2 bg-[#faff69]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="square"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="M10 1 L18 5 L18 15 L10 19 L2 15 L2 5 Z" />
|
||||||
|
<path d="M6 8 L14 12" />
|
||||||
|
<path d="M14 8 L6 12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -40,6 +40,10 @@ function useActiveCompanyPrefix(): string | null {
|
||||||
|
|
||||||
export * from "react-router-dom";
|
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<HTMLAnchorElement, React.ComponentProps<typeof RouterDom.Link>>(
|
export const Link = React.forwardRef<HTMLAnchorElement, React.ComponentProps<typeof RouterDom.Link>>(
|
||||||
function CompanyLink({ to, ...props }, ref) {
|
function CompanyLink({ to, ...props }, ref) {
|
||||||
const companyPrefix = useActiveCompanyPrefix();
|
const companyPrefix = useActiveCompanyPrefix();
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue