From 8f46b9a39df6a163bbe718906486d49c76feec0a Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 9 Apr 2026 19:14:58 -0700 Subject: [PATCH 1/5] docs: add cockpit mobile menu implementation plan 2 tasks: create MobileNavOverlay component with tests, integrate into cockpit-shell.tsx. Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-04-09-cockpit-mobile-menu.md | 442 ++++++++++++++++++ 1 file changed, 442 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-09-cockpit-mobile-menu.md diff --git a/docs/superpowers/plans/2026-04-09-cockpit-mobile-menu.md b/docs/superpowers/plans/2026-04-09-cockpit-mobile-menu.md new file mode 100644 index 000000000..6c804da38 --- /dev/null +++ b/docs/superpowers/plans/2026-04-09-cockpit-mobile-menu.md @@ -0,0 +1,442 @@ +# Cockpit Mobile Menu Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the cockpit's narrow w-64 mobile drawer with a full-screen overlay using product cards and topic chips. + +**Architecture:** A new `MobileNavOverlay` client component renders a `fixed inset-0` overlay on mobile with product groups as glass cards and topics as pill-shaped `` links. It reuses the `LanguagePicker` component and `toCockpitPath` helper. The existing `CockpitSidebar` is untouched (desktop only). The `cockpit-shell.tsx` swaps the old mobile overlay JSX for the new component. + +**Tech Stack:** React 19, Next.js 15, Tailwind CSS v4, CSS custom properties (`--ds-*` design tokens), Vitest + +**Spec:** `docs/superpowers/specs/2026-04-09-cockpit-mobile-menu-design.md` + +--- + +## File Map + +| File | Action | Responsibility | +|------|--------|----------------| +| `apps/cockpit/src/components/mobile-nav-overlay.tsx` | Create | Full-screen mobile nav overlay with product cards + topic chips | +| `apps/cockpit/src/components/mobile-nav-overlay.spec.tsx` | Create | Tests for the overlay component | +| `apps/cockpit/src/components/cockpit-shell.tsx` | Modify (lines 73-95) | Replace old mobile drawer with `MobileNavOverlay` | + +--- + +### Task 1: Create MobileNavOverlay component with tests + +**Files:** +- Create: `apps/cockpit/src/components/mobile-nav-overlay.spec.tsx` +- Create: `apps/cockpit/src/components/mobile-nav-overlay.tsx` + +- [ ] **Step 1: Write the failing tests** + +Create `apps/cockpit/src/components/mobile-nav-overlay.spec.tsx`: + +```tsx +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { cockpitManifest } from '@cacheplane/cockpit-registry'; +import { buildNavigationTree } from '../lib/route-resolution'; +import { MobileNavOverlay } from './mobile-nav-overlay'; + +const tree = buildNavigationTree(cockpitManifest); +const entry = cockpitManifest.find( + (e) => + e.product === 'render' && + e.section === 'core-capabilities' && + e.topic === 'spec-rendering' && + e.language === 'python' +)!; + +describe('MobileNavOverlay', () => { + it('renders nothing when closed', () => { + const html = renderToStaticMarkup( + {}} + /> + ); + expect(html).toBe(''); + }); + + it('renders all four product groups when open', () => { + const html = renderToStaticMarkup( + {}} + /> + ); + expect(html).toContain('LangGraph'); + expect(html).toContain('Render'); + expect(html).toContain('Chat'); + expect(html).toContain('Deep Agents'); + }); + + it('renders topic chips as links', () => { + const html = renderToStaticMarkup( + {}} + /> + ); + expect(html).toContain('Streaming'); + expect(html).toContain('Persistence'); + expect(html).toContain('Messages'); + expect(html).toContain('href="/'); + }); + + it('highlights the active entry chip', () => { + const html = renderToStaticMarkup( + {}} + /> + ); + expect(html).toContain('aria-current="page"'); + }); + + it('strips product prefix from topic titles', () => { + const html = renderToStaticMarkup( + {}} + /> + ); + // "Render Spec Rendering" should become "Spec Rendering" + // Should NOT contain the double-prefixed form + expect(html).toContain('Spec Rendering'); + expect(html).not.toContain('>Render Spec Rendering<'); + }); + + it('filters out overview topics', () => { + const html = renderToStaticMarkup( + {}} + /> + ); + // overview entries should be excluded + const overviewLinkCount = (html.match(/\/overview\//g) || []).length; + // Only the path segments that are part of other routes, not standalone overview chips + expect(html).not.toMatch(/data-topic="overview"/); + }); + + it('includes the language picker', () => { + const html = renderToStaticMarkup( + {}} + /> + ); + expect(html).toContain('Python'); + }); + + it('includes a close button', () => { + const html = renderToStaticMarkup( + {}} + /> + ); + expect(html).toContain('aria-label="Close navigation"'); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx nx test cockpit -- --run --reporter=verbose apps/cockpit/src/components/mobile-nav-overlay.spec.tsx` + +Expected: FAIL — `MobileNavOverlay` does not exist yet. + +- [ ] **Step 3: Implement MobileNavOverlay** + +Create `apps/cockpit/src/components/mobile-nav-overlay.tsx`: + +```tsx +'use client'; + +import React, { useEffect, useRef, useState } from 'react'; +import type { CockpitManifestEntry } from '@cacheplane/cockpit-registry'; +import type { NavigationProduct } from '../lib/route-resolution'; +import { toCockpitPath } from '../lib/route-resolution'; +import { LanguagePicker } from './sidebar/language-picker'; + +const PRODUCT_LABELS: Record = { + 'deep-agents': 'Deep Agents', + 'langgraph': 'LangGraph', + 'render': 'Render', + 'chat': 'Chat', +}; + +function stripProductPrefix(title: string): string { + const prefixes = ['Deep Agents ', 'LangGraph ', 'Render ', 'Chat ']; + for (const p of prefixes) { + if (title.startsWith(p)) return title.slice(p.length); + } + return title; +} + +function CloseIcon() { + return ( + + ); +} + +interface MobileNavOverlayProps { + navigationTree: NavigationProduct[]; + manifest: CockpitManifestEntry[]; + entry: CockpitManifestEntry; + isOpen: boolean; + onClose: () => void; +} + +export function MobileNavOverlay({ + navigationTree, + manifest, + entry, + isOpen, + onClose, +}: MobileNavOverlayProps) { + const overlayRef = useRef(null); + const [state, setState] = useState<'closed' | 'open' | 'closing'>('closed'); + + useEffect(() => { + if (isOpen) { + setState('open'); + } else if (state === 'open') { + setState('closing'); + } + }, [isOpen]); + + useEffect(() => { + if (state === 'closing') { + const timer = setTimeout(() => setState('closed'), 150); + return () => clearTimeout(timer); + } + }, [state]); + + useEffect(() => { + if (state !== 'open') return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [state, onClose]); + + if (state === 'closed') return null; + + return ( +
+ {/* Header */} +
+

+ Cockpit +

+
+ + +
+
+ + {/* Scrollable product cards */} +
+ {navigationTree.map((product) => { + const label = PRODUCT_LABELS[product.product] ?? product.product; + const topics = product.sections.flatMap((section) => + section.entries.filter((e) => e.topic !== 'overview') + ); + + if (topics.length === 0) return null; + + return ( +
+

+ {label} +

+
+
+ ); + })} +
+
+ ); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx nx test cockpit -- --run --reporter=verbose apps/cockpit/src/components/mobile-nav-overlay.spec.tsx` + +Expected: All 7 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/cockpit/src/components/mobile-nav-overlay.tsx apps/cockpit/src/components/mobile-nav-overlay.spec.tsx +git commit -m "feat(cockpit): add full-screen mobile nav overlay component" +``` + +--- + +### Task 2: Integrate MobileNavOverlay into cockpit-shell.tsx + +**Files:** +- Modify: `apps/cockpit/src/components/cockpit-shell.tsx:1-148` + +- [ ] **Step 1: Add the import** + +At the top of `cockpit-shell.tsx`, add the import after line 12 (`import { CockpitSidebar }...`): + +```tsx +import { MobileNavOverlay } from './mobile-nav-overlay'; +``` + +- [ ] **Step 2: Replace the mobile sidebar overlay** + +Remove lines 73-95 (the `{isSidebarOpen && (<>...)}` block) and replace with: + +```tsx + {/* Mobile full-screen nav overlay */} + setIsSidebarOpen(false)} + /> +``` + +Everything else in the file stays the same — the `isSidebarOpen` state, the hamburger button, the desktop sidebar, the header, and the content area are all unchanged. + +- [ ] **Step 3: Run the full cockpit test suite** + +Run: `npx nx test cockpit -- --run --reporter=verbose` + +Expected: All existing tests PASS (sidebar, language-picker, mode-switcher, run-mode, etc.) plus the new mobile-nav-overlay tests. + +- [ ] **Step 4: Verify locally in the browser** + +Run: `npx nx serve cockpit --port 4201` + +Open `http://localhost:4201/render/core-capabilities/spec-rendering/overview/python` in a mobile viewport (Chrome DevTools device toolbar, ~375px wide): +- Tap the hamburger menu icon +- Overlay should appear full-screen with animated fade-in +- All 4 product cards visible with topic chips +- "Spec Rendering" chip should be highlighted +- Tap X to close — should fade out +- Tap a different chip — should navigate to that page + +- [ ] **Step 5: Commit** + +```bash +git add apps/cockpit/src/components/cockpit-shell.tsx +git commit -m "feat(cockpit): integrate full-screen mobile nav overlay in shell" +``` From 772b5e5a4e909f1938af17e84781034eaac1c35c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 9 Apr 2026 19:17:51 -0700 Subject: [PATCH 2/5] feat(cockpit): add full-screen mobile nav overlay component Co-Authored-By: Claude Opus 4.6 --- .../components/mobile-nav-overlay.spec.tsx | 128 ++++++++++++ .../src/components/mobile-nav-overlay.tsx | 192 ++++++++++++++++++ 2 files changed, 320 insertions(+) create mode 100644 apps/cockpit/src/components/mobile-nav-overlay.spec.tsx create mode 100644 apps/cockpit/src/components/mobile-nav-overlay.tsx diff --git a/apps/cockpit/src/components/mobile-nav-overlay.spec.tsx b/apps/cockpit/src/components/mobile-nav-overlay.spec.tsx new file mode 100644 index 000000000..f0777faec --- /dev/null +++ b/apps/cockpit/src/components/mobile-nav-overlay.spec.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { cockpitManifest } from '@cacheplane/cockpit-registry'; +import { buildNavigationTree } from '../lib/route-resolution'; +import { MobileNavOverlay } from './mobile-nav-overlay'; + +const tree = buildNavigationTree(cockpitManifest); +const entry = cockpitManifest.find( + (e) => + e.product === 'render' && + e.section === 'core-capabilities' && + e.topic === 'spec-rendering' && + e.language === 'python' +)!; + +describe('MobileNavOverlay', () => { + it('renders nothing when closed', () => { + const html = renderToStaticMarkup( + {}} + /> + ); + expect(html).toBe(''); + }); + + it('renders all four product groups when open', () => { + const html = renderToStaticMarkup( + {}} + /> + ); + expect(html).toContain('LangGraph'); + expect(html).toContain('Render'); + expect(html).toContain('Chat'); + expect(html).toContain('Deep Agents'); + }); + + it('renders topic chips as links', () => { + const html = renderToStaticMarkup( + {}} + /> + ); + expect(html).toContain('Streaming'); + expect(html).toContain('Persistence'); + expect(html).toContain('Messages'); + expect(html).toContain('href="/'); + }); + + it('highlights the active entry chip', () => { + const html = renderToStaticMarkup( + {}} + /> + ); + expect(html).toContain('aria-current="page"'); + }); + + it('strips product prefix from topic titles', () => { + const html = renderToStaticMarkup( + {}} + /> + ); + expect(html).toContain('Spec Rendering'); + expect(html).not.toContain('>Render Spec Rendering<'); + }); + + it('filters out overview topics', () => { + const html = renderToStaticMarkup( + {}} + /> + ); + expect(html).not.toMatch(/data-topic="overview"/); + }); + + it('includes the language picker', () => { + const html = renderToStaticMarkup( + {}} + /> + ); + expect(html).toContain('Python'); + }); + + it('includes a close button', () => { + const html = renderToStaticMarkup( + {}} + /> + ); + expect(html).toContain('aria-label="Close navigation"'); + }); +}); diff --git a/apps/cockpit/src/components/mobile-nav-overlay.tsx b/apps/cockpit/src/components/mobile-nav-overlay.tsx new file mode 100644 index 000000000..8ce5c42a7 --- /dev/null +++ b/apps/cockpit/src/components/mobile-nav-overlay.tsx @@ -0,0 +1,192 @@ +'use client'; + +import React, { useEffect, useRef, useState } from 'react'; +import type { CockpitManifestEntry } from '@cacheplane/cockpit-registry'; +import type { NavigationProduct } from '../lib/route-resolution'; +import { toCockpitPath } from '../lib/route-resolution'; +import { LanguagePicker } from './sidebar/language-picker'; + +const PRODUCT_LABELS: Record = { + 'deep-agents': 'Deep Agents', + 'langgraph': 'LangGraph', + 'render': 'Render', + 'chat': 'Chat', +}; + +function stripProductPrefix(title: string): string { + const prefixes = ['Deep Agents ', 'LangGraph ', 'Render ', 'Chat ']; + for (const p of prefixes) { + if (title.startsWith(p)) return title.slice(p.length); + } + return title; +} + +function CloseIcon() { + return ( + + ); +} + +interface MobileNavOverlayProps { + navigationTree: NavigationProduct[]; + manifest: CockpitManifestEntry[]; + entry: CockpitManifestEntry; + isOpen: boolean; + onClose: () => void; +} + +export function MobileNavOverlay({ + navigationTree, + manifest, + entry, + isOpen, + onClose, +}: MobileNavOverlayProps) { + const overlayRef = useRef(null); + const [state, setState] = useState<'closed' | 'open' | 'closing'>( + isOpen ? 'open' : 'closed' + ); + + useEffect(() => { + if (isOpen) { + setState('open'); + } else if (state === 'open') { + setState('closing'); + } + }, [isOpen]); + + useEffect(() => { + if (state === 'closing') { + const timer = setTimeout(() => setState('closed'), 150); + return () => clearTimeout(timer); + } + }, [state]); + + useEffect(() => { + if (state !== 'open') return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [state, onClose]); + + if (state === 'closed') return null; + + return ( +
+ {/* Header */} +
+

+ Cockpit +

+
+ + +
+
+ + {/* Scrollable product cards */} +
+ {navigationTree.map((product) => { + const label = PRODUCT_LABELS[product.product] ?? product.product; + const topics = product.sections.flatMap((section) => + section.entries.filter((e) => e.topic !== 'overview') + ); + + if (topics.length === 0) return null; + + return ( +
+

+ {label} +

+
+ {topics.map((topicEntry) => { + const isActive = + topicEntry.product === entry.product && + topicEntry.section === entry.section && + topicEntry.topic === entry.topic && + topicEntry.page === entry.page; + + return ( + + {stripProductPrefix(topicEntry.title)} + + ); + })} +
+
+ ); + })} +
+
+ ); +} From a7d89157f1717bd26f1ec475616931549cfc1e8c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 9 Apr 2026 19:23:02 -0700 Subject: [PATCH 3/5] =?UTF-8?q?fix(cockpit):=20address=20code=20review=20?= =?UTF-8?q?=E2=80=94=20extract=20shared=20labels,=20remove=20dead=20ref,?= =?UTF-8?q?=20fix=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../components/mobile-nav-overlay.spec.tsx | 5 ++++- .../src/components/mobile-nav-overlay.tsx | 20 ++----------------- .../components/sidebar/navigation-groups.tsx | 17 +--------------- apps/cockpit/src/lib/navigation-labels.ts | 14 +++++++++++++ 4 files changed, 21 insertions(+), 35 deletions(-) create mode 100644 apps/cockpit/src/lib/navigation-labels.ts diff --git a/apps/cockpit/src/components/mobile-nav-overlay.spec.tsx b/apps/cockpit/src/components/mobile-nav-overlay.spec.tsx index f0777faec..7808c4252 100644 --- a/apps/cockpit/src/components/mobile-nav-overlay.spec.tsx +++ b/apps/cockpit/src/components/mobile-nav-overlay.spec.tsx @@ -97,7 +97,10 @@ describe('MobileNavOverlay', () => { onClose={() => {}} /> ); - expect(html).not.toMatch(/data-topic="overview"/); + // No chip should link to an overview topic (topic segment = "overview") + const hrefMatches = html.match(/href="[^"]+"/g) || []; + const overviewHrefs = hrefMatches.filter((h) => /\/overview\/overview\//.test(h)); + expect(overviewHrefs).toHaveLength(0); }); it('includes the language picker', () => { diff --git a/apps/cockpit/src/components/mobile-nav-overlay.tsx b/apps/cockpit/src/components/mobile-nav-overlay.tsx index 8ce5c42a7..3e8d1dd1a 100644 --- a/apps/cockpit/src/components/mobile-nav-overlay.tsx +++ b/apps/cockpit/src/components/mobile-nav-overlay.tsx @@ -1,26 +1,12 @@ 'use client'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import type { CockpitManifestEntry } from '@cacheplane/cockpit-registry'; import type { NavigationProduct } from '../lib/route-resolution'; import { toCockpitPath } from '../lib/route-resolution'; +import { PRODUCT_LABELS, stripProductPrefix } from '../lib/navigation-labels'; import { LanguagePicker } from './sidebar/language-picker'; -const PRODUCT_LABELS: Record = { - 'deep-agents': 'Deep Agents', - 'langgraph': 'LangGraph', - 'render': 'Render', - 'chat': 'Chat', -}; - -function stripProductPrefix(title: string): string { - const prefixes = ['Deep Agents ', 'LangGraph ', 'Render ', 'Chat ']; - for (const p of prefixes) { - if (title.startsWith(p)) return title.slice(p.length); - } - return title; -} - function CloseIcon() { return (
= { - 'deep-agents': 'Deep Agents', - 'langgraph': 'LangGraph', - 'render': 'Render', - 'chat': 'Chat', -}; - - -function stripProductPrefix(title: string): string { - const prefixes = ['Deep Agents ', 'LangGraph ', 'Render ', 'Chat ']; - for (const p of prefixes) { - if (title.startsWith(p)) return title.slice(p.length); - } - return title; -} +import { PRODUCT_LABELS, stripProductPrefix } from '../../lib/navigation-labels'; interface NavigationGroupsProps { tree: NavigationProduct[]; diff --git a/apps/cockpit/src/lib/navigation-labels.ts b/apps/cockpit/src/lib/navigation-labels.ts new file mode 100644 index 000000000..73641ee93 --- /dev/null +++ b/apps/cockpit/src/lib/navigation-labels.ts @@ -0,0 +1,14 @@ +export const PRODUCT_LABELS: Record = { + 'deep-agents': 'Deep Agents', + 'langgraph': 'LangGraph', + 'render': 'Render', + 'chat': 'Chat', +}; + +export function stripProductPrefix(title: string): string { + const prefixes = ['Deep Agents ', 'LangGraph ', 'Render ', 'Chat ']; + for (const p of prefixes) { + if (title.startsWith(p)) return title.slice(p.length); + } + return title; +} From 843c07a443e0f79c2ea8855e10bd971e90dc71bb Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 9 Apr 2026 19:23:54 -0700 Subject: [PATCH 4/5] feat(cockpit): integrate full-screen mobile nav overlay in shell --- apps/cockpit/src/components/cockpit-shell.tsx | 33 +++++-------------- 1 file changed, 9 insertions(+), 24 deletions(-) diff --git a/apps/cockpit/src/components/cockpit-shell.tsx b/apps/cockpit/src/components/cockpit-shell.tsx index fcb803147..695b7a842 100644 --- a/apps/cockpit/src/components/cockpit-shell.tsx +++ b/apps/cockpit/src/components/cockpit-shell.tsx @@ -10,6 +10,7 @@ import { NarrativeDocs } from './narrative-docs/narrative-docs'; import { ModeSwitcher } from './modes/mode-switcher'; import { RunMode } from './run-mode/run-mode'; import { CockpitSidebar } from './sidebar/cockpit-sidebar'; +import { MobileNavOverlay } from './mobile-nav-overlay'; const PRIMARY_MODES = ['Run', 'Code', 'Docs', 'API'] as const; type PrimaryMode = (typeof PRIMARY_MODES)[number]; @@ -69,30 +70,14 @@ export function CockpitShell({ />
- {/* Mobile sidebar overlay */} - {isSidebarOpen && ( - <> -
setIsSidebarOpen(false)} - /> -
- -
- - )} + {/* Mobile full-screen nav overlay */} + setIsSidebarOpen(false)} + />
From f6dc3fe0ff1889d38a0873cf80c3f815649cb416 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 9 Apr 2026 19:36:43 -0700 Subject: [PATCH 5/5] fix(cockpit): fix useEffect return type for strict TS build Early-return pattern ensures all code paths return consistently. Co-Authored-By: Claude Opus 4.6 --- apps/cockpit/src/components/mobile-nav-overlay.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/cockpit/src/components/mobile-nav-overlay.tsx b/apps/cockpit/src/components/mobile-nav-overlay.tsx index 3e8d1dd1a..f99fe6d70 100644 --- a/apps/cockpit/src/components/mobile-nav-overlay.tsx +++ b/apps/cockpit/src/components/mobile-nav-overlay.tsx @@ -43,10 +43,9 @@ export function MobileNavOverlay({ }, [isOpen]); useEffect(() => { - if (state === 'closing') { - const timer = setTimeout(() => setState('closed'), 150); - return () => clearTimeout(timer); - } + if (state !== 'closing') return; + const timer = setTimeout(() => setState('closed'), 150); + return () => clearTimeout(timer); }, [state]); useEffect(() => {