# Nexus Phase 8 — Frame Skeleton Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the current `Layout.tsx` chrome with the new global frame — a 56px left icon rail with 4 primary destinations (Assistant, Studio, Projects, Settings) and a 48px top strip with a mode breadcrumb, ⌘K button, and global mic button. Delete the 280px sidebar, the global `ChatPanel` slide-in, the global `PropertiesPanel`, the footer row with theme/chat/settings buttons, and the `BreadcrumbBar` component from the chrome. Pages still render into `` unchanged; they will look wrong in the new frame, and that's expected — phases 9–13 rebuild them.
**Architecture:** A new `ui/src/components/frame/` subdir holds five new components (`IconRail`, `ModeBreadcrumb`, `CmdKButton`, `GlobalMicButton`, `TopStrip`). `Layout.tsx` is rewritten to compose the new frame, preserving all non-chrome responsibilities (company-prefix URL sync, first-run onboarding, dialogs, overlays, body overflow management, instance settings memory). Existing pages render into `` unchanged. Mobile in Phase 8 keeps the existing `MobileBottomNav` intact (Phase 15 replaces it); only desktop gets the new frame.
**Tech Stack:**
- React 18, TypeScript strict, Vite
- Tailwind v4 with DESIGN.md token remap (phases 1–3 of MIGRATION-PLAN.md already shipped `--primary` → volt, `--muted-foreground` → silver, `--border` → charcoal)
- `lucide-react` (icons: `MessageCircle`, `Sparkles`, `FolderKanban`, `Settings`)
- vitest + jsdom + React 18 manual `createRoot` + `act()` (existing test pattern — see `ui/src/components/ChatInput.test.tsx` for the reference shape)
- Custom router wrapper `@/lib/router` (re-exports `Link`, `Navigate`, `Outlet`, `useLocation`, `useNavigate`, `useParams`)
- Existing `useCompany()` context from `../context/CompanyContext`
**Binding constraints (from `docs/specs/2026-04-11-nexus-layout-overhaul.md`):**
- §3 DESIGN.md inheritance: pure black canvas, volt `#faff69` as sole accent, forest `#166534` as secondary CTA, charcoal `rgba(65,65,65,0.8)` borders, Inter typography, sharp 4/8 radii, border-based depth
- §4.1 icon rail: 56px wide, locked (no collapse/expand), silver default + volt active with 2px volt bar on right edge, uppercase 1.4px-tracking tooltip labels, Lucide icons
- §4.2 top strip: 48px tall, sticky, charcoal bottom border, mode label slash-separated breadcrumb in uppercase 1.4px tracking (last segment volt)
- §4.3 no global right rail — `ChatPanel` and `PropertiesPanel` are removed from chrome
- §10.1 ⌘K is the universal palette trigger (CmdK shim in Phase 8; full globalization is Phase 14)
- §5.5 global mic is visible everywhere (idle state only in Phase 8; full voice routing is Phase 14)
**What Phase 8 explicitly does NOT build (deferred to later phases):**
- The Assistant full-bleed chat UI (Phase 9)
- The Studio workshop grid (Phase 10)
- The Projects hero-stat list or Builder-mode tabs (Phase 11)
- The promote-to-project transition animation (Phase 12)
- Settings consolidation (Phase 13)
- Voice routing from non-Assistant modes (Phase 14)
- The globalized ⌘K palette searching across projects/conversations (Phase 14)
- Mobile icon rail as a bottom tab bar (Phase 15)
- Removal of dead code (old Sidebar component, old ChatPanel file, old MobileBottomNav) — the files stay in the repo, just unmounted from the chrome. Phase 16 deletes them.
**File inventory**
| Action | Path | Responsibility |
|---|---|---|
| Create | `ui/src/components/frame/IconRail.tsx` | 56px left rail with 4 primary destinations + Nexus mark |
| Create | `ui/src/components/frame/IconRail.test.tsx` | Renders 4 links, applies active state, has correct aria |
| Create | `ui/src/components/frame/ModeBreadcrumb.tsx` | Derives slash-separated uppercase breadcrumb from current route |
| Create | `ui/src/components/frame/ModeBreadcrumb.test.tsx` | Pure function tests for route → label mapping |
| Create | `ui/src/components/frame/CmdKButton.tsx` | Renders the kbd badge; onClick dispatches synthetic `Cmd+K` keydown |
| Create | `ui/src/components/frame/CmdKButton.test.tsx` | Renders, dispatches event on click |
| Create | `ui/src/components/frame/GlobalMicButton.tsx` | Renders mic button in idle state; onClick is a no-op in Phase 8 |
| Create | `ui/src/components/frame/GlobalMicButton.test.tsx` | Renders with aria-label |
| Create | `ui/src/components/frame/TopStrip.tsx` | Composes `ModeBreadcrumb` + `CmdKButton` + `GlobalMicButton` |
| Create | `ui/src/components/frame/TopStrip.test.tsx` | Renders all three children |
| Modify | `ui/src/components/Layout.tsx` | Rewrite JSX to use new frame; remove sidebar/ChatPanel/PropertiesPanel/BreadcrumbBar/footer |
11 files touched. 5 new components + 5 test files + 1 rewrite.
**Commit scheme:** one commit per task, message prefix `feat(nexus):` or `refactor(nexus):` per convention. Each commit co-authored.
---
## Task 1: Create `IconRail` component
**Files:**
- Create: `ui/src/components/frame/IconRail.tsx`
- Create: `ui/src/components/frame/IconRail.test.tsx`
The rail is 56px wide, fixed height (100% of viewport), has four primary destination links plus a Nexus mark at the top. Active state is derived from the current pathname. Routes are company-prefixed: the rail reads `companyPrefix` from `useParams` and builds URLs like `/${companyPrefix}/assistant`. The Settings destination points at `/instance/settings/general` (not company-prefixed — Paperclip instance settings live at a global route).
### Active state derivation
| Pathname contains | Active icon |
|---|---|
| `/assistant` | Assistant |
| `/content-studio` or `/studio` or `/convert` | Studio |
| `/projects` or `/issues` or `/agents` or `/routines` or `/goals` or `/approvals` or `/costs` or `/activity` or `/inbox` | Projects |
| `/instance/settings` | Settings |
(The Projects group is broad because Phase 8 still has these as top-level routes. Phase 11 demotes them to per-project tabs and the grouping becomes natural.)
- [ ] **Step 1: Write the failing test**
Create `ui/src/components/frame/IconRail.test.tsx`:
```tsx
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { MemoryRouter } from "@/lib/router";
import { IconRail } from "./IconRail";
// 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();
const labels = links.map((a) => a.getAttribute("aria-label"));
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");
});
});
```
**Note:** this test imports `MemoryRouter` from `@/lib/router`. If that export doesn't exist, check the wrapper at `ui/src/lib/router.ts` — there is likely a `MemoryRouter` or `HashRouter` re-exported from `react-router-dom`. If not, add it: `export { MemoryRouter } from "react-router-dom";` in `ui/src/lib/router.ts`. That's a 1-line edit that belongs in this task's commit.
- [ ] **Step 2: Run test to verify it fails**
```bash
cd /opt/nexus/ui && npx vitest run src/components/frame/IconRail.test.tsx
```
Expected: FAIL — `Cannot find module './IconRail'`.
- [ ] **Step 3: Implement `IconRail`**
Create `ui/src/components/frame/IconRail.tsx`:
```tsx
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 (
);
}
```
- [ ] **Step 4: If `MemoryRouter` is not exported from `@/lib/router`, add it**
Check `ui/src/lib/router.ts` for a `MemoryRouter` export. If absent, append:
```ts
export { MemoryRouter } from "react-router-dom";
```
This export is used only by tests; production code should not import it.
- [ ] **Step 5: Run test to verify it passes**
```bash
cd /opt/nexus/ui && npx vitest run src/components/frame/IconRail.test.tsx
```
Expected: PASS — all 7 test cases green.
- [ ] **Step 6: Commit**
```bash
cd /opt/nexus && git add ui/src/components/frame/IconRail.tsx ui/src/components/frame/IconRail.test.tsx ui/src/lib/router.ts
git commit -m "$(cat <<'EOF'
feat(nexus): add IconRail 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)
EOF
)"
```
---
## Task 2: Create `ModeBreadcrumb` component
**Files:**
- Create: `ui/src/components/frame/ModeBreadcrumb.tsx`
- Create: `ui/src/components/frame/ModeBreadcrumb.test.tsx`
The breadcrumb derives its segments from the current pathname. It's a pure function of route state, so its tests can be mostly unit-style (call the derivation function, assert segments).
### Derivation rules
| Pathname | Segments |
|---|---|
| `/NEX/assistant` | `["ASSISTANT"]` |
| `/NEX/assistant/conv-123` | `["ASSISTANT"]` |
| `/NEX/content-studio` | `["STUDIO"]` |
| `/NEX/content-studio/diagrams` | `["STUDIO", "DIAGRAMS"]` |
| `/NEX/convert/pdf/docx` | `["STUDIO", "CONVERT"]` |
| `/NEX/projects` | `["PROJECTS"]` |
| `/NEX/projects/nexus-design` | `["PROJECTS", "NEXUS-DESIGN"]` |
| `/NEX/issues/NEX-42` | `["PROJECTS", "NEX-42"]` (Phase 11 will scope issues under projects; Phase 8 flattens) |
| `/instance/settings/general` | `["SETTINGS"]` |
| `/instance/settings/integrations` | `["SETTINGS", "INTEGRATIONS"]` |
| `/` or unknown | `["HOME"]` |
The leaf segment is rendered in volt, non-leaf segments are silver with ` / ` separators.
- [ ] **Step 1: Write the failing test**
Create `ui/src/components/frame/ModeBreadcrumb.test.tsx`:
```tsx
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { MemoryRouter } from "@/lib/router";
import { ModeBreadcrumb, deriveBreadcrumbSegments } from "./ModeBreadcrumb";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
describe("deriveBreadcrumbSegments", () => {
it.each([
["/NEX/assistant", ["ASSISTANT"]],
["/NEX/assistant/conv-abc", ["ASSISTANT"]],
["/NEX/content-studio", ["STUDIO"]],
["/NEX/content-studio/diagrams", ["STUDIO", "DIAGRAMS"]],
["/NEX/convert", ["STUDIO", "CONVERT"]],
["/NEX/convert/pdf/docx", ["STUDIO", "CONVERT"]],
["/NEX/projects", ["PROJECTS"]],
["/NEX/projects/nexus-design", ["PROJECTS", "NEXUS-DESIGN"]],
["/NEX/issues", ["PROJECTS"]],
["/NEX/issues/NEX-42", ["PROJECTS", "NEX-42"]],
["/NEX/agents", ["PROJECTS"]],
["/NEX/routines", ["PROJECTS"]],
["/instance/settings/general", ["SETTINGS"]],
["/instance/settings/integrations", ["SETTINGS", "INTEGRATIONS"]],
["/", ["HOME"]],
["/unknown/path", ["HOME"]],
])("maps %s to %o", (input, expected) => {
expect(deriveBreadcrumbSegments(input)).toEqual(expected);
});
});
describe("ModeBreadcrumb (rendered)", () => {
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 renderCrumb(pathname: string) {
root = createRoot(container);
act(() => {
root!.render(
);
});
return {
getRoot: () => container.querySelector("[data-testid='mode-breadcrumb']") as HTMLElement | null,
getSegments: () =>
Array.from(container.querySelectorAll("[data-testid='mode-breadcrumb-segment']")).map(
(el) => el.textContent?.trim() ?? ""
),
getSeparators: () =>
container.querySelectorAll("[data-testid='mode-breadcrumb-sep']").length,
};
}
it("renders a single ASSISTANT segment and no separator", () => {
const { getSegments, getSeparators } = renderCrumb("/NEX/assistant");
expect(getSegments()).toEqual(["ASSISTANT"]);
expect(getSeparators()).toBe(0);
});
it("renders two segments with one separator for /NEX/content-studio/diagrams", () => {
const { getSegments, getSeparators } = renderCrumb("/NEX/content-studio/diagrams");
expect(getSegments()).toEqual(["STUDIO", "DIAGRAMS"]);
expect(getSeparators()).toBe(1);
});
it("renders PROJECTS / NEX-42 for /NEX/issues/NEX-42", () => {
const { getSegments } = renderCrumb("/NEX/issues/NEX-42");
expect(getSegments()).toEqual(["PROJECTS", "NEX-42"]);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
```bash
cd /opt/nexus/ui && npx vitest run src/components/frame/ModeBreadcrumb.test.tsx
```
Expected: FAIL — `Cannot find module './ModeBreadcrumb'`.
- [ ] **Step 3: Implement `ModeBreadcrumb`**
Create `ui/src/components/frame/ModeBreadcrumb.tsx`:
```tsx
import { useLocation } from "@/lib/router";
import { cn } from "@/lib/utils";
/**
* Derive breadcrumb segments from a pathname.
*
* Exposed for unit testing — the component itself just consumes this.
*
* Mapping rules:
* - `/:prefix/assistant*` → `["ASSISTANT"]` (any suffix collapses)
* - `/:prefix/content-studio` → `["STUDIO"]`
* - `/:prefix/content-studio/` → `["STUDIO", ""]`
* - `/:prefix/convert*` → `["STUDIO", "CONVERT"]` (Phase 10 folds Convert into Studio)
* - `/:prefix/projects` → `["PROJECTS"]`
* - `/:prefix/projects/*` → `["PROJECTS", ""]`
* - `/:prefix/(issues|agents|routines|goals|approvals|costs|activity|inbox)` → `["PROJECTS"]`
* - `/:prefix/issues/` → `["PROJECTS", ""]`
* - `/instance/settings` → `["SETTINGS"]`
* - `/instance/settings/` → `["SETTINGS", ""]`
* - everything else → `["HOME"]`
*/
export function deriveBreadcrumbSegments(pathname: string): string[] {
// /instance/settings/...
if (pathname.startsWith("/instance/settings")) {
const rest = pathname.replace(/^\/instance\/settings\/?/, "");
if (!rest) return ["SETTINGS"];
const leaf = rest.split("/")[0] ?? "";
if (!leaf || leaf === "general") return ["SETTINGS"];
return ["SETTINGS", leaf.toUpperCase()];
}
// Company-prefixed routes: /NEX/
const match = pathname.match(/^\/[^/]+\/(.*)$/);
if (!match) return ["HOME"];
const rest = match[1] ?? "";
if (!rest) return ["HOME"];
const segments = rest.split("/").filter(Boolean);
const head = segments[0];
const next = segments[1];
switch (head) {
case "assistant":
return ["ASSISTANT"];
case "content-studio":
case "studio":
return next ? ["STUDIO", next.toUpperCase()] : ["STUDIO"];
case "convert":
return ["STUDIO", "CONVERT"];
case "projects":
return next ? ["PROJECTS", next.toUpperCase()] : ["PROJECTS"];
case "issues":
case "agents":
case "routines":
case "goals":
case "approvals":
case "costs":
case "activity":
case "inbox":
case "execution-workspaces":
return next ? ["PROJECTS", next.toUpperCase()] : ["PROJECTS"];
default:
return ["HOME"];
}
}
export function ModeBreadcrumb() {
const { pathname } = useLocation();
const segments = deriveBreadcrumbSegments(pathname);
return (
);
}
```
- [ ] **Step 4: Run test to verify it passes**
```bash
cd /opt/nexus/ui && npx vitest run src/components/frame/ModeBreadcrumb.test.tsx
```
Expected: PASS — all test cases green (the `it.each` parameterized table + the 3 render tests).
- [ ] **Step 5: Commit**
```bash
cd /opt/nexus && git add ui/src/components/frame/ModeBreadcrumb.tsx ui/src/components/frame/ModeBreadcrumb.test.tsx
git commit -m "$(cat <<'EOF'
feat(nexus): add ModeBreadcrumb for layout overhaul (phase 8)
Uppercase slash-separated breadcrumb that derives from the current
pathname per docs/specs/2026-04-11-nexus-layout-overhaul.md §4.2.
Leaf segment in volt, non-leaf segments in silver. Pure function
deriveBreadcrumbSegments() is exported for unit testing.
The derivation intentionally collapses Phase 11's soon-to-be-demoted
routes (issues/agents/routines/goals/approvals/costs/activity/inbox)
under the PROJECTS umbrella segment. When Phase 11 lands, the actual
URLs will be /projects/:slug/ and the derivation will naturally
produce PROJECTS / PROJECT-SLUG without code changes.
Part of Phase 8 of the Nexus layout overhaul.
Co-Authored-By: Claude Opus 4.6 (1M context)
EOF
)"
```
---
## Task 3: Create `CmdKButton` component
**Files:**
- Create: `ui/src/components/frame/CmdKButton.tsx`
- Create: `ui/src/components/frame/CmdKButton.test.tsx`
This is a **shim** for Phase 8. The button renders the ⌘K kbd glyph and opens the existing `CommandPalette` by dispatching a synthetic `keydown` event that `CommandPalette.tsx` already listens for on `document`. Phase 14 will replace this with a proper command-palette context. The shim is documented inline so a reviewer doesn't mistake it for production quality.
**Why not refactor CommandPalette's state into a context now?** Because `CommandPalette.tsx` is ~400 lines and touches routing, company selection, dialogs, sidebar state. Refactoring it is out of scope for Phase 8 (which is about chrome, not search). The synthetic keydown is a 1-line hack that works today and gets replaced by Phase 14's real globalization.
- [ ] **Step 1: Write the failing test**
Create `ui/src/components/frame/CmdKButton.test.tsx`:
```tsx
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CmdKButton } from "./CmdKButton";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
describe("CmdKButton", () => {
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 renderButton() {
root = createRoot(container);
act(() => {
root!.render();
});
return {
getButton: () => container.querySelector("button[aria-label='Open command palette']") as HTMLButtonElement,
getKbd: () => container.querySelector("kbd")?.textContent?.trim(),
};
}
it("renders a button with the ⌘K kbd glyph", () => {
const { getButton, getKbd } = renderButton();
expect(getButton()).not.toBeNull();
expect(getKbd()).toBe("⌘K");
});
it("dispatches a Meta+K keydown on document when clicked", () => {
const listener = vi.fn();
document.addEventListener("keydown", listener);
const { getButton } = renderButton();
act(() => {
getButton().click();
});
expect(listener).toHaveBeenCalledTimes(1);
const event = listener.mock.calls[0]![0] as KeyboardEvent;
expect(event.key).toBe("k");
expect(event.metaKey).toBe(true);
document.removeEventListener("keydown", listener);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
```bash
cd /opt/nexus/ui && npx vitest run src/components/frame/CmdKButton.test.tsx
```
Expected: FAIL — `Cannot find module './CmdKButton'`.
- [ ] **Step 3: Implement `CmdKButton`**
Create `ui/src/components/frame/CmdKButton.tsx`:
```tsx
/**
* CmdKButton — Phase 8 shim for the top-strip command palette trigger.
*
* This button renders the ⌘K keyboard glyph and, when clicked, dispatches a
* synthetic Meta+K keydown event on `document`. The existing CommandPalette
* component in ui/src/components/CommandPalette.tsx installs a document-level
* keydown listener for Meta+K and opens itself when that key is pressed, so
* the synthetic event reaches it without needing a refactor to share state.
*
* Phase 14 of docs/specs/2026-04-11-nexus-layout-overhaul.md replaces this
* shim with a proper command-palette context and globalizes the palette's
* search index. This file will either be deleted or gutted at that point.
*/
export function CmdKButton() {
const handleClick = () => {
const event = new KeyboardEvent("keydown", {
key: "k",
metaKey: true,
bubbles: true,
cancelable: true,
});
document.dispatchEvent(event);
};
return (
);
}
```
- [ ] **Step 4: Run test to verify it passes**
```bash
cd /opt/nexus/ui && npx vitest run src/components/frame/CmdKButton.test.tsx
```
Expected: PASS — both test cases green.
- [ ] **Step 5: Commit**
```bash
cd /opt/nexus && git add ui/src/components/frame/CmdKButton.tsx ui/src/components/frame/CmdKButton.test.tsx
git commit -m "$(cat <<'EOF'
feat(nexus): add CmdKButton shim for layout overhaul (phase 8)
Top-strip button that renders the ⌘K glyph and opens the existing
CommandPalette by dispatching a synthetic Meta+K keydown on document,
which CommandPalette.tsx already listens for. This is explicitly a
Phase 8 shim; Phase 14 of docs/specs/2026-04-11-nexus-layout-overhaul.md
replaces it with a proper command-palette context when globalizing
the palette's search index.
Part of Phase 8 of the Nexus layout overhaul.
Co-Authored-By: Claude Opus 4.6 (1M context)
EOF
)"
```
---
## Task 4: Create `GlobalMicButton` component
**Files:**
- Create: `ui/src/components/frame/GlobalMicButton.tsx`
- Create: `ui/src/components/frame/GlobalMicButton.test.tsx`
This is a **visual-only** component for Phase 8. It renders the mic button in its three states (idle / listening / speaking) via a prop. The button's onClick is a no-op in Phase 8; Phase 14 wires it to the actual voice pipeline and adds the queueing-to-Assistant behavior. For Phase 8 we accept only `idle` as the state — the other states are defined as TypeScript types so Phase 14 just has to pass the right state value without changing the component's signature.
The states are specified in `docs/specs/2026-04-11-nexus-layout-overhaul.md` §4.2:
- **idle**: forest green (`#166534`) 8×8 dot centered in a 32×32 rounded-[8px] button
- **listening**: volt (`#faff69`) fill with a 1.5s pulse loop and expanding volt ring (not required in Phase 8)
- **speaking**: silver (`#a0a0a0`) fill, no pulse (not required in Phase 8)
- [ ] **Step 1: Write the failing test**
Create `ui/src/components/frame/GlobalMicButton.test.tsx`:
```tsx
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { GlobalMicButton } from "./GlobalMicButton";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
describe("GlobalMicButton", () => {
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 render(state: "idle" | "listening" | "speaking" = "idle") {
root = createRoot(container);
act(() => {
root!.render();
});
return {
getButton: () => container.querySelector("button[aria-label='Voice']") as HTMLButtonElement,
getDataState: () => container.querySelector("button[aria-label='Voice']")?.getAttribute("data-state"),
};
}
it("renders a button with aria-label 'Voice' by default", () => {
const { getButton } = render();
expect(getButton()).not.toBeNull();
});
it("reflects the state prop via data-state", () => {
expect(render("idle").getDataState()).toBe("idle");
expect(render("listening").getDataState()).toBe("listening");
expect(render("speaking").getDataState()).toBe("speaking");
});
it("is a no-op on click in Phase 8 (does not throw)", () => {
const { getButton } = render();
act(() => {
getButton().click();
});
// If this didn't throw, the test passes.
expect(getButton()).not.toBeNull();
});
});
```
- [ ] **Step 2: Run test to verify it fails**
```bash
cd /opt/nexus/ui && npx vitest run src/components/frame/GlobalMicButton.test.tsx
```
Expected: FAIL — `Cannot find module './GlobalMicButton'`.
- [ ] **Step 3: Implement `GlobalMicButton`**
Create `ui/src/components/frame/GlobalMicButton.tsx`:
```tsx
import { cn } from "@/lib/utils";
export type GlobalMicState = "idle" | "listening" | "speaking";
interface GlobalMicButtonProps {
state?: GlobalMicState;
/**
* Phase 14 will wire this to the voice pipeline. In Phase 8 it's a no-op
* by default; callers can override for manual testing.
*/
onClick?: () => void;
}
/**
* GlobalMicButton — Phase 8 visual-only mic button for the top strip.
*
* Per docs/specs/2026-04-11-nexus-layout-overhaul.md §4.2, three states are
* specified:
* - idle: forest-green dot, no animation
* - listening: volt fill, 1.5s pulse loop + expanding volt ring
* - speaking: silver fill, no pulse
*
* Phase 8 renders all three but only `idle` is wired up functionally. The
* listening / speaking visuals are scaffolded so Phase 14 can toggle the
* state prop without changing this component's signature.
*/
export function GlobalMicButton({ state = "idle", onClick }: GlobalMicButtonProps) {
return (
);
}
```
- [ ] **Step 4: Run test to verify it passes**
```bash
cd /opt/nexus/ui && npx vitest run src/components/frame/GlobalMicButton.test.tsx
```
Expected: PASS — all 3 test cases green.
- [ ] **Step 5: Commit**
```bash
cd /opt/nexus && git add ui/src/components/frame/GlobalMicButton.tsx ui/src/components/frame/GlobalMicButton.test.tsx
git commit -m "$(cat <<'EOF'
feat(nexus): add GlobalMicButton scaffold for layout overhaul (phase 8)
Visual-only mic button for the top strip per
docs/specs/2026-04-11-nexus-layout-overhaul.md §4.2. Renders three
specified states (idle / listening / speaking) but Phase 8 only wires
the idle state functionally. Phase 14 will toggle the state prop from
the voice pipeline without changing this component's signature.
Part of Phase 8 of the Nexus layout overhaul.
Co-Authored-By: Claude Opus 4.6 (1M context)
EOF
)"
```
---
## Task 5: Create `TopStrip` composite
**Files:**
- Create: `ui/src/components/frame/TopStrip.tsx`
- Create: `ui/src/components/frame/TopStrip.test.tsx`
Composes `ModeBreadcrumb` (left) + `CmdKButton` and `GlobalMicButton` (right). 48px tall, charcoal bottom border, pure black background, sticky at the top of the main column.
- [ ] **Step 1: Write the failing test**
Create `ui/src/components/frame/TopStrip.test.tsx`:
```tsx
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { MemoryRouter } from "@/lib/router";
import { TopStrip } from "./TopStrip";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
describe("TopStrip", () => {
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 render(pathname: string) {
root = createRoot(container);
act(() => {
root!.render(
);
});
}
it("renders the ModeBreadcrumb with derived segments", () => {
render("/NEX/assistant");
const segment = container.querySelector("[data-testid='mode-breadcrumb-segment']");
expect(segment?.textContent?.trim()).toBe("ASSISTANT");
});
it("renders the CmdK button", () => {
render("/NEX/assistant");
expect(container.querySelector("button[aria-label='Open command palette']")).not.toBeNull();
});
it("renders the global mic button", () => {
render("/NEX/assistant");
expect(container.querySelector("button[aria-label='Voice']")).not.toBeNull();
});
it("is wrapped in a header element for landmark semantics", () => {
render("/NEX/assistant");
const header = container.querySelector("header");
expect(header).not.toBeNull();
expect(header?.getAttribute("aria-label")).toBe("Top bar");
});
});
```
- [ ] **Step 2: Run test to verify it fails**
```bash
cd /opt/nexus/ui && npx vitest run src/components/frame/TopStrip.test.tsx
```
Expected: FAIL — `Cannot find module './TopStrip'`.
- [ ] **Step 3: Implement `TopStrip`**
Create `ui/src/components/frame/TopStrip.tsx`:
```tsx
import { CmdKButton } from "./CmdKButton";
import { GlobalMicButton } from "./GlobalMicButton";
import { ModeBreadcrumb } from "./ModeBreadcrumb";
export function TopStrip() {
return (
);
}
```
- [ ] **Step 4: Run test to verify it passes**
```bash
cd /opt/nexus/ui && npx vitest run src/components/frame/TopStrip.test.tsx
```
Expected: PASS — all 4 test cases green.
- [ ] **Step 5: Run the whole frame test suite to verify no regressions**
```bash
cd /opt/nexus/ui && npx vitest run src/components/frame/
```
Expected: PASS — all 5 test files green, all test cases from tasks 1–5 pass together.
- [ ] **Step 6: Commit**
```bash
cd /opt/nexus && git add ui/src/components/frame/TopStrip.tsx ui/src/components/frame/TopStrip.test.tsx
git commit -m "$(cat <<'EOF'
feat(nexus): add TopStrip composite for layout overhaul (phase 8)
48px sticky top strip per docs/specs/2026-04-11-nexus-layout-overhaul.md
§4.2. Composes ModeBreadcrumb (left) + CmdKButton and GlobalMicButton
(right) inside a landmark.
Part of Phase 8 of the Nexus layout overhaul. Completes the frame
components; next task mounts them in Layout.tsx.
Co-Authored-By: Claude Opus 4.6 (1M context)
EOF
)"
```
---
## Task 6: Rewrite `Layout.tsx` to use the new frame
**Files:**
- Modify: `ui/src/components/Layout.tsx`
This task is the largest single change in Phase 8. The existing `Layout.tsx` is 510 lines and interleaves chrome (sidebar, ChatPanel, PropertiesPanel, BreadcrumbBar, footer with theme toggle) with non-chrome responsibilities (company-prefix URL sync, first-run onboarding trigger, scroll handling, body overflow, instance settings memory, hasUnknownCompanyPrefix fallback).
**What to keep:**
- Lines 43–52: `readRememberedInstanceSettingsPath()` helper
- Lines 54–98: hook setup (`useSidebar`, `useDialog`, `usePanel`, `useChatPanel`, `useCompany`, `useTheme`, `useParams`, `useNavigate`, `useLocation`, `useQuery` for health + keyboardShortcutsEnabled)
- Lines 100–157: company-prefix URL sync effect
- Lines 110–116: first-run onboarding trigger
- Lines 161–166: the `setPanelVisible(false)` effect can be deleted since PropertiesPanel is killed
- Lines 168: `useCompanyPageMemory()`
- Lines 170–179: `useKeyboardShortcuts` — remove the `onSearch` path that references `chatOpen` / `setChatOpen` (Phase 14 reintroduces a different onSearch)
- Lines 181–233: mobile nav visibility + swipe gesture — keep (mobile still uses the old MobileBottomNav for Phase 8)
- Lines 235–266: scroll tracking for mobile nav visibility — keep
- Lines 268–276: body overflow management — keep
- Lines 278–291: instance settings memory — keep
- Lines 465–490: `hasUnknownCompanyPrefix` fallback redirect — keep
- Overlays at lines 501–506: CommandPalette, NewIssueDialog, NewProjectDialog, NewGoalDialog, NewAgentDialog, ToastViewport — keep
- WorktreeBanner, DevRestartBanner — keep
- Skip-to-main-content link — keep (accessibility)
**What to delete from Layout.tsx:**
- Imports: `Sidebar`, `InstanceSidebar`, `BreadcrumbBar`, `ChatPanel`, `PropertiesPanel`, `MessageSquare`, `Moon`, `Sun`, `BookOpen`, `Tooltip`/`TooltipTrigger`/`TooltipContent`, `Button`, `useChatPanel`, `useTheme`, `usePanel`, `instanceSettingsTarget`-related imports of Settings icon (the Settings icon in the rail is inside IconRail, not Layout)
- `const { chatOpen, setChatOpen, toggleChat } = useChatPanel();` — no longer needed
- `const { theme, toggleTheme } = useTheme();` — no longer needed in Layout (ThemeContext still exists, settings page uses it)
- `const { togglePanelVisible, setPanelVisible } = usePanel();` — no longer needed
- The `togglePanel` declaration
- The effect that closes panel on chatOpen
- The entire mobile sidebar drawer block (lines 319–372)
- The entire desktop sidebar block (lines 374–446)
- The BreadcrumbBar mount (lines 449–455)
- The ChatPanel mount (line 495)
- The PropertiesPanel mount (line 496)
**What to add to Layout.tsx:**
- Import: `IconRail` from `./frame/IconRail`
- Import: `TopStrip` from `./frame/TopStrip`
- Pass `matchedCompany?.issuePrefix ?? selectedCompany?.issuePrefix ?? null` as `companyPrefix` to `IconRail`
- Place `IconRail` and `TopStrip` in the new layout flex structure
- Keep `MobileBottomNav` mounted for mobile only (Phase 15 replaces it)
### New `Layout.tsx` structure (after rewrite)
```tsx
return (
);
}
```
**Note on the `hasUnknownCompanyPrefix` fallback redirect:** the old code defaulted the `restOfPath` fallback to `"/dashboard"`. In the new IA, Dashboard is killed, so the fallback changes to `"/assistant"` (which is the new landing route). This is a Phase 8 vocabulary change that aligns with the spec.
- [ ] **Step 3: Run the full UI test suite to verify nothing else broke**
```bash
cd /opt/nexus/ui && npx vitest run 2>&1 | tail -30
```
Expected: all tests pass. If test files fail due to unrelated pre-existing issues (we saw several at the start of the overhaul — `AgentConfigForm`, onboarding tests, `useKeyboardShortcuts.ts`, etc.), check that none of them were *caused* by the Layout rewrite. Failures in files we didn't touch are pre-existing; failures in Layout itself are new and must be fixed before committing.
- [ ] **Step 4: Run typecheck**
```bash
cd /opt/nexus/ui && npx tsc --noEmit 2>&1 | grep -E "(frame/|Layout\.tsx)" || echo "NO NEW ERRORS in frame/ or Layout.tsx"
```
Expected: `NO NEW ERRORS in frame/ or Layout.tsx`. If new errors appear in those files, fix them before committing.
- [ ] **Step 5: Commit**
```bash
cd /opt/nexus && git add ui/src/components/Layout.tsx
git commit -m "$(cat <<'EOF'
refactor(nexus): mount new frame in Layout.tsx; kill old chrome (phase 8)
Rewrites Layout.tsx to compose the new Phase 8 frame (IconRail +
TopStrip) and remove the old chrome elements specified as killed in
docs/specs/2026-04-11-nexus-layout-overhaul.md §2:
Removed from chrome:
- 280px collapsible Sidebar / InstanceSidebar
- ChatPanel global slide-in right rail
- PropertiesPanel global slide-in right rail
- BreadcrumbBar (replaced by ModeBreadcrumb inside TopStrip)
- Footer row with Docs link, version tooltip, instance settings button,
chat toggle button, theme toggle button
- The effect that closed PropertiesPanel when chat opened (no longer
needed — both panels are unmounted)
Preserved:
- Company-prefix URL sync and fallback redirect machinery
- First-run onboarding trigger
- WorktreeBanner, DevRestartBanner
- Scroll-based mobile nav visibility tracking
- Body overflow management
- Instance settings path memory
- Dialog overlays (NewIssue, NewProject, NewGoal, NewAgent)
- ToastViewport, CommandPalette
- MobileBottomNav (mobile only; Phase 15 replaces)
Added:
- IconRail mount with derived companyPrefix from matchedCompany or
selectedCompany
- TopStrip mount above the main content area
- hasUnknownCompanyPrefix fallback defaults to /assistant instead of
/dashboard (Dashboard is killed in the new IA)
- useKeyboardShortcuts.onSearch now dispatches the same synthetic
Meta+K keydown as the CmdKButton shim
The Sidebar, InstanceSidebar, BreadcrumbBar, ChatPanel, PropertiesPanel,
and ThemeContext files remain in the repo; Phase 16 deletes dead files.
Pages render unchanged in the new frame and will look visually wrong
until Phases 9-13 rebuild their internals. That is the expected
intermediate state per the spec.
Part of Phase 8 of the Nexus layout overhaul.
Co-Authored-By: Claude Opus 4.6 (1M context)
EOF
)"
```
---
## Task 7: Manual smoke test (non-coding verification)
**Files:** none
This is a verification step, not a code change. No commit is produced.
- [ ] **Step 1: Start the dev server**
```bash
cd /opt/nexus && pnpm dev
```
Or if `pnpm` is unavailable on the dev machine:
```bash
cd /opt/nexus/ui && npx vite dev
```
- [ ] **Step 2: Load the app in a browser**
Open `http://10.5.0.128:6100/NEX/assistant` (or whatever `//assistant` resolves to locally).
Expected visual checks:
| Check | Pass criteria |
|---|---|
| Left icon rail visible | 56px wide strip on the left, pure black, showing Nexus mark + 4 Lucide icons (MessageCircle, Sparkles, FolderKanban, Settings) |
| Top strip visible | 48px horizontal strip at the top of the main area, showing `ASSISTANT` in volt on the left and the ⌘K button + mic button on the right |
| Old sidebar gone | No 280px left sidebar with project/agent lists |
| Old ChatPanel gone | No 380px slide-in chat panel on the right when clicking around |
| Old PropertiesPanel gone | No right-side properties panel even on Issue Detail or Agent Detail |
| Theme toggle button gone from chrome | No Sun/Moon button in any footer or header |
| BreadcrumbBar gone | No separate breadcrumb bar below the top strip — the mode label IS the breadcrumb |
| Icon rail active state | The Assistant icon shows volt color and a 2px volt bar on its right edge when on `/NEX/assistant` |
| Icon rail navigation | Clicking Studio navigates to `/NEX/content-studio`; Projects → `/NEX/projects`; Settings → `/instance/settings/general` |
| ⌘K button opens palette | Clicking the ⌘K button opens the existing command palette |
| Mode breadcrumb updates | Navigating to `/NEX/issues/NEX-42` shows `PROJECTS / NEX-42` breadcrumb |
| Pages still render | The old `PersonalAssistant`, `ContentStudio`, `Projects`, `Issues` pages still render inside the main area (they look wrong in the new frame, that's fine) |
| `/NEX/assistant` does not blank | The piper-tts fix from commit `137bd3d0` is in place; the Assistant page loads and renders its current (old) layout inside the new frame |
Any check that fails blocks Phase 8 completion. Log the failure, return to the offending task, fix, and re-run this smoke.
- [ ] **Step 3: Stop the dev server when done**
---
## Self-review checklist
Run this checklist against the plan before dispatching tasks to subagents:
- [ ] Spec coverage: §4.1 IconRail → Task 1 ✓ · §4.2 TopStrip / ModeBreadcrumb → Tasks 2, 3, 4, 5 ✓ · §4.3 kill right rail → Task 6 ✓ · §13 Phase 8 scope ("frame skeleton") → all tasks combined ✓
- [ ] No `TBD` / `TODO` / "add appropriate error handling" style placeholders
- [ ] Every code step contains the actual code
- [ ] Every commit step contains the actual commit command with message
- [ ] File paths are absolute or project-relative throughout
- [ ] Task granularity is bite-sized (write test → run fail → implement → run pass → commit)
- [ ] Task 6 preserves every non-chrome responsibility of the original Layout.tsx
- [ ] The `companyPrefix` prop flows from Layout → IconRail consistently
- [ ] The test pattern matches `ui/src/components/ChatInput.test.tsx` (manual createRoot + act, not react-testing-library)
- [ ] Tailwind classes reference semantic tokens (`bg-background`, `text-muted-foreground`, `border-border`) where possible, with literal hex only for brand-specific one-offs (`#faff69`, `#166534`, `#a0a0a0`)
---
## Execution handoff
After this plan is approved, Phase 8 is executed via **superpowers:subagent-driven-development** — a fresh subagent per task, with two-stage review (design review after the test is written; code review after implementation lands). The dispatcher (main session) reviews between tasks and does not write code itself.
Tasks 1 → 5 touch disjoint files and could in principle run in parallel, but task 6 depends on all of them, and the task-by-task review cadence of subagent-driven-development gives better quality than parallelism saves. Run sequentially.
After all 7 tasks complete and Task 7's smoke test passes, Wave 1 is done. Write the next plan: `docs/plans/2026-04-11-nexus-phase-9-10-11-wave-2.md` or three separate per-phase plans for Wave 2 (Assistant, Studio, Projects), and dispatch those in parallel via **superpowers:dispatching-parallel-agents**.