Skip to content

Commit 8b0633f

Browse files
bloveclaude
andauthored
feat: cockpit dark mode token system (#298)
* docs: cockpit dark mode token system design Spec for cockpit-only dark mode: dark by default with light toggle, typed `cssVars(theme)` resolution, cookie source of truth, per-frame postMessage sync for embedded iframes, paired OG image flip. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs: cockpit dark mode implementation plan 15 bite-sized tasks covering token split, cssVars(theme), ThemeProvider, ThemedFrame, useEmbeddedTheme, ThemeToggle, cookie route, layout wiring, sidebar footer, RunMode iframe wrap, OG flip, e2e, version bumps, PR. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(design-tokens): add base/light/dark split and Theme type * refactor(design-tokens): restructure barrel for theme-aware tokens, remove surfaces.ts * feat(ui-react): convert cssVars to cssVars(theme) function Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(ui-react): add ThemeProvider + useTheme context * feat(ui-react): add ThemedFrame for per-iframe theme postMessage * feat(ui-react): add useEmbeddedTheme hook for iframed apps * feat(ui-react): add ThemeToggle component * feat(cockpit): add /api/theme cookie-write route Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(cockpit): cookie-driven theme in root layout Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(cockpit): add ThemeToggle to sidebar footer Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(cockpit): wrap run-mode iframe with ThemedFrame Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(cockpit): flip OG image to dark canvas * test(cockpit): e2e for dark mode default + toggle + persistence * chore: bump design-tokens and ui-react patch versions - @ngaf/design-tokens: 0.0.31 → 0.0.32 - @ngaf/ui-react: 0.0.29 → 0.0.30 Also includes follow-ups required to keep the check stack green after Task 10 added <ThemeToggle> (which calls useRouter from next/navigation) to the cockpit sidebar: - apps/cockpit/test-setup.ts: mock next/navigation so renderToStaticMarkup tests of CockpitSidebar / CockpitShell don't trip the "expected app router to be mounted" invariant. - apps/cockpit/vite.config.mts: wire setupFiles to the new test-setup.ts. - libs/ui-react/src/lib/themed-frame.spec.tsx: replace non-null assertion with explicit guard to clear the @typescript-eslint/no-non-null-assertion lint warning. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(design-tokens): restore backwards-compat colors + surfaces exports 54 website consumers use `tokens.colors.X`, `colors.textPrimary`, etc. directly. Restore the original shapes as light-theme aliases so the website (light-only) keeps working without per-file migration. New theme-aware API (baseTokens, lightOverrides, darkOverrides, cssVars(theme)) is unchanged. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 40509f7 commit 8b0633f

35 files changed

Lines changed: 2731 additions & 367 deletions

apps/cockpit/e2e/dark-mode.spec.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
const COOKIE_URL = 'http://127.0.0.1:4201';
4+
5+
test.describe('dark mode', () => {
6+
test('defaults to dark when no cookie is set', async ({ page, context }) => {
7+
await context.clearCookies();
8+
await page.goto('/');
9+
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
10+
const canvas = await page
11+
.locator('html')
12+
.evaluate((el) => getComputedStyle(el).getPropertyValue('--ds-canvas').trim());
13+
expect(canvas).toBe('#0e1117');
14+
});
15+
16+
test('honors theme=light cookie on server render', async ({ page, context }) => {
17+
await context.addCookies([
18+
{ name: 'theme', value: 'light', url: COOKIE_URL },
19+
]);
20+
await page.goto('/');
21+
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
22+
const canvas = await page
23+
.locator('html')
24+
.evaluate((el) => getComputedStyle(el).getPropertyValue('--ds-canvas').trim());
25+
expect(canvas).toBe('#fafbfc');
26+
});
27+
28+
test('toggle flips data-theme optimistically and persists across reload', async ({
29+
page,
30+
context,
31+
}) => {
32+
await context.clearCookies();
33+
await page.goto('/');
34+
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
35+
36+
// Wait for the POST that persists the cookie so the reload below sees it.
37+
const themePost = page.waitForResponse(
38+
(resp) => resp.url().endsWith('/api/theme') && resp.request().method() === 'POST',
39+
);
40+
await page.getByRole('button', { name: /switch to light/i }).click();
41+
// Optimistic: data-theme flips synchronously
42+
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
43+
44+
// Persistence: wait for the cookie write, then reload and confirm
45+
await themePost;
46+
await page.reload();
47+
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
48+
});
49+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { NextResponse } from 'next/server';
2+
3+
const ONE_YEAR_S = 60 * 60 * 24 * 365;
4+
5+
export async function POST(req: Request) {
6+
let body: unknown;
7+
try {
8+
body = await req.json();
9+
} catch {
10+
return new NextResponse('invalid json', { status: 400 });
11+
}
12+
const theme =
13+
body && typeof body === 'object' && 'theme' in body ? (body as { theme: unknown }).theme : null;
14+
if (theme !== 'light' && theme !== 'dark') {
15+
return new NextResponse('bad theme', { status: 400 });
16+
}
17+
const res = new NextResponse(null, { status: 204 });
18+
res.cookies.set('theme', theme, {
19+
path: '/',
20+
maxAge: ONE_YEAR_S,
21+
sameSite: 'lax',
22+
httpOnly: false,
23+
});
24+
return res;
25+
}

apps/cockpit/src/app/layout.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { ReactNode } from 'react';
2-
import { cssVars } from '@ngaf/ui-react';
2+
import { cookies } from 'next/headers';
3+
import { cssVars, ThemeProvider } from '@ngaf/ui-react';
4+
import type { Theme } from '@ngaf/design-tokens';
35
import './cockpit.css';
46

57
export const metadata = {
@@ -22,17 +24,21 @@ interface RootLayoutProps {
2224
children: ReactNode;
2325
}
2426

25-
export default function RootLayout({ children }: RootLayoutProps) {
27+
export default async function RootLayout({ children }: RootLayoutProps) {
28+
const cookieStore = await cookies();
29+
const cookieValue = cookieStore.get('theme')?.value;
30+
const theme: Theme = cookieValue === 'light' ? 'light' : 'dark';
31+
2632
return (
27-
<html lang="en" style={cssVars as React.CSSProperties}>
33+
<html lang="en" data-theme={theme} style={cssVars(theme) as React.CSSProperties}>
2834
<body
2935
className="min-h-screen font-sans antialiased"
3036
style={{
3137
background: 'var(--ds-surface)',
3238
color: 'var(--ds-text-primary)',
3339
}}
3440
>
35-
{children}
41+
<ThemeProvider theme={theme}>{children}</ThemeProvider>
3642
</body>
3743
</html>
3844
);

apps/cockpit/src/app/opengraph-image.tsx

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
* don't need to bundle a serif TTF.
1111
*/
1212
import { ImageResponse } from 'next/og';
13+
import { darkOverrides } from '@ngaf/design-tokens';
1314

1415
export const runtime = 'edge';
1516
export const alt = 'Cockpit — the live reference app for the Angular Agent Framework';
@@ -50,11 +51,11 @@ export default async function OpenGraphImage() {
5051
style={{
5152
width: '100%',
5253
height: '100%',
53-
background: '#f4f6fb',
54+
background: darkOverrides.canvas,
5455
display: 'flex',
5556
flexDirection: 'column',
5657
padding: '64px 72px',
57-
color: '#1a1a2e',
58+
color: darkOverrides.textPrimary,
5859
fontFamily: 'Inter, sans-serif',
5960
}}
6061
>
@@ -64,7 +65,7 @@ export default async function OpenGraphImage() {
6465
fontFamily: 'JetBrains Mono, monospace',
6566
fontSize: 16,
6667
letterSpacing: '0.12em',
67-
color: '#004090',
68+
color: darkOverrides.accent,
6869
fontWeight: 700,
6970
textTransform: 'uppercase',
7071
marginBottom: 24,
@@ -80,7 +81,7 @@ export default async function OpenGraphImage() {
8081
lineHeight: 1.08,
8182
fontWeight: 700,
8283
letterSpacing: '-0.02em',
83-
color: '#1a1a2e',
84+
color: darkOverrides.textPrimary,
8485
marginBottom: 22,
8586
maxWidth: 1000,
8687
}}
@@ -93,7 +94,7 @@ export default async function OpenGraphImage() {
9394
style={{
9495
fontSize: 24,
9596
lineHeight: 1.5,
96-
color: '#555770',
97+
color: darkOverrides.textSecondary,
9798
maxWidth: 940,
9899
marginBottom: 'auto',
99100
}}
@@ -124,7 +125,7 @@ export default async function OpenGraphImage() {
124125
fontFamily: 'JetBrains Mono, monospace',
125126
fontSize: 18,
126127
fontWeight: 700,
127-
color: '#1a1a2e',
128+
color: darkOverrides.textPrimary,
128129
}}
129130
>
130131
<span style={{ fontSize: 26 }}>🛩️</span>
@@ -154,9 +155,9 @@ function ModePill({ active, children }: ModePillProps) {
154155
alignItems: 'center',
155156
padding: '8px 20px',
156157
borderRadius: 999,
157-
background: active ? '#004090' : '#ffffff',
158-
border: `1px solid ${active ? '#004090' : '#e6e8ee'}`,
159-
color: active ? '#ffffff' : '#555770',
158+
background: active ? darkOverrides.accent : darkOverrides.surface,
159+
border: `1px solid ${active ? darkOverrides.accent : darkOverrides.border}`,
160+
color: active ? darkOverrides.textInverted : darkOverrides.textSecondary,
160161
fontFamily: 'JetBrains Mono, monospace',
161162
fontSize: 15,
162163
fontWeight: 700,

apps/cockpit/src/components/run-mode/run-mode.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React from 'react';
2+
import { ThemedFrame } from '@ngaf/ui-react';
23

34
interface RunModeProps {
45
entryTitle: string;
@@ -16,7 +17,7 @@ export function RunMode({ entryTitle, runtimeUrl }: RunModeProps) {
1617

1718
return (
1819
<section aria-label="Run mode" className="h-full">
19-
<iframe
20+
<ThemedFrame
2021
src={runtimeUrl}
2122
title={`${entryTitle} live example`}
2223
allow="clipboard-write"

apps/cockpit/src/components/sidebar/cockpit-sidebar.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React from 'react';
2+
import { ThemeToggle } from '@ngaf/ui-react';
23
import type {
34
CockpitManifestEntry,
45
} from '@ngaf/cockpit-registry';
@@ -20,7 +21,7 @@ export function CockpitSidebar({
2021
return (
2122
<aside
2223
aria-label="Cockpit sidebar"
23-
className="grid gap-4 py-6 px-0 border-r bg-[var(--ds-surface-tinted)] content-start overflow-y-auto"
24+
className="flex flex-col gap-4 py-6 px-0 border-r bg-[var(--ds-surface-tinted)] overflow-y-auto"
2425
style={{
2526
position: 'sticky',
2627
top: 0,
@@ -33,6 +34,10 @@ export function CockpitSidebar({
3334
<LanguagePicker manifest={manifest} entry={entry} />
3435
</header>
3536
<NavigationGroups tree={navigationTree} currentEntry={entry} />
37+
<div className="mt-auto border-t border-[var(--ds-border)] px-4 py-3 flex items-center justify-between">
38+
<span className="text-xs text-[var(--ds-text-muted)]">Theme</span>
39+
<ThemeToggle className="rounded-md p-1.5 text-[var(--ds-text-secondary)] hover:bg-[var(--ds-surface-tinted)] hover:text-[var(--ds-text-primary)] transition-colors" />
40+
</div>
3641
</aside>
3742
);
3843
}

apps/cockpit/test-setup.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { vi } from 'vitest';
2+
3+
// next/navigation's useRouter throws "invariant expected app router to be
4+
// mounted" when rendered outside an AppRouterContext (e.g. via
5+
// renderToStaticMarkup). Provide a no-op mock so components that call
6+
// useRouter (e.g. <ThemeToggle> in the sidebar) render in tests.
7+
vi.mock('next/navigation', () => ({
8+
useRouter: () => ({
9+
refresh: () => undefined,
10+
push: () => undefined,
11+
replace: () => undefined,
12+
back: () => undefined,
13+
forward: () => undefined,
14+
prefetch: () => undefined,
15+
}),
16+
}));

apps/cockpit/vite.config.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,6 @@ export default defineConfig({
77
environment: 'jsdom',
88
globals: true,
99
include: ['src/**/*.spec.ts', 'src/**/*.spec.tsx'],
10+
setupFiles: ['./test-setup.ts'],
1011
},
1112
});

0 commit comments

Comments
 (0)