Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 9 additions & 24 deletions apps/cockpit/src/components/cockpit-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -69,30 +70,14 @@ export function CockpitShell({
/>
</div>

{/* Mobile sidebar overlay */}
{isSidebarOpen && (
<>
<div
className="fixed inset-0 z-40 bg-black/30 backdrop-blur-sm md:hidden"
onClick={() => setIsSidebarOpen(false)}
/>
<div
className="fixed top-0 left-0 bottom-0 w-64 z-50 overflow-y-auto md:hidden"
style={{
background: 'var(--ds-glass-bg)',
backdropFilter: 'blur(var(--ds-glass-blur))',
borderRight: '1px solid var(--ds-glass-border)',
boxShadow: 'var(--ds-glass-shadow)',
}}
>
<CockpitSidebar
navigationTree={navigationTree}
manifest={cockpitManifest}
entry={entry}
/>
</div>
</>
)}
{/* Mobile full-screen nav overlay */}
<MobileNavOverlay
navigationTree={navigationTree}
manifest={cockpitManifest}
entry={entry}
isOpen={isSidebarOpen}
onClose={() => setIsSidebarOpen(false)}
/>

<section className="grid grid-rows-[auto_1fr] gap-2 p-4 overflow-hidden bg-[var(--ds-glass-bg)] backdrop-blur-[var(--ds-glass-blur)]">
<header className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
Expand Down
131 changes: 131 additions & 0 deletions apps/cockpit/src/components/mobile-nav-overlay.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
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(
<MobileNavOverlay
navigationTree={tree}
manifest={cockpitManifest}
entry={entry}
isOpen={false}
onClose={() => {}}
/>
);
expect(html).toBe('');
});

it('renders all four product groups when open', () => {
const html = renderToStaticMarkup(
<MobileNavOverlay
navigationTree={tree}
manifest={cockpitManifest}
entry={entry}
isOpen={true}
onClose={() => {}}
/>
);
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(
<MobileNavOverlay
navigationTree={tree}
manifest={cockpitManifest}
entry={entry}
isOpen={true}
onClose={() => {}}
/>
);
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(
<MobileNavOverlay
navigationTree={tree}
manifest={cockpitManifest}
entry={entry}
isOpen={true}
onClose={() => {}}
/>
);
expect(html).toContain('aria-current="page"');
});

it('strips product prefix from topic titles', () => {
const html = renderToStaticMarkup(
<MobileNavOverlay
navigationTree={tree}
manifest={cockpitManifest}
entry={entry}
isOpen={true}
onClose={() => {}}
/>
);
expect(html).toContain('Spec Rendering');
expect(html).not.toContain('>Render Spec Rendering<');
});

it('filters out overview topics', () => {
const html = renderToStaticMarkup(
<MobileNavOverlay
navigationTree={tree}
manifest={cockpitManifest}
entry={entry}
isOpen={true}
onClose={() => {}}
/>
);
// 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', () => {
const html = renderToStaticMarkup(
<MobileNavOverlay
navigationTree={tree}
manifest={cockpitManifest}
entry={entry}
isOpen={true}
onClose={() => {}}
/>
);
expect(html).toContain('Python');
});

it('includes a close button', () => {
const html = renderToStaticMarkup(
<MobileNavOverlay
navigationTree={tree}
manifest={cockpitManifest}
entry={entry}
isOpen={true}
onClose={() => {}}
/>
);
expect(html).toContain('aria-label="Close navigation"');
});
});
175 changes: 175 additions & 0 deletions apps/cockpit/src/components/mobile-nav-overlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
'use client';

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';

function CloseIcon() {
return (
<svg aria-hidden="true" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
);
}

interface MobileNavOverlayProps {
navigationTree: NavigationProduct[];
manifest: CockpitManifestEntry[];
entry: CockpitManifestEntry;
isOpen: boolean;
onClose: () => void;
}

export function MobileNavOverlay({
navigationTree,
manifest,
entry,
isOpen,
onClose,
}: MobileNavOverlayProps) {
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') return;
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 (
<div
data-state={state}
className="fixed inset-0 z-50 md:hidden flex flex-col"
style={{
background: 'var(--ds-glass-bg)',
backdropFilter: 'blur(var(--ds-glass-blur))',
WebkitBackdropFilter: 'blur(var(--ds-glass-blur))',
opacity: state === 'open' ? 1 : 0,
transform: state === 'open' ? 'translateY(0)' : 'translateY(8px)',
transition: state === 'open'
? 'opacity 200ms ease-out, transform 200ms ease-out'
: 'opacity 150ms ease-in, transform 150ms ease-in',
}}
>
{/* Header */}
<header
className="flex items-center justify-between px-4 py-3"
style={{ borderBottom: '1px solid var(--ds-glass-border)' }}
>
<p
className="font-mono text-xs font-semibold uppercase tracking-wide"
style={{ color: 'var(--ds-text-muted)' }}
>
Cockpit
</p>
<div className="flex items-center gap-3">
<LanguagePicker manifest={manifest} entry={entry} />
<button
type="button"
onClick={onClose}
aria-label="Close navigation"
className="p-2 -m-2"
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--ds-text-muted)',
}}
>
<CloseIcon />
</button>
</div>
</header>

{/* Scrollable product cards */}
<div className="flex-1 overflow-y-auto px-4 py-3" style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{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 (
<div
key={product.product}
style={{
background: 'var(--ds-glass-bg)',
border: '1px solid var(--ds-glass-border)',
borderRadius: 10,
padding: 12,
}}
>
<p
style={{
fontFamily: 'var(--ds-font-mono)',
fontSize: '0.7rem',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: 'var(--ds-accent)',
marginBottom: 8,
}}
>
{label}
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{topics.map((topicEntry) => {
const isActive =
topicEntry.product === entry.product &&
topicEntry.section === entry.section &&
topicEntry.topic === entry.topic &&
topicEntry.page === entry.page;

return (
<a
key={`${topicEntry.product}-${topicEntry.topic}`}
href={toCockpitPath(topicEntry)}
aria-current={isActive ? 'page' : undefined}
style={{
padding: '4px 10px',
borderRadius: 20,
fontSize: '0.8rem',
textDecoration: 'none',
transition: 'all 0.15s',
background: isActive ? 'var(--ds-accent-surface)' : 'rgba(0, 0, 0, 0.04)',
color: isActive ? 'var(--ds-accent)' : 'var(--ds-text-secondary)',
border: isActive ? '1px solid var(--ds-accent-border)' : '1px solid transparent',
}}
>
{stripProductPrefix(topicEntry.title)}
</a>
);
})}
</div>
</div>
);
})}
</div>
</div>
);
}
17 changes: 1 addition & 16 deletions apps/cockpit/src/components/sidebar/navigation-groups.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,7 @@ import React, { useState } from 'react';
import type { CockpitManifestEntry } from '@cacheplane/cockpit-registry';
import type { NavigationProduct } from '../../lib/route-resolution';
import { toCockpitPath } from '../../lib/route-resolution';

const PRODUCT_LABELS: Record<string, string> = {
'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[];
Expand Down
14 changes: 14 additions & 0 deletions apps/cockpit/src/lib/navigation-labels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export const PRODUCT_LABELS: Record<string, string> = {
'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;
}
Loading
Loading