import { expect, test, type Locator, type Page } from '@playwright/test'; import { bookURL, treeNodeCount } from './documents'; // Deliberately independent of the default project preferences (including no-JS). test.use({ colorScheme: 'dark' }); const stage = '[data-landing-stage]'; const article = 'article.selected-content.prose[data-selected-content]'; const nodeAt = (page: Page, path: string) => page.locator(`.demo-node[data-path="${path}"]`); type RGB = [number, number, number]; function luminance(rgb: RGB) { const linear = rgb.map((value) => { const channel = value / 255; return channel <= .04045 ? channel / 12.92 : ((channel + .055) / 1.055) ** 2.4; }); return .2126 * linear[0] + .7152 * linear[1] + .0722 * linear[2]; } function contrast(a: RGB, b: RGB) { const first = luminance(a), second = luminance(b); return (Math.max(first, second) + .05) / (Math.min(first, second) + .05); } // Canvas resolves rgb(), color(srgb ...), and color-mix() consistently. Composite // translucent backgrounds through the ancestor chain, not against assumed white. async function paint(locator: Locator, property = 'color') { return locator.evaluate((element, property) => { const canvas = document.createElement('canvas'); canvas.width = canvas.height = 1; const ctx = canvas.getContext('2d', { willReadFrequently: true })!; const rgba = (color: string) => { if (!CSS.supports('color', color)) throw new Error(`Unsupported computed color: ${color}`); ctx.clearRect(0, 0, 1, 1); ctx.fillStyle = color; ctx.fillRect(0, 0, 1, 1); return Array.from(ctx.getImageData(0, 0, 1, 1).data); }; const over = (front: number[], back: number[]) => front.slice(0, 3).map((channel, i) => Math.round(channel * front[3] / 255 + back[i] * (1 - front[3] / 255))); const ancestors: Element[] = []; for (let current: Element | null = element; current; current = current.parentElement) ancestors.unshift(current); let background = [255, 255, 255]; for (const ancestor of ancestors) background = over(rgba(getComputedStyle(ancestor).backgroundColor), background); const style = getComputedStyle(element); const raw = style.getPropertyValue(property); const color = over(rgba(raw === 'currentcolor' ? style.color : raw), background); return { color: color as [number, number, number], background: background as [number, number, number] }; }, property); } async function readable(locator: Locator, minimum = 4.5, property = 'color') { // Assert the requested palette state, not a scheduler-dependent intermediate // frame of the existing hover/focus color transition. await locator.evaluate(async (element) => { await Promise.all(element.getAnimations().map((animation) => animation.finished.catch(() => {}))); }); const colors = await paint(locator, property); expect(contrast(colors.color, colors.background), `${property} contrast for ${locator}`).toBeGreaterThanOrEqual(minimum); return colors; } async function darkPage(page: Page) { const colors = await readable(page.locator('body')); expect(luminance(colors.background)).toBeLessThan(.05); await expect(page.locator('html')).toHaveCSS('color-scheme', 'dark'); return colors; } async function noOverflow(page: Page) { expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), 'no page-level horizontal overflow').toBe(true); } async function complete(page: Page) { await expect(page.locator(stage)).toHaveAttribute('data-phase', 'complete'); await expect(page.locator('html')).not.toHaveAttribute('data-landing'); } async function treeInk(page: Page) { await readable(page.locator('.trunk-line'), 3, 'fill'); await expect(page.locator('.trunk-line')).toHaveCSS('opacity', '1'); await readable(page.locator('.node-row:not([hidden]) .branch-vector use[data-cell="1"]').first(), 3, 'fill'); await readable(nodeAt(page, '/book').locator('.node-label')); } // Both are canonical public chapters. Fail clearly if the content moves instead // of silently skipping code/table assertions or manufacturing a test fixture. async function codeAndTableChapter(page: Page, base: string) { for (const path of ['/3-protocol.md', '/2-core-model.md']) { await page.goto(bookURL(path, base)); if (await page.locator(`${article} pre.astro-code code span[style]`).count() && await page.locator(`${article} table`).count()) return path; } throw new Error('Expected /book/3-protocol/ or /book/2-core-model/ to contain Shiki code and a table'); } function observeIntro() { const states: string[] = []; (window as Window & { themeIntroStates?: string[] }).themeIntroStates = states; new MutationObserver((mutations) => { for (const mutation of mutations) { const element = mutation.target as Element; for (const value of [mutation.oldValue, element.getAttribute(mutation.attributeName!)]) { if (value && (mutation.attributeName === 'data-landing' || ['armed', 'flow'].includes(value))) states.push(value); } } }).observe(document, { subtree: true, attributes: true, attributeOldValue: true, attributeFilter: ['data-phase', 'data-landing'] }); } test.describe('automatic dark theme without JavaScript', () => { test.use({ javaScriptEnabled: false }); test('home, directory, code/table chapter and 404 remain readable and directly linked', async ({ page }, testInfo) => { const base = testInfo.project.use.baseURL!; await page.goto('./'); await expect(page.locator(article).getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible(); await expect(page.locator('html')).not.toHaveAttribute('data-landing'); await darkPage(page); await readable(page.locator(article + ' p').first()); await treeInk(page); await noOverflow(page); await page.screenshot({ path: testInfo.outputPath('dark-home.png'), fullPage: true }); const nav = page.getByRole('navigation', { name: 'ルートページのナビゲーション', exact: true }); const book = nav.getByRole('link', { name: /^Book(?:\s|$)/ }); await readable(book); await expect(book).toHaveAttribute('href', new URL('book/', base).pathname); await book.click(); await expect(page).toHaveURL(new URL('book/', base).href); await expect(nodeAt(page, '/book')).toHaveAttribute('aria-current', 'page'); await darkPage(page); await readable(page.locator(article + ' a').first()); await noOverflow(page); const chapterLink = page.locator(article + ' a[href$="/book/3-protocol/"]'); await expect(chapterLink).toBeVisible(); await chapterLink.click(); await expect(page).toHaveURL(bookURL('/3-protocol.md', base)); await expect(page.locator(article + ' h1')).toHaveText('3. Protocol'); await codeAndTableChapter(page, base); await darkPage(page); await readable(page.locator(article + ' p').first()); await readable(page.locator(article + ' table th').first()); await readable(page.locator(article + ' table td').first()); await readable(page.locator(article + ' pre.astro-code').first()); await treeInk(page); await noOverflow(page); await page.evaluate(() => document.fonts.ready.then(() => undefined)); await page.screenshot({ path: testInfo.outputPath('dark-book.png'), fullPage: false }); const response = await page.goto(new URL('missing-dark-theme-page/', base).href); expect(response!.status()).toBe(404); await expect(page.locator(article + ' h1')).toHaveText('このページは見つかりません。'); await darkPage(page); await readable(page.locator(article + ' h1')); await expect(page.locator('.demo-node')).toHaveCount(treeNodeCount); await noOverflow(page); await nodeAt(page, '/').click(); await expect(page).toHaveURL(base); await expect(page.locator(article).getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible(); }); test('screen palette and media-qualified browser chrome follow the preference with no script', async ({ page }) => { await page.goto('./'); const dark = await darkPage(page); const metas = page.locator('meta[name="theme-color"]'); await expect(metas).toHaveCount(2); const metaColors = await metas.evaluateAll((elements) => elements.map((element) => ({ media: element.getAttribute('media')!, color: element.getAttribute('content')!, }))); expect(metaColors.map(({ media }) => media).sort()).toEqual([ '(prefers-color-scheme: dark)', '(prefers-color-scheme: light)', ]); const matchingMetaColor = async () => page.evaluate(() => { const meta = [...document.querySelectorAll('meta[name="theme-color"]')] .find((element) => matchMedia(element.media).matches)!; const canvas = document.createElement('canvas'); canvas.width = canvas.height = 1; const ctx = canvas.getContext('2d')!; ctx.fillStyle = meta.content; ctx.fillRect(0, 0, 1, 1); return Array.from(ctx.getImageData(0, 0, 1, 1).data).slice(0, 3); }); expect(await matchingMetaColor()).toEqual(dark.background); await page.emulateMedia({ colorScheme: 'light' }); await expect(page.locator('html')).toHaveCSS('color-scheme', 'light'); const light = await readable(page.locator('body')); expect(luminance(light.background)).toBeGreaterThan(.8); expect(light.color).not.toEqual(dark.color); expect(await matchingMetaColor()).toEqual(light.background); await page.emulateMedia({ colorScheme: 'dark' }); expect(await darkPage(page)).toEqual(dark); }); test('Shiki switches pre and token colors without flattening syntax or changing code', async ({ page }, testInfo) => { await codeAndTableChapter(page, testInfo.project.use.baseURL!); // The first fence may be plain text; exercise a genuinely highlighted block. const blockIndex = await page.locator(article + ' pre.astro-code').evaluateAll((blocks) => { const counts = blocks.map((block) => new Set([...block.querySelectorAll('code span[style]')] .filter((token) => token.textContent!.trim()).map((token) => getComputedStyle(token).color)).size); return counts.indexOf(Math.max(...counts)); }); const pre = page.locator(article + ' pre.astro-code').nth(blockIndex); const text = await pre.textContent(); const sample = async () => ({ pre: await paint(pre), tokens: await pre.locator('code span[style]').evaluateAll((elements) => { const canvas = document.createElement('canvas'); canvas.width = canvas.height = 1; const ctx = canvas.getContext('2d')!; return elements.filter((element) => element.textContent!.trim()).map((element) => { ctx.clearRect(0, 0, 1, 1); ctx.fillStyle = getComputedStyle(element).color; ctx.fillRect(0, 0, 1, 1); return Array.from(ctx.getImageData(0, 0, 1, 1).data).slice(0, 3).join(','); }); }), }); const dark = await sample(); expect(new Set(dark.tokens).size).toBeGreaterThan(1); await expect(pre.locator('[style*="--shiki-dark"]')).not.toHaveCount(0); await page.emulateMedia({ colorScheme: 'light' }); const light = await sample(); expect(light.pre.background).not.toEqual(dark.pre.background); expect(light.pre.color).not.toEqual(dark.pre.color); expect(light.tokens.length).toBe(dark.tokens.length); expect(new Set(light.tokens).size).toBeGreaterThan(1); expect(dark.tokens.some((color, index) => color !== light.tokens[index])).toBe(true); expect(await pre.textContent()).toBe(text); await page.emulateMedia({ colorScheme: 'dark' }); expect(await sample()).toEqual(dark); }); test('print retains the light paper, ink and code despite a dark screen preference', async ({ page }, testInfo) => { await codeAndTableChapter(page, testInfo.project.use.baseURL!); await page.emulateMedia({ media: 'print', colorScheme: 'light' }); const lightPrint = await paint(page.locator('body')); const lightCode = await paint(page.locator(article + ' pre.astro-code').first()); const tokenColors = () => page.locator(article + ' pre.astro-code code span[style]').evaluateAll((tokens) => tokens.map((token) => getComputedStyle(token).color)); const lightTokens = await tokenColors(); await page.emulateMedia({ media: 'print', colorScheme: 'dark' }); expect(await page.evaluate(() => matchMedia('(prefers-color-scheme: dark)').matches)).toBe(true); const darkPrint = await readable(page.locator('body')); expect(darkPrint).toEqual(lightPrint); expect(luminance(darkPrint.background)).toBeGreaterThan(.8); await expect(page.locator('html')).toHaveCSS('color-scheme', 'light'); await readable(page.locator(article + ' p').first()); expect(await paint(page.locator(article + ' pre.astro-code').first())).toEqual(lightCode); expect(await tokenColors()).toEqual(lightTokens); await page.emulateMedia({ media: 'screen', colorScheme: 'dark' }); await darkPage(page); }); }); test.describe('live CSS theme with JavaScript enabled', () => { test.use({ javaScriptEnabled: true, reducedMotion: 'no-preference' }); test('light → dark → light preserves route, article/tree identity, collapse and reading position without intro replay', async ({ page }, testInfo) => { await page.emulateMedia({ colorScheme: 'light' }); await page.goto('./'); await expect(page.locator(stage)).toHaveAttribute('data-phase', 'armed'); await page.keyboard.press('Escape'); await complete(page); await page.evaluate(observeIntro); const tree = await page.locator('.demo-tree').elementHandle(); await nodeAt(page, '/3-protocol.md').click(); await expect(page).toHaveURL(bookURL('/3-protocol.md', testInfo.project.use.baseURL!)); await complete(page); await page.evaluate(() => document.fonts.ready.then(() => undefined)); await nodeAt(page, '/1-concept.md').locator('..').hover(); await page.locator('.node-toggle[data-toggle-path="/1-concept.md"]').click(); await expect(nodeAt(page, '/1-concept.md')).toHaveAttribute('aria-expanded', 'false'); const content = await page.locator(article).elementHandle(); const html = await page.locator(article).innerHTML(); const url = page.url(); const storage = await page.evaluate(() => ({ local: { ...localStorage }, session: { ...sessionStorage } })); const position = await page.locator(article).evaluate((element) => { const max = element.scrollHeight - element.clientHeight; if (max > 100) element.scrollTop = Math.min(240, max); else window.scrollTo(0, Math.min(320, document.documentElement.scrollHeight - innerHeight)); return { article: element.scrollTop, window: scrollY, tree: document.querySelector('.tree-viewport')!.scrollTop }; }); expect(position.article + position.window, 'test must start partway through the real chapter').toBeGreaterThan(0); const light = await paint(page.locator('body')); for (const colorScheme of ['dark', 'light'] as const) { await page.emulateMedia({ colorScheme }); await expect(page.locator('html')).toHaveCSS('color-scheme', colorScheme); if (colorScheme === 'dark') await darkPage(page); else expect(await paint(page.locator('body'))).toEqual(light); expect(page.url()).toBe(url); expect(await content!.evaluate((element) => element === document.querySelector('article.prose'))).toBe(true); expect(await tree!.evaluate((element) => element === document.querySelector('.demo-tree'))).toBe(true); expect(await page.locator(article).innerHTML()).toBe(html); await expect(nodeAt(page, '/1-concept.md')).toHaveAttribute('aria-expanded', 'false'); await expect(nodeAt(page, '/1.1-interaction-abstraction.md')).toBeHidden(); await expect(nodeAt(page, '/3-protocol.md')).toHaveAttribute('aria-current', 'page'); expect(await page.locator(article).evaluate((element) => ({ article: element.scrollTop, window: scrollY, tree: document.querySelector('.tree-viewport')!.scrollTop, }))).toEqual(position); await complete(page); } expect(await page.evaluate(() => (window as Window & { themeIntroStates?: string[] }).themeIntroStates)).toEqual([]); expect(await page.evaluate(() => ({ local: { ...localStorage }, session: { ...sessionStorage } }))).toEqual(storage); }); for (const finish of ['complete', 'skip'] as const) { test(`dark intro can ${finish}; switching while armed and playing does not reset or strand it`, async ({ page }) => { await page.clock.install({ time: new Date('2026-01-01T00:00:00Z') }); await page.clock.pauseAt(new Date('2026-01-01T00:00:01Z')); await page.goto('./'); // Do not wait for the inert/revealed article or hidden tree to be visible. await expect(page.locator(stage)).toHaveAttribute('data-phase', 'armed'); const root = await page.locator('.root-shape').elementHandle(); const trunk = page.locator('.trunk-line'); const armedDark = await paint(trunk, 'fill'); await readable(page.locator('.root-shape'), 3, 'fill'); await page.emulateMedia({ colorScheme: 'light' }); const armedLight = await paint(trunk, 'fill'); expect(armedLight.color).not.toEqual(armedDark.color); await expect(page.locator(stage)).toHaveAttribute('data-phase', 'armed'); await page.emulateMedia({ colorScheme: 'dark' }); expect(await paint(trunk, 'fill')).toEqual(armedDark); await expect(page.locator(stage)).toHaveAttribute('data-phase', 'armed'); await page.keyboard.press('Enter'); await page.clock.runFor(2600); await expect(page.locator(stage)).toHaveAttribute('data-phase', 'flow'); await expect(page.locator('html')).toHaveAttribute('data-landing', 'playing'); const playingDark = await readable(trunk, 3, 'fill'); await expect(trunk).toHaveCSS('opacity', '1'); const reveal = await page.locator('.trunk-reveal').getAttribute('height'); expect(Number(reveal)).toBeGreaterThan(0); await page.emulateMedia({ colorScheme: 'light' }); expect((await paint(trunk, 'fill')).color).not.toEqual(playingDark.color); await expect(page.locator(stage)).toHaveAttribute('data-phase', 'flow'); await page.emulateMedia({ colorScheme: 'dark' }); expect(await paint(trunk, 'fill')).toEqual(playingDark); await expect(page.locator('.trunk-reveal')).toHaveAttribute('height', reveal!); expect(await root!.evaluate((element) => element === document.querySelector('.root-shape'))).toBe(true); if (finish === 'skip') await page.keyboard.press('Escape'); await page.clock.runFor(4000); await complete(page); await darkPage(page); await treeInk(page); await readable(page.locator(article + ' p').first()); await expect(page.locator('.document-pane')).not.toHaveAttribute('inert'); await expect(page.locator('.intro-skip')).toBeHidden(); await noOverflow(page); }); } test('selected, hovered, keyboard-focused nodes and home navigation stay legible in dark mode', async ({ page }) => { await page.goto('./'); await expect(page.locator(stage)).toHaveAttribute('data-phase', 'armed'); await page.keyboard.press('Escape'); await complete(page); const book = nodeAt(page, '/book'); await readable(book.locator('.node-label')); await book.hover(); await readable(book.locator('.node-label')); await page.mouse.move(0, 0); await nodeAt(page, '/').focus(); await page.keyboard.press('ArrowDown'); await expect(book).toBeFocused(); await readable(book.locator('.node-label')); expect(await book.evaluate((element) => { const style = getComputedStyle(element); return element.matches(':focus-visible') && style.outlineStyle !== 'none' && parseFloat(style.outlineWidth) > 0; })).toBe(true); const outline = await paint(book, 'outline-color'); expect(contrast(outline.color, (await paint(page.locator('body'))).background)).toBeGreaterThanOrEqual(3); const nav = page.getByRole('navigation', { name: 'ルートページのナビゲーション', exact: true }); const homeBook = nav.getByRole('link', { name: /^Book(?:\s|$)/ }); await readable(homeBook); await homeBook.hover(); await readable(homeBook); await homeBook.focus(); await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab'); await expect(homeBook).toBeFocused(); await readable(homeBook); await homeBook.click(); await complete(page); await expect(book).toHaveAttribute('aria-selected', 'true'); await readable(book.locator('.node-label')); await book.hover(); await readable(book.locator('.node-label')); }); });