nexus/ui/src/components/frame/IconRail.tsx
Nexus Dev 332ed47bc0 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>
2026-04-11 10:52:18 +00:00

177 lines
5.4 KiB
TypeScript

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>
);
}