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
49 changes: 49 additions & 0 deletions apps/cockpit/e2e/dark-mode.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
25 changes: 25 additions & 0 deletions apps/cockpit/src/app/api/theme/route.ts
Original file line number Diff line number Diff line change
@@ -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;
}
14 changes: 10 additions & 4 deletions apps/cockpit/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -22,17 +24,21 @@ 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 (
<html lang="en" style={cssVars as React.CSSProperties}>
<html lang="en" data-theme={theme} style={cssVars(theme) as React.CSSProperties}>
<body
className="min-h-screen font-sans antialiased"
style={{
background: 'var(--ds-surface)',
color: 'var(--ds-text-primary)',
}}
>
{children}
<ThemeProvider theme={theme}>{children}</ThemeProvider>
</body>
</html>
);
Expand Down
19 changes: 10 additions & 9 deletions apps/cockpit/src/app/opengraph-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
}}
>
Expand All @@ -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,
Expand All @@ -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,
}}
Expand All @@ -93,7 +94,7 @@ export default async function OpenGraphImage() {
style={{
fontSize: 24,
lineHeight: 1.5,
color: '#555770',
color: darkOverrides.textSecondary,
maxWidth: 940,
marginBottom: 'auto',
}}
Expand Down Expand Up @@ -124,7 +125,7 @@ export default async function OpenGraphImage() {
fontFamily: 'JetBrains Mono, monospace',
fontSize: 18,
fontWeight: 700,
color: '#1a1a2e',
color: darkOverrides.textPrimary,
}}
>
<span style={{ fontSize: 26 }}>🛩️</span>
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion apps/cockpit/src/components/run-mode/run-mode.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react';
import { ThemedFrame } from '@ngaf/ui-react';

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

return (
<section aria-label="Run mode" className="h-full">
<iframe
<ThemedFrame
src={runtimeUrl}
title={`${entryTitle} live example`}
allow="clipboard-write"
Expand Down
7 changes: 6 additions & 1 deletion apps/cockpit/src/components/sidebar/cockpit-sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react';
import { ThemeToggle } from '@ngaf/ui-react';
import type {
CockpitManifestEntry,
} from '@ngaf/cockpit-registry';
Expand All @@ -20,7 +21,7 @@ export function CockpitSidebar({
return (
<aside
aria-label="Cockpit sidebar"
className="grid gap-4 py-6 px-0 border-r bg-[var(--ds-surface-tinted)] content-start overflow-y-auto"
className="flex flex-col gap-4 py-6 px-0 border-r bg-[var(--ds-surface-tinted)] overflow-y-auto"
style={{
position: 'sticky',
top: 0,
Expand All @@ -33,6 +34,10 @@ export function CockpitSidebar({
<LanguagePicker manifest={manifest} entry={entry} />
</header>
<NavigationGroups tree={navigationTree} currentEntry={entry} />
<div className="mt-auto border-t border-[var(--ds-border)] px-4 py-3 flex items-center justify-between">
<span className="text-xs text-[var(--ds-text-muted)]">Theme</span>
<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" />
</div>
</aside>
);
}
16 changes: 16 additions & 0 deletions apps/cockpit/test-setup.ts
Original file line number Diff line number Diff line change
@@ -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. <ThemeToggle> in the sidebar) render in tests.
vi.mock('next/navigation', () => ({
useRouter: () => ({
refresh: () => undefined,
push: () => undefined,
replace: () => undefined,
back: () => undefined,
forward: () => undefined,
prefetch: () => undefined,
}),
}));
1 change: 1 addition & 0 deletions apps/cockpit/vite.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ export default defineConfig({
environment: 'jsdom',
globals: true,
include: ['src/**/*.spec.ts', 'src/**/*.spec.tsx'],
setupFiles: ['./test-setup.ts'],
},
});
Loading
Loading