Skip to content

Commit 7844eb0

Browse files
committed
[dark-mode] Dark theme implementation, Playwright e2e tests, bug fixes, and NeDB migration
Dark Mode Color Palette (neutral zinc grays): - Redesigned dark-mode.pcss with Tailwind zinc-based neutral palette (bg: #18181B, surface: #27272A, borders: #3F3F46, text: #E4E4E7) - Vibrant accent colors for syntax highlighting (indigo keywords, cyan variables, pink params, emerald classes) - System preference fallback (@media prefers-color-scheme) mirrors the data-theme='dark' values exactly - Added diff color CSS variables to vars.pcss (light) and dark-mode.pcss Structural CSS Fixes: - main.pcss: Added background: var(--color-bg-main) to body element - header.pcss: Replaced hardcoded 'background: white' with CSS variable - copy-button.pcss: Replaced hardcoded 'background: white' with CSS variable - diff.pcss: Converted 4 hardcoded colors to CSS custom properties Bug Fixes: - themeToggle.js: Fixed event listener using wrong event name (was 'themeToggle' via onThemeToggle, now listens to 'themeChange' which is what ThemeManager.setTheme() actually dispatches) - index.twig: Added main.bundle.js script tag so ThemeManager initializes on the greeting/landing page (dark mode was lost after login) - pages.ts: Added missing 'await' on alias.save() in both insert() and update() methods, fixing race condition where page redirect arrived before alias was persisted to database NeDB Migration (Node 24 compatibility): - Replaced unmaintained 'nedb' 1.8.0 with '@seald-io/nedb' (maintained fork) - Fixes util.isDate, util.isRegExp, util.isArray removal in Node 24 - Updated imports in local.ts and test/database.ts using createRequire() for clean CJS/ESM interop with Node16 module resolution - Added explicit callback parameter types for @seald-io/nedb type defs Playwright E2E Test Suite (35 tests): - playwright.config.ts: Chromium-only config with webServer auto-start - e2e/fixtures/setup.ts: Shared selectors, color constants, helpers - theme-toggle.spec.ts: Button visibility, ARIA, click/keyboard toggle - theme-persistence.spec.ts: localStorage save/restore across reloads - system-preference.spec.ts: prefers-color-scheme emulation fallback - components.spec.ts: CSS variable verification, rendered element colors - no-fouc.spec.ts: Flash-of-unstyled-content prevention checks - Added test scripts and @playwright/test dependency to package.json - Updated .gitignore for Playwright artifacts
1 parent 884d469 commit 7844eb0

23 files changed

Lines changed: 1468 additions & 315 deletions
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Plan: Implement Playwright UI Tests for Dark Mode
2+
3+
Add Playwright e2e tests to verify the dark mode feature — theme toggle, localStorage persistence, system preference detection, and visual correctness across all components. Tests auto-start the dev server on port 7777 using NeDB (no external DB required).
4+
5+
---
6+
7+
### Phase A: Setup (sequential)
8+
1. Install `@playwright/test` + browsers
9+
2. Create `playwright.config.ts` with `webServer` config → `yarn dev` on port 7777
10+
3. Update `.gitignore` with Playwright artifacts (`test-results/`, `playwright-report/`, `playwright/.cache`)
11+
4. Add npm scripts: `test:e2e` and `test:e2e:ui`
12+
13+
### Phase B: Core Theme Tests (parallel after A)
14+
5. **`e2e/dark-mode/theme-toggle.spec.ts`** — Toggle button exists, click switches `html[data-theme]` light↔dark, correct ARIA attributes, keyboard accessible (Tab + Enter/Space)
15+
6. **`e2e/dark-mode/theme-persistence.spec.ts`** — Saved to `localStorage` key `codex-docs-theme`, restored on reload, defaults to light when no preference
16+
7. **`e2e/dark-mode/system-preference.spec.ts`** — Uses Playwright's `colorScheme` emulation to test `prefers-color-scheme: dark` as fallback, saved preference overrides system
17+
18+
### Phase C: Visual Component Tests (parallel after A)
19+
8. **`e2e/dark-mode/components.spec.ts`** — Header, sidebar, page content, buttons, auth form, toggle icon visibility — all verified in both themes via `getComputedStyle` on CSS variables
20+
9. **`e2e/dark-mode/no-fouc.spec.ts`** — Set localStorage before navigation, verify `data-theme` is correct on first meaningful paint
21+
22+
### Phase D: Test Fixtures
23+
10. **`e2e/fixtures/setup.ts`** — Auth helper (POST `/auth` with password `secretpassword`), page creation helper, shared selectors
24+
25+
---
26+
27+
### Key Decisions
28+
- **Test against `/auth` page** for guaranteed `layout.twig` — the greeting page (`/` with empty DB) doesn't include `main.bundle.js`, so ThemeManager won't run there
29+
- **CSS variable assertions** — use `getComputedStyle` to verify `--color-bg-main` resolves to `#1E1E1E` in dark, `#ffffff` in light
30+
- **Icon visibility** — assert `style.display` on sun/moon SVGs (set inline by JS)
31+
- **System preference** — Playwright's built-in `colorScheme: 'dark'` context option emulates `prefers-color-scheme`
32+
33+
### Relevant Files
34+
| File | Action |
35+
|------|--------|
36+
| `playwright.config.ts` | Create |
37+
| `e2e/dark-mode/*.spec.ts` (5 files) | Create |
38+
| `e2e/fixtures/setup.ts` | Create |
39+
| `package.json` | Modify (scripts + devDep) |
40+
| `.gitignore` | Modify |
41+
42+
### Verification
43+
1. `npx playwright install` succeeds
44+
2. `yarn test:e2e` — all tests pass (server auto-starts/stops)
45+
3. `yarn test:e2e:ui` — interactive mode works
46+
4. All dark mode acceptance criteria from Requirements.md covered
47+
48+
### Constraint
49+
The greeting page (`/` with empty DB) does **not** load `main.bundle.js` — ThemeManager is inactive there. All tests target pages using `layout.twig` (i.e., `/auth` or created pages).

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,5 +87,10 @@ db/
8787
static-build
8888
.vs/
8989

90+
# Playwright
91+
test-results/
92+
playwright-report/
93+
playwright/.cache
94+
9095
# Dark mode agent reference (local development only)
9196
.github/specs/dark-mode/Agents.md

e2e/dark-mode/components.spec.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { test, expect, selectors, getCSSVariable, getThemeAttribute, goToThemedPage, themeColors } from '../fixtures/setup';
2+
3+
test.describe('Component Styles - Light Mode', () => {
4+
test.beforeEach(async ({ page }) => {
5+
await goToThemedPage(page);
6+
expect(await getThemeAttribute(page)).toBe('light');
7+
});
8+
9+
test('header has correct light mode colors', async ({ page }) => {
10+
const bgColor = await getCSSVariable(page, '--color-bg-main');
11+
expect(bgColor).toBe(themeColors.light['--color-bg-main']);
12+
});
13+
14+
test('body text uses light mode color', async ({ page }) => {
15+
const textColor = await getCSSVariable(page, '--color-text-main');
16+
expect(textColor).toBe(themeColors.light['--color-text-main']);
17+
});
18+
19+
test('border uses light mode color', async ({ page }) => {
20+
const borderColor = await getCSSVariable(page, '--color-line-gray');
21+
expect(borderColor).toBe(themeColors.light['--color-line-gray']);
22+
});
23+
});
24+
25+
test.describe('Component Styles - Dark Mode', () => {
26+
test.beforeEach(async ({ page }) => {
27+
await goToThemedPage(page);
28+
await page.click(selectors.themeToggleButton);
29+
expect(await getThemeAttribute(page)).toBe('dark');
30+
});
31+
32+
test('background uses dark mode color', async ({ page }) => {
33+
const bgColor = await getCSSVariable(page, '--color-bg-main');
34+
expect(bgColor).toBe(themeColors.dark['--color-bg-main']);
35+
});
36+
37+
test('text uses dark mode color', async ({ page }) => {
38+
const textColor = await getCSSVariable(page, '--color-text-main');
39+
expect(textColor).toBe(themeColors.dark['--color-text-main']);
40+
});
41+
42+
test('border uses dark mode color', async ({ page }) => {
43+
const borderColor = await getCSSVariable(page, '--color-line-gray');
44+
expect(borderColor).toBe(themeColors.dark['--color-line-gray']);
45+
});
46+
47+
test('background light variable uses dark mode value', async ({ page }) => {
48+
const bgLight = await getCSSVariable(page, '--color-bg-light');
49+
expect(bgLight).toBe(themeColors.dark['--color-bg-light']);
50+
});
51+
});
52+
53+
test.describe('Rendered Element Colors', () => {
54+
test('body background is dark in dark mode', async ({ page }) => {
55+
await goToThemedPage(page);
56+
await page.click(selectors.themeToggleButton);
57+
58+
const bodyBg = await page.evaluate(() => getComputedStyle(document.body).backgroundColor);
59+
expect(bodyBg).toBe('rgb(24, 24, 27)');
60+
});
61+
62+
test('body background is white in light mode', async ({ page }) => {
63+
await goToThemedPage(page);
64+
65+
const bodyBg = await page.evaluate(() => getComputedStyle(document.body).backgroundColor);
66+
expect(bodyBg).toBe('rgb(255, 255, 255)');
67+
});
68+
69+
test('header background is dark in dark mode', async ({ page }) => {
70+
await goToThemedPage(page);
71+
await page.click(selectors.themeToggleButton);
72+
73+
const headerBg = await page.evaluate(() => {
74+
const header = document.querySelector('header.docs-header');
75+
return header ? getComputedStyle(header).backgroundColor : '';
76+
});
77+
expect(headerBg).toBe('rgb(24, 24, 27)');
78+
});
79+
80+
test('header background is white in light mode', async ({ page }) => {
81+
await goToThemedPage(page);
82+
83+
const headerBg = await page.evaluate(() => {
84+
const header = document.querySelector('header.docs-header');
85+
return header ? getComputedStyle(header).backgroundColor : '';
86+
});
87+
expect(headerBg).toBe('rgb(255, 255, 255)');
88+
});
89+
90+
test('body text color changes in dark mode', async ({ page }) => {
91+
await goToThemedPage(page);
92+
await page.click(selectors.themeToggleButton);
93+
94+
const bodyColor = await page.evaluate(() => getComputedStyle(document.body).color);
95+
expect(bodyColor).toBe('rgb(228, 228, 231)');
96+
});
97+
});
98+
99+
test.describe('Component Styles - Toggle Consistency', () => {
100+
test('all CSS variables update when toggling from light to dark', async ({ page }) => {
101+
await goToThemedPage(page);
102+
103+
// Verify light mode variables
104+
for (const [variable, expected] of Object.entries(themeColors.light)) {
105+
const value = await getCSSVariable(page, variable);
106+
expect(value, `Light mode ${variable}`).toBe(expected);
107+
}
108+
109+
// Toggle to dark
110+
await page.click(selectors.themeToggleButton);
111+
112+
// Verify dark mode variables
113+
for (const [variable, expected] of Object.entries(themeColors.dark)) {
114+
const value = await getCSSVariable(page, variable);
115+
expect(value, `Dark mode ${variable}`).toBe(expected);
116+
}
117+
});
118+
119+
test('auth form is visible in both themes', async ({ page }) => {
120+
await goToThemedPage(page);
121+
const form = page.locator(selectors.authForm);
122+
await expect(form).toBeVisible();
123+
124+
// Toggle to dark
125+
await page.click(selectors.themeToggleButton);
126+
await expect(form).toBeVisible();
127+
});
128+
129+
test('header remains visible after theme switch', async ({ page }) => {
130+
await goToThemedPage(page);
131+
const header = page.locator(selectors.header);
132+
await expect(header).toBeVisible();
133+
134+
await page.click(selectors.themeToggleButton);
135+
await expect(header).toBeVisible();
136+
});
137+
138+
test('toggle button icon swaps correctly on multiple toggles', async ({ page }) => {
139+
await goToThemedPage(page);
140+
const sunIcon = page.locator(selectors.sunIcon);
141+
const moonIcon = page.locator(selectors.moonIcon);
142+
143+
// Light → sun visible
144+
await expect(sunIcon).toHaveCSS('display', 'block');
145+
await expect(moonIcon).toHaveCSS('display', 'none');
146+
147+
// Toggle 1: dark → moon visible
148+
await page.click(selectors.themeToggleButton);
149+
await expect(sunIcon).toHaveCSS('display', 'none');
150+
await expect(moonIcon).toHaveCSS('display', 'block');
151+
152+
// Toggle 2: light → sun visible
153+
await page.click(selectors.themeToggleButton);
154+
await expect(sunIcon).toHaveCSS('display', 'block');
155+
await expect(moonIcon).toHaveCSS('display', 'none');
156+
157+
// Toggle 3: dark → moon visible
158+
await page.click(selectors.themeToggleButton);
159+
await expect(sunIcon).toHaveCSS('display', 'none');
160+
await expect(moonIcon).toHaveCSS('display', 'block');
161+
});
162+
});

e2e/dark-mode/no-fouc.spec.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { test, expect, STORAGE_KEY, getThemeAttribute, selectors } from '../fixtures/setup';
2+
3+
test.describe('No Flash of Unstyled Content (FOUC)', () => {
4+
test('dark theme is applied before page renders when localStorage has dark preference', async ({ page }) => {
5+
// First visit to set up localStorage
6+
await page.goto('/auth');
7+
await page.waitForSelector(selectors.themeToggleButton, { timeout: 10000 });
8+
await page.evaluate((key) => localStorage.setItem(key, 'dark'), STORAGE_KEY);
9+
10+
// Navigate to a new page — theme should be correct on first check
11+
await page.goto('/auth');
12+
await page.waitForSelector(selectors.themeToggleButton, { timeout: 10000 });
13+
14+
// data-theme should be 'dark' immediately
15+
const theme = await getThemeAttribute(page);
16+
expect(theme).toBe('dark');
17+
});
18+
19+
test('light theme is applied before page renders when localStorage has light preference', async ({ page }) => {
20+
// First visit to set up localStorage
21+
await page.goto('/auth');
22+
await page.waitForSelector(selectors.themeToggleButton, { timeout: 10000 });
23+
await page.evaluate((key) => localStorage.setItem(key, 'light'), STORAGE_KEY);
24+
25+
// Navigate to a new page
26+
await page.goto('/auth');
27+
await page.waitForSelector(selectors.themeToggleButton, { timeout: 10000 });
28+
29+
const theme = await getThemeAttribute(page);
30+
expect(theme).toBe('light');
31+
});
32+
33+
test('data-theme attribute exists on html element after page load', async ({ page }) => {
34+
await page.goto('/auth');
35+
await page.waitForSelector(selectors.themeToggleButton, { timeout: 10000 });
36+
37+
const hasAttribute = await page.evaluate(() => {
38+
return document.documentElement.hasAttribute('data-theme');
39+
});
40+
expect(hasAttribute).toBe(true);
41+
});
42+
43+
test('theme is consistent between data-attribute and CSS variables after reload', async ({ page }) => {
44+
// Set dark theme
45+
await page.goto('/auth');
46+
await page.waitForSelector(selectors.themeToggleButton, { timeout: 10000 });
47+
await page.evaluate((key) => localStorage.setItem(key, 'dark'), STORAGE_KEY);
48+
49+
// Reload
50+
await page.reload();
51+
await page.waitForSelector(selectors.themeToggleButton, { timeout: 10000 });
52+
53+
// Check that data-theme and CSS variables are both dark
54+
const theme = await getThemeAttribute(page);
55+
expect(theme).toBe('dark');
56+
57+
const bgColor = await page.evaluate(() => {
58+
return getComputedStyle(document.documentElement).getPropertyValue('--color-bg-main').trim().toLowerCase();
59+
});
60+
// Dark mode background should be #18181B (zinc-900)
61+
expect(bgColor).toBe('#18181b');
62+
});
63+
});
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { test, expect, STORAGE_KEY, getThemeAttribute, selectors } from '../fixtures/setup';
2+
3+
test.describe('System Preference Detection', () => {
4+
test('respects system dark preference when no saved preference', async ({ browser }) => {
5+
const context = await browser.newContext({
6+
colorScheme: 'dark',
7+
});
8+
const page = await context.newPage();
9+
10+
await page.goto('/auth');
11+
await page.waitForSelector(selectors.themeToggleButton, { timeout: 10000 });
12+
13+
expect(await getThemeAttribute(page)).toBe('dark');
14+
15+
await context.close();
16+
});
17+
18+
test('respects system light preference when no saved preference', async ({ browser }) => {
19+
const context = await browser.newContext({
20+
colorScheme: 'light',
21+
});
22+
const page = await context.newPage();
23+
24+
await page.goto('/auth');
25+
await page.waitForSelector(selectors.themeToggleButton, { timeout: 10000 });
26+
27+
expect(await getThemeAttribute(page)).toBe('light');
28+
29+
await context.close();
30+
});
31+
32+
test('saved preference overrides system dark preference', async ({ browser }) => {
33+
const context = await browser.newContext({
34+
colorScheme: 'dark',
35+
});
36+
const page = await context.newPage();
37+
38+
// Set localStorage to light before navigating
39+
await page.goto('/auth');
40+
await page.evaluate((key) => localStorage.setItem(key, 'light'), STORAGE_KEY);
41+
42+
// Reload to let ThemeManager pick up the saved preference
43+
await page.reload();
44+
await page.waitForSelector(selectors.themeToggleButton, { timeout: 10000 });
45+
46+
// Saved preference (light) should override system preference (dark)
47+
expect(await getThemeAttribute(page)).toBe('light');
48+
49+
await context.close();
50+
});
51+
52+
test('saved preference overrides system light preference', async ({ browser }) => {
53+
const context = await browser.newContext({
54+
colorScheme: 'light',
55+
});
56+
const page = await context.newPage();
57+
58+
// Set localStorage to dark before navigating
59+
await page.goto('/auth');
60+
await page.evaluate((key) => localStorage.setItem(key, 'dark'), STORAGE_KEY);
61+
62+
// Reload to let ThemeManager pick up the saved preference
63+
await page.reload();
64+
await page.waitForSelector(selectors.themeToggleButton, { timeout: 10000 });
65+
66+
// Saved preference (dark) should override system preference (light)
67+
expect(await getThemeAttribute(page)).toBe('dark');
68+
69+
await context.close();
70+
});
71+
});

0 commit comments

Comments
 (0)