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 (
-