diff --git a/site/astro.config.mjs b/site/astro.config.mjs index a27a0a3..0fc04e0 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -19,6 +19,6 @@ export default defineConfig({ trailingSlash: 'always', markdown: { processor: unified({ remarkPlugins: [[documentLinks, { base }]], smartypants: false }), - shikiConfig: { theme: 'github-light', langAlias: { wip: 'text' } }, + shikiConfig: { themes: { light: 'github-light', dark: 'github-dark' }, langAlias: { wip: 'text' } }, }, }); diff --git a/site/playwright.config.ts b/site/playwright.config.ts index 39851d3..113457d 100644 --- a/site/playwright.config.ts +++ b/site/playwright.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ use: { baseURL: `http://127.0.0.1:${port}${base}/`, javaScriptEnabled: false, + colorScheme: 'light', launchOptions: { executablePath: process.env.CHROME_PATH }, }, projects: [ diff --git a/site/src/components/Landing.astro b/site/src/components/Landing.astro index 9671534..be873e9 100644 --- a/site/src/components/Landing.astro +++ b/site/src/components/Landing.astro @@ -7,7 +7,7 @@ import TreeGlyphs from './TreeGlyphs.astro'; import { trunkPath, stemWidth, branchY, branchThickness } from '../lib/landing-glyphs.mjs'; interface Props { currentPath?: string; label?: string; draft?: boolean; } const entries = await documents(); -const { currentPath = `/${entries[0].id}.md`, label, draft = false } = Astro.props; +const { currentPath = '/', label, draft = false } = Astro.props; const folderHref = (path: string) => path === '/' ? href() : path === '/book' ? href('book/') : href(`book${path}/`); const objects = bookTree(entries.map((entry) => ({ id: entry.id, title: title(entry), href: bookHref(entry.id) }))) .map((object) => ({ ...object, href: object.href ?? folderHref(object.path) })); diff --git a/site/src/components/RootPage.astro b/site/src/components/RootPage.astro new file mode 100644 index 0000000..257d119 --- /dev/null +++ b/site/src/components/RootPage.astro @@ -0,0 +1,124 @@ +--- +import { href, repository } from '../lib/book'; +--- +
+
+

Worldspace Interface Protocol

+

WIP

+

操作に、
共通の言葉を。

+
+

人間も、AIも、システムも。
アプリケーションの発見・理解・操作を、
ひとつのインタフェースで。

+ +
+ + diff --git a/site/src/layouts/Site.astro b/site/src/layouts/Site.astro index d39d155..487d7db 100644 --- a/site/src/layouts/Site.astro +++ b/site/src/layouts/Site.astro @@ -29,7 +29,9 @@ const canonical = Astro.site ? new URL(Astro.url.pathname, Astro.site) : undefin - + + + diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro index ea62cbe..25383d3 100644 --- a/site/src/pages/index.astro +++ b/site/src/pages/index.astro @@ -1,9 +1,10 @@ --- import Site from '../layouts/Site.astro'; import Landing from '../components/Landing.astro'; +import RootPage from '../components/RootPage.astro'; --- - +
- +
diff --git a/site/src/scripts/landing.ts b/site/src/scripts/landing.ts index 11d762a..89327af 100644 --- a/site/src/scripts/landing.ts +++ b/site/src/scripts/landing.ts @@ -340,8 +340,8 @@ export async function initLanding(lifetime: AbortSignal) { const longLength = Math.max(0, innerHeight * .4 - root.offsetHeight * 7.2 / 2 - 8); drawTrunk(mix(longLength * extension / scale, restingLength(), converge)); - const ink = [[32, 142], [34, 152], [31, 134]].map(([from, to]) => Math.round(mix(from, to, converge))); - trunk.style.fill = 'rgb(' + ink.join(',') + ')'; + // Resolve theme tokens in CSS so even a paused intro follows OS changes. + trunk.style.fill = `color-mix(in srgb, var(--ink), var(--tree-ink) ${converge * 100}%)`; trunk.style.opacity = String(cues.trunkOpacity); // Keep the circle centred on the moving root until the narrow viewport diff --git a/site/src/styles/global.css b/site/src/styles/global.css index f6d3bb4..eb679fd 100644 --- a/site/src/styles/global.css +++ b/site/src/styles/global.css @@ -1,15 +1,7 @@ @import './fonts.css'; +@import './theme.css'; :root { - color-scheme: light; - --paper: #f7f7f2; - --surface: #fff; - --ink: #202f2c; - --muted: #596762; - --line: #dce1d8; - --green: #123c39; - --accent: #d8ecc9; - --soft: #edf1e8; --mono: 'Lyth Mono', ui-monospace, 'SFMono-Regular', Consolas, 'Liberation Mono', 'Noto Sans Mono CJK JP', monospace; --serif: 'EB Garamond', 'Noto Serif JP', 'Hiragino Mincho ProN', 'Yu Mincho', 'YuMincho', serif; font-family: var(--serif); @@ -23,16 +15,16 @@ * { box-sizing: border-box; } body { margin: 0; } a { color: inherit; text-underline-offset: .22em; } -a:hover { color: #226c51; } -:focus-visible { outline: 3px solid #33846c; outline-offset: 4px; border-radius: 3px; } +a:hover { color: var(--link-hover); } +:focus-visible { outline: 3px solid var(--focus); outline-offset: 4px; border-radius: 3px; } ::selection { background: var(--accent); color: var(--green); } h1, h2, h3, p { margin-top: 0; } h1, h2, h3 { line-height: 1.5; letter-spacing: -.035em; } h1 { font-size: clamp(2rem, 3.6vw, 3.8rem); font-weight: 800; } h2 { font-size: clamp(1.65rem, 2.5vw, 2.5rem); } code, pre { font-family: var(--mono); } -.skip-link { position: fixed; top: -6rem; left: 1rem; z-index: 100; padding: .7rem 1rem; background: var(--green); color: white; } -.skip-link:focus { top: 1rem; color: white; } +.skip-link { position: fixed; top: -6rem; left: 1rem; z-index: 100; padding: .7rem 1rem; background: var(--green); color: var(--on-green); } +.skip-link:focus { top: 1rem; color: var(--on-green); } .site-header { min-height: 90px; display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem max(5vw, calc((100vw - 1400px)/2)); border-bottom: 1px solid var(--line); background: var(--paper); } .brand { display: flex; align-items: center; gap: .8rem; text-decoration: none; font-size: 1.45rem; font-weight: 800; line-height: 1.3; letter-spacing: -.04em; } .brand-mark { display: grid; place-items: center; background: var(--green); color: var(--accent); width: 42px; height: 42px; border-radius: 10px; font: 800 1.4rem var(--mono); } @@ -44,7 +36,7 @@ code, pre { font-family: var(--mono); } .status-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: #73ab72; margin-right: .6rem; } .lead { color: var(--muted); line-height: 2; font-size: 1.05rem; } .button { display: inline-flex; align-items: center; gap: 2rem; padding: .9rem 1.4rem; border-radius: 5px; text-decoration: none; font-size: .88rem; font-weight: 600; } -.primary { background: var(--green); color: white; border: 1px solid var(--green); } +.primary { background: var(--green); color: var(--on-green); border: 1px solid var(--green); } .primary:hover { background: #23534a; color: white; } .text-link { font-size: .85rem; font-weight: 600; } .hero { display: grid; grid-template-columns: 1.25fr 1fr; gap: 5vw; max-width: 1280px; margin: auto; padding: 6rem 3rem 5rem; align-items: center; } @@ -97,7 +89,7 @@ code, pre { font-family: var(--mono); } .chapter-nav ul, .chapter-nav ol, .page-toc ul { list-style: none; padding: 0; margin: 0; } .chapter-nav a { display: block; padding: .6rem .7rem; text-decoration: none; font-size: .77rem; line-height: 1.6; border-radius: 4px; color: var(--muted); } .chapter-nav a:hover { background: var(--soft); color: var(--green); } -.chapter-nav a[aria-current] { background: var(--green); color: white; } +.chapter-nav a[aria-current] { background: var(--green); color: var(--on-green); } .chapter-nav .subchapter { padding-left: .7rem; } .draft-label { margin-top: 2.5rem; } .book-content { min-width: 0; padding: 3rem 0 4rem; } @@ -122,17 +114,17 @@ code, pre { font-family: var(--mono); } .prose h3 { font-size: 1.13rem; margin: 2rem 0 .8rem; } .prose h4 { font-size: 1rem; } .prose :is(h1,h2,h3,h4) { scroll-margin-top: 1.5rem; } -.prose a { color: #246449; } +.prose a { color: var(--link); } .prose p, .prose ul, .prose ol { margin-bottom: 1.2rem; } .prose ul, .prose ol { padding-left: 1.5rem; } .prose li + li { margin-top: .35rem; } -.prose :not(pre) > code { background: #e9eee6; padding: .15em .35em; border-radius: 3px; font-size: .86em; color: #284c3a; } +.prose :not(pre) > code { background: var(--code-bg); padding: .15em .35em; border-radius: 3px; font-size: .86em; color: var(--code-ink); } .prose pre { max-width: 100%; overflow-x: auto; padding: 1.25rem; border: 1px solid var(--line); border-radius: 6px; font-size: .78rem; line-height: 1.7; } .prose pre code { background: none; } .prose table { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; font-size: .82rem; margin: 1.5rem 0; } .prose th, .prose td { border: 1px solid var(--line); padding: .65rem .8rem; text-align: left; vertical-align: top; min-width: 100px; } .prose th { background: var(--soft); font-weight: 600; } -.prose blockquote { margin: 1rem 0; padding: .5rem 1.2rem; border-left: 3px solid #8ca58a; color: var(--muted); } +.prose blockquote { margin: 1rem 0; padding: .5rem 1.2rem; border-left: 3px solid var(--quote-line); color: var(--muted); } .prose blockquote p:last-child { margin-bottom: 0; } .prose img { max-width: 100%; height: auto; } .page-toc { padding-top: 3rem; } diff --git a/site/src/styles/landing.css b/site/src/styles/landing.css index 86098f5..ce08090 100644 --- a/site/src/styles/landing.css +++ b/site/src/styles/landing.css @@ -1,6 +1,5 @@ .landing-page { - --paper: #fafaf8; --ink: #20221f; --muted: #686d64; --green: #283e25; - --accent: #dcf9b0; --line: #d9ddd3; --row: 24px; + --row: 24px; font: 14px/var(--row) var(--mono); background: var(--paper); } .landing-page button { font: inherit; } @@ -29,16 +28,16 @@ html[data-landing] .tree-viewport { overflow: visible; } .node-row:not(.root-slot) { transform-origin: 0 0; } /* The slash foot and the persistent trunk share the same mono column. */ .node-row.root-slot { padding: 0 0 0 calc(var(--row) * .1875); height: calc(3 * var(--row)); align-items: end; } -.branch-prefix { display: block; flex: 0 0 auto; height: var(--row); color: #8e9886; pointer-events: none; } +.branch-prefix { display: block; flex: 0 0 auto; height: var(--row); color: var(--tree-ink); pointer-events: none; } .branch-vector { display: block; width: 100%; height: var(--row); fill: currentColor; overflow: visible; } -.demo-node { position: relative; flex: 0 0 auto; border: 0; border-radius: 0; padding: 0 1ch; margin: 0; height: var(--row); display: flex; align-items: center; gap: 1ch; cursor: pointer; background: transparent; color: inherit; text-align: left; text-decoration: none; transition: background .18s; } -.demo-node[aria-selected="true"] { background: var(--accent); color: #273621; } -.demo-node:hover { background: #eaf0e2; } -.demo-node[aria-selected="true"]:hover { background: #d1f39b; } +.demo-node { position: relative; flex: 0 0 auto; border: 0; border-radius: 0; padding: 0 1ch 0 0; margin: 0; height: var(--row); display: flex; align-items: center; gap: 1ch; cursor: pointer; background: transparent; color: inherit; text-align: left; text-decoration: none; transition: background .18s; } +.demo-node[aria-selected="true"] { background: var(--accent); color: var(--node-selected-ink); } +.demo-node:hover { background: var(--node-hover); } +.demo-node[aria-selected="true"]:hover { background: var(--node-selected-hover); } .node-indicator { color: var(--muted); } .node-toggle { display: none; border: 0; padding: 0 1ch; height: var(--row); background: transparent; cursor: pointer; } .demo-tree[data-enhanced] .node-toggle { display: block; } -.node-toggle:hover { background: #eaf0e2; } +.node-toggle:hover { background: var(--node-hover); } html[data-landing] .node-toggle { visibility: hidden; } .document-pane { min-width: 0; } .root-mark { width: 3ch; height: var(--row); display: block; padding: 0; background: transparent !important; transform-origin: center; z-index: 25; transition: none; } @@ -46,7 +45,7 @@ html[data-landing] .node-toggle { visibility: hidden; } .slash-art { --slash-rest-scale: 0.5833333333333333; transform-origin: 12px 64px; transform: scale(var(--slash-scale, var(--slash-rest-scale))); } html[data-landing="boot"] .slash-art { --slash-scale: 1; } .root-shape { fill: currentColor; } -.root-mark[aria-selected="true"] { color: #4e752d; } +.root-mark[aria-selected="true"] { color: var(--root-selected); } .selected-content { min-width: 0; max-height: calc(100svh - 8 * var(--row)); overflow: auto; margin-top: calc(2 * var(--row)); padding-inline: 2ch; font: 18px/1.8 var(--serif); } .selected-content[hidden] { display: none; } .selected-content h1 { font-size: 24px; font-weight: 500; margin-bottom: var(--row); } @@ -63,7 +62,7 @@ html[data-landing="boot"] .root-mark { position: fixed; left: calc(50vw - 1.5ch) html[data-landing] .intro-art { display: block; position: fixed; z-index: 10; width: 100%; height: 100svh; inset: 0; pointer-events: none; } .orbit { position: absolute; left: 50%; top: 50%; width: min(640px, 112vw); height: min(640px, 112vw); overflow: visible; transform: translate(-50%, -50%); opacity: 0; } .trunk-anchor { pointer-events: none; } -.trunk-line { fill: #8e9886; stroke: none; } +.trunk-line { fill: var(--tree-ink); stroke: none; } html[data-landing="boot"] .trunk-line { opacity: 0; } .orbit text { fill: var(--ink); font: 500 17px var(--mono); letter-spacing: 1.5px; } .tap-mask { pointer-events: none; } diff --git a/site/src/styles/theme.css b/site/src/styles/theme.css new file mode 100644 index 0000000..d56abf1 --- /dev/null +++ b/site/src/styles/theme.css @@ -0,0 +1,65 @@ +/* Theme colors are CSS-only: first paint, no-JS, and live OS changes agree. */ +:root { + color-scheme: light; + --paper: #f7f7f2; + --surface: #fff; + --ink: #202f2c; + --muted: #596762; + --line: #dce1d8; + --green: #123c39; + --accent: #d8ecc9; + --soft: #edf1e8; + --link: #246449; + --link-hover: #226c51; + --focus: #33846c; + --on-green: #fff; + --code-bg: #e9eee6; + --code-ink: #284c3a; + --quote-line: #8ca58a; + --tree-ink: #8e9886; + --node-selected-ink: #273621; + --node-hover: #eaf0e2; + --node-selected-hover: #d1f39b; + --root-selected: #4e752d; +} +.landing-page { + --paper: #fafaf8; + --ink: #20221f; + --muted: #686d64; + --green: #283e25; + --accent: #dcf9b0; + --line: #d9ddd3; +} +/* Print deliberately keeps the light palette and Shiki's light defaults. */ +@media screen and (prefers-color-scheme: dark) { + :root { color-scheme: dark; } + :root, .landing-page { + --paper: #171a17; + --surface: #20251e; + --ink: #e1e4dc; + --muted: #a7b09e; + --line: #454e40; + --green: #b4c995; + --accent: #374a2f; + --soft: #22291f; + --link: #b4d69c; + --link-hover: #cce4af; + --focus: #b7d68f; + --on-green: #171a17; + --code-bg: #283023; + --code-ink: #cedfbb; + --quote-line: #899580; + --tree-ink: #899580; + --node-selected-ink: #e4edda; + --node-hover: #283023; + --node-selected-hover: #425b36; + --root-selected: #b7d68f; + } + .astro-code, .astro-code span { + color: var(--shiki-dark) !important; + background-color: var(--shiki-dark-bg) !important; + font-style: var(--shiki-dark-font-style) !important; + font-weight: var(--shiki-dark-font-weight) !important; + text-decoration: var(--shiki-dark-text-decoration) !important; + } +} diff --git a/site/tests/browser/fonts.spec.ts b/site/tests/browser/fonts.spec.ts index 41a44eb..ccdc10c 100644 --- a/site/tests/browser/fonts.spec.ts +++ b/site/tests/browser/fonts.spec.ts @@ -31,7 +31,7 @@ test('self-hosted Lyth Mono and EB Garamond load with the configured base', asyn expect(external).toEqual([]); await page.keyboard.press('Escape'); await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-phase', 'complete'); - await expect(page.locator('[data-selected-content] h1')).toHaveText('1. コンセプト'); + await expect(page.locator('[data-selected-content]').getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible(); expect(await page.locator('[data-selected-content]').evaluate((element) => getComputedStyle(element).fontFamily)).toContain('EB Garamond'); await page.locator('[data-selected-content]').screenshot({ path: testInfo.outputPath('eb-garamond-selected-content.png') }); const tree = await page.locator('.tree-pane').elementHandle(); @@ -96,6 +96,25 @@ test.describe('static font and tree rendering', () => { expect(columns.slice(gapStart, gapEnd).every((ink) => ink > 710), JSON.stringify(columns)).toBe(true); } }); + test('node names start exactly one space after the branch', async ({ page }) => { + await page.goto('./'); + await page.evaluate(() => document.fonts.ready); + const spacing = await page.locator('.demo-node:not(.root-mark)').evaluateAll((nodes) => nodes.map((node) => { + const svg = node.parentElement!.querySelector('.branch-vector')!; + const width = svg.viewBox.baseVal.width; + const cell = Number(svg.dataset.cellWidth); + const matrix = svg.getScreenCTM()!; + const branchEnd = new DOMPoint(width - cell, 12).matrixTransform(matrix).x; + const prefixEnd = new DOMPoint(width, 12).matrixTransform(matrix).x; + const label = node.querySelector('.node-label')!.getBoundingClientRect(); + return { padding: getComputedStyle(node).paddingLeft, gap: label.left - branchEnd, space: prefixEnd - branchEnd }; + })); + expect(spacing.length).toBeGreaterThan(0); + for (const { padding, gap, space } of spacing) { + expect(padding).toBe('0px'); + expect(Math.abs(gap - space)).toBeLessThan(.1); + } + }); test('the same SVG trunk and Lyth Mono joins are visible without JavaScript', async ({ page }, testInfo) => { await page.goto('./'); await expect(page.locator('html')).not.toHaveAttribute('data-landing'); @@ -106,7 +125,7 @@ test.describe('static font and tree rendering', () => { await expect(page.locator('.branch-prefix').first().locator('use')).toHaveCount(3); await expect(page.locator('.branch-prefix').first().locator('.branch-spine use')).toHaveAttribute('href', '#tree-glyph-9492'); await expect(page.locator('.tree-glyphs defs path')).toHaveCount(5); - await expect(page.getByRole('link', { name: 'Book', exact: true })).toBeVisible(); + await expect(page.locator('.site-header').getByRole('link', { name: 'Book', exact: true })).toBeVisible(); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); await page.screenshot({ path: testInfo.outputPath('lyth-mono-static.png') }); }); diff --git a/site/tests/browser/landing.spec.ts b/site/tests/browser/landing.spec.ts index f161a20..793b0ea 100644 --- a/site/tests/browser/landing.spec.ts +++ b/site/tests/browser/landing.spec.ts @@ -108,7 +108,7 @@ async function complete(page: import('@playwright/test').Page) { await expect(page.locator('html')).not.toHaveAttribute('data-landing'); expect((await page.locator('.root-shape').boundingBox())!.height).toBeCloseTo(12.25, 4); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); - await expect(page.getByRole('link', { name: 'Book', exact: true })).toBeVisible(); + await expect(page.locator('.site-header').getByRole('link', { name: 'Book', exact: true })).toBeVisible(); } test('only the resting slash shrinks; intro ink and tree dimensions stay unchanged', async ({ page }, testInfo) => { @@ -460,7 +460,7 @@ test('continuous motion overlaps growth, travel and reveal without intermediate await expect(root).toHaveCount(1); await expect(page.locator('.document-pane[data-reveal]')).not.toHaveAttribute('inert'); await expect(page.locator('[data-glitching]')).toHaveCount(0); - await expect(page.locator(selectedContent + ' h1')).toHaveText('1. コンセプト'); + await expect(page.locator(selectedContent).getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible(); await expect(page.locator('.branch-lines')).toHaveCount(0); await expect(page.locator('.demo-node')).toHaveCount(treeNodeCount); await expect(page.getByRole('treeitem')).toHaveCount(treeNodeCount); @@ -557,7 +557,7 @@ test('landing contains only the real filename tree and selected canonical conten await expect(page.getByRole('button', { name: '本文を読む' })).toHaveCount(0); await expect(page.getByRole('link', { name: 'Home', exact: true })).toHaveCount(0); await expect(page.locator('.site-header')).toContainText('WIP'); - await expect(page.getByRole('link', { name: 'Source', exact: true })).toBeVisible(); + await expect(page.locator('.site-header').getByRole('link', { name: 'Source', exact: true })).toBeVisible(); await expect(page.locator('.demo-node')).toHaveCount(treeNodeCount); await expect(page.getByRole('treeitem')).toHaveCount(treeNodeCount); expect(await page.locator('.demo-node').evaluateAll((nodes) => nodes.map((node) => (node as HTMLElement).dataset.path))).toEqual(treePaths); @@ -609,10 +609,14 @@ test('landing contains only the real filename tree and selected canonical conten expect(folder).not.toHaveProperty('html'); expect(folder.href).toBe(new URL(path === '/' ? './' : 'book/', testInfo.project.use.baseURL!).pathname); } - await expect(page.locator(stage)).toHaveAttribute('data-current-path', initialPath); - await expect(nodeAt(page, initialPath)).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator(stage)).toHaveAttribute('data-current-path', '/'); + await expect(nodeAt(page, '/')).toHaveAttribute('aria-selected', 'true'); + await expect(nodeAt(page, '/')).toHaveAttribute('aria-current', 'page'); + await expect(nodeAt(page, initialPath)).toHaveAttribute('aria-selected', 'false'); await expect(page.locator(selectedContent)).toBeVisible(); - await expect(page.locator(selectedContent + ' h1')).toHaveText('1. コンセプト'); + await expect(page.locator(selectedContent).getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible(); + await expect(page.locator(selectedContent + ' .root-page[data-root-page]')).toHaveCount(1); + await expect(page.locator(selectedContent)).not.toContainText('1. コンセプト'); expect(await page.locator('.landing h1, .landing h2, .landing h3, .landing h4, .landing h5, .landing h6').evaluateAll((headings) => headings.every((heading) => heading.closest('[data-selected-content]')))).toBe(true); await checkGlyphRefs(page); }); @@ -667,7 +671,10 @@ test('each file navigates to exactly its Book HTML and title, including links', documents.set(path, { html: await book.locator('article.prose').innerHTML(), title: await book.title() }); } await book.close(); - expect(await page.locator(selectedContent).innerHTML()).toBe(documents.get(initialPath)!.html); + expect(await page.locator(selectedContent).innerHTML()).not.toBe(documents.get(initialPath)!.html); + await nodeAt(page, initialPath).click(); + await expect(page).toHaveURL(bookURL(initialPath, testInfo.project.use.baseURL!)); + await expect.poll(() => page.locator(selectedContent).innerHTML()).toBe(documents.get(initialPath)!.html); for (const path of documentPaths) { await nodeAt(page, path).click(); await expect(page).toHaveURL(bookURL(path, testInfo.project.use.baseURL!)); @@ -692,9 +699,15 @@ test('keyboard arrows only move focus or fold; Enter and Space activate route li const unchanged = async () => { expect(page.url()).toBe(originalURL); expect(await page.locator(selectedContent).innerHTML()).toBe(originalHTML); - await expect(nodeAt(page, initialPath)).toHaveAttribute('aria-selected', 'true'); + await expect(nodeAt(page, '/')).toHaveAttribute('aria-selected', 'true'); }; + await expect(nodeAt(page, '/')).toBeFocused(); + await page.keyboard.press('ArrowDown'); + await expect(nodeAt(page, '/book')).toBeFocused(); + await unchanged(); + await page.keyboard.press('ArrowDown'); await expect(nodeAt(page, initialPath)).toBeFocused(); + await unchanged(); await page.keyboard.press('ArrowDown'); await expect(nodeAt(page, '/1.1-interaction-abstraction.md')).toBeFocused(); await expect(nodeAt(page, '/1.1-interaction-abstraction.md')).toHaveAttribute('aria-selected', 'false'); @@ -867,10 +880,19 @@ test.describe('glyph trunk regressions', () => { test.describe('JavaScript-free glyph tree', () => { test.use({ javaScriptEnabled: false }); - test('initial canonical article and every file link remain usable without JavaScript', async ({ page }, testInfo) => { + test('independent home article and every file link remain usable without JavaScript', async ({ page }, testInfo) => { await page.goto('./'); await expect(page.locator(selectedContent)).toBeVisible(); - await expect(page.locator(selectedContent + ' h1')).toHaveText('1. コンセプト'); + await expect(page.locator(selectedContent).getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible(); + await expect(page.locator(stage)).toHaveAttribute('data-current-path', '/'); + await expect(nodeAt(page, '/')).toHaveAttribute('aria-current', 'page'); + await expect(nodeAt(page, '/')).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator(selectedContent)).not.toContainText('1. コンセプト'); + const home = page.locator(selectedContent + ' .root-page[data-root-page]'); + await expect(home).toContainText('Worldspace Interface Protocol'); + const nav = home.getByRole('navigation', { name: 'ルートページのナビゲーション', exact: true }); + await expect(nav.getByRole('link', { name: /^Book(?:\s|$)/ })).toHaveAttribute('href', new URL('book/', testInfo.project.use.baseURL!).pathname); + await expect(nav.getByRole('link', { name: /^Source(?:\s|$)/ })).toHaveAttribute('href', (await page.locator('.site-header').getByRole('link', { name: 'Source', exact: true }).getAttribute('href'))!); const initialHTML = await page.locator(selectedContent).innerHTML(); await expect(page.locator('.static-guides, .guide-body, .ascii-panel')).toHaveCount(0); await expect(page.getByRole('treeitem')).toHaveCount(treeNodeCount); @@ -884,7 +906,10 @@ test.describe('JavaScript-free glyph tree', () => { await expect(page.locator('.demo-node')).toHaveCount(treeNodeCount); await expect(nodeAt(page, path)).toHaveAttribute('aria-current', 'page'); await expect(page.locator(stage)).toHaveAttribute('data-current-path', path); - if (path === initialPath) expect(await page.locator('article.prose').innerHTML()).toBe(initialHTML); + if (path === initialPath) { + await expect(page.locator('article.prose h1')).toHaveText('1. コンセプト'); + expect(await page.locator('article.prose').innerHTML()).not.toBe(initialHTML); + } } }); @@ -904,7 +929,7 @@ test.describe('JavaScript-free glyph tree', () => { expect(Math.abs(trunk.axis)).toBeLessThan(.1); expect(trunk.gapRatio).toBeCloseTo(1 / 14, 5); await page.locator('.demo-tree').screenshot({ path: testInfo.outputPath('no-js-glyph-tree.png') }); - await page.getByRole('link', { name: 'Book', exact: true }).click(); + await page.getByRole('navigation', { name: 'ルートページのナビゲーション', exact: true }).getByRole('link', { name: /^Book(?:\s|$)/ }).click(); await expect(page).toHaveURL(/\/book\/$/); await expect(nodeAt(page, '/book')).toHaveAttribute('aria-current', 'page'); await expect(page.locator('.landing a[href$="/book/1-concept/"]').last()).toBeVisible(); @@ -928,6 +953,12 @@ test('changing motion preference and resizing cannot strand the intro', async ({ await expect(page.locator('html')).toHaveAttribute('data-landing', 'playing'); await page.emulateMedia({ reducedMotion: 'reduce' }); await complete(page); + // The dedicated home is deliberately short; retain the mobile reading-scroll + // regression on the long canonical chapter rather than requiring home filler. + await nodeAt(page, initialPath).click(); + await expect(page.locator(selectedContent + ' h1')).toHaveText('1. コンセプト'); + // Keep the wheel outside the tree's overscroll-contained scroll viewport. + await page.mouse.move(5, page.viewportSize()!.height / 2); await page.mouse.wheel(0, 650); await expect.poll(() => page.evaluate(() => scrollY)).toBeGreaterThan(0); }); @@ -936,14 +967,14 @@ test('blocked animation module fails open to readable content', async ({ page }) await page.route('**/*.js', (route) => route.abort()); await page.goto('./'); await expect(page.locator('html')).not.toHaveAttribute('data-landing', { timeout: 6000 }); - await expect(page.locator(selectedContent + ' h1')).toHaveText('1. コンセプト'); + await expect(page.locator(selectedContent).getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible(); await expect(page.locator(selectedContent)).toBeVisible(); await nodeAt(page, '/2-core-model.md').click(); await expect(page).toHaveURL(/\/book\/2-core-model\/$/); await expect(page.locator('article.prose h1')).toBeVisible(); await page.goto('./'); await expect(page.locator(selectedContent)).toBeVisible(); - await page.getByRole('link', { name: 'Book', exact: true }).click(); + await page.locator('.site-header').getByRole('link', { name: 'Book', exact: true }).click(); await expect(page).toHaveURL(/\/book\/$/); await expect(nodeAt(page, '/book')).toHaveAttribute('aria-current', 'page'); await expect(page.locator('.landing a[href$="/book/1-concept/"]').last()).toBeVisible(); diff --git a/site/tests/browser/routing.spec.ts b/site/tests/browser/routing.spec.ts index dd816fc..2a8aeba 100644 --- a/site/tests/browser/routing.spec.ts +++ b/site/tests/browser/routing.spec.ts @@ -55,6 +55,104 @@ async function expectMetadata(page: Page, expectedURL: string) { } } +test('home response contains independent root content and the unchanged public tree', async ({ request }, testInfo) => { + const base = testInfo.project.use.baseURL!; + const response = await request.get(base); + expect(response.ok()).toBe(true); + const $ = load(await response.text()); + const chapterResponse = await request.get(bookURL('/1-concept.md', base)); + expect(chapterResponse.ok()).toBe(true); + const chapter = load(await chapterResponse.text()); + expect($(stage).attr('data-current-path')).toBe('/'); + expect($(article).length).toBe(1); + expect($('article.prose').length).toBe(1); + const home = $(article).find('.root-page[data-root-page]'); + expect(home.length).toBe(1); + expect($(article).find('h1').length).toBe(1); + expect(home.find('h1').attr('aria-label')).toBe('WIP'); + const heading = home.find('h1').clone(); + heading.find('[aria-hidden="true"]').remove(); + expect(heading.text().trim()).toBe('WIP'); + expect(home.find('p').text()).toContain('Worldspace Interface Protocol'); + const copy = home.find('p').text(); + expect(copy).toMatch(/人(?:間)?/); + expect(copy).toMatch(/AI/); + expect(copy).toMatch(/システム/); + expect(copy).toMatch(/共通/); + expect(copy).toMatch(/インタフェース/); + const nav = home.find('nav[aria-label="ルートページのナビゲーション"]'); + expect(nav.length).toBe(1); + expect(nav.find('a').length).toBe(2); + expect(nav.find('a').filter((_, el) => $(el).text().includes('Book')).attr('href')).toBe(new URL('book/', base).pathname); + const source = $('.site-header a').filter((_, el) => $(el).text().includes('Source')).attr('href'); + expect(source).toMatch(/^https:\/\//); + expect(nav.find('a').filter((_, el) => $(el).text().includes('Source')).attr('href')).toBe(source); + expect($(article).html()).not.toBe(chapter(article).html()); + expect($(article).text()).not.toContain('1. コンセプト'); + const chapterCopy = chapter(article).find('p').filter((_, el) => Boolean(chapter(el).text().trim())).first().text().trim(); + expect(chapterCopy).toBeTruthy(); + expect($(article).text()).not.toContain(chapterCopy); + expect($('.root-mark').attr('aria-current')).toBe('page'); + expect($('.root-mark').attr('aria-selected')).toBe('true'); + expect($('.demo-node[aria-current="page"]').length).toBe(1); + const treeShape = (document: ReturnType) => document('.demo-node').map((_, el) => { + const node = document(el); + return { tag: node.prop('tagName'), path: node.attr('data-path'), href: node.attr('href'), role: node.attr('role'), level: node.attr('aria-level'), parent: node.parent().prop('tagName') }; + }).get(); + expect(treeShape($)).toEqual(treeShape(chapter)); + expect($('.demo-node').map((_, el) => $(el).attr('data-path')).get()).toEqual(treePaths); + const serialized = $('#landing-documents').text(); + expect(serialized).not.toContain('<'); + const nodes = JSON.parse(serialized) as Record[]; + expect(nodes.map((node) => node.path)).toEqual(treePaths); + for (const node of nodes) { + expect(node).not.toHaveProperty('html'); + expect(node).not.toHaveProperty('body'); + } + expect($('meta[property="og:title"]').attr('content')).toBe($('title').text()); + expect($('meta[name="description"]').attr('content')).toBeTruthy(); + expect($('meta[property="og:description"]').attr('content')).toBe($('meta[name="description"]').attr('content')); + if (process.env.SITE_URL) { + expect($('link[rel="canonical"]').attr('href')).toBe(new URL(new URL(base).pathname, process.env.SITE_URL).href); + } else { + expect($('link[rel="canonical"]').length).toBe(0); + } +}); + +test('root content navigation works without changing the tree or overflowing narrow mobile viewports', async ({ page }, testInfo) => { + const base = testInfo.project.use.baseURL!; + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto('./'); + await settled(page, '/'); + const home = page.locator(article + ' .root-page[data-root-page]'); + await expect(home).toBeVisible(); + await expect(home.getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible(); + await expect(page.locator(article + ' h1')).toHaveCount(1); + await expect(home.locator('p').filter({ hasText: 'Worldspace Interface Protocol' })).toBeVisible(); + await expect(page.locator(article)).not.toContainText('1. コンセプト'); + const nav = home.getByRole('navigation', { name: 'ルートページのナビゲーション', exact: true }); + await expect(nav.getByRole('link')).toHaveCount(2); + await expect(nav.getByRole('link', { name: /^Book(?:\s|$)/ })).toHaveAttribute('href', new URL('book/', base).pathname); + const source = await page.locator('.site-header').getByRole('link', { name: 'Source', exact: true }).getAttribute('href'); + expect(source).toMatch(/^https:\/\//); + await expect(nav.getByRole('link', { name: /^Source(?:\s|$)/ })).toHaveAttribute('href', source!); + if (testInfo.project.name === 'mobile') { + for (const width of [320, 360, 390]) { + await page.setViewportSize({ width, height: 740 }); + await expect(home).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), `${width}px home has no horizontal overflow`).toBe(true); + const bounds = await home.boundingBox(); + expect(bounds!.x).toBeGreaterThanOrEqual(0); + expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(width); + } + } + await nav.getByRole('link', { name: /^Book(?:\s|$)/ }).click(); + await expect(page).toHaveURL(new URL('book/', base).href); + await settled(page, '/book'); + await expect(page.locator('[data-root-page]')).toHaveCount(0); + await expect(page.locator(article).locator('a[href$="/book/1-concept/"]')).toBeVisible(); +}); + test('all 14 document responses contain the current article and metadata-only public tree', async ({ request }, testInfo) => { expect(documentPaths).toHaveLength(14); expect(treeNodeCount).toBe(16); @@ -293,8 +391,8 @@ test('directory anchors navigate independently from the sibling plus/minus toggl await nodeAt(page, '/').focus(); await page.keyboard.press('Enter'); await expect(page).toHaveURL(base); - await settled(page, '/1-concept.md'); - await expect(page.locator(article + ' h1')).toHaveText('1. コンセプト'); + await settled(page, '/'); + await expect(page.locator(article).getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible(); await expect(page.locator('.sidebar, .mobile-chapters, .mobile-toc, .page-toc, .pagination')).toHaveCount(0); }); @@ -327,7 +425,7 @@ test('persisted tree retains scroll and collapse, expanding only an ancestor nee await expect(page.getByRole('treeitem')).toHaveCount(1); expect(page.url()).toBe(bookURL(second, base)); expect(await page.locator(article).innerHTML()).toBe(secondHTML); - await page.getByRole('link', { name: 'Book', exact: true }).click(); + await page.locator('.site-header').getByRole('link', { name: 'Book', exact: true }).click(); await settled(page, '/book'); await expect(nodeAt(page, '/')).toHaveAttribute('aria-expanded', 'true'); await expect(nodeAt(page, '/4-client.md')).toHaveAttribute('aria-expanded', 'false'); @@ -346,6 +444,49 @@ test('persisted tree retains scroll and collapse, expanding only an ancestor nee expect(await pane!.evaluate((el) => el === document.querySelector('.tree-pane'))).toBe(true); }); +test('home preserves a collapsed root until selecting a child requires expansion', async ({ page }, testInfo) => { + const base = testInfo.project.use.baseURL!; + const path = '/1-concept.md'; + await page.addInitScript(recordIntroStates); + await page.goto(bookURL(path, base)); + await settled(page, path); + const pane = await page.locator('.tree-pane').elementHandle(); + await page.locator('.node-toggle[data-toggle-path="/4-client.md"]').click(); + await toggle(page).click(); + await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false'); + await nodeAt(page, '/').focus(); + await page.keyboard.press('ArrowLeft'); + await expect(nodeAt(page, '/')).toHaveAttribute('aria-expanded', 'false'); + await page.keyboard.press('Enter'); + await expect(page).toHaveURL(base); + await settled(page, '/'); + await expect(nodeAt(page, '/')).toHaveAttribute('aria-expanded', 'false'); + await expect(nodeAt(page, '/book')).toBeHidden(); + await expect(page.getByRole('treeitem')).toHaveCount(1); + await expect(page.locator(article).getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible(); + // The content Book link selects a root child, not its descendants. + await page.getByRole('navigation', { name: 'ルートページのナビゲーション', exact: true }).getByRole('link', { name: /^Book(?:\s|$)/ }).click(); + await settled(page, '/book'); + await expect(nodeAt(page, '/')).toHaveAttribute('aria-expanded', 'true'); + await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false'); + await expect(nodeAt(page, '/4-client.md')).toHaveAttribute('aria-expanded', 'false'); + await nodeAt(page, '/').click(); + await settled(page, '/'); + await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false'); + await nodeAt(page, '/').focus(); + await page.keyboard.press('ArrowLeft'); + await expect(nodeAt(page, '/')).toHaveAttribute('aria-expanded', 'false'); + // Native hidden file activation must reveal only the ancestors needed by it. + await nodeAt(page, path).evaluate((el) => (el as HTMLAnchorElement).click()); + await settled(page, path); + await expect(nodeAt(page, '/')).toHaveAttribute('aria-expanded', 'true'); + await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'true'); + await expect(nodeAt(page, path)).toBeVisible(); + await expect(nodeAt(page, '/4-client.md')).toHaveAttribute('aria-expanded', 'false'); + expect(await pane!.evaluate((el) => el === document.querySelector('.tree-pane'))).toBe(true); + await expectNoIntroStates(page); +}); + test('internal Markdown cross-page and fragment links use the same router and current tree', async ({ page }, testInfo) => { const base = testInfo.project.use.baseURL!; await page.goto(bookURL('/3-protocol.md', base)); @@ -454,34 +595,72 @@ test('client return home skips intro, while a hard reload home may arm it again' const base = testInfo.project.use.baseURL!; await page.goto('./'); await expect(page.locator(stage)).toHaveAttribute('data-phase', 'armed'); + await expect(page.locator(stage)).toHaveAttribute('data-current-path', '/'); await page.keyboard.press('Escape'); - await expect(page.locator(stage)).toHaveAttribute('data-phase', 'complete'); + await settled(page, '/'); + const homeHTML = await page.locator(article).innerHTML(); + const homeTitle = await page.title(); + await expectMetadata(page, base); await page.evaluate(recordIntroStates); const pane = await page.locator('.tree-pane').elementHandle(); + const tree = await page.locator('.demo-tree').elementHandle(); + const nodes = await page.locator('.demo-node').elementHandles(); + const expectHome = async () => { + await expect(page).toHaveURL(base); + await settled(page, '/'); + await expect(page.locator('.intro-skip')).toBeHidden(); + await expect(page.locator(article).getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible(); + expect(await page.locator(article).innerHTML()).toBe(homeHTML); + await expect(page).toHaveTitle(homeTitle); + await expectMetadata(page, base); + }; await nodeAt(page, '/2-core-model.md').click(); await settled(page, '/2-core-model.md'); + expect(await page.locator(article).innerHTML()).not.toBe(homeHTML); await page.locator('.brand').click(); - await expect(page).toHaveURL(base); - await settled(page, '/1-concept.md'); - await expect(page.locator('.intro-skip')).toBeHidden(); - await expect(page.locator(article + ' h1')).toHaveText('1. コンセプト'); + await expectHome(); expect(await pane!.evaluate((el) => el === document.querySelector('.tree-pane'))).toBe(true); // The book directory has its own route; the resting slash returns to HOME. - await nodeAt(page, '/book').click(); + await page.getByRole('navigation', { name: 'ルートページのナビゲーション', exact: true }).getByRole('link', { name: /^Book(?:\s|$)/ }).click(); await expect(page).toHaveURL(new URL('book/', base).href); await settled(page, '/book'); + const bookHTML = await page.locator(article).innerHTML(); + const bookTitle = await page.title(); + expect(bookHTML).not.toBe(homeHTML); + await expectMetadata(page, new URL('book/', base).href); await nodeAt(page, '/').click(); - await expect(page).toHaveURL(base); - await settled(page, '/1-concept.md'); + await expectHome(); await page.goBack(); await expect(page).toHaveURL(new URL('book/', base).href); await settled(page, '/book'); + expect(await page.locator(article).innerHTML()).toBe(bookHTML); + await expect(page).toHaveTitle(bookTitle); + await expectMetadata(page, new URL('book/', base).href); await page.goBack(); - await expect(page).toHaveURL(base); - await settled(page, '/1-concept.md'); + await expectHome(); + await page.goBack(); + await expect(page).toHaveURL(bookURL('/2-core-model.md', base)); + await settled(page, '/2-core-model.md'); + await expectMetadata(page, bookURL('/2-core-model.md', base)); + await page.goBack(); + await expectHome(); + for (const path of ['/2-core-model.md', '/', '/book', '/']) { + await page.goForward(); + await expect(page).toHaveURL(urlFor(path, base)); + await settled(page, path); + await expectMetadata(page, urlFor(path, base)); + if (path === '/') await expectHome(); + } + expect(await tree!.evaluate((el) => el === document.querySelector('.demo-tree'))).toBe(true); + for (const node of nodes) { + expect(await node.evaluate((el) => el === document.querySelector(`.demo-node[data-path="${(el as HTMLElement).dataset.path}"]`))).toBe(true); + } + await expect(page.locator('.demo-node')).toHaveCount(treeNodeCount); + expect(await page.locator('.demo-node').evaluateAll((nodes) => nodes.map((node) => (node as HTMLElement).dataset.path))).toEqual(treePaths); await expectNoIntroStates(page); await page.reload(); await expect(page.locator(stage)).toHaveAttribute('data-phase', 'armed'); + await expect(page.locator(stage)).toHaveAttribute('data-current-path', '/'); }); for (const action of ['skip-click', 'escape', 'root-click', 'key'] as const) { @@ -541,16 +720,16 @@ for (const action of ['skip-click', 'escape', 'root-click', 'key'] as const) { released = true; releaseFont(); if (skipping) { - await settled(page, '/1-concept.md'); + await settled(page, '/'); await page.clock.runFor(4000); - await settled(page, '/1-concept.md'); // Font completion must not replay an already-skipped intro. + await settled(page, '/'); // Font completion must not replay an already-skipped intro. } else { // No second click/key: the input received during boot must start the timeline. await expect(page.locator('html')).toHaveAttribute('data-landing', 'playing'); await expect(page.locator(stage)).toHaveAttribute('data-phase', 'flow'); expect(page.url()).toBe(testInfo.project.use.baseURL!); await page.clock.runFor(4000); - await settled(page, '/1-concept.md'); + await settled(page, '/'); } expect(page.url()).toBe(testInfo.project.use.baseURL!); } finally { @@ -589,7 +768,10 @@ test.describe('JavaScript-free directory and error routes', () => { await expect(page.locator(stage)).toHaveAttribute('data-current-path', '/book'); await nodeAt(page, '/').click(); await expect(page).toHaveURL(base); - await expect(page.locator(stage)).toHaveAttribute('data-current-path', '/1-concept.md'); + await expect(page.locator(stage)).toHaveAttribute('data-current-path', '/'); + await expect(nodeAt(page, '/')).toHaveAttribute('aria-current', 'page'); + await expect(nodeAt(page, '/')).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator(article).getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible(); }); }); diff --git a/site/tests/browser/site.spec.ts b/site/tests/browser/site.spec.ts index 4bf5c3c..c8f389c 100644 --- a/site/tests/browser/site.spec.ts +++ b/site/tests/browser/site.spec.ts @@ -5,7 +5,11 @@ test('home and book share the usable tree without client JavaScript', async ({ p const failures: string[] = []; page.on('response', (response) => { if (response.status() >= 400) failures.push(response.url()); }); await page.goto('./'); - await expect(page.locator('[data-selected-content] h1')).toHaveText('1. コンセプト'); + await expect(page.locator('.selected-content.prose[data-selected-content] .root-page[data-root-page]').getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible(); + await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-current-path', '/'); + await expect(page.locator('.root-mark')).toHaveAttribute('aria-current', 'page'); + await expect(page.locator('.root-mark')).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('[data-selected-content]')).not.toContainText('1. コンセプト'); expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '└─ '); const root = await page.locator('.root-mark').boundingBox(); @@ -19,7 +23,7 @@ test('home and book share the usable tree without client JavaScript', async ({ p expect(Math.abs(footX - spine.x)).toBeLessThan(1); expect(Math.abs(root!.y + root!.height - spine.y)).toBeLessThan(1); await page.screenshot({ path: testInfo.outputPath('home.png'), fullPage: true }); - await page.getByRole('link', { name: 'Book', exact: true }).click(); + await page.getByRole('navigation', { name: 'ルートページのナビゲーション', exact: true }).getByRole('link', { name: /^Book(?:\s|$)/ }).click(); await expect(page).toHaveURL(new URL('book/', testInfo.project.use.baseURL!).href); await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-current-path', '/book'); await expect(page.locator('.demo-node')).toHaveCount(treeNodeCount); diff --git a/site/tests/browser/theme.spec.ts b/site/tests/browser/theme.spec.ts new file mode 100644 index 0000000..1e5f0fa --- /dev/null +++ b/site/tests/browser/theme.spec.ts @@ -0,0 +1,376 @@ +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') { + 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 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')); + }); +});