refactor(nexus): dry shared destinations into frame module

IconRail (desktop) and MobileTabBar (mobile) each carried hand-
synced copies of the 4-destination nav config — labels, href
builders, isActive regex predicates, and Lucide icons. Phase 15
explicitly deferred the DRY to Phase 16 cleanup.

Phase 16a extracts the shared config into `frame/destinations.ts`
and replaces both consumers' inline definitions with a single
import. The Projects umbrella regex list (issues, agents, routines,
goals, approvals, costs, activity, inbox, execution-workspaces)
lives in the shared module so both surfaces stay in lockstep.

Net line change for the two consumers:
- IconRail.tsx: -67 lines (inline config)
- MobileTabBar.tsx: -63 lines (inline config)
- +82 lines (new destinations.ts with shared types + regex list)

Test sweep: src/components/frame/ — 53 tests still green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nexus Dev 2026-04-11 15:56:58 +00:00
parent 5957290073
commit a3102351d8
3 changed files with 89 additions and 128 deletions

View file

@ -1,8 +1,8 @@
import { MessageCircle, Sparkles, FolderKanban, Settings } from "lucide-react";
import { Link, useLocation } from "@/lib/router";
import { cn } from "@/lib/utils";
import { useCompany } from "../../context/CompanyContext";
import { useGateIndicator } from "../../hooks/useGateIndicator";
import { DESTINATIONS, type Destination } from "./destinations";
interface IconRailProps {
/**
@ -13,73 +13,6 @@ interface IconRailProps {
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();
// Phase 11 integration: pending gates → volt dot on the Assistant destination.

View file

@ -6,16 +6,14 @@
// Projects, Settings. Silver default, volt active, with a 2px volt bar
// above the active icon and safe-area aware bottom padding.
//
// The destination-matching regexes are intentionally duplicated from
// IconRail rather than shared via a module: Phase 8 owns IconRail, and
// touching it inside Phase 15 is out of scope. Phase 16 cleanup can DRY
// the helpers into a shared `frame/destinations.ts`.
// Phase 16a DRY'd the destination config into `./destinations.ts`; both
// this bar and the desktop IconRail now import the same list.
//
// Spec: docs/specs/2026-04-11-nexus-layout-overhaul.md §9 (Mobile).
import { MessageCircle, Sparkles, FolderKanban, Settings } from "lucide-react";
import { Link, useLocation } from "@/lib/router";
import { cn } from "@/lib/utils";
import { useCompany } from "../../context/CompanyContext";
import { DESTINATIONS, type Destination } from "./destinations";
interface MobileTabBarProps {
/**
@ -26,61 +24,6 @@ interface MobileTabBarProps {
visible?: boolean;
}
type DestinationKey = "assistant" | "studio" | "projects" | "settings";
interface Destination {
key: DestinationKey;
label: string;
href: (prefix: string | null) => string;
isActive: (pathname: string) => boolean;
icon: typeof MessageCircle;
}
// Regexes duplicated from IconRail intentionally — see file header.
const DESTINATIONS: Destination[] = [
{
key: "assistant",
label: "Assistant",
href: (prefix) => (prefix ? `/${prefix}/assistant` : "/"),
isActive: (pathname) => /\/assistant(\/|$)/.test(pathname),
icon: MessageCircle,
},
{
key: "studio",
label: "Studio",
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` : "/"),
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",
href: () => "/instance/settings/general",
isActive: (pathname) => pathname.startsWith("/instance/settings"),
icon: Settings,
},
];
export function MobileTabBar({ visible = true }: MobileTabBarProps) {
const { pathname } = useLocation();
// Company-prefix scoping mirrors IconRail: the first three destinations

View file

@ -0,0 +1,85 @@
// [nexus] Phase 16a — shared destination config for the primary nav.
//
// Previously IconRail (desktop) and MobileTabBar (mobile) each carried
// hand-synced copies of the 4-destination nav config (labels, href
// builders, isActive regex predicates, Lucide icons). Phase 16a DRYs
// them into this module so the two surfaces always agree.
//
// The Projects destination acts as the umbrella for every route that
// Phase 11 demoted into a per-project tab (issues, agents, routines,
// etc.); the regex list lives here so both rail and tab bar stay in
// lockstep.
//
// Spec: docs/specs/2026-04-11-nexus-layout-overhaul.md §4 (IconRail),
// §9 (MobileTabBar).
import { MessageCircle, Sparkles, FolderKanban, Settings, type LucideIcon } from "lucide-react";
export type DestinationKey = "assistant" | "studio" | "projects" | "settings";
export interface Destination {
key: DestinationKey;
label: string;
/**
* Given a company prefix (possibly null), return the destination URL.
*/
href: (companyPrefix: string | null) => string;
/**
* Return true if the destination should be marked active for the
* given pathname.
*/
isActive: (pathname: string) => boolean;
icon: LucideIcon;
}
export 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 consolidated the settings into this single
// surface; the rail and tab bar both point to `/general`.
href: () => "/instance/settings/general",
isActive: (pathname) => pathname.startsWith("/instance/settings"),
icon: Settings,
},
];