From 6e9e675c7024a2d397cf51ac87280d55ec32c0ed Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 13 Oct 2026 19:06:31 +0900 Subject: [PATCH] fix: reduce landing to existing documents and their tree --- site/src/components/BoxFrame.astro | 12 - site/src/components/Landing.astro | 139 +++----- site/src/layouts/Site.astro | 6 +- site/src/lib/landing-content.ts | 480 ---------------------------- site/src/lib/landing-demo.ts | 72 ----- site/src/lib/landing-tree.ts | 63 ++++ site/src/pages/index.astro | 24 -- site/src/scripts/landing.ts | 147 +++------ site/src/styles/landing.css | 152 ++------- site/tests/browser/documents.ts | 22 ++ site/tests/browser/fonts.spec.ts | 16 +- site/tests/browser/landing.spec.ts | 459 ++++++++++++-------------- site/tests/browser/site.spec.ts | 2 +- site/tests/landing-content.test.mjs | 109 ------- site/tests/landing-demo.test.mjs | 49 --- site/tests/landing-tree.test.mjs | 75 +++++ 16 files changed, 479 insertions(+), 1348 deletions(-) delete mode 100644 site/src/components/BoxFrame.astro delete mode 100644 site/src/lib/landing-content.ts delete mode 100644 site/src/lib/landing-demo.ts create mode 100644 site/src/lib/landing-tree.ts create mode 100644 site/tests/browser/documents.ts delete mode 100644 site/tests/landing-content.test.mjs delete mode 100644 site/tests/landing-demo.test.mjs create mode 100644 site/tests/landing-tree.test.mjs diff --git a/site/src/components/BoxFrame.astro b/site/src/components/BoxFrame.astro deleted file mode 100644 index bcdfeb5..0000000 --- a/site/src/components/BoxFrame.astro +++ /dev/null @@ -1,12 +0,0 @@ ---- -// Overrun is clipped inside complete mono cells. No JS measurement, SVG, -// borders, or background gradients are needed, including for static HTML. -const horizontal = '─'.repeat(160); -const vertical = '│\n'.repeat(160).trimEnd(); ---- - diff --git a/site/src/components/Landing.astro b/site/src/components/Landing.astro index 80eada5..01656fe 100644 --- a/site/src/components/Landing.astro +++ b/site/src/components/Landing.astro @@ -1,17 +1,18 @@ --- -import { objects, initialObject, childrenOf, treePrefix, objectSignature, interfaceSignature, interfaceReference, initialCollapsedPaths, initialVisibleObjects } from '../lib/landing-demo'; -import { href, bookHref } from '../lib/book'; +import { documents, bookHref, title } from '../lib/book'; +import { documentTree, childrenOf, treePrefix, serializeTree } from '../lib/landing-tree'; import '../styles/landing.css'; -import BoxFrame from './BoxFrame.astro'; import TreePrefix from './TreePrefix.astro'; import TreeGlyphs from './TreeGlyphs.astro'; import { trunkPath, stemWidth, branchY, branchThickness } from '../lib/landing-glyphs.mjs'; -const initialPaths = new Set(initialVisibleObjects.map((object) => object.path)); -const collapsed = new Set(initialCollapsedPaths); -const articles = objects.filter((object) => object.body); -const trunkLength = (initialVisibleObjects.findLastIndex((object) => object.depth === 2) - 1) * 24 + branchY + branchThickness / 2; +const objects = documentTree((await documents()).map((entry) => { + if (!entry.rendered) throw new Error(`Missing rendered content: ${entry.id}`); + return { id: entry.id, title: title(entry), href: bookHref(entry.id), html: entry.rendered.html }; +})); +const initialObject = objects.find((object) => object.href)!; +const trunkLength = (objects.findLastIndex((object) => object.depth === 2) - 1) * 24 + branchY + branchThickness / 2; --- -
+
-
EVERY WORLD BEGINS AT A ROOT.

Click · Scroll · Press any key

- - -
-
-

WIP / IMPLEMENTATION FIELD GUIDE

設計から運用まで、
判断の手がかりを探す。

- 4 AREAS / {articles.length} GUIDES / {objects.length} OBJECTS -
-
-
-
01 / WORLDSPACE↑ ↓ navigate · ← → expand
-
- -
{initialVisibleObjects.length} / {objects.length} objects
-
-
-
+ +
+
+
+
- {objects.map((object, index) => { - const root = object.path === '/'; - const children = childrenOf(object.path); - const prefix = treePrefix(object, initialVisibleObjects); - return ; - })} + + + + + : <>{object.name}{children.length > 0 && }} + +
; + })}
-

階層を辿る · 項目を選ぶ · 本文を読む。検索はこのガイド全体が対象です。

-
-
-
- -

02 / Observe

PUBLIC INFORMATION
-

{initialObject.description}

-
Object signature
{objectSignature(initialObject)}
-
-
- -

03 / Read

EXPLICIT OPERATION
-
TARGET{initialObject.path}
-
Interface signature
{interfaceSignature(initialObject)}
-
{interfaceReference(initialObject)}#read_body
-
-
RETURN / bodyREADY
-

{initialObject.title}

-
項目を選ぶと公開情報を確認できます。「本文を読む」で、判断基準とチェックリストを取得してください。
- -
-

このサイトの実用ガイドです。操作はブラウザ内で完結し、外部への変更は行いません。

-
-
- - +
+ in a source code example must remain JSON, not markup. +export const serializeTree = (nodes: readonly TreeNode[]) => JSON.stringify(nodes).replace(/
-
-
-

WEB INTERFACE PROTOCOL

-

ツールの一覧から、
探索できる世界へ。

-

Filesystemも、Issueも、Databaseも。
WIPはデジタル環境を、発見・観測・操作できる
ひとつのWorldspaceとして公開するProtocolです。

- -

One worldspace. Three core interactions.

-
-
-
-

A SMALL COMMON LANGUAGE

共通の約束は、小さく。

環境ごとの巨大なAPIではなく、探索と実行のための3つのCore interaction。

-
{interactions.map((item, index) => 0{index + 1}{item.name}

{item.label}

{item.text}

)}
-
-
-

BOUNDARIES, NOT A BIGGER API

見えるものと、
扱えることを分ける。

-

ObjectとInterface

Objectは公開情報とInterface参照を持つ。Operationの型や説明は、共有できるDescriptorとして分離する。

発見と保持

結果から得たentryは、必要なときに観測する。ClientのKnown Spaceは過去の観測であり、Hostの状態のreplicaではない。

意味論と通信

Coreはtransportから独立。標準HTTP bindingが、同じ契約をHTTPとJSONへ対応付ける。

-
-

START READING

仕様の全体像を、Bookで。

コンセプトからCore Model、Client、HTTP bindingまで。
正本と草案を分け、章を辿って読めます。

目次を開く →
diff --git a/site/src/scripts/landing.ts b/site/src/scripts/landing.ts index dde2561..9c87582 100644 --- a/site/src/scripts/landing.ts +++ b/site/src/scripts/landing.ts @@ -1,5 +1,5 @@ import { animate, createTimeline, cubicBezier } from 'animejs'; -import { objects, initialObject, childrenOf, objectSignature, interfaceSignature, interfaceReference, initialCollapsedPaths, visibleObjects, treePrefix } from '../lib/landing-demo'; +import { childrenOf, visibleNodes, treePrefix, type TreeNode } from '../lib/landing-tree'; import { glitchText } from '../lib/landing-ascii.mjs'; const clamp = (value: number) => Math.max(0, Math.min(1, value)); @@ -16,7 +16,7 @@ const mix = (a: number, b: number, value: number) => a + (b - a) * value; const flow = { duration: 3900, release: 560, grow: 1320, converge: 1900, branch: 2320, stagger: 55, - textPanel: 2520, callPanel: 2700, panelDuration: 900, + content: 2520, }; const orbitRadius = 180; const circumference = 2 * Math.PI * orbitRadius; @@ -34,6 +34,8 @@ export async function initLanding() { const element = document.querySelector('[data-landing-stage]'); if (!element) return; const stage: HTMLElement = element; + const objects: TreeNode[] = JSON.parse(stage.querySelector('#landing-documents')!.textContent!); + const initialObject = objects.find((object) => object.href)!; const html = document.documentElement; const motion = matchMedia('(prefers-reduced-motion: reduce)'); const get = (selector: string) => stage.querySelector(selector)!; @@ -49,11 +51,7 @@ export async function initLanding() { const slot = get('.root-slot'); const tree = get('.demo-tree'); const viewport = get('.tree-viewport'); - const search = get('.tree-search'); - const expandAll = get('[data-expand-all]'); - const collapseAll = get('[data-collapse-all]'); - const sources = get('[data-sources]'); - const nodes = [...stage.querySelectorAll('.demo-node')]; + const nodes = [...stage.querySelectorAll('.demo-node')]; const rows = nodes.map((node) => node.parentElement!); const introRows = rows.filter((row) => !row.hidden); const stagger = Math.min(flow.stagger, 700 / Math.max(1, introRows.length - 2)); @@ -64,32 +62,17 @@ export async function initLanding() { const orbit = get('.orbit'); const orbitPath = get('#welcome-orbit'); const textPath = get('.orbit textPath'); - const invitation = get('.intro-invitation'); const skip = get('.intro-skip'); - const call = get('.demo-call'); - const result = get('[data-result]'); - const status = get('.demo-status'); - const callState = get('[data-call-state]'); - const treeCaption = get('.tree-caption'); + const result = get('[data-selected-content]'); const blocked = [...document.querySelectorAll('.site-header, .site-footer, main > :not(.landing)')]; const abort = new AbortController(); let state: 'armed' | 'playing' | 'complete' = 'complete'; let selected = initialObject; let keyboardStarted = false; - let animation: ReturnType | undefined; - const collapsed = new Set(initialCollapsedPaths); - const panels = [...stage.querySelectorAll('.ascii-panel')]; - const glitchTargets = [...stage.querySelectorAll([ - '.node-label', '.box-fill', '.box-column', '.box-corner', - '.panel-title h3', '.panel-title > span', '[data-object-signature]', '[data-interface-signature]', - '[data-target]', '[data-operation]', '.result-label > span', '[data-result]', - ].join(','))].map((element, seed) => { - const panel = element.closest('.ascii-panel'); - const row = element.closest('.node-row'); - const start = panel ? (panel.classList.contains('call-panel') ? flow.callPanel : flow.textPanel) - : rowStart(row as HTMLElement); - return { element, text: element.textContent ?? '', start, duration: panel ? flow.panelDuration : 580, seed }; - }); + const collapsed = new Set(); + const glitchTargets = [...stage.querySelectorAll('.node-label')].map((element, seed) => ({ + element, text: element.textContent ?? '', start: rowStart(element.closest('.node-row') as HTMLElement), duration: 580, seed, + })); let lastGlitchFrame = -1; function renderGlitch(time: number) { @@ -101,15 +84,11 @@ export async function initLanding() { const value = progress(time, target.start, target.duration); target.element.textContent = glitchText(target.text, value, frame, target.seed); } - panels.forEach((panel) => { - const start = panel.classList.contains('call-panel') ? flow.callPanel : flow.textPanel; - panel.toggleAttribute('data-glitching', time >= start && time < start + flow.panelDuration); - }); + } function restoreGlitch() { glitchTargets.forEach(({ element, text }) => { element.textContent = text; }); - panels.forEach((panel) => panel.removeAttribute('data-glitching')); } const cues = { time: 0, travel: 0, scale: 7.2, slashScale: 1, extension: 0, converge: 0, release: 0, turn: -28, trunkOpacity: 0 }; @@ -124,7 +103,7 @@ export async function initLanding() { }) .label('growth', flow.grow) .label('branches', flow.branch) - .label('panels', flow.textPanel) + .label('content', flow.content) .add(cues, { time: [0, flow.duration], duration: flow.duration }, 0) .add(cues, { travel: [0, 1], duration: 3000, ease: timing.travel }, 0) .add(cues, { scale: [7.2, 1], duration: 2650, ease: timing.scale }, 350) @@ -134,7 +113,6 @@ export async function initLanding() { .add(cues, { converge: [0, 1], duration: 560, ease: timing.travel }, flow.converge) .add(cues, { release: [0, 1], duration: 1620, ease: timing.release }, flow.release) .add(cues, { turn: [-28, 0], duration: flow.release, ease: timing.trace }, 0) - .add(invitation, { opacity: [1, 0], duration: 220 }, 0) .add(orbit, { opacity: [0, 1], duration: 300 }, 0) .add(html, { '--landing-chrome': [0, 1], duration: 600 }, 3000); branches.forEach((wire, index) => { @@ -145,10 +123,8 @@ export async function initLanding() { nodes.slice(1).forEach((node, index) => { timeline.add(node, { opacity: [0, 1], duration: 220 }, rowStart(rows[index + 1]) + 180); }); - reveals.forEach((element, index) => { - const isPanel = element.classList.contains('ascii-panel'); - const start = isPanel ? (element.classList.contains('call-panel') ? flow.callPanel : flow.textPanel) : 2540 + index * 35; - timeline.add(element, { opacity: [0, 1], duration: isPanel ? flow.panelDuration / 6 : 420 }, start); + reveals.forEach((element) => { + timeline.add(element, { opacity: [0, 1], duration: 420 }, 'content'); }); const tapLoops = [...stage.querySelectorAll('.tap-stream text')].map((text, index) => animate(text, { // Measure one repeated phrase: Lyth Mono's standard arrows can be wider @@ -173,29 +149,16 @@ export async function initLanding() { } function select(index: number, focus = false) { - animation?.cancel(); - result.style.removeProperty('opacity'); - result.style.removeProperty('transform'); - result.scrollTop = 0; selected = objects[index]; nodes.forEach((node, i) => { node.setAttribute('aria-selected', String(i === index)); node.tabIndex = i === index ? 0 : -1; }); - get('[data-object-signature]').textContent = objectSignature(selected); - get('[data-interface-signature]').textContent = interfaceSignature(selected); - get('[data-target]').textContent = selected.path; - get('[data-description]').textContent = selected.description; - get('[data-result-title]').textContent = selected.title ?? (selected.name || 'WIP実装ガイド'); - get('[data-operation]').textContent = selected.interface ? `${interfaceReference(selected)}#read_body` : 'No operation · explore the children'; - call.disabled = !selected.interface; - callState.textContent = 'READY'; - callState.dataset.callState = 'ready'; - result.textContent = selected.interface - ? '「本文を読む」で、この項目の判断基準とチェックリストを取得します。公開情報の観測と本文の取得は別の操作です。' - : 'このまとまりには ' + childrenOf(selected.path).length + ' 項目があります。子Objectを選び、目的に合うガイドを探してください。'; - sources.hidden = true; - status.textContent = selected.path + ' を選択。本文はまだ取得していません。'; + // HTML is the site's existing, build-rendered Markdown, not generated copy. + result.innerHTML = selected.html ?? ''; + result.hidden = selected.html === undefined; + result.setAttribute('aria-label', selected.title ?? selected.path); + result.scrollTop = 0; if (focus) { nodes[index].focus({ preventScroll: true }); const box = nodes[index].getBoundingClientRect(), bounds = viewport.getBoundingClientRect(); @@ -207,20 +170,19 @@ export async function initLanding() { } function updateExpanded() { - const filtering = Boolean(search.value.trim()); - const shown = visibleObjects(collapsed, search.value); + const shown = visibleNodes(objects, collapsed); const paths = new Set(shown.map((object) => object.path)); objects.forEach((object, index) => { rows[index].hidden = !paths.has(object.path); - if (childrenOf(object.path).length) { - const expanded = filtering ? shown.some((child) => child.parent === object.path) : !collapsed.has(object.path); + if (childrenOf(objects, object.path).length) { + const expanded = !collapsed.has(object.path); nodes[index].setAttribute('aria-expanded', String(expanded)); const indicator = nodes[index].querySelector('.node-indicator'); if (indicator) indicator.textContent = expanded ? '−' : '+'; } const prefixElement = rows[index].querySelector('.branch-prefix'); if (prefixElement && paths.has(object.path)) { - const prefix = treePrefix(object, shown); + const prefix = treePrefix(object, objects, shown); prefixElement.dataset.prefix = prefix; prefixElement.querySelectorAll('use[data-cell]').forEach((use) => { const cell = Number(use.dataset.cell); @@ -229,34 +191,19 @@ export async function initLanding() { }); } }); - get('[data-tree-count]').textContent = filtering && shown.length === 1 ? '一致する項目がありません' : shown.length + ' / ' + objects.length + ' objects'; - expandAll.disabled = collapseAll.disabled = filtering; drawTrunk(restingLength()); if (!paths.has(selected.path)) { - const next = shown.find((object) => object.body) ?? shown[0]; + const next = shown.find((object) => object.href) ?? shown[0]; select(objects.indexOf(next)); } } - search.addEventListener('input', () => { if (state === 'complete') updateExpanded(); }); - search.addEventListener('keydown', (event) => { - if (event.key === 'Enter' && state === 'complete') { event.preventDefault(); select(objects.indexOf(selected), true); } - }); - expandAll.addEventListener('click', () => { - if (state !== 'complete') return; - collapsed.clear(); updateExpanded(); - }); - collapseAll.addEventListener('click', () => { - if (state !== 'complete') return; - collapsed.delete('/'); - objects.filter((object) => object.path !== '/' && childrenOf(object.path).length).forEach((object) => collapsed.add(object.path)); - updateExpanded(); - }); - - nodes.forEach((node, index) => node.addEventListener('click', () => { + nodes.forEach((node, index) => node.addEventListener('click', (event) => { if (state !== 'complete') return; + if (node instanceof HTMLAnchorElement && (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey)) return; + event.preventDefault(); select(index); - if (!search.value.trim() && childrenOf(objects[index].path).length) { + if (childrenOf(objects, objects[index].path).length) { const path = objects[index].path; collapsed.has(path) ? collapsed.delete(path) : collapsed.add(path); updateExpanded(); @@ -265,24 +212,25 @@ export async function initLanding() { tree.addEventListener('keydown', (event) => { if (state !== 'complete') return; - const index = nodes.indexOf(document.activeElement as HTMLButtonElement); + const index = nodes.indexOf(document.activeElement as HTMLElement); if (index < 0) return; const visible = nodes.map((_, i) => i).filter((i) => !rows[i].hidden); const position = visible.indexOf(index); let target = index; switch (event.key) { + case ' ': event.preventDefault(); nodes[index].click(); return; case 'ArrowDown': target = visible[Math.min(position + 1, visible.length - 1)]; break; case 'ArrowUp': target = visible[Math.max(position - 1, 0)]; break; case 'Home': target = visible[0]; break; case 'End': target = visible[visible.length - 1]; break; case 'ArrowRight': - if (childrenOf(objects[index].path).length) { - if (!search.value.trim() && collapsed.delete(objects[index].path)) updateExpanded(); + if (childrenOf(objects, objects[index].path).length) { + if (collapsed.delete(objects[index].path)) updateExpanded(); else target = visible.find((i) => objects[i].parent === objects[index].path) ?? index; } break; case 'ArrowLeft': - if (!search.value.trim() && childrenOf(objects[index].path).length && !collapsed.has(objects[index].path)) { + if (childrenOf(objects, objects[index].path).length && !collapsed.has(objects[index].path)) { collapsed.add(objects[index].path); updateExpanded(); } else if (objects[index].parent) target = objects.findIndex((object) => object.path === objects[index].parent); @@ -293,24 +241,6 @@ export async function initLanding() { select(target, true); }); - call.addEventListener('click', () => { - if (state !== 'complete' || selected.body === undefined) return; - animation?.cancel(); - result.textContent = selected.body; - const label = document.createElement('span'); label.textContent = '詳しい仕様'; - sources.replaceChildren(label, ...(selected.sources ?? []).map((source) => { - const link = document.createElement('a'); - link.href = stage.dataset.bookBase! + source.document.split('/').map(encodeURIComponent).join('/') + '/'; - link.textContent = source.label + ' ↗'; - return link; - })); - sources.hidden = !selected.sources?.length; - callState.textContent = 'RETURNED'; - callState.dataset.callState = 'returned'; - status.textContent = `${selected.path} の read_body が完了。関連する仕様も参照できます。`; - if (!motion.matches) animation = animate(result, { opacity: [.25, 1], translateY: [5, 0], duration: 280, ease: cubicBezier(.2, .7, .2, 1) }); - }); - function finish() { timeline.pause(); state = 'complete'; @@ -328,9 +258,8 @@ export async function initLanding() { trunk.style.removeProperty('fill'); drawTrunk(restingLength()); root.setAttribute('aria-label', 'ルート /'); - invitation.style.removeProperty('opacity'); nodes.forEach((node) => { - node.disabled = false; + node.inert = false; node.style.removeProperty('opacity'); node.style.removeProperty('transform'); node.style.removeProperty('will-change'); @@ -347,7 +276,6 @@ export async function initLanding() { wire.style.removeProperty('opacity'); wire.style.removeProperty('clip-path'); }); - search.disabled = false; updateExpanded(); select(objects.indexOf(selected), keyboardStarted || document.activeElement === skip || document.activeElement === root); } @@ -416,13 +344,12 @@ export async function initLanding() { stage.dataset.phase = 'armed'; blocked.forEach((element) => { element.inert = true; }); reveals.forEach((element) => { element.inert = true; element.setAttribute('aria-hidden', 'true'); }); - root.disabled = false; + nodes.forEach((node) => { node.inert = node !== root; }); root.tabIndex = 0; root.setAttribute('aria-label', 'Worldspaceを開く'); timeline.seek(0, true); render(); stage.dataset.phase = 'armed'; - invitation.style.opacity = '1'; const signal = abort.signal; root.addEventListener('pointerenter', (event) => { if (state === 'armed' && event.pointerType !== 'touch') { @@ -456,7 +383,7 @@ export async function initLanding() { } const resize = new ResizeObserver(() => { - if (state === 'armed') { render(); stage.dataset.phase = 'armed'; invitation.style.opacity = '1'; } + if (state === 'armed') { render(); stage.dataset.phase = 'armed'; } else if (state === 'complete') drawTrunk(restingLength()); }); resize.observe(get('.tree-pane')); @@ -469,7 +396,6 @@ export async function initLanding() { window.addEventListener('pageshow', onPageShow); window.addEventListener('pagehide', (event) => { if (state !== 'complete') finish(); - animation?.cancel(); if (event.persisted) return; timeline.cancel(); tapLoops.forEach((loop) => loop.cancel()); @@ -478,5 +404,4 @@ export async function initLanding() { motion.removeEventListener('change', onMotion); window.removeEventListener('pageshow', onPageShow); }); - treeCaption.title = '↑ ↓: 選択 / ←: 閉じる・親へ / →: 開く・子へ / Home・End: 先頭・末尾'; } diff --git a/site/src/styles/landing.css b/site/src/styles/landing.css index 976c76e..7a8ee3f 100644 --- a/site/src/styles/landing.css +++ b/site/src/styles/landing.css @@ -1,13 +1,10 @@ -/* One mono cell horizontally, one 24px baseline vertically. The frame is - quantized to four cells so both columns and their gutter remain on-grid. */ .landing-page { --paper: #fafaf8; --ink: #20221f; --muted: #686d64; --green: #283e25; --accent: #dcf9b0; --line: #d9ddd3; --row: 24px; font: 14px/var(--row) var(--mono); background: var(--paper); } -.landing-page :is(button, input, code, pre) { font: inherit; } -.landing-page :is(h1, h2, h3, p) { letter-spacing: 0; } -.landing-page :is(.site-header, .landing-content, .hero, .interaction-section, .principles, .site-footer) { +.landing-page button { font: inherit; } +.landing-page :is(.site-header, .landing-content) { width: calc(100% - 4ch); width: round(down, calc(100% - 4ch), 4ch); max-width: 160ch; margin-inline: auto; } @@ -15,138 +12,46 @@ html[data-landing] .landing-page .site-header { opacity: 0; } .landing-page .brand { font: bold 14px/var(--row) var(--mono); letter-spacing: 0; } .landing-page .brand-mark { display: none; } -.landing-page .brand-caption { font: inherit; font-weight: normal; } .landing-page .site-header nav { font: inherit; gap: 3ch; } .landing-page .site-header nav a { padding: 0; } -.landing { position: relative; isolation: isolate; overflow: clip; min-height: 100svh; padding: calc(5 * var(--row)) 0 var(--row); background: var(--paper); } +.landing { position: relative; isolation: isolate; overflow: clip; min-height: 100svh; padding: calc(4 * var(--row)) 0 var(--row); background: var(--paper); } .landing button { color: inherit; } -.explorer-heading { display: flex; align-items: end; justify-content: space-between; gap: 4ch; margin-bottom: var(--row); } -.explorer-heading .eyebrow { margin: 0; font: inherit; letter-spacing: 0; } -.explorer-heading h2 { font-size: 21px; line-height: calc(2 * var(--row)); font-weight: 500; margin: 0; } -.demo-badge { font: inherit; color: var(--muted); } -.demo-badge i { display: none; } -.explorer { display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); gap: var(--row) 4ch; align-items: start; } -.explorer-inspector { display: grid; gap: var(--row); min-width: 0; } +.explorer { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); gap: 4ch; align-items: start; } .tree-pane { min-width: 0; } .tree-glyphs { position: absolute; pointer-events: none; } -.tree-toolbar { display: grid; gap: var(--row); margin: var(--row) 0; } -.tree-search-label { display: flex; align-items: center; gap: 2ch; color: var(--muted); } -.tree-search { min-width: 0; flex: 1; border: 0; border-bottom: 1px solid var(--line); border-radius: 0; padding: 0 1ch; height: var(--row); background: transparent; color: var(--ink); } -.tree-actions { display: flex; flex-wrap: wrap; gap: 2ch; align-items: baseline; } -.tree-actions button { padding: 0; border: 0; background: transparent; text-decoration: underline; text-underline-offset: .2em; cursor: pointer; } -.tree-actions button:disabled { cursor: default; color: var(--muted); } -[data-tree-count] { margin-left: auto; color: var(--muted); } -.tree-viewport { height: calc(28 * var(--row)); overflow: auto; overscroll-behavior: contain; scrollbar-gutter: stable; } +.tree-viewport { max-height: calc(100svh - 6 * var(--row)); overflow: auto; overscroll-behavior: contain; scrollbar-gutter: stable; } html[data-landing] .tree-viewport { overflow: visible; } -.tree-help { margin: var(--row) 0 0; color: var(--muted); } .demo-tree { width: max-content; min-width: 100%; } -.node-row { width: max-content; min-width: 100%; } -.tree-caption { display: flex; justify-content: space-between; gap: 2ch; color: var(--muted); height: var(--row); margin: 0; white-space: nowrap; } -.tree-caption span:last-child { font-size: inherit; } .demo-tree, .tree-scene { position: relative; } .tree-scene { transform-origin: 0 0; } -.node-row { position: relative; padding-left: 1ch; height: var(--row); display: flex; align-items: center; white-space: nowrap; } +.node-row { position: relative; width: max-content; min-width: 100%; padding-left: 1ch; height: var(--row); display: flex; align-items: center; white-space: nowrap; } .node-row[hidden] { display: none; } .node-row:not(.root-slot) { transform-origin: 0 0; } -/* The slash's lower edge is centred at x=12, not the viewBox centre x=24. - Offset its box by 12/64 of the row so the foot sits on the mono spine. */ +/* 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; } -/* Lyth Mono outlines are emitted at build time. They remain the same SVG - paths through growth, landing, collapsed states and JavaScript-free SSR. */ .branch-prefix { display: block; flex: 0 0 auto; height: var(--row); color: #8e9886; 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; text-align: left; transition: background .18s; } +.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:disabled { cursor: default; opacity: 1; } .node-indicator { width: 1ch; color: var(--muted); } .root-mark { width: 3ch; height: var(--row); display: block; padding: 0; background: transparent !important; transform-origin: center; z-index: 25; transition: none; } .root-vector { display: block; width: 100%; height: 100%; overflow: visible; } -/* Only the slash ink shrinks from its original intro size to 14px / 24px. - Pivot at the fixed trunk origin; never scale the trunk or the tree layout. */ .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-caption { margin-left: calc(2ch - var(--row) * .1875); height: var(--row); color: var(--muted); } -.ascii-panel { position: relative; min-width: 0; padding: calc(2 * var(--row)) 2ch; } -.box-frame { position: absolute; inset: 0; pointer-events: none; user-select: none; color: #8e9886; } -.box-edge { position: absolute; inset-inline: 0; height: var(--row); display: grid; grid-template-columns: 1ch minmax(0, 1fr) 1ch; align-items: center; } -.box-top { top: 0; } -.box-bottom { bottom: 0; } -.box-corner, .box-fill { display: block; height: 14px; font: 14px/14px var(--mono); white-space: pre; transform: scaleY(1.7142857143); } -.box-fill { overflow: hidden; } -.box-side { position: absolute; top: var(--row); bottom: var(--row); width: 1ch; overflow: hidden; } -.box-left { left: 0; } -.box-right { right: 0; } -.box-column { display: block; white-space: pre; font: 14px/14px var(--mono); transform: scaleY(1.7142857143); transform-origin: top; } -/* Mutated glyphs are decorative during the intro; opacity remains steady. - The actual content is restored atomically before the controls are enabled. */ -.ascii-panel[data-glitching] .box-frame { color: #68745c; } -.panel-title { display: flex; justify-content: space-between; gap: 2ch; align-items: baseline; min-height: var(--row); margin-bottom: var(--row); } -.panel-title h3 { font: inherit; margin: 0; } -.panel-title > span { color: var(--muted); } -.ascii-panel pre { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; tab-size: 2; } -.text-panel { min-width: 0; } -.text-panel pre { color: #425238; } -.panel-footnote { color: var(--muted); margin: var(--row) 0 0; } -.call-panel { min-width: 0; display: flex; flex-direction: column; } -.call-target { display: grid; grid-template-columns: 7ch minmax(0, 1fr); margin: 0 0 var(--row); } -.call-target > span { color: var(--muted); } -.call-target code { overflow-wrap: anywhere; } -.interface-signature { color: #58604f; } -.call-controls { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--row) 2ch; margin-top: var(--row); padding-bottom: var(--row); box-shadow: 0 1px var(--line); } -.call-controls > code { overflow-wrap: anywhere; } -.demo-call { display: flex; justify-content: space-between; gap: 2ch; align-items: center; background: var(--ink); color: white !important; border: 0; padding: 0 2ch; height: calc(2 * var(--row)); cursor: pointer; transition: background .18s; } -.demo-call:hover { background: #3f5036; } -.demo-call:disabled { opacity: .4; cursor: default; } -.result-shell { min-height: calc(12 * var(--row)); margin-top: var(--row); } -.selected-description { margin: 0 0 var(--row); } -.signature-details summary { cursor: pointer; color: var(--muted); } -.signature-details[open] pre { margin-top: var(--row); } -.result-title { margin: 0 0 var(--row); font: 600 20px/1.5 var(--serif); } -.landing .guide-body { font: 18px/1.65 var(--serif); white-space: pre-wrap; overflow-wrap: anywhere; } -.result-shell .guide-body { max-height: calc(18 * var(--row)); overflow: auto; padding-right: 1ch; } -.guide-sources { display: flex; flex-wrap: wrap; gap: .5ch 2ch; margin-top: var(--row); } -.guide-sources[hidden] { display: none; } -.guide-sources > span { color: var(--muted); } -.static-guides { margin-top: calc(2 * var(--row)); } -.static-guides details { padding: var(--row) 0; border-top: 1px solid var(--line); } -.static-guides summary { cursor: pointer; } -.static-guides .guide-body { margin-top: var(--row); } -.result-label { display: flex; justify-content: space-between; gap: 2ch; color: var(--muted); margin-bottom: var(--row); } -.result-label [data-call-state="returned"] { color: #4d722a; } -.demo-status { color: var(--muted); margin: var(--row) 0 0; min-height: var(--row); } -.explorer-footer { display: flex; justify-content: space-between; gap: 4ch; align-items: start; margin-top: var(--row); padding-top: var(--row); box-shadow: 0 -1px var(--line); } -.explorer-footer > span { color: var(--muted); } -.explorer-footer a { text-decoration: none; white-space: nowrap; } -.explorer-footer a span { margin-left: 2ch; } -.no-script-note { color: var(--muted); margin-top: var(--row); } -/* The explanatory sections use the same frame, baseline, and mono type. */ -.landing-page .hero, .landing-page .interaction-section, .landing-page .principles { padding: calc(3 * var(--row)) 0; } -.landing-explanation { display: block; text-align: left; border-top: 1px solid var(--line); margin-top: calc(3 * var(--row)); } -.landing-explanation .hero-actions { justify-content: start; } -.landing-page :is(.eyebrow, .lead, .hero-note, .text-link, .step, .section-heading p, .principle-list p, .interaction-card p, .interaction-card code, .reading-cta p) { font: inherit; letter-spacing: 0; } -.landing-page :is(.hero h1, .section-heading h2, .principles h2, .reading-cta h2) { font-size: 28px; line-height: calc(2 * var(--row)); margin: 0 0 var(--row); } -.landing-page :is(.principle-list h3, .interaction-card h3) { font-size: inherit; line-height: var(--row); margin: 0 0 var(--row); } -.landing-page p { margin-bottom: var(--row); } -.landing-page .hero-actions { gap: 2ch; margin: var(--row) 0; } -.landing-page .button { font: inherit; border-radius: 0; padding: var(--row) 2ch; border: 0; } -.landing-page .section-heading { margin-bottom: var(--row); } -.landing-page .interaction-grid { border-radius: 0; } -.landing-page .interaction-card { padding: var(--row) 2ch; } -.landing-page .step { margin-bottom: var(--row); } -.landing-page .principles { gap: 4ch; } -.landing-page .principle-list > div + div { padding-top: var(--row); } -.landing-page .reading-cta { padding: calc(3 * var(--row)) 2ch; margin: 0; } -.landing-page .site-footer { padding: var(--row) 0; font: inherit; gap: 2ch; } -.mobile-only { display: none; } -.intro-art, .intro-invitation, .intro-skip { display: none; } +.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); } +.selected-content h2 { font-size: 21px; border: 0; } +.selected-content h3 { font-size: 19px; } +.intro-art, .intro-skip { display: none; } html[data-landing] { overflow: hidden; } html[data-landing] .landing { touch-action: none; } -html[data-landing] .landing-page .site-header, html[data-landing] .landing-page .site-footer, html[data-landing] .landing-page main > :not(.landing) { visibility: hidden; } -html[data-landing="playing"] .landing-page :is(.site-header, .site-footer), html[data-landing="playing"] .landing-page main > :not(.landing) { visibility: visible; opacity: var(--landing-chrome, 0); transition: none; } +html[data-landing] .landing-page .site-header { visibility: hidden; } +html[data-landing="playing"] .landing-page .site-header { visibility: visible; opacity: var(--landing-chrome, 0); transition: none; } html[data-landing] [data-reveal], html[data-landing] .demo-node:not(.root-mark), html[data-landing] :is(.branch-spine, .branch-arm) { opacity: 0; } html[data-landing] .root-mark { color: var(--ink); } html[data-landing="boot"] .root-mark { position: fixed; left: calc(50vw - 1.5ch); top: calc(50svh - var(--row) / 2); transform: scale(7.2); } @@ -156,39 +61,22 @@ html[data-landing] .intro-art { display: block; position: fixed; z-index: 10; wi .trunk-line { fill: #8e9886; stroke: none; } html[data-landing="boot"] .trunk-line { opacity: 0; } .orbit text { fill: var(--ink); font: 500 17px var(--mono); letter-spacing: 1.5px; } -/* The mask stays in the slash's coordinate system (y=4…60), outside the - rotated text lanes. Thus both streams enter/leave at horizontal edges. */ .tap-mask { pointer-events: none; } .tap-stream { fill: currentColor; font: 500 3.7px var(--mono); letter-spacing: .74px; opacity: 0; } .tap-stream text { white-space: pre; } .landing[data-hover="true"] .tap-stream { opacity: 1; } -html[data-landing] .intro-invitation { display: block; position: fixed; left: 2ch; right: 2ch; bottom: calc(4 * var(--row)); text-align: center; z-index: 11; pointer-events: none; color: var(--muted); } -.intro-invitation p { margin: var(--row) 0 0; } html[data-landing] .intro-skip { display: block; position: fixed; right: 2ch; bottom: var(--row); z-index: 40; border: 0; padding: 0 1ch; height: var(--row); background: transparent; color: var(--muted); cursor: pointer; } html[data-landing="armed"] .root-mark { cursor: pointer; } -@media (max-width: 1100px) { - .explorer-heading { display: block; } - .demo-badge { display: block; } - .tree-caption span:last-child { display: none; } - .panel-title > span { display: none; } -} @media (max-width: 800px) { - .landing { padding-top: calc(4 * var(--row)); } - .landing-page .brand-caption { display: none; } .explorer { grid-template-columns: minmax(0, 1fr); } - .tree-viewport { height: calc(23 * var(--row)); } - .call-panel { min-height: calc(20 * var(--row)); } - .explorer-footer { flex-wrap: wrap; gap: var(--row); } - .mobile-only { display: block; } - .explorer-heading h2 { line-height: calc(2 * var(--row)); } - .landing-page .principles { grid-template-columns: 1fr; } - .landing-page .hero h1 { font-size: 21px; } + .tree-viewport { max-height: 50svh; } + .selected-content { max-height: none; margin-top: var(--row); padding: 0; } } @media (prefers-reduced-motion: reduce) { .landing-page *, .landing-page *::before, .landing-page *::after { transition: none !important; animation: none !important; } } @media print { - .intro-art, .intro-invitation, .intro-skip { display: none !important; } + .intro-art, .intro-skip { display: none !important; } .landing { padding: var(--row); } - .tree-viewport { height: auto; overflow: visible; } + .tree-viewport, .selected-content { max-height: none; overflow: visible; } } diff --git a/site/tests/browser/documents.ts b/site/tests/browser/documents.ts new file mode 100644 index 0000000..91caf74 --- /dev/null +++ b/site/tests/browser/documents.ts @@ -0,0 +1,22 @@ +// Existing Astro book collection filenames, not a second authored guide dataset. +export const documentPaths = [ + '/1-concept.md', + '/2-core-model.md', + '/2.1-object-tree-schema.md', + '/2.2-operation-and-interface.md', + '/3-protocol.md', + '/4-client.md', + '/4.1-ai-tools.md', + '/4.2-text-view.md', + '/5-transports.md', + '/5.1-wip-over-http.md', + '/draft/dynamic-worldspace-projection.md', + '/draft/filesystem-discovery.md', + '/draft/rust-api-model.md', + '/draft/typescript-api-model.md', + '/draft/unresolved-issues.md', +]; +export const treePaths = ['/', ...documentPaths.filter((path) => !path.startsWith('/draft/')), '/draft', ...documentPaths.filter((path) => path.startsWith('/draft/'))]; +export const treeNodeCount = treePaths.length; +export const rootChildCount = treePaths.filter((path) => path !== '/' && path.lastIndexOf('/') === 0).length; +export const bookURL = (path: string, base: string) => new URL(`book/${path.slice(1, -3).split('/').map(encodeURIComponent).join('/')}/`, base).href; diff --git a/site/tests/browser/fonts.spec.ts b/site/tests/browser/fonts.spec.ts index c564327..0e5a670 100644 --- a/site/tests/browser/fonts.spec.ts +++ b/site/tests/browser/fonts.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from '@playwright/test'; +import { rootChildCount } from './documents'; test.use({ javaScriptEnabled: true }); @@ -30,10 +31,9 @@ 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 page.getByRole('button', { name: '本文を読む' }).click(); - await expect(page.locator('[data-result]')).toContainText('Unicode正規化'); - expect(await page.locator('[data-result]').evaluate((element) => getComputedStyle(element).fontFamily)).toContain('EB Garamond'); - await page.locator('.call-panel').screenshot({ path: testInfo.outputPath('eb-garamond-guide.png') }); + await expect(page.locator('[data-selected-content] h1')).toHaveText('1. コンセプト'); + 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') }); await page.goto('./book/1-concept/'); expect(await page.locator('.prose').evaluate((element) => getComputedStyle(element).fontFamily)).toContain('EB Garamond'); await page.screenshot({ path: testInfo.outputPath('eb-garamond-book.png') }); @@ -44,7 +44,7 @@ test.describe('static font and tree rendering', () => { test('glyph joins paint continuously across SVG cell boundaries', async ({ page }, testInfo) => { await page.goto('./'); await page.evaluate(() => document.fonts.ready); - // Sample all four joins without clipping the final branch in the scrollable tree. + // Sample every root-child join without clipping the final branch in the scrollable tree. await page.setViewportSize({ width: page.viewportSize()!.width, height: 2000 }); await page.locator('.tree-viewport').evaluate((el) => { (el as HTMLElement).style.height = 'auto'; }); const joins = await page.locator('.demo-node[aria-level="2"]').evaluateAll((nodes) => nodes.map((node) => { @@ -52,7 +52,7 @@ test.describe('static font and tree rendering', () => { const p = new DOMPoint(Number(svg.dataset.cellWidth) / 2, 12).matrixTransform(svg.getScreenCTM()!); return { x: p.x, y: p.y }; })); - expect(joins).toHaveLength(4); + expect(joins).toHaveLength(rootChildCount); for (const [index, join] of joins.entries()) { const clip = { x: Math.floor(join.x), y: Math.floor(join.y) - 1, width: 14, height: 3 }; const png = (await page.screenshot({ clip, path: testInfo.outputPath(`glyph-join-${index}.png`) })).toString('base64'); @@ -99,6 +99,6 @@ test('failed font downloads do not strand the animation or interactive demo', as await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-phase', 'armed'); await page.keyboard.press('Escape'); await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-phase', 'complete'); - await page.getByRole('button', { name: '本文を読む' }).click(); - await expect(page.locator('[data-result]')).toContainText('Unicode正規化'); + await page.locator('.demo-node[data-path="/2-core-model.md"]').click(); + await expect(page.locator('[data-selected-content] h1')).toHaveText('2. コアモデル'); }); diff --git a/site/tests/browser/landing.spec.ts b/site/tests/browser/landing.spec.ts index e85fef2..b93fcc5 100644 --- a/site/tests/browser/landing.spec.ts +++ b/site/tests/browser/landing.spec.ts @@ -1,12 +1,20 @@ import { expect, test } from '@playwright/test'; +import { documentPaths, treePaths, treeNodeCount, bookURL } from './documents'; test.use({ javaScriptEnabled: true }); const stage = '[data-landing-stage]'; -const initialPath = '/design/object-tree/paths/names'; +const initialPath = '/1-concept.md'; +const selectedContent = '.selected-content.prose[data-selected-content]'; const nodeAt = (page: import('@playwright/test').Page, path: string) => page.locator(`.demo-node[data-path="${path}"]`); const visiblePrefixes = '.node-row:not([hidden]) .branch-prefix'; +async function expectNodeGlitch(page: import('@playwright/test').Page) { + expect(await page.locator('.demo-node:not(.root-mark)').evaluateAll((nodes) => nodes.some((node) => + node.querySelector('.node-label')!.textContent !== (node as HTMLElement).dataset.path!.split('/').at(-1) + ))).toBe(true); +} + // Ink sampling only: expose the last branch without scroll-container clipping. // Interaction tests retain the real scrollable viewport. async function exposeTree(page: import('@playwright/test').Page) { @@ -147,19 +155,14 @@ for (const trigger of ['click', 'wheel', 'key']) { const errors: string[] = []; page.on('pageerror', (error) => errors.push(error.message)); await armed(page); - if (trigger === 'click') await page.getByRole('treeitem', { name: 'Worldspaceを開く' }).click(); + if (trigger === 'click') await nodeAt(page, '/').click(); if (trigger === 'wheel') await page.mouse.wheel(0, 240); if (trigger === 'key') await page.keyboard.press('a'); await expect(page.locator('html')).toHaveAttribute('data-landing', 'playing'); await complete(page); await nodeAt(page, initialPath).click(); - await expect(page.locator('[data-object-signature]')).toBeHidden(); - await page.getByText('Object signature', { exact: true }).click(); - await expect(page.locator('[data-object-signature]')).toContainText('object at "/design/object-tree/paths/names"'); - await expect(page.locator('[data-result]')).not.toContainText('Unicode正規化'); - await page.getByRole('button', { name: '本文を読む' }).click(); - await expect(page.locator('[data-result]')).toContainText('Unicode正規化'); - await expect(page.getByRole('status')).toContainText('read_body が完了'); + await expect(page.locator(selectedContent)).toBeVisible(); + await expect(page.locator(selectedContent + ' h1')).toHaveText('1. コンセプト'); expect(errors).toEqual([]); }); } @@ -342,7 +345,7 @@ test('continuous motion overlaps growth, travel and reveal without intermediate const root = page.locator('.root-mark'); await expect(root.locator('svg .root-shape')).toHaveCount(1); await expect(root.locator('.root-shape')).toHaveAttribute('d', 'M31 4H41L17 60H7Z'); // The slash is geometry, not a font glyph. - await expect(page.locator('.call-panel')).toHaveAttribute('inert', ''); + await expect(page.locator(selectedContent)).toHaveAttribute('inert', ''); const initial = await root.boundingBox(); expect(initial).not.toBeNull(); expect(Math.abs(initial!.x + initial!.width / 2 - page.viewportSize()!.width / 2)).toBeLessThan(2); @@ -410,7 +413,7 @@ test('continuous motion overlaps growth, travel and reveal without intermediate expect(extended.gap).toBeCloseTo(0, 1); expect(extended.bottom).toBeLessThan(page.viewportSize()!.height); await page.screenshot({ path: testInfo.outputPath('04-travelling-trunk.png') }); - await page.clock.runFor(920); // 2600ms: moving shared scene, persistent trunk AND panel onset. + await page.clock.runFor(920); // 2600ms: moving shared scene, persistent trunk AND article onset. const overlap = await page.evaluate(() => { const anchor = document.querySelector('.trunk-anchor')!; const matrix = anchor.getScreenCTM()!; @@ -426,7 +429,7 @@ test('continuous motion overlaps growth, travel and reveal without intermediate spineOpacity: Number(getComputedStyle(first.querySelector('.branch-spine')!).opacity), trunkOpacity: Number(getComputedStyle(document.querySelector('.trunk-line')!).opacity), labelOpacity: Number(getComputedStyle(document.querySelector('.demo-node:not(.root-mark)')!).opacity), - panelOpacity: Number(getComputedStyle(document.querySelector('.text-panel')!).opacity), + articleOpacity: Number(getComputedStyle(document.querySelector('[data-selected-content]')!).opacity), sceneTransform: getComputedStyle(document.querySelector('.tree-scene')!).transform, rowTransforms: [...document.querySelectorAll('.node-row, .demo-node')].map((row) => getComputedStyle(row).transform), }; @@ -436,7 +439,7 @@ test('continuous motion overlaps growth, travel and reveal without intermediate expect(overlap.spineOpacity).toBeGreaterThan(0); expect(overlap.trunkOpacity).toBe(1); // No opacity handoff or replacement text spine. expect(overlap.labelOpacity).toBeGreaterThan(0); - expect(overlap.panelOpacity).toBeGreaterThan(0); + expect(overlap.articleOpacity).toBeGreaterThan(0); expect(overlap.sceneTransform).not.toBe('none'); expect(overlap.rowTransforms.every((transform) => transform === 'none')).toBe(true); const growingTreeRoot = (await root.boundingBox())!; @@ -444,8 +447,8 @@ test('continuous motion overlaps growth, travel and reveal without intermediate await page.screenshot({ path: testInfo.outputPath('05-moving-tree.png') }); await page.clock.runFor(200); expect((await root.boundingBox())!.height).toBeLessThan(growingTreeRoot.height); - await expect(page.locator('.call-panel')).toHaveAttribute('data-glitching'); - expect(await page.locator('.call-panel .box-fill').first().textContent()).not.toMatch(/^─+$/); + await expectNodeGlitch(page); + await expect(page.locator(selectedContent + ' [data-glitching]')).toHaveCount(0); await page.screenshot({ path: testInfo.outputPath('06-overlapping-glitch.png') }); await page.clock.runFor(400); const chromeOpacity = await page.locator('.site-header').evaluate((el) => Number(getComputedStyle(el).opacity)); @@ -455,25 +458,16 @@ test('continuous motion overlaps growth, travel and reveal without intermediate await page.clock.runFor(800); await complete(page); await expect(root).toHaveCount(1); - await expect(page.locator('.call-panel')).not.toHaveAttribute('inert'); + await expect(page.locator(selectedContent)).not.toHaveAttribute('inert'); await expect(page.locator('[data-glitching]')).toHaveCount(0); - await expect(page.locator('[data-object-signature]')).toContainText('object at "/design/object-tree/paths/names"'); - for (const panel of await page.locator('.ascii-panel').all()) { - await expect(panel).toHaveCSS('outline-style', 'none'); - await expect(panel).toHaveCSS('border-top-width', '0px'); - await expect(panel.locator('.box-corner')).toHaveText(['┌', '┐', '└', '┘']); - for (const fill of await panel.locator('.box-fill').all()) await expect(fill).toHaveText(/^─+$/); - for (const side of await panel.locator('.box-column').all()) await expect(side).toHaveText(/^[│\s]+$/); - } + await expect(page.locator(selectedContent + ' h1')).toHaveText('1. コンセプト'); await expect(page.locator('.branch-lines')).toHaveCount(0); - await expect(page.locator('.demo-node')).toHaveCount(42); - await expect(page.getByRole('treeitem')).toHaveCount(24); - await expect(page.locator('[data-tree-count]')).toHaveText('24 / 42 objects'); - await expect(page.locator('.demo-badge')).toHaveText('4 AREAS / 26 GUIDES / 42 OBJECTS'); + await expect(page.locator('.demo-node')).toHaveCount(treeNodeCount); + await expect(page.getByRole('treeitem')).toHaveCount(treeNodeCount); await checkGlyphRefs(page); await expect(page.locator('.root-wire')).toHaveCount(0); - await expect(page.locator(visiblePrefixes + ' svg')).toHaveCount(23); - await expect(page.locator(visiblePrefixes + ' .branch-spine use')).toHaveCount(23); + await expect(page.locator(visiblePrefixes + ' svg')).toHaveCount(treeNodeCount - 1); + await expect(page.locator(visiblePrefixes + ' .branch-spine use')).toHaveCount(treeNodeCount - 1); await expect(page.locator('.tree-scene')).toHaveCSS('transform', 'none'); const grid = await page.evaluate(() => { const root = document.querySelector('.root-mark')!.getBoundingClientRect(); @@ -489,7 +483,6 @@ test('continuous motion overlaps growth, travel and reveal without intermediate frameCells: frame.width / cell, rowHeights: rows.map((row) => row.height), nodeColumns: nodes.map((node) => (node.left - frame.left) / cell), - panels: [...document.querySelectorAll('.ascii-panel')].map((panel) => panel.getBoundingClientRect().toJSON()), font: getComputedStyle(prefix).fontFamily, }; }); @@ -498,9 +491,6 @@ test('continuous motion overlaps growth, travel and reveal without intermediate expect(Math.abs(grid.frameCells / 4 - Math.round(grid.frameCells / 4))).toBeLessThan(.02); expect(grid.rowHeights.every((height) => height === 24)).toBe(true); expect(grid.nodeColumns.every((col) => Math.abs(col - Math.round(col)) < .02)).toBe(true); - // Prose determines panel height; the panels still must not overlap. - expect(grid.panels[1].top - grid.panels[0].bottom).toBeCloseTo(24, 4); - expect(grid.panels.every((panel) => panel.height > 0 && panel.width > 0)).toBe(true); expect(grid.font).toContain('Mono'); await page.screenshot({ path: testInfo.outputPath('06-world.png'), fullPage: true, animations: 'disabled' }); await page.keyboard.press('z'); @@ -528,44 +518,155 @@ for (const time of [1500, 2600]) { }); } -test('skipping an active glitch restores exact text and frames', async ({ page }) => { +test('skipping an active node glitch restores exact filenames without altering document HTML', 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 armed(page); + const html = await page.locator(selectedContent).innerHTML(); await page.keyboard.press('a'); await expect(page.locator(stage)).toHaveAttribute('data-phase', 'flow'); await expect(page.locator('.tap-stream-a')).toHaveCSS('opacity', '0'); await page.clock.runFor(3000); - await expect(page.locator('.call-panel')).toHaveAttribute('data-glitching'); + await expectNodeGlitch(page); + await expect(page.locator(selectedContent + ' [data-glitching]')).toHaveCount(0); + expect(await page.locator(selectedContent).innerHTML()).toBe(html); await page.keyboard.press('Escape'); await complete(page); await expect(page.locator('[data-glitching]')).toHaveCount(0); - await expect(page.locator('#call-result-title')).toHaveText('03 / Read'); - await expect(page.locator('.call-panel .box-corner')).toHaveText(['┌', '┐', '└', '┘']); - await expect(page.locator('.call-panel .box-fill').first()).toHaveText(/^─+$/); + for (const path of treePaths.slice(1)) await expect(nodeAt(page, path).locator('.node-label')).toHaveText(path.split('/').at(-1)!); await page.clock.runFor(2000); - await expect(page.locator('[data-object-signature]')).toContainText('object at "/design/object-tree/paths/names"'); + expect(await page.locator(selectedContent).innerHTML()).toBe(html); await page.locator('.root-mark').hover(); await expect(page.locator(stage)).not.toHaveAttribute('data-hover'); }); -test('tree selection, collapse and reading are keyboard accessible in the scrollable viewport', async ({ page }) => { +test('landing contains only the real filename tree and selected canonical content', async ({ page }, testInfo) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto('./'); + await complete(page); + await expect(page.locator([ + '.intro-invitation', '.tree-toolbar', '.tree-search', '[data-tree-count]', '.tree-help', + '.tree-caption', '.root-caption', '.demo-badge', '.node-description', '.ascii-panel', + '.box-frame', '.panel-title', '[data-object-signature]', '[data-interface-signature]', + '[data-result]', '[data-sources]', '.demo-call', '.demo-status', '.static-guides', + '.guide-body', '.no-script-note', '.brand-caption', '.site-footer', + 'main > :not(.landing):not(script):not(style)', + ].join(','))).toHaveCount(0); + await expect(page.getByRole('status')).toHaveCount(0); + await expect(page.getByRole('searchbox')).toHaveCount(0); + 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('.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); + for (const path of documentPaths) { + const node = nodeAt(page, path); + await expect(node).toHaveAttribute('role', 'treeitem'); + await expect(node).toHaveAttribute('aria-level', path.startsWith('/draft/') ? '3' : '2'); + await expect(node.locator('.node-label')).toHaveText(path.split('/').at(-1)!); + expect(await node.evaluate((node) => node.tagName)).toBe('A'); + expect(await node.evaluate((node) => (node as HTMLAnchorElement).href)).toBe(bookURL(path, testInfo.project.use.baseURL!)); + await expect(node).not.toHaveAttribute('aria-expanded'); + } + for (const path of ['/', '/draft']) { + const folder = nodeAt(page, path); + expect(await folder.evaluate((node) => node.tagName)).toBe('BUTTON'); + await expect(folder).toHaveAttribute('aria-expanded', 'true'); + await expect(folder).toHaveAttribute('aria-level', path === '/' ? '1' : '2'); + } + expect(await page.locator('.demo-node:not(.root-mark)').evaluateAll((nodes) => nodes.every((node) => + node.textContent === node.querySelector('.node-label')!.textContent + (node.querySelector('.node-indicator')?.textContent ?? '') + && [...node.children].every((child) => child.matches('.node-label, .node-indicator')) + && [...node.querySelectorAll('.node-indicator')].every((indicator) => /^[+−-]$/.test(indicator.textContent!)) + ))).toBe(true); + const payload = page.locator('script#landing-documents'); + await expect(payload).toHaveCount(1); + await expect(payload).toHaveAttribute('type', 'application/json'); + const serialized = (await payload.textContent())!; + expect(serialized).not.toContain('<'); // Rendered HTML cannot terminate its JSON script element. + const stored: { path: string; html?: string; title?: string; href?: string }[] = JSON.parse(serialized); + expect(stored.map((document) => document.path)).toEqual(treePaths); + for (const path of documentPaths) { + const document = stored.find((document) => document.path === path)!; + expect(document.href).toBe(new URL(bookURL(path, testInfo.project.use.baseURL!)).pathname); + expect(document.title).toBeTruthy(); + expect(document.html).toContain(' document.path === path)!; + expect(folder.html).toBeUndefined(); + expect(folder.title).toBeUndefined(); + expect(folder.href).toBeUndefined(); + } + await expect(nodeAt(page, initialPath)).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator(selectedContent)).toBeVisible(); + await expect(page.locator(selectedContent + ' h1')).toHaveText('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); +}); + +test('each file immediately displays exactly its Book HTML, including links, without fetching or navigating', async ({ page }, testInfo) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto('./'); + await complete(page); + const book = await page.context().newPage(); + const bodies = new Map(); + for (const path of documentPaths) { + await book.goto(bookURL(path, testInfo.project.use.baseURL!)); + bodies.set(path, await book.locator('article.prose').innerHTML()); + } + await book.close(); + const landingURL = page.url(); + expect(await page.locator(selectedContent).innerHTML()).toBe(bodies.get(initialPath)); + const requests: string[] = []; + page.on('request', (request) => requests.push(request.url())); + for (const path of documentPaths) { + // Observe the body in the same task as the click, not after waiting for a read/reveal. + const displayed = await nodeAt(page, path).evaluate((node) => { + (node as HTMLElement).click(); + const article = document.querySelector('[data-selected-content]')!; + return { html: article.innerHTML, hidden: article.hidden }; + }); + expect(displayed).toEqual({ html: bodies.get(path), hidden: false }); + await expect(nodeAt(page, path)).toHaveAttribute('aria-selected', 'true'); + expect(page.url()).toBe(landingURL); + } + expect(requests).toEqual([]); + for (const path of ['/draft', '/']) { + await nodeAt(page, path).click(); + await expect(page.locator(selectedContent)).toBeHidden(); + expect(await page.locator(selectedContent).innerHTML()).toBe(''); + } +}); + +test('file selection and folder collapse work with keyboard navigation in the scrollable viewport', async ({ page }) => { await armed(page); await page.keyboard.press('Escape'); await complete(page); await expect(nodeAt(page, initialPath)).toBeFocused(); - await page.keyboard.press('ArrowUp'); - const paths = nodeAt(page, '/design/object-tree/paths'); - await expect(paths).toBeFocused(); + await page.keyboard.press('ArrowDown'); + await expect(nodeAt(page, '/2-core-model.md')).toBeFocused(); + await expect(nodeAt(page, '/2-core-model.md')).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator(selectedContent + ' h1')).toHaveText('2. コアモデル'); + const draft = nodeAt(page, '/draft'); + await draft.focus(); await page.keyboard.press('ArrowLeft'); - await expect(paths).toHaveAttribute('aria-expanded', 'false'); - await expect(nodeAt(page, initialPath)).toBeHidden(); + await expect(draft).toHaveAttribute('aria-expanded', 'false'); + for (const path of documentPaths.filter((path) => path.startsWith('/draft/'))) await expect(nodeAt(page, path)).toBeHidden(); + await expect(page.getByRole('treeitem')).toHaveCount(treeNodeCount - 5); + await expect(page.locator(selectedContent)).toBeHidden(); + expect(await page.locator(selectedContent).innerHTML()).toBe(''); + await checkGlyphRefs(page); await page.keyboard.press('ArrowRight'); - await expect(nodeAt(page, initialPath)).toBeVisible(); + await expect(draft).toHaveAttribute('aria-expanded', 'true'); await page.keyboard.press('ArrowRight'); - await expect(nodeAt(page, initialPath)).toBeFocused(); + await expect(nodeAt(page, documentPaths[10])).toBeFocused(); + await expect(page.locator(selectedContent)).toBeVisible(); await page.keyboard.press('ArrowLeft'); - await expect(paths).toBeFocused(); + await expect(draft).toBeFocused(); + await expect(page.locator(selectedContent)).toBeHidden(); await page.keyboard.press('Home'); const root = nodeAt(page, '/'); await expect(root).toBeFocused(); @@ -573,165 +674,31 @@ test('tree selection, collapse and reading are keyboard accessible in the scroll await expect(page.getByRole('treeitem')).toHaveCount(1); expect((await trunkGeometry(page)).length).toBe(0); await page.keyboard.press('ArrowRight'); - await expect(page.getByRole('treeitem')).toHaveCount(24); + await expect(page.getByRole('treeitem')).toHaveCount(treeNodeCount); await checkGlyphRefs(page); - await page.getByRole('button', { name: 'すべて展開', exact: true }).click(); - await root.focus(); await page.keyboard.press('End'); - const finalLeaf = nodeAt(page, '/review/boundary-checks/failure-tests'); + const finalLeaf = nodeAt(page, documentPaths.at(-1)!); await expect(finalLeaf).toBeFocused(); await expect(finalLeaf).toHaveAttribute('aria-selected', 'true'); const scroll = await page.locator('.tree-viewport').evaluate((viewport) => { const node = document.activeElement!.getBoundingClientRect(), box = viewport.getBoundingClientRect(); - return { top: viewport.scrollTop, inside: node.top >= box.top && node.bottom <= box.bottom && node.left >= box.left && node.right <= box.right }; + return { top: viewport.scrollTop, needsScroll: viewport.scrollHeight > viewport.clientHeight, inside: node.top >= box.top && node.bottom <= box.bottom && node.left >= box.left && node.right <= box.right }; }); - expect(scroll.top).toBeGreaterThan(0); + if (scroll.needsScroll) expect(scroll.top).toBeGreaterThan(0); expect(scroll.inside).toBe(true); await page.keyboard.press('ArrowUp'); - await expect(nodeAt(page, '/review/boundary-checks/validation')).toBeFocused(); + await expect(nodeAt(page, '/draft/typescript-api-model.md')).toBeFocused(); await page.keyboard.press('ArrowDown'); await expect(finalLeaf).toBeFocused(); await expect(page.locator('.demo-node[tabindex="0"]')).toHaveCount(1); - await page.keyboard.press('Tab'); - await expect(page.locator('.text-panel summary')).toBeFocused(); await page.keyboard.press('Enter'); - await expect(page.locator('[data-object-signature]')).toBeVisible(); + await expect(finalLeaf).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator(selectedContent)).toBeVisible(); + await page.keyboard.press('Space'); + await expect(finalLeaf).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator(selectedContent)).toBeVisible(); await page.keyboard.press('Tab'); - await expect(page.locator('.call-panel summary')).toBeFocused(); - await page.keyboard.press('Enter'); - await expect(page.locator('[data-interface-signature]')).toBeVisible(); - await page.keyboard.press('Tab'); - await expect(page.getByRole('button', { name: '本文を読む' })).toBeFocused(); - await page.keyboard.press('Enter'); - await expect(page.locator('[data-call-state]')).toHaveText('RETURNED'); - await expect(page.locator('[data-result]')).toContainText('結果不明やTransport不達でcallを自動再実行しない'); -}); - -test('expand/collapse all preserves visible selection and rehomes hidden selection, including a closed root', async ({ page }, testInfo) => { - await page.emulateMedia({ reducedMotion: 'reduce' }); - await page.goto('./'); - await complete(page); - const expand = page.getByRole('button', { name: 'すべて展開', exact: true }); - const collapse = page.getByRole('button', { name: 'たたむ', exact: true }); - await expect(nodeAt(page, initialPath)).toHaveAttribute('aria-selected', 'true'); - await expand.click(); - await expect(page.getByRole('treeitem')).toHaveCount(42); - await expect(page.locator('[data-tree-count]')).toHaveText('42 / 42 objects'); - const labelsOnly = await page.locator('.demo-node:not(.root-mark)').evaluateAll((nodes) => nodes.every((node) => - node.textContent === node.querySelector('.node-label')!.textContent + (node.querySelector('.node-indicator')?.textContent ?? '') - )); - expect(labelsOnly).toBe(true); - await expect(page.locator('.demo-node:not([aria-expanded])')).toHaveCount(26); - await expect(nodeAt(page, initialPath)).toHaveAttribute('aria-selected', 'true'); - await checkGlyphRefs(page); - await collapse.click(); - await expect(page.getByRole('treeitem')).toHaveCount(5); - await expect(page.locator('[data-tree-count]')).toHaveText('5 / 42 objects'); - await expect(nodeAt(page, '/')).toHaveAttribute('aria-selected', 'true'); - await expect(page.locator('[data-target]')).toHaveText('/'); - await expect(page.getByRole('button', { name: '本文を読む' })).toBeDisabled(); - await checkGlyphRefs(page); - await nodeAt(page, '/build').click(); - await expand.click(); - await expect(nodeAt(page, '/build')).toHaveAttribute('aria-selected', 'true'); - await collapse.click(); - await expect(nodeAt(page, '/build')).toHaveAttribute('aria-selected', 'true'); - await nodeAt(page, '/').focus(); - await page.keyboard.press('ArrowLeft'); - await expect(page.getByRole('treeitem')).toHaveCount(1); - await collapse.click(); // Regression: collapse-all must reopen a keyboard-collapsed root. - await expect(nodeAt(page, '/')).toHaveAttribute('aria-expanded', 'true'); - await expect(page.getByRole('treeitem')).toHaveCount(5); - await checkGlyphRefs(page); - await page.locator('.tree-pane').screenshot({ path: testInfo.outputPath('guide-collapse-all-root-reopened.png') }); -}); - -test('body search exposes ancestors, preserves or rehomes selection, and handles no results', async ({ page }, testInfo) => { - await page.emulateMedia({ reducedMotion: 'reduce' }); - await page.goto('./'); - await complete(page); - const search = page.getByRole('searchbox', { name: 'コンテンツを検索' }); - const expand = page.getByRole('button', { name: 'すべて展開', exact: true }); - const collapse = page.getByRole('button', { name: 'たたむ', exact: true }); - await search.fill('Unicode正規化'); // Body only, not a name/title/description. - await expect(page.getByRole('treeitem')).toHaveCount(5); - await expect(page.locator('[data-tree-count]')).toHaveText('5 / 42 objects'); - await expect(nodeAt(page, initialPath)).toHaveAttribute('aria-selected', 'true'); - await expect(expand).toBeDisabled(); - await expect(collapse).toBeDisabled(); - await checkGlyphRefs(page); - await expect(nodeAt(page, '/design').locator('..').locator('.branch-spine use')).toHaveAttribute('href', '#tree-glyph-9492'); - const shortTrunk = (await trunkGeometry(page)).length; - await page.locator('.tree-pane').screenshot({ path: testInfo.outputPath('guide-search-body-ancestors.png') }); - await search.fill('疑似ID'); // Initially visible identity, also body-only. - await expect(nodeAt(page, '/design/object-tree/paths/identity')).toHaveAttribute('aria-selected', 'true'); - await expect(page.locator('[data-target]')).toHaveText('/design/object-tree/paths/identity'); - await checkGlyphRefs(page); - await search.fill('観測cache'); // A leaf behind an initially collapsed parent. - await expect(nodeAt(page, '/build/client/cache')).toBeVisible(); - await expect(nodeAt(page, '/build/client/cache')).toHaveAttribute('aria-selected', 'true'); - await expect(nodeAt(page, '/build/client')).toHaveAttribute('aria-expanded', 'true'); - await checkGlyphRefs(page); - await search.press('Enter'); - await expect(nodeAt(page, '/build/client/cache')).toBeFocused(); - await page.keyboard.press('Home'); - await page.keyboard.press('ArrowRight'); - await expect(nodeAt(page, '/build')).toBeFocused(); - await page.keyboard.press('ArrowRight'); - await expect(nodeAt(page, '/build/client')).toBeFocused(); - await page.keyboard.press('ArrowRight'); - await expect(nodeAt(page, '/build/client/cache')).toBeFocused(); - await page.keyboard.press('ArrowLeft'); - await expect(nodeAt(page, '/build/client')).toBeFocused(); - await expect(nodeAt(page, '/build/client')).toHaveAttribute('aria-expanded', 'true'); - await search.fill('no-guide-can-match-this-98371'); - await expect(page.getByRole('treeitem')).toHaveCount(1); - await expect(page.locator('[data-tree-count]')).toHaveText('一致する項目がありません'); - await expect(nodeAt(page, '/')).toHaveAttribute('aria-selected', 'true'); - await expect(page.locator('[data-target]')).toHaveText('/'); - expect((await trunkGeometry(page)).length).toBe(0); - await expect(page.getByRole('button', { name: '本文を読む' })).toBeDisabled(); - await page.locator('.tree-pane').screenshot({ path: testInfo.outputPath('guide-search-no-results.png') }); - await search.fill(''); - await expect(page.getByRole('treeitem')).toHaveCount(24); - await expect(expand).toBeEnabled(); - await expect(collapse).toBeEnabled(); - await expect(nodeAt(page, '/build/client')).toHaveAttribute('aria-expanded', 'false'); - await expect(nodeAt(page, '/')).toHaveAttribute('aria-selected', 'true'); - expect((await trunkGeometry(page)).length).toBeGreaterThan(shortTrunk); - await checkGlyphRefs(page); -}); - -test('reading uses explicit operations, disclosure signatures, and navigable SITE_BASE-aware sources', async ({ page }, testInfo) => { - await page.emulateMedia({ reducedMotion: 'reduce' }); - await page.goto('./'); - await complete(page); - await expect(page.locator('[data-target]')).toHaveText(initialPath); - await expect(page.locator('[data-object-signature]')).toBeHidden(); - await expect(page.locator('[data-interface-signature]')).toBeHidden(); - await expect(page.locator('[data-sources]')).toBeHidden(); - await expect(page.locator('[data-result]')).not.toContainText('Unicode正規化'); - await page.getByText('Object signature', { exact: true }).click(); - await expect(page.locator('[data-object-signature]')).toBeVisible(); - await expect(page.locator('[data-object-signature]')).toContainText('object at "' + initialPath + '"'); - await page.getByText('Interface signature', { exact: true }).click(); - await expect(page.locator('[data-interface-signature]')).toBeVisible(); - await expect(page.locator('[data-interface-signature]')).toContainText('operation read_body() -> { body: string }'); - await page.getByRole('button', { name: '本文を読む' }).click(); - await expect(page.locator('[data-result]')).toContainText('Unicode正規化'); - await expect(page.locator('[data-call-state]')).toHaveText('RETURNED'); - await expect(page.getByRole('status')).toContainText('read_body が完了'); - await expect(page.locator('[data-sources]')).toBeVisible(); - const links = page.locator('[data-sources] a'); - await expect(links).toHaveCount(2); - const base = testInfo.project.use.baseURL!; - const expected = ['book/2.1-object-tree-schema/', 'book/3-protocol/'].map((path) => new URL(path, base).href); - expect(await links.evaluateAll((anchors) => anchors.map((a) => (a as HTMLAnchorElement).href))).toEqual(expected); - await page.locator('.call-panel').screenshot({ path: testInfo.outputPath('guide-read-signatures-sources.png') }); - await links.first().click(); - await expect(page).toHaveURL(expected[0]); - await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); - await expect(page.locator('.prose')).toContainText('Object'); + await expect(page.locator(selectedContent)).toBeFocused(); }); test.describe('glyph trunk regressions', () => { @@ -773,7 +740,7 @@ test.describe('glyph trunk regressions', () => { await complete(page); await exposeTree(page); const checkEnd = async (name: string) => { - // Keyboard selection can scroll wide prose summaries horizontally. Reset + // Keyboard selection can scroll long filenames horizontally. Reset // the sampling column before measuring, never after computing screen points. await page.locator('.tree-viewport').evaluate((el) => { el.scrollTop = el.scrollLeft = 0; }); await page.evaluate(() => scrollTo(0, 0)); @@ -821,68 +788,55 @@ test.describe('glyph trunk regressions', () => { } return trunk.length; }; - let previous = await checkEnd('expanded'); - for (const name of ['design', 'build', 'operate']) { - const node = page.getByRole('treeitem', { name, exact: true }); - await node.focus(); - await page.keyboard.press('ArrowLeft'); - await expect(node).toHaveAttribute('aria-expanded', 'false'); - const length = await checkEnd(name); - expect(length).toBeLessThan(previous); - previous = length; - } - const review = page.getByRole('treeitem', { name: 'review', exact: true }); - await review.focus(); + const expanded = await checkEnd('expanded'); + const draft = nodeAt(page, '/draft'); + await draft.focus(); await page.keyboard.press('ArrowLeft'); - expect(await checkEnd('review')).toBe(previous); // Last child never extends the first-column spine. + await expect(draft).toHaveAttribute('aria-expanded', 'false'); + // Draft is the last root child: its descendants never extend the first-column spine. + expect(await checkEnd('draft')).toBe(expanded); await page.keyboard.press('Home'); await page.keyboard.press('ArrowLeft'); await expect(page.getByRole('treeitem')).toHaveCount(1); await checkEnd('root'); await page.keyboard.press('ArrowRight'); - expect(await checkEnd('root-reopened')).toBe(previous); - for (const name of ['design', 'build', 'operate', 'review']) { - await page.getByRole('treeitem', { name, exact: true }).focus(); - await page.keyboard.press('ArrowRight'); - } - expect(await checkEnd('all-reopened')).toBeGreaterThan(previous); + expect(await checkEnd('root-reopened')).toBe(expanded); + await draft.focus(); + await page.keyboard.press('ArrowRight'); + expect(await checkEnd('all-reopened')).toBe(expanded); }); }); test.describe('JavaScript-free glyph tree', () => { test.use({ javaScriptEnabled: false }); - test('all 26 authored bodies are accessible without JavaScript', async ({ page }, testInfo) => { + test('initial canonical article and every file link remain usable without JavaScript', async ({ page }, testInfo) => { await page.goto('./'); - const articles = page.locator('.static-guides details'); - await expect(articles).toHaveCount(26); - await expect(page.locator('.static-guides .guide-body')).toHaveCount(26); - const bodies = await page.locator('.static-guides .guide-body').allTextContents(); - expect(new Set(bodies).size).toBe(26); - for (const article of await articles.all()) { - await article.locator('summary').click(); - await expect(article).toHaveAttribute('open', ''); - await expect(article.locator('.guide-body')).toBeVisible(); - expect((await article.locator('.guide-body').textContent())!.length).toBeGreaterThan(200); - const sources = await article.locator('a').evaluateAll((links) => links.map((a) => (a as HTMLAnchorElement).href)); - expect(sources.length).toBeGreaterThan(0); - for (const source of sources) expect(source.startsWith(new URL('book/', testInfo.project.use.baseURL!).href)).toBe(true); - await article.locator('summary').click(); - await expect(article.locator('.guide-body')).toBeHidden(); + await expect(page.locator(selectedContent)).toBeVisible(); + await expect(page.locator(selectedContent + ' h1')).toHaveText('1. コンセプト'); + 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); + for (const path of documentPaths) { + await page.goto('./'); + const node = nodeAt(page, path); + expect(await node.evaluate((node) => (node as HTMLAnchorElement).href)).toBe(bookURL(path, testInfo.project.use.baseURL!)); + await node.click(); + await expect(page).toHaveURL(bookURL(path, testInfo.project.use.baseURL!)); + await expect(page.locator('article.prose h1')).toBeVisible(); + if (path === initialPath) expect(await page.locator('article.prose').innerHTML()).toBe(initialHTML); } - await articles.last().locator('summary').click(); - await articles.last().screenshot({ path: testInfo.outputPath('no-js-final-article-body.png') }); }); test('SSR paints one filled trunk and SVG branch outlines without animation code', async ({ page }, testInfo) => { await page.goto('./'); await expect(page.locator('html')).not.toHaveAttribute('data-landing'); - await expect(page.locator('.no-script-note')).toBeVisible(); + await expect(page.locator(selectedContent)).toBeVisible(); expect((await page.locator('.root-shape').boundingBox())!.height).toBeCloseTo(12.25, 4); await expect(page.locator('.trunk-line')).toHaveCount(1); await expect(page.locator('.trunk-line')).toHaveCSS('opacity', '1'); await expect(page.locator('.trunk-line')).toHaveCSS('stroke', 'none'); - await expect(page.locator(visiblePrefixes + ' svg')).toHaveCount(23); + await expect(page.locator(visiblePrefixes + ' svg')).toHaveCount(treeNodeCount - 1); expect(await page.locator('.branch-prefix').evaluateAll((prefixes) => prefixes.every((prefix) => !prefix.textContent!.trim()))).toBe(true); const trunk = await trunkGeometry(page); expect(trunk.width).toBeCloseTo(1.33, 5); @@ -895,35 +849,14 @@ test.describe('JavaScript-free glyph tree', () => { }); }); -test('selecting another guide cancels a read reveal and resets its state', 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 armed(page); - await page.keyboard.press('Escape'); - await complete(page); - await expect(page.locator('[data-call-state]')).toHaveText('READY'); - await page.getByRole('button', { name: '本文を読む' }).click(); - await page.clock.runFor(32); - await expect(page.locator('[data-call-state]')).toHaveText('RETURNED'); - await nodeAt(page, '/design/object-tree/paths/identity').click(); - await expect(page.locator('[data-call-state]')).toHaveText('READY'); - await expect(page.locator('[data-result]')).toHaveCSS('opacity', '1'); - await expect(page.locator('[data-result]')).toHaveCSS('transform', 'none'); - await expect(page.locator('[data-sources]')).toBeHidden(); - await expect(page.locator('[data-result]')).not.toContainText('Unicode正規化'); - await page.clock.runFor(500); - await expect(page.locator('[data-call-state]')).toHaveText('READY'); - await page.getByRole('button', { name: '本文を読む' }).click(); - await expect(page.locator('[data-result]')).toContainText('疑似ID'); -}); - -test('reduced motion opens the working demo immediately', async ({ page }) => { +test('reduced motion opens the real tree and article immediately', async ({ page }) => { await page.emulateMedia({ reducedMotion: 'reduce' }); await page.goto('./'); await complete(page); await expect(page.locator('.intro-skip')).toBeHidden(); - await page.getByRole('button', { name: '本文を読む' }).click(); - await expect(page.locator('[data-result]')).toContainText('Unicode正規化'); + await expect(page.locator(selectedContent)).toHaveCSS('opacity', '1'); + await nodeAt(page, '/2-core-model.md').click(); + await expect(page.locator(selectedContent + ' h1')).toHaveText('2. コアモデル'); }); test('changing motion preference and resizing cannot strand the intro', async ({ page }) => { @@ -941,7 +874,13 @@ 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.getByRole('heading', { level: 1 })).toBeVisible(); + await expect(page.locator(selectedContent + ' h1')).toHaveText('1. コンセプト'); + await expect(page.locator(selectedContent)).toBeVisible(); + await nodeAt(page, '/draft/rust-api-model.md').click(); + await expect(page).toHaveURL(/\/book\/draft\/rust-api-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 expect(page.getByRole('heading', { level: 1 })).toContainText('通信の約束まで'); }); diff --git a/site/tests/browser/site.spec.ts b/site/tests/browser/site.spec.ts index cc1d7b2..6a9c9f5 100644 --- a/site/tests/browser/site.spec.ts +++ b/site/tests/browser/site.spec.ts @@ -4,7 +4,7 @@ test('home and book are usable without client JavaScript', async ({ page }, test const failures: string[] = []; page.on('response', (response) => { if (response.status() >= 400) failures.push(response.url()); }); await page.goto('./'); - await expect(page.getByRole('heading', { level: 1 })).toContainText('探索できる世界へ'); + await expect(page.locator('[data-selected-content] h1')).toHaveText('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(); diff --git a/site/tests/landing-content.test.mjs b/site/tests/landing-content.test.mjs deleted file mode 100644 index f5d3d78..0000000 --- a/site/tests/landing-content.test.mjs +++ /dev/null @@ -1,109 +0,0 @@ -import assert from 'node:assert/strict'; -import { stat } from 'node:fs/promises'; -import path from 'node:path'; -import test from 'node:test'; -import { rootContent } from '../src/lib/landing-content.ts'; -import { contentsRoot } from '../plugins/document-links.mjs'; - -// Independent traversal: the content file owns no derived paths or UI helpers. -function traverse(node, parent, depth = 1) { - const nodePath = parent ? `${parent.path === '/' ? '' : parent.path}/${node.name}` : '/'; - const entry = { node, path: nodePath, parent, depth }; - return [entry, ...(node.children ?? []).flatMap((child) => traverse(child, entry, depth + 1))]; -} -const entries = traverse(rootContent); -const leaves = entries.filter(({ node }) => !node.children?.length); -const byPath = new Map(entries.map((entry) => [entry.path, entry])); - -function visibleNodes(node) { - return 1 + (node.open ? (node.children ?? []).reduce((count, child) => count + visibleNodes(child), 0) : 0); -} - -test('the tree has unique names, canonical derived paths and single structural parents', () => { - assert.equal(rootContent.name, ''); - assert.equal(entries[0].path, '/'); - assert.equal(entries[0].depth, 1); - assert.equal(entries[0].parent, undefined); - assert.equal(new Set(entries.map(({ node }) => node)).size, entries.length, 'nodes cannot be reused under different parents'); - assert.equal(new Set(entries.map(({ node }) => node.name)).size, entries.length, 'guide names are unique across the tree'); - assert.equal(byPath.size, entries.length); - for (const { node, path: nodePath, parent, depth } of entries) { - assert.ok(node.description?.trim().length >= 15, `${nodePath}: meaningful description`); - assert.equal('parent' in node, false, 'parents are derived, not stored in content'); - assert.equal('path' in node, false, 'paths are derived, not stored in content'); - assert.equal('depth' in node, false, 'depth is derived, not stored in content'); - if (parent) { - assert.match(node.name, /^[a-z]+(?:-[a-z]+)*$/, nodePath); - assert.ok(parent.node.children.includes(node)); - assert.equal(depth, parent.depth + 1); - assert.equal(byPath.get(nodePath.slice(0, nodePath.lastIndexOf('/')) || '/'), parent); - assert.equal(nodePath.split('/').at(-1), node.name); - assert.equal(nodePath.endsWith('/'), false); - } - if (node.children) { - assert.ok(node.children.length >= 2, `${nodePath}: no padding with single-child groups`); - assert.equal(new Set(node.children.map((child) => child.name)).size, node.children.length); - } - } -}); - -test('four ordered work areas have substantial breadth and useful depth', (t) => { - assert.deepEqual(rootContent.children.map((node) => node.name), ['design', 'build', 'operate', 'review']); - assert.ok(entries.length >= 40 && entries.length <= 50, `${entries.length} total nodes`); - assert.ok(leaves.length >= 22 && leaves.length <= 30, `${leaves.length} substantive leaves`); - const maxDepth = Math.max(...entries.map(({ depth }) => depth)); - assert.ok(maxDepth >= 5 && maxDepth <= 6, `${maxDepth} levels including root`); - for (const area of rootContent.children) { - assert.ok(area.children.length >= 2, area.name); - const areaLeaves = leaves.filter(({ path: nodePath }) => nodePath.startsWith(`/${area.name}/`)); - assert.ok(areaLeaves.length >= 4, `${area.name}: substantive breadth`); - } - t.diagnostic(`nodes=${entries.length}; leaves=${leaves.length}; maxDepth=${maxDepth}; initiallyVisible=${visibleNodes(rootContent)}`); -}); - -test('all leaves provide distinct, structured Japanese guidance and canonical section references', async (t) => { - assert.equal(new Set(leaves.map(({ node }) => node.body)).size, leaves.length, 'no repeated filler body'); - for (const { node, path: nodePath } of leaves) { - assert.ok(node.title?.trim().length >= 5, `${nodePath}: title`); - assert.match(node.title, /[ぁ-んァ-ヶ一-龯]/u); - const length = Array.from(node.body ?? '').length; - assert.ok(length >= 250 && length <= 700, `${nodePath}: ${length} body characters`); - assert.match(node.body, /[ぁ-んァ-ヶ一-龯]/u); - assert.ok(node.body.split('\n\n').length >= 3, `${nodePath}: readable sections`); - assert.ok(node.body.split('\n').filter((line) => line.startsWith('・')).length >= 3, `${nodePath}: actionable checklist`); - assert.ok(node.sources?.length >= 1, `${nodePath}: canonical sources`); - for (const source of node.sources) { - // document stores a Book ID, never a URL, fragment, filename, or API draft. - assert.match(source.document, /^\d+(?:\.\d+)?-[a-z]+(?:-[a-z]+)*$/, nodePath); - assert.ok(source.label?.trim().length > 0, `${nodePath}: section label`); - const sourcePath = path.join(contentsRoot, `${source.document}.md`); - assert.ok((await stat(sourcePath)).isFile(), `${nodePath}: existing contents/${source.document}.md`); - } - } - const bodyLengths = leaves.map(({ node }) => Array.from(node.body).length); - const sourceIds = [...new Set(leaves.flatMap(({ node }) => node.sources.map(({ document }) => document)))]; - t.diagnostic(`bodyCharacters=${Math.min(...bodyLengths)}..${Math.max(...bodyLengths)}; canonicalDocuments=${sourceIds.length}; all contents files exist`); - for (const { node, path: nodePath } of entries.filter(({ node }) => node.children?.length)) { - assert.equal(node.body, undefined, `${nodePath}: branches introduce, leaves explain`); - } -}); - -test('the default guide is reachable through the intended initial expansion', () => { - const defaultPath = '/design/object-tree/paths/names'; - const selected = byPath.get(defaultPath); - assert.ok(selected); - assert.equal(selected.node.title, 'パスと名前を決める'); - assert.ok(selected.node.body); - assert.equal(selected.node.children, undefined); - assert.ok(byPath.get('/design/object-tree/paths/identity')?.node.body); - for (const nodePath of ['/', '/design', '/design/object-tree', '/design/object-tree/paths', '/build', '/build/host', '/operate', '/review']) { - assert.equal(byPath.get(nodePath)?.node.open, true, `${nodePath}: open`); - } - const visible = visibleNodes(rootContent); - assert.ok(visible >= 20 && visible <= 26, `${visible} visible nodes including root`); - let ancestor = selected.parent; - while (ancestor) { - assert.equal(ancestor.node.open, true, `${ancestor.path}: selected leaf is visible`); - ancestor = ancestor.parent; - } -}); diff --git a/site/tests/landing-demo.test.mjs b/site/tests/landing-demo.test.mjs deleted file mode 100644 index f02e68c..0000000 --- a/site/tests/landing-demo.test.mjs +++ /dev/null @@ -1,49 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { objects, childrenOf, initialObject, initialCollapsedPaths, initialVisibleObjects, visibleObjects, treePrefix, objectSignature, interfaceSignature } from '../src/lib/landing-demo.ts'; - -test('flattening derives paths and exposes a read contract only for articles', () => { - assert.equal(objects.length, 42); - assert.equal(initialVisibleObjects.length, 24); - assert.equal(initialObject.path, '/design/object-tree/paths/names'); - for (const object of objects) { - assert.equal(object.interface, object.body ? 'document.read' : undefined); - if (object.parent) { - assert.ok(childrenOf(object.parent).includes(object)); - assert.equal(object.depth, objects.find((parent) => parent.path === object.parent).depth + 1); - } - assert.match(objectSignature(object), /interfaces: \[/); - assert.equal(interfaceSignature(object).includes('read_body() -> { body: string }'), Boolean(object.body)); - } -}); - -test('collapse affects descendants, not the Object or its siblings', () => { - const collapsed = new Set(initialCollapsedPaths); - collapsed.add('/design'); - const shown = visibleObjects(collapsed); - assert.ok(shown.some((object) => object.path === '/design')); - assert.ok(!shown.some((object) => object.path.startsWith('/design/'))); - assert.ok(shown.some((object) => object.path === '/build/host/dispatch')); - assert.deepEqual(visibleObjects(new Set(['/'])).map((object) => object.path), ['/']); - assert.deepEqual(visibleObjects(new Set()), objects); -}); - -test('search matches body terms case-insensitively and retains only actual ancestors', () => { - const shown = visibleObjects(new Set(['/']), ' OPERATIONOUTCOMEUNKNOWN rollback '); - assert.deepEqual(shown.map((object) => object.path), ['/', '/operate', '/operate/recovery', '/operate/recovery/unknown-outcomes']); - assert.deepEqual(shown.map((object) => treePrefix(object, shown)), ['', '└── ', ' └── ', ' └── ']); - assert.deepEqual(visibleObjects(new Set(), 'no-such-guide-xyz').map((object) => object.path), ['/']); - assert.deepEqual(visibleObjects(new Set(initialCollapsedPaths), ' '), initialVisibleObjects); -}); - -test('prefix joins follow visible siblings with four cells per ancestor', () => { - for (const shown of [initialVisibleObjects, objects, visibleObjects(new Set(), 'validator')]) { - for (const object of shown) { - const prefix = treePrefix(object, shown); - assert.equal(prefix.length, (object.depth - 1) * 4); - if (!object.parent) continue; - const siblings = shown.filter((sibling) => sibling.parent === object.parent); - assert.ok(prefix.endsWith(siblings.at(-1) === object ? '└── ' : '├── ')); - } - } -}); diff --git a/site/tests/landing-tree.test.mjs b/site/tests/landing-tree.test.mjs new file mode 100644 index 0000000..a6fd963 --- /dev/null +++ b/site/tests/landing-tree.test.mjs @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import { readdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; +import { contentsRoot } from '../plugins/document-links.mjs'; +import { documentTree, childrenOf, visibleNodes, treePrefix, serializeTree } from '../src/lib/landing-tree.ts'; + +async function sourceFiles(directory, prefix = '') { + const entries = await readdir(directory, { withFileTypes: true }); + return (await Promise.all(entries.map(async (entry) => { + const relative = prefix + entry.name; + if (entry.isDirectory()) return sourceFiles(path.join(directory, entry.name), relative + '/'); + return entry.name.endsWith('.md') ? [relative] : []; + }))).flat(); +} +const files = await sourceFiles(contentsRoot); +const documents = await Promise.all(files.map(async (file) => { + const body = await readFile(path.join(contentsRoot, file), 'utf8'); + return { id: file.slice(0, -3), title: body.match(/^#\s+(.+)$/m)?.[1], href: '/book/' + file.slice(0, -3) + '/', html: body }; +})); +const nodes = documentTree(documents); + +test('tree mirrors only real content files and their directory parents', () => { + assert.deepEqual(nodes.filter((node) => node.href).map((node) => node.path).sort(), files.map((file) => '/' + file).sort()); + const directories = new Set(['/']); + for (const file of files) { + const segments = file.split('/'); + for (let length = 1; length < segments.length; length++) directories.add('/' + segments.slice(0, length).join('/')); + } + assert.deepEqual(new Set(nodes.filter((node) => !node.href).map((node) => node.path)), directories); + assert.equal(new Set(nodes.map((node) => node.path)).size, nodes.length); + for (const node of nodes.slice(1)) { + assert.equal(node.name, node.path.split('/').at(-1)); + assert.equal(node.depth, node.path.split('/').length); + assert.ok(childrenOf(nodes, node.parent).includes(node)); + assert.ok(nodes.findIndex((parent) => parent.path === node.parent) < nodes.indexOf(node)); + } +}); + +test('existing titles, hrefs and content pass through without added descriptions or operations', () => { + for (const document of documents) { + const node = nodes.find((node) => node.path === '/' + document.id + '.md'); + assert.equal(node.html, document.html); + assert.equal(node.title, document.title); + assert.equal(node.href, document.href); + assert.equal('description' in node, false); + assert.equal('interface' in node, false); + } + for (const folder of nodes.filter((node) => !node.href)) { + assert.equal(folder.html, undefined); + assert.equal(folder.title, undefined); + } +}); + +test('collapse hides descendants only and joins follow the visible tree', () => { + assert.deepEqual(visibleNodes(nodes, new Set()), nodes); + assert.deepEqual(visibleNodes(nodes, new Set(['/'])), [nodes[0]]); + const collapsed = visibleNodes(nodes, new Set(['/draft'])); + assert.ok(collapsed.some((node) => node.path === '/draft')); + assert.ok(!collapsed.some((node) => node.path.startsWith('/draft/'))); + for (const shown of [nodes, collapsed]) { + for (const node of shown) { + const prefix = treePrefix(node, nodes, shown); + assert.equal(prefix.length, (node.depth - 1) * 4); + if (node.parent) assert.ok(prefix.endsWith(childrenOf(shown, node.parent).at(-1) === node ? '└── ' : '├── ')); + } + } +}); + +test('embedded data cannot close its JSON script and roundtrips exact content', () => { + const input = documentTree([{ id: 'source', title: '', href: '/book/source/', html: '</script><script>alert(1)</script>\u2028&' }]); + const serialized = serializeTree(input); + assert.equal(serialized.includes('<'), false); + assert.deepEqual(JSON.parse(serialized), input); +});