diff --git a/apps/cockpit/e2e/dark-mode.spec.ts b/apps/cockpit/e2e/dark-mode.spec.ts
new file mode 100644
index 00000000..38ee9515
--- /dev/null
+++ b/apps/cockpit/e2e/dark-mode.spec.ts
@@ -0,0 +1,49 @@
+import { expect, test } from '@playwright/test';
+
+const COOKIE_URL = 'http://127.0.0.1:4201';
+
+test.describe('dark mode', () => {
+ test('defaults to dark when no cookie is set', async ({ page, context }) => {
+ await context.clearCookies();
+ await page.goto('/');
+ await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
+ const canvas = await page
+ .locator('html')
+ .evaluate((el) => getComputedStyle(el).getPropertyValue('--ds-canvas').trim());
+ expect(canvas).toBe('#0e1117');
+ });
+
+ test('honors theme=light cookie on server render', async ({ page, context }) => {
+ await context.addCookies([
+ { name: 'theme', value: 'light', url: COOKIE_URL },
+ ]);
+ await page.goto('/');
+ await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
+ const canvas = await page
+ .locator('html')
+ .evaluate((el) => getComputedStyle(el).getPropertyValue('--ds-canvas').trim());
+ expect(canvas).toBe('#fafbfc');
+ });
+
+ test('toggle flips data-theme optimistically and persists across reload', async ({
+ page,
+ context,
+ }) => {
+ await context.clearCookies();
+ await page.goto('/');
+ await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
+
+ // Wait for the POST that persists the cookie so the reload below sees it.
+ const themePost = page.waitForResponse(
+ (resp) => resp.url().endsWith('/api/theme') && resp.request().method() === 'POST',
+ );
+ await page.getByRole('button', { name: /switch to light/i }).click();
+ // Optimistic: data-theme flips synchronously
+ await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
+
+ // Persistence: wait for the cookie write, then reload and confirm
+ await themePost;
+ await page.reload();
+ await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
+ });
+});
diff --git a/apps/cockpit/src/app/api/theme/route.ts b/apps/cockpit/src/app/api/theme/route.ts
new file mode 100644
index 00000000..881b6676
--- /dev/null
+++ b/apps/cockpit/src/app/api/theme/route.ts
@@ -0,0 +1,25 @@
+import { NextResponse } from 'next/server';
+
+const ONE_YEAR_S = 60 * 60 * 24 * 365;
+
+export async function POST(req: Request) {
+ let body: unknown;
+ try {
+ body = await req.json();
+ } catch {
+ return new NextResponse('invalid json', { status: 400 });
+ }
+ const theme =
+ body && typeof body === 'object' && 'theme' in body ? (body as { theme: unknown }).theme : null;
+ if (theme !== 'light' && theme !== 'dark') {
+ return new NextResponse('bad theme', { status: 400 });
+ }
+ const res = new NextResponse(null, { status: 204 });
+ res.cookies.set('theme', theme, {
+ path: '/',
+ maxAge: ONE_YEAR_S,
+ sameSite: 'lax',
+ httpOnly: false,
+ });
+ return res;
+}
diff --git a/apps/cockpit/src/app/layout.tsx b/apps/cockpit/src/app/layout.tsx
index e09ddaca..b28ceeb0 100644
--- a/apps/cockpit/src/app/layout.tsx
+++ b/apps/cockpit/src/app/layout.tsx
@@ -1,5 +1,7 @@
import type { ReactNode } from 'react';
-import { cssVars } from '@ngaf/ui-react';
+import { cookies } from 'next/headers';
+import { cssVars, ThemeProvider } from '@ngaf/ui-react';
+import type { Theme } from '@ngaf/design-tokens';
import './cockpit.css';
export const metadata = {
@@ -22,9 +24,13 @@ interface RootLayoutProps {
children: ReactNode;
}
-export default function RootLayout({ children }: RootLayoutProps) {
+export default async function RootLayout({ children }: RootLayoutProps) {
+ const cookieStore = await cookies();
+ const cookieValue = cookieStore.get('theme')?.value;
+ const theme: Theme = cookieValue === 'light' ? 'light' : 'dark';
+
return (
-
+
- {children}
+ {children}
);
diff --git a/apps/cockpit/src/app/opengraph-image.tsx b/apps/cockpit/src/app/opengraph-image.tsx
index 6e398551..5a5be8d9 100644
--- a/apps/cockpit/src/app/opengraph-image.tsx
+++ b/apps/cockpit/src/app/opengraph-image.tsx
@@ -10,6 +10,7 @@
* don't need to bundle a serif TTF.
*/
import { ImageResponse } from 'next/og';
+import { darkOverrides } from '@ngaf/design-tokens';
export const runtime = 'edge';
export const alt = 'Cockpit â the live reference app for the Angular Agent Framework';
@@ -50,11 +51,11 @@ export default async function OpenGraphImage() {
style={{
width: '100%',
height: '100%',
- background: '#f4f6fb',
+ background: darkOverrides.canvas,
display: 'flex',
flexDirection: 'column',
padding: '64px 72px',
- color: '#1a1a2e',
+ color: darkOverrides.textPrimary,
fontFamily: 'Inter, sans-serif',
}}
>
@@ -64,7 +65,7 @@ export default async function OpenGraphImage() {
fontFamily: 'JetBrains Mono, monospace',
fontSize: 16,
letterSpacing: '0.12em',
- color: '#004090',
+ color: darkOverrides.accent,
fontWeight: 700,
textTransform: 'uppercase',
marginBottom: 24,
@@ -80,7 +81,7 @@ export default async function OpenGraphImage() {
lineHeight: 1.08,
fontWeight: 700,
letterSpacing: '-0.02em',
- color: '#1a1a2e',
+ color: darkOverrides.textPrimary,
marginBottom: 22,
maxWidth: 1000,
}}
@@ -93,7 +94,7 @@ export default async function OpenGraphImage() {
style={{
fontSize: 24,
lineHeight: 1.5,
- color: '#555770',
+ color: darkOverrides.textSecondary,
maxWidth: 940,
marginBottom: 'auto',
}}
@@ -124,7 +125,7 @@ export default async function OpenGraphImage() {
fontFamily: 'JetBrains Mono, monospace',
fontSize: 18,
fontWeight: 700,
- color: '#1a1a2e',
+ color: darkOverrides.textPrimary,
}}
>
đŠī¸
@@ -154,9 +155,9 @@ function ModePill({ active, children }: ModePillProps) {
alignItems: 'center',
padding: '8px 20px',
borderRadius: 999,
- background: active ? '#004090' : '#ffffff',
- border: `1px solid ${active ? '#004090' : '#e6e8ee'}`,
- color: active ? '#ffffff' : '#555770',
+ background: active ? darkOverrides.accent : darkOverrides.surface,
+ border: `1px solid ${active ? darkOverrides.accent : darkOverrides.border}`,
+ color: active ? darkOverrides.textInverted : darkOverrides.textSecondary,
fontFamily: 'JetBrains Mono, monospace',
fontSize: 15,
fontWeight: 700,
diff --git a/apps/cockpit/src/components/run-mode/run-mode.tsx b/apps/cockpit/src/components/run-mode/run-mode.tsx
index 7dc2a0bb..1f8772ac 100644
--- a/apps/cockpit/src/components/run-mode/run-mode.tsx
+++ b/apps/cockpit/src/components/run-mode/run-mode.tsx
@@ -1,4 +1,5 @@
import React from 'react';
+import { ThemedFrame } from '@ngaf/ui-react';
interface RunModeProps {
entryTitle: string;
@@ -16,7 +17,7 @@ export function RunMode({ entryTitle, runtimeUrl }: RunModeProps) {
return (
-
+
+ Theme
+
+
);
}
diff --git a/apps/cockpit/test-setup.ts b/apps/cockpit/test-setup.ts
new file mode 100644
index 00000000..d3ec8158
--- /dev/null
+++ b/apps/cockpit/test-setup.ts
@@ -0,0 +1,16 @@
+import { vi } from 'vitest';
+
+// next/navigation's useRouter throws "invariant expected app router to be
+// mounted" when rendered outside an AppRouterContext (e.g. via
+// renderToStaticMarkup). Provide a no-op mock so components that call
+// useRouter (e.g. in the sidebar) render in tests.
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({
+ refresh: () => undefined,
+ push: () => undefined,
+ replace: () => undefined,
+ back: () => undefined,
+ forward: () => undefined,
+ prefetch: () => undefined,
+ }),
+}));
diff --git a/apps/cockpit/vite.config.mts b/apps/cockpit/vite.config.mts
index 1d1e9f3c..9007f78f 100644
--- a/apps/cockpit/vite.config.mts
+++ b/apps/cockpit/vite.config.mts
@@ -7,5 +7,6 @@ export default defineConfig({
environment: 'jsdom',
globals: true,
include: ['src/**/*.spec.ts', 'src/**/*.spec.tsx'],
+ setupFiles: ['./test-setup.ts'],
},
});
diff --git a/docs/superpowers/plans/2026-05-13-cockpit-dark-mode.md b/docs/superpowers/plans/2026-05-13-cockpit-dark-mode.md
new file mode 100644
index 00000000..33c1be5a
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-13-cockpit-dark-mode.md
@@ -0,0 +1,1535 @@
+# Cockpit Dark Mode 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:** Ship cockpit dark mode as the default with a working light toggle, driven by typed `cssVars(theme)` resolution, cookie-backed persistence, and a per-frame postMessage contract for embedded iframes. Flip the cockpit OG image to match.
+
+**Architecture:** Tokens split into invariant `baseTokens` plus tiny `lightOverrides` / `darkOverrides`. `cssVars(theme)` resolves to a flat `--ds-*` map, applied inline on `` in cockpit's root layout. A `theme` cookie is the source of truth; `` uses `useOptimistic` for instant feel and POSTs to `/api/theme` to persist. `` wraps iframes and posts `ngaf:theme` to each contentWindow via a per-component ref; `useEmbeddedTheme()` is the consumer-side hook for the iframed app.
+
+**Tech Stack:** Next.js 16 App Router, React 19, `@ngaf/design-tokens`, `@ngaf/ui-react`, Vitest (unit), Playwright (e2e), Nx, pnpm.
+
+**Spec:** `docs/superpowers/specs/2026-05-13-cockpit-dark-mode-design.md`
+
+---
+
+## File Structure
+
+**Created:**
+- `libs/design-tokens/src/lib/base.ts` â invariant tokens (typography, spacing, radii, shadows, motion, brand colors)
+- `libs/design-tokens/src/lib/light.ts` â theme-variant token values for light
+- `libs/design-tokens/src/lib/dark.ts` â theme-variant token values for dark
+- `libs/design-tokens/src/lib/theme.ts` â `Theme` type
+- `libs/ui-react/src/lib/theme-context.tsx` â `ThemeProvider` + `useTheme`
+- `libs/ui-react/src/lib/theme-toggle.tsx` â sidebar-footer toggle component
+- `libs/ui-react/src/lib/themed-frame.tsx` â iframe wrapper that pushes theme via postMessage
+- `libs/ui-react/src/lib/use-embedded-theme.ts` â consumer-side hook for iframed apps
+- `libs/ui-react/src/lib/theme-context.spec.ts` â context unit tests
+- `libs/ui-react/src/lib/theme-toggle.spec.tsx` â toggle unit tests
+- `libs/ui-react/src/lib/themed-frame.spec.tsx` â frame unit tests
+- `libs/ui-react/src/lib/use-embedded-theme.spec.ts` â hook unit tests
+- `apps/cockpit/src/app/api/theme/route.ts` â cookie writer
+- `apps/cockpit/e2e/dark-mode.spec.ts` â Playwright e2e
+
+**Modified:**
+- `libs/design-tokens/src/lib/colors.ts` â slim down to raw brand color constants
+- `libs/design-tokens/src/lib/surfaces.ts` â **deleted** (values move into light/dark)
+- `libs/design-tokens/src/lib/tokens.ts` â re-shape combined export
+- `libs/design-tokens/src/lib/tokens.spec.ts` â update expectations
+- `libs/design-tokens/src/index.ts` â export new modules
+- `libs/design-tokens/package.json` â version bump 0.0.31 â 0.0.32
+- `libs/ui-react/src/lib/css-vars.ts` â convert to `cssVars(theme)` function
+- `libs/ui-react/src/lib/css-vars.spec.ts` â assert both themes
+- `libs/ui-react/src/index.ts` â export new components and hook
+- `apps/cockpit/src/app/layout.tsx` â read theme cookie, wrap in `ThemeProvider`
+- `apps/cockpit/src/components/sidebar/cockpit-sidebar.tsx` â add footer with `ThemeToggle`
+- `apps/cockpit/src/components/run-mode.tsx` (or wherever `