From a3102351d81178690edbb33991160c232af6c2be Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Sat, 11 Apr 2026 15:56:58 +0000 Subject: [PATCH] refactor(nexus): dry shared destinations into frame module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ui/src/components/frame/IconRail.tsx | 69 +------------------ ui/src/components/frame/MobileTabBar.tsx | 63 +----------------- ui/src/components/frame/destinations.ts | 85 ++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 128 deletions(-) create mode 100644 ui/src/components/frame/destinations.ts diff --git a/ui/src/components/frame/IconRail.tsx b/ui/src/components/frame/IconRail.tsx index 50729b1b..b6ff42a9 100644 --- a/ui/src/components/frame/IconRail.tsx +++ b/ui/src/components/frame/IconRail.tsx @@ -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. diff --git a/ui/src/components/frame/MobileTabBar.tsx b/ui/src/components/frame/MobileTabBar.tsx index 0fb073ee..04b395f4 100644 --- a/ui/src/components/frame/MobileTabBar.tsx +++ b/ui/src/components/frame/MobileTabBar.tsx @@ -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 diff --git a/ui/src/components/frame/destinations.ts b/ui/src/components/frame/destinations.ts new file mode 100644 index 00000000..f32ba9a0 --- /dev/null +++ b/ui/src/components/frame/destinations.ts @@ -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, + }, +];