# 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 `<Outlet />` 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 `<Outlet />` 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<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();
    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 (
    <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>

      <div className="mt-auto pb-1">
        <DestinationLink
          destination={DESTINATIONS[3]!}
          companyPrefix={companyPrefix}
          pathname={pathname}
        />
      </div>
    </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>
  );
}
```

- [ ] **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) <noreply@anthropic.com>
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<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 renderCrumb(pathname: string) {
    root = createRoot(container);
    act(() => {
      root!.render(
        <MemoryRouter initialEntries={[pathname]}>
          <ModeBreadcrumb />
        </MemoryRouter>
      );
    });
    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/<sub>` → `["STUDIO", "<SUB>"]`
 * - `/:prefix/convert*` → `["STUDIO", "CONVERT"]` (Phase 10 folds Convert into Studio)
 * - `/:prefix/projects` → `["PROJECTS"]`
 * - `/:prefix/projects/<slug>*` → `["PROJECTS", "<SLUG>"]`
 * - `/:prefix/(issues|agents|routines|goals|approvals|costs|activity|inbox)` → `["PROJECTS"]`
 * - `/:prefix/issues/<slug>` → `["PROJECTS", "<SLUG>"]`
 * - `/instance/settings` → `["SETTINGS"]`
 * - `/instance/settings/<sub>` → `["SETTINGS", "<SUB>"]`
 * - 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/<rest>
  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 (
    <div
      data-testid="mode-breadcrumb"
      className="flex items-center gap-2 text-[14px] font-semibold uppercase tracking-[0.1em]"
    >
      {segments.map((segment, index) => {
        const isLeaf = index === segments.length - 1;
        return (
          <span key={index} className="flex items-center gap-2">
            {index > 0 && (
              <span
                data-testid="mode-breadcrumb-sep"
                aria-hidden="true"
                className="text-muted-foreground"
              >
                /
              </span>
            )}
            <span
              data-testid="mode-breadcrumb-segment"
              className={cn(isLeaf ? "text-[#faff69]" : "text-muted-foreground")}
            >
              {segment}
            </span>
          </span>
        );
      })}
    </div>
  );
}
```

- [ ] **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/<tab> 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) <noreply@anthropic.com>
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<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 renderButton() {
    root = createRoot(container);
    act(() => {
      root!.render(<CmdKButton />);
    });
    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 (
    <button
      type="button"
      onClick={handleClick}
      aria-label="Open command palette"
      title="Open command palette (⌘K)"
      className="inline-flex h-8 items-center gap-2 rounded-[4px] border border-border bg-card px-2 text-[12px] font-medium text-muted-foreground transition-colors hover:text-[#faff69] hover:border-[#faff69]"
    >
      <kbd className="font-mono text-[12px]">⌘K</kbd>
    </button>
  );
}
```

- [ ] **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) <noreply@anthropic.com>
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<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 render(state: "idle" | "listening" | "speaking" = "idle") {
    root = createRoot(container);
    act(() => {
      root!.render(<GlobalMicButton state={state} />);
    });
    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 (
    <button
      type="button"
      onClick={onClick}
      aria-label="Voice"
      title="Voice (Phase 14 — not yet wired)"
      data-state={state}
      className={cn(
        "relative inline-flex h-8 w-8 items-center justify-center rounded-[8px]",
        "border border-border bg-card transition-colors",
        "hover:border-[#faff69]",
      )}
    >
      {state === "idle" && (
        <span
          aria-hidden="true"
          className="h-2 w-2 rounded-full bg-[#166534]"
        />
      )}
      {state === "listening" && (
        <>
          <span
            aria-hidden="true"
            className="h-2 w-2 rounded-full bg-[#faff69] animate-pulse"
          />
          <span
            aria-hidden="true"
            className="absolute inset-0 rounded-[8px] border-2 border-[#faff69] opacity-60 animate-ping"
          />
        </>
      )}
      {state === "speaking" && (
        <span
          aria-hidden="true"
          className="h-2 w-2 rounded-full bg-[#a0a0a0]"
        />
      )}
    </button>
  );
}
```

- [ ] **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) <noreply@anthropic.com>
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<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 render(pathname: string) {
    root = createRoot(container);
    act(() => {
      root!.render(
        <MemoryRouter initialEntries={[pathname]}>
          <TopStrip />
        </MemoryRouter>
      );
    });
  }

  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 (
    <header
      aria-label="Top bar"
      className="sticky top-0 z-30 flex h-12 shrink-0 items-center justify-between border-b border-border bg-background px-6"
    >
      <ModeBreadcrumb />
      <div className="flex items-center gap-4">
        <CmdKButton />
        <GlobalMicButton state="idle" />
      </div>
    </header>
  );
}
```

- [ ] **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 <header aria-label="Top bar"> 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) <noreply@anthropic.com>
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 (
  <GeneralSettingsProvider value={{ keyboardShortcutsEnabled }}>
    <div
      className={cn(
        "bg-background text-foreground pt-[env(safe-area-inset-top)]",
        isMobile ? "min-h-dvh" : "flex h-dvh flex-col overflow-hidden",
      )}
    >
      <a href="#main-content" className="/* skip link */">Skip to Main Content</a>

      <WorktreeBanner />
      <DevRestartBanner devServer={health?.devServer} />

      <div className={cn("min-h-0 flex-1", isMobile ? "w-full" : "flex overflow-hidden")}>
        {/* Desktop-only icon rail */}
        <IconRail companyPrefix={railCompanyPrefix} />

        {/* Main column */}
        <div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
          <TopStrip />

          <main
            id="main-content"
            tabIndex={-1}
            className={cn(
              "flex-1",
              isMobile ? "overflow-visible pb-[calc(5rem+env(safe-area-inset-bottom))]" : "overflow-auto",
            )}
          >
            {hasUnknownCompanyPrefix ? /* existing fallback logic */ : <Outlet />}
          </main>
        </div>
      </div>

      {/* Mobile bottom nav stays until Phase 15 */}
      {isMobile && <MobileBottomNav visible={mobileNavVisible} />}

      {/* Overlays unchanged */}
      <CommandPalette />
      <NewIssueDialog />
      <NewProjectDialog />
      <NewGoalDialog />
      <NewAgentDialog />
      <ToastViewport />
    </div>
  </GeneralSettingsProvider>
);
```

There is no test added for this task — the component tests from tasks 1–5 verify the new frame, and Layout.tsx primarily composes them. A manual smoke test (Step 7) verifies the integration.

- [ ] **Step 1: Read the current `Layout.tsx`**

Open `ui/src/components/Layout.tsx` in the editor. The file is 510 lines; the above "what to keep / delete / add" lists map every section.

- [ ] **Step 2: Rewrite `Layout.tsx`**

Replace the entire file contents with:

```tsx
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Navigate, Outlet, useLocation, useNavigate, useParams } from "@/lib/router";
// [nexus] CompanyRail intentionally not rendered — single-workspace mode.
// The file is preserved for upstream rebase compatibility.
import { CommandPalette } from "./CommandPalette";
import { NewIssueDialog } from "./NewIssueDialog";
import { NewProjectDialog } from "./NewProjectDialog";
import { NewGoalDialog } from "./NewGoalDialog";
import { NewAgentDialog } from "./NewAgentDialog";
import { ToastViewport } from "./ToastViewport";
import { MobileBottomNav } from "./MobileBottomNav";
import { WorktreeBanner } from "./WorktreeBanner";
import { DevRestartBanner } from "./DevRestartBanner";
import { IconRail } from "./frame/IconRail";
import { TopStrip } from "./frame/TopStrip";
import { useDialog } from "../context/DialogContext";
import { GeneralSettingsProvider } from "../context/GeneralSettingsContext";
import { useCompany } from "../context/CompanyContext";
import { useSidebar } from "../context/SidebarContext";
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
import { healthApi } from "../api/health";
import { instanceSettingsApi } from "../api/instanceSettings";
import { shouldSyncCompanySelectionFromRoute } from "../lib/company-selection";
import {
  DEFAULT_INSTANCE_SETTINGS_PATH,
  normalizeRememberedInstanceSettingsPath,
} from "../lib/instance-settings";
import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils";
import { NotFoundPage } from "../pages/NotFound";

const INSTANCE_SETTINGS_MEMORY_KEY = "paperclip.lastInstanceSettingsPath";

function readRememberedInstanceSettingsPath(): string {
  if (typeof window === "undefined") return DEFAULT_INSTANCE_SETTINGS_PATH;
  try {
    return normalizeRememberedInstanceSettingsPath(window.localStorage.getItem(INSTANCE_SETTINGS_MEMORY_KEY));
  } catch {
    return DEFAULT_INSTANCE_SETTINGS_PATH;
  }
}

export function Layout() {
  const { sidebarOpen, setSidebarOpen, isMobile } = useSidebar();
  const { openNewIssue, openOnboarding } = useDialog();
  const {
    companies,
    loading: companiesLoading,
    selectedCompany,
    selectedCompanyId,
    selectionSource,
    setSelectedCompanyId,
  } = useCompany();
  const { companyPrefix } = useParams<{ companyPrefix: string }>();
  const navigate = useNavigate();
  const location = useLocation();
  const onboardingTriggered = useRef(false);
  const lastMainScrollTop = useRef(0);
  const [mobileNavVisible, setMobileNavVisible] = useState(true);
  const [, setInstanceSettingsTarget] = useState<string>(() => readRememberedInstanceSettingsPath());

  const matchedCompany = useMemo(() => {
    if (!companyPrefix) return null;
    const requestedPrefix = companyPrefix.toUpperCase();
    return companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix) ?? null;
  }, [companies, companyPrefix]);
  const hasUnknownCompanyPrefix =
    Boolean(companyPrefix) && !companiesLoading && companies.length > 0 && !matchedCompany;

  const railCompanyPrefix = matchedCompany?.issuePrefix ?? selectedCompany?.issuePrefix ?? null;

  const { data: health } = useQuery({
    queryKey: queryKeys.health,
    queryFn: () => healthApi.get(),
    retry: false,
    refetchInterval: (query) => {
      const data = query.state.data as { devServer?: { enabled?: boolean } } | undefined;
      return data?.devServer?.enabled ? 2000 : false;
    },
    refetchIntervalInBackground: true,
  });
  const keyboardShortcutsEnabled = useQuery({
    queryKey: queryKeys.instance.generalSettings,
    queryFn: () => instanceSettingsApi.getGeneral(),
  }).data?.keyboardShortcuts === true;

  // [nexus] First-run onboarding trigger. See original Layout.tsx for the full
  // rationale comment; this is retained as a belt-and-suspenders fallback.
  useEffect(() => {
    if (companiesLoading || onboardingTriggered.current) return;
    if (companies.length === 0) {
      onboardingTriggered.current = true;
      openOnboarding();
    }
  }, [companies, companiesLoading, openOnboarding]);

  // Company-prefix URL sync.
  useEffect(() => {
    if (!companyPrefix || companiesLoading || companies.length === 0) return;

    if (!matchedCompany) {
      const fallback = (selectedCompanyId ? companies.find((company) => company.id === selectedCompanyId) : null)
        ?? companies[0]
        ?? null;
      if (fallback && selectedCompanyId !== fallback.id) {
        setSelectedCompanyId(fallback.id, { source: "route_sync" });
      }
      return;
    }

    if (companyPrefix !== matchedCompany.issuePrefix) {
      const suffix = location.pathname.replace(/^\/[^/]+/, "");
      navigate(`/${matchedCompany.issuePrefix}${suffix}${location.search}`, { replace: true });
      return;
    }

    if (
      shouldSyncCompanySelectionFromRoute({
        selectionSource,
        selectedCompanyId,
        routeCompanyId: matchedCompany.id,
      })
    ) {
      setSelectedCompanyId(matchedCompany.id, { source: "route_sync" });
    }
  }, [
    companyPrefix,
    companies,
    companiesLoading,
    matchedCompany,
    location.pathname,
    location.search,
    navigate,
    selectionSource,
    selectedCompanyId,
    setSelectedCompanyId,
  ]);

  useCompanyPageMemory();

  useKeyboardShortcuts({
    enabled: keyboardShortcutsEnabled,
    onNewIssue: () => openNewIssue(),
    onToggleSidebar: () => {
      // Phase 8: sidebar toggle is a no-op from keyboard — the rail is fixed.
      // Kept as a stub so useKeyboardShortcuts' type contract is satisfied.
    },
    onTogglePanel: () => {
      // Phase 8: PropertiesPanel is no longer mounted globally.
    },
    onSearch: () => {
      // Phase 8: open the command palette via synthetic keydown, mirroring
      // the CmdKButton shim. Phase 14 replaces with a real palette context.
      document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true, bubbles: true }));
    },
  });

  useEffect(() => {
    if (!isMobile) {
      setMobileNavVisible(true);
      return;
    }
    lastMainScrollTop.current = 0;
    setMobileNavVisible(true);
  }, [isMobile]);

  // Swipe gesture is a no-op in Phase 8 (no drawer to open) but the listener
  // is cheap to retain; Phase 15 may reintroduce a gesture for the mobile
  // bottom tab bar state.
  useEffect(() => {
    if (!isMobile) return;
    const onTouchStart = () => {};
    const onTouchEnd = () => {};
    document.addEventListener("touchstart", onTouchStart, { passive: true });
    document.addEventListener("touchend", onTouchEnd, { passive: true });
    return () => {
      document.removeEventListener("touchstart", onTouchStart);
      document.removeEventListener("touchend", onTouchEnd);
    };
  }, [isMobile, sidebarOpen, setSidebarOpen]);

  const updateMobileNavVisibility = useCallback((currentTop: number) => {
    const delta = currentTop - lastMainScrollTop.current;
    if (currentTop <= 24) setMobileNavVisible(true);
    else if (delta > 8) setMobileNavVisible(false);
    else if (delta < -8) setMobileNavVisible(true);
    lastMainScrollTop.current = currentTop;
  }, []);

  useEffect(() => {
    if (!isMobile) {
      setMobileNavVisible(true);
      lastMainScrollTop.current = 0;
      return;
    }
    const onScroll = () => {
      updateMobileNavVisibility(window.scrollY || document.documentElement.scrollTop || 0);
    };
    onScroll();
    window.addEventListener("scroll", onScroll, { passive: true });
    return () => window.removeEventListener("scroll", onScroll);
  }, [isMobile, updateMobileNavVisibility]);

  useEffect(() => {
    const previousOverflow = document.body.style.overflow;
    document.body.style.overflow = isMobile ? "visible" : "hidden";
    return () => {
      document.body.style.overflow = previousOverflow;
    };
  }, [isMobile]);

  useEffect(() => {
    if (!location.pathname.startsWith("/instance/settings/")) return;
    const nextPath = normalizeRememberedInstanceSettingsPath(
      `${location.pathname}${location.search}${location.hash}`,
    );
    setInstanceSettingsTarget(nextPath);
    try {
      window.localStorage.setItem(INSTANCE_SETTINGS_MEMORY_KEY, nextPath);
    } catch {
      // Ignore storage failures in restricted environments.
    }
  }, [location.hash, location.pathname, location.search]);

  return (
    <GeneralSettingsProvider value={{ keyboardShortcutsEnabled }}>
      <div
        className={cn(
          "bg-background text-foreground pt-[env(safe-area-inset-top)]",
          isMobile ? "min-h-dvh" : "flex h-dvh flex-col overflow-hidden",
        )}
      >
        <a
          href="#main-content"
          className="sr-only focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:z-[200] focus:rounded-md focus:bg-background focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
        >
          Skip to Main Content
        </a>

        <WorktreeBanner />
        <DevRestartBanner devServer={health?.devServer} />

        <div className={cn("min-h-0 flex-1", isMobile ? "w-full" : "flex overflow-hidden")}>
          <IconRail companyPrefix={railCompanyPrefix} />

          <div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
            <TopStrip />

            <main
              id="main-content"
              tabIndex={-1}
              className={cn(
                "flex-1",
                isMobile ? "overflow-visible pb-[calc(5rem+env(safe-area-inset-bottom))]" : "overflow-auto p-4 md:p-6",
              )}
            >
              {hasUnknownCompanyPrefix ? (
                (() => {
                  const fallbackCompany = selectedCompany ?? companies[0] ?? null;
                  if (!fallbackCompany) {
                    return (
                      <NotFoundPage
                        scope="invalid_company_prefix"
                        requestedPrefix={companyPrefix ?? undefined}
                      />
                    );
                  }
                  const restOfPath = location.pathname.replace(/^\/[^/]+/, "") || "/assistant";
                  return (
                    <Navigate
                      to={`/${fallbackCompany.issuePrefix}${restOfPath}${location.search}${location.hash}`}
                      replace
                    />
                  );
                })()
              ) : (
                <Outlet />
              )}
            </main>
          </div>
        </div>

        {isMobile && <MobileBottomNav visible={mobileNavVisible} />}

        <CommandPalette />
        <NewIssueDialog />
        <NewProjectDialog />
        <NewGoalDialog />
        <NewAgentDialog />
        <ToastViewport />
      </div>
    </GeneralSettingsProvider>
  );
}
```

**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) <noreply@anthropic.com>
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 `/<prefix>/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**.
