-
+
Skip ↗
+
+
+
+
- {objects.map((object, index) => {
- const root = object.path === '/';
- const children = childrenOf(object.path);
- const prefix = treePrefix(object, initialVisibleObjects);
- return
- {!root &&
}
-
item.path === object.path) + 1}
- aria-selected={object.path === initialObject.path ? 'true' : 'false'}
- tabindex={object.path === initialObject.path ? 0 : -1} disabled
- >
- {root ?
-
-
-
-
-
-
-
- {'Tap → '.repeat(8)}
- {'Tap → '.repeat(8)}
+ {objects.map((object, index) => {
+ const root = object.path === '/';
+ const children = childrenOf(objects, object.path);
+ const Tag = object.href ? 'a' : 'button';
+ return
+ {!root &&
}
+
item.path === object.path) + 1}
+ aria-selected={object.path === initialObject.path}
+ tabindex={object.href ? 0 : -1}
+ >
+ {root ?
+
+
+
+
+
+
+
+ {'Tap → '.repeat(8)}
+ {'Tap → '.repeat(8)}
+
+
-
-
-
-
-
-
- : <>{object.name} {children.length > 0 && {collapsed.has(object.path) ? '+' : '−'} }>}
-
- {root && wip / field-guide }
- ;
- })}
+
+
+
+
+ : <>{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 / body READY
-
{initialObject.title}
-
項目を選ぶと公開情報を確認できます。「本文を読む」で、判断基準とチェックリストを取得してください。
-
-
- このサイトの実用ガイドです。操作はブラウザ内で完結し、外部への変更は行いません。
-
-
-
-
ガイドを読む JavaScriptなしでも、すべての本文をここから読めます。
{articles.map((article) => {article.title} {article.body} {article.sources?.map((source) => {source.label} ↗ )}
)}
+
+
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。
-
-
-
- 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: '\u2028&' }]);
+ const serialized = serializeTree(input);
+ assert.equal(serialized.includes('<'), false);
+ assert.deepEqual(JSON.parse(serialized), input);
+});