fix: reduce landing to existing documents and their tree
Reference site / build (push) Canceled after 0s

This commit is contained in:
2026-10-13 19:06:31 +09:00
parent 822272cad3
commit 6e9e675c70
16 changed files with 479 additions and 1348 deletions
-12
View File
@@ -1,12 +0,0 @@
---
// Overrun is clipped inside complete mono cells. No JS measurement, SVG,
// borders, or background gradients are needed, including for static HTML.
const horizontal = '─'.repeat(160);
const vertical = '│\n'.repeat(160).trimEnd();
---
<div class="box-frame" aria-hidden="true">
<span class="box-edge box-top"><span class="box-corner">┌</span><span class="box-fill">{horizontal}</span><span class="box-corner">┐</span></span>
<span class="box-side box-left"><span class="box-column">{vertical}</span></span>
<span class="box-side box-right"><span class="box-column">{vertical}</span></span>
<span class="box-edge box-bottom"><span class="box-corner">└</span><span class="box-fill">{horizontal}</span><span class="box-corner">┘</span></span>
</div>
+52 -87
View File
@@ -1,17 +1,18 @@
---
import { objects, initialObject, childrenOf, treePrefix, objectSignature, interfaceSignature, interfaceReference, initialCollapsedPaths, initialVisibleObjects } from '../lib/landing-demo';
import { href, bookHref } from '../lib/book';
import { documents, bookHref, title } from '../lib/book';
import { documentTree, childrenOf, treePrefix, serializeTree } from '../lib/landing-tree';
import '../styles/landing.css';
import BoxFrame from './BoxFrame.astro';
import TreePrefix from './TreePrefix.astro';
import TreeGlyphs from './TreeGlyphs.astro';
import { trunkPath, stemWidth, branchY, branchThickness } from '../lib/landing-glyphs.mjs';
const initialPaths = new Set(initialVisibleObjects.map((object) => object.path));
const collapsed = new Set(initialCollapsedPaths);
const articles = objects.filter((object) => object.body);
const trunkLength = (initialVisibleObjects.findLastIndex((object) => object.depth === 2) - 1) * 24 + branchY + branchThickness / 2;
const objects = documentTree((await documents()).map((entry) => {
if (!entry.rendered) throw new Error(`Missing rendered content: ${entry.id}`);
return { id: entry.id, title: title(entry), href: bookHref(entry.id), html: entry.rendered.html };
}));
const initialObject = objects.find((object) => object.href)!;
const trunkLength = (objects.findLastIndex((object) => object.depth === 2) - 1) * 24 + branchY + branchThickness / 2;
---
<section class="landing" aria-label="WIP実装ガイドのWorldspace" data-landing-stage data-book-base={href('book/')}>
<section class="landing" aria-label="WIP" data-landing-stage>
<TreeGlyphs />
<div class="intro-art" aria-hidden="true">
<svg class="orbit" viewBox="-300 -300 600 600" focusable="false">
@@ -19,92 +20,56 @@ const trunkLength = (initialVisibleObjects.findLastIndex((object) => object.dept
<text><textPath href="#welcome-orbit" textLength="1080" lengthAdjust="spacing">WELCOME TO WIP  /  DISCOVERY  /  UNDERSTANDING  /  ACTION  / </textPath></text>
</svg>
</div>
<div class="intro-invitation"><span>EVERY WORLD BEGINS AT A ROOT.</span><p>Click · Scroll · Press any key</p></div>
<button class="intro-skip" type="button">アニメーションをスキップ ↗</button>
<div class="landing-content">
<div class="explorer-heading" data-reveal>
<div><p class="eyebrow">WIP / IMPLEMENTATION FIELD GUIDE</p><h2>設計から運用まで、<br class="mobile-only" />判断の手がかりを探す。</h2></div>
<span class="demo-badge">4 AREAS / {articles.length} GUIDES / {objects.length} OBJECTS</span>
</div>
<div class="explorer">
<div class="tree-pane">
<div class="tree-caption" data-reveal><span>01 / WORLDSPACE</span><span>↑ ↓ navigate · ← → expand</span></div>
<div class="tree-toolbar" data-reveal>
<label class="tree-search-label">コンテンツを検索<input type="search" class="tree-search" placeholder="名前・説明・本文" aria-controls="guide-tree" disabled /></label>
<div class="tree-actions"><button type="button" data-expand-all disabled>すべて展開</button><button type="button" data-collapse-all disabled>たたむ</button><span data-tree-count aria-live="polite">{initialVisibleObjects.length} / {objects.length} objects</span></div>
</div>
<div class="tree-viewport">
<div id="guide-tree" class="demo-tree" role="tree" aria-label="WIP実装ガイド">
<button class="intro-skip" type="button" aria-label="アニメーションをスキップ">Skip ↗</button>
<div class="landing-content explorer">
<div class="tree-pane">
<div class="tree-viewport">
<div class="demo-tree" role="tree" aria-label="contents">
<div class="tree-scene">
{objects.map((object, index) => {
const root = object.path === '/';
const children = childrenOf(object.path);
const prefix = treePrefix(object, initialVisibleObjects);
return <div hidden={!initialPaths.has(object.path)} class:list={['node-row', { 'root-slot': root }]}>
{!root && <TreePrefix prefix={prefix} id={index} />}
<button
type="button" role="treeitem" class:list={['demo-node', { 'root-mark': root }]}
data-path={object.path} aria-label={root ? 'ルート /' : object.name}
aria-description={object.description} aria-level={object.depth} aria-expanded={children.length ? !collapsed.has(object.path) : undefined}
aria-setsize={root ? 1 : childrenOf(object.parent!).length}
aria-posinset={root ? 1 : childrenOf(object.parent!).findIndex((item) => item.path === object.path) + 1}
aria-selected={object.path === initialObject.path ? 'true' : 'false'}
tabindex={object.path === initialObject.path ? 0 : -1} disabled
>
{root ? <svg class="root-vector" viewBox="0 0 48 64" aria-hidden="true" focusable="false">
<g class="slash-art">
<defs>
<mask id="tap-edge-mask" maskUnits="userSpaceOnUse" x="-8" y="4" width="64" height="56" mask-type="alpha">
<rect x="-8" y="4" width="64" height="56" fill="white" />
</mask>
</defs>
<g class="tap-mask" mask="url(#tap-edge-mask)">
<g class="tap-stream tap-stream-a" transform="translate(5 60) rotate(-66.801409)"><text x="-18" y="0">{'Tap → '.repeat(8)}</text></g>
<g class="tap-stream tap-stream-b" transform="translate(22 60) rotate(-66.801409)"><text x="-18" y="0">{'Tap → '.repeat(8)}</text></g>
{objects.map((object, index) => {
const root = object.path === '/';
const children = childrenOf(objects, object.path);
const Tag = object.href ? 'a' : 'button';
return <div class:list={['node-row', { 'root-slot': root }]}>
{!root && <TreePrefix prefix={treePrefix(object, objects)} id={index} />}
<Tag
href={object.href} type={object.href ? undefined : 'button'}
role="treeitem" class:list={['demo-node', { 'root-mark': root }]}
data-path={object.path} aria-label={root ? 'ルート /' : object.name}
aria-level={object.depth} aria-expanded={children.length ? true : undefined}
aria-setsize={root ? 1 : childrenOf(objects, object.parent!).length}
aria-posinset={root ? 1 : childrenOf(objects, object.parent!).findIndex((item) => item.path === object.path) + 1}
aria-selected={object.path === initialObject.path}
tabindex={object.href ? 0 : -1}
>
{root ? <svg class="root-vector" viewBox="0 0 48 64" aria-hidden="true" focusable="false">
<g class="slash-art">
<defs>
<mask id="tap-edge-mask" maskUnits="userSpaceOnUse" x="-8" y="4" width="64" height="56" mask-type="alpha">
<rect x="-8" y="4" width="64" height="56" fill="white" />
</mask>
</defs>
<g class="tap-mask" mask="url(#tap-edge-mask)">
<g class="tap-stream tap-stream-a" transform="translate(5 60) rotate(-66.801409)"><text x="-18" y="0">{'Tap → '.repeat(8)}</text></g>
<g class="tap-stream tap-stream-b" transform="translate(22 60) rotate(-66.801409)"><text x="-18" y="0">{'Tap → '.repeat(8)}</text></g>
</g>
<path class="root-shape" d="M31 4H41L17 60H7Z" />
</g>
<path class="root-shape" d="M31 4H41L17 60H7Z" />
</g>
<g class="trunk-anchor" transform="translate(12 64) scale(2.6666666667)" data-stem-width={stemWidth} data-branch-y={branchY} data-branch-thickness={branchThickness}>
<defs><mask id="trunk-growth-mask" maskUnits="userSpaceOnUse" x={-stemWidth} y="0" width={stemWidth * 2} height="10000" mask-type="alpha"><rect class="trunk-reveal" x={-stemWidth} y="0" width={stemWidth * 2} height={trunkLength} fill="white" /></mask></defs>
<g mask="url(#trunk-growth-mask)"><path class="trunk-line" d={trunkPath} transform={`scale(1 ${trunkLength})`} /></g>
</g>
</svg> : <><span class="node-label">{object.name}</span>{children.length > 0 && <span class="node-indicator" aria-hidden="true">{collapsed.has(object.path) ? '+' : '−'}</span>}</>}
</button>
{root && <span class="root-caption" data-reveal>wip / field-guide</span>}
</div>;
})}
<g class="trunk-anchor" transform="translate(12 64) scale(2.6666666667)" data-stem-width={stemWidth} data-branch-y={branchY} data-branch-thickness={branchThickness}>
<defs><mask id="trunk-growth-mask" maskUnits="userSpaceOnUse" x={-stemWidth} y="0" width={stemWidth * 2} height="10000" mask-type="alpha"><rect class="trunk-reveal" x={-stemWidth} y="0" width={stemWidth * 2} height={trunkLength} fill="white" /></mask></defs>
<g mask="url(#trunk-growth-mask)"><path class="trunk-line" d={trunkPath} transform={`scale(1 ${trunkLength})`} /></g>
</g>
</svg> : <><span class="node-label">{object.name}</span>{children.length > 0 && <span class="node-indicator" aria-hidden="true">−</span>}</>}
</Tag>
</div>;
})}
</div>
</div>
</div>
<p class="tree-help" data-reveal>階層を辿る · 項目を選ぶ · 本文を読む。検索はこのガイド全体が対象です。</p>
</div>
<div class="explorer-inspector">
<section class="ascii-panel text-panel" aria-labelledby="text-view-title" data-reveal>
<BoxFrame />
<div class="panel-title"><h3 id="text-view-title">02 / Observe</h3><span>PUBLIC INFORMATION</span></div>
<p class="selected-description" data-description>{initialObject.description}</p>
<details class="signature-details"><summary>Object signature</summary><pre data-object-signature>{objectSignature(initialObject)}</pre></details>
</section>
<section class="ascii-panel call-panel" aria-labelledby="call-result-title" data-reveal>
<BoxFrame />
<div class="panel-title"><h3 id="call-result-title">03 / Read</h3><span>EXPLICIT OPERATION</span></div>
<div class="call-target"><span>TARGET</span><code data-target>{initialObject.path}</code></div>
<details class="signature-details"><summary>Interface signature</summary><pre class="interface-signature" data-interface-signature>{interfaceSignature(initialObject)}</pre></details>
<div class="call-controls"><code data-operation>{interfaceReference(initialObject)}#read_body</code><button type="button" class="demo-call" disabled>本文を読む <span aria-hidden="true">↗</span></button></div>
<div class="result-shell">
<div class="result-label"><span>RETURN / body</span><span data-call-state>READY</span></div>
<h4 class="result-title" data-result-title>{initialObject.title}</h4>
<pre class="guide-body" data-result tabindex="0" aria-label="選択したガイドの本文">項目を選ぶと公開情報を確認できます。「本文を読む」で、判断基準とチェックリストを取得してください。</pre>
<div class="guide-sources" data-sources hidden><span>詳しい仕様</span>{initialObject.sources?.map((source) => <a href={bookHref(source.document)}>{source.label} ↗</a>)}</div>
</div>
<p class="demo-status" role="status" aria-live="polite" aria-atomic="true">このサイトの実用ガイドです。操作はブラウザ内で完結し、外部への変更は行いません。</p>
</section>
</div>
</div>
<div class="explorer-footer" data-reveal><span>NOT A LIST OF TOOLS. A WORLD TO EXPLORE.</span><a href={href('book/')}>Read the Book <span aria-hidden="true">↗</span></a></div>
<noscript><section class="static-guides"><h3>ガイドを読む</h3><p class="no-script-note">JavaScriptなしでも、すべての本文をここから読めます。</p>{articles.map((article) => <details><summary>{article.title}</summary><pre class="guide-body">{article.body}</pre><p>{article.sources?.map((source) => <a href={bookHref(source.document)}>{source.label} ↗ </a>)}</p></details>)}</section></noscript>
<article class="selected-content prose" data-selected-content data-reveal aria-label={initialObject.title} tabindex="0" set:html={initialObject.html} />
</div>
<script type="application/json" id="landing-documents" set:html={serializeTree(objects)} />
</section>
<script>
import { initLanding } from '../scripts/landing';
+3 -3
View File
@@ -32,14 +32,14 @@ const canonical = Astro.site ? new URL(Astro.url.pathname, Astro.site) : undefin
<body class:list={{ 'landing-page': landing }}>
<a class="skip-link" href="#main">本文へスキップ</a>
<header class="site-header">
<a class="brand" href={href()} aria-label="WIP トップページ"><span class="brand-mark">W</span><span>WIP<span class="brand-caption">Web Interface Protocol</span></span></a>
<a class="brand" href={href()} aria-label="WIP トップページ"><span class="brand-mark">W</span><span>WIP{!landing && <span class="brand-caption">Web Interface Protocol</span>}</span></a>
<nav aria-label="メインナビゲーション">
<a href={href()} aria-current={!book ? 'page' : undefined}>Home</a>
{!landing && <a href={href()} aria-current={!book ? 'page' : undefined}>Home</a>}
<a href={href('book/')} aria-current={book ? 'true' : undefined}>Book</a>
<a href={repository}>Source <span aria-hidden="true">↗</span></a>
</nav>
</header>
<slot />
<footer class="site-footer"><span>WIP / Web Interface Protocol</span><span>文書を正本に、ひとつのWorldspaceを。</span><a href={repository}>ソースと変更履歴 ↗</a></footer>
{!landing && <footer class="site-footer"><span>WIP / Web Interface Protocol</span><span>文書を正本に、ひとつのWorldspaceを。</span><a href={repository}>ソースと変更履歴 ↗</a></footer>}
</body>
</html>
-480
View File
@@ -1,480 +0,0 @@
// Original practical guidance for this site's local Worldspace, not a wire codec.
export interface ContentNode {
name: string;
description: string;
title?: string;
body?: string;
sources?: { document: string; label: string }[];
children?: ContentNode[];
open?: boolean;
}
export const rootContent: ContentNode = {
name: '',
description: 'このサイトで読む、WIPの設計・実装・運用・レビューの実践ガイド。仕様上の必須事項と実装の工夫を分け、判断と確認の手順を辿る。',
open: true,
children: [
{
name: 'design', description: '何を空間として公開するか。名前、内包関係、操作契約、可視性を先に決める。', open: true,
children: [
{
name: 'object-tree', description: '探索できる配置を設計する。住所と同一性、所有と関連、列挙の費用を混同しない。', open: true,
children: [
{
name: 'paths', description: '利用者が辿る座標を決め、移動や改名で変わるものを明確にする。', open: true,
children: [
{
name: 'names', description: '表示用タイトルと区別して、長く使える名前とパスを決める。', title: 'パスと名前を決める',
body: `決めること
最初に「利用者は何を起点に、どの単位を探すか」を書く。nameは親の下の識別子であり、見出しではない。このガイドでは短いASCII名を採用するが、これは編集方針であってWIPの文字制限ではない。
仕様上の必須事項
・rootのnameは空文字列、pathは /。
・非root名は空にせず、/ を含めず、. と .. を使わない。同じ親の直下では重複させない。
・pathはrootからnameを連結する。空segmentやroot以外の末尾 / を許さず、Unicode正規化で別名を同一視しない。
設計の手順
・人向けの短い案内はdescriptionへ、長い本文やtitleはOperationの結果へ分ける。
・改名は子孫と同じObjectの全配置のpathに影響する。保存済みの参照をどう再発見するか、変更前に確認する。
・pathを恒久IDとして扱わず、変更理由と影響範囲を設計記録に残す。`,
sources: [{ document: '2.1-object-tree-schema', label: 'Object / エントリ / path:name・pathの構成' }, { document: '3-protocol', label: 'Path' }],
},
{
name: 'identity', description: 'path、ref、validatorを別の役割として設計する。', title: '住所と同一性を分ける',
body: `仕様上の区別
pathは配置の座標、refは同じWorldspace内で同一Objectを示す任意のmarker、validatorは状態の確認に使うopaqueな値である。どれも相互の代用品にはならない。refがない、または異なることだけから、別Objectだとは断定できない。
設計チェック
・同じrefを複数配置へ返すなら、同じ現在状態を共有する。同じrefとvalidatorでname・description・Interface集合が矛盾しないか確認する。
・childrenは配置ごとの観測であり、refによる同一性へ含めない。
・refをnavigationに使わず、再起動やsessionを越えた永続性を利用者へ約束しない。
実装の工夫
配置の記録とObject状態の記録を分けると、改名や複数配置の影響を追いやすい。ただし保存形式は自由であり、共有recordを必須にしない。refの欠落を埋めるためにpathから疑似IDを作らず、追跡できない範囲を明示する。`,
sources: [{ document: '2.1-object-tree-schema', label: '同じrefを持つObject・childrenの意味' }, { document: '4-client', label: 'Objectの同一性・観測整合性' }],
},
],
},
{
name: 'ownership', description: '親子を内包関係に絞り、関連先へは型付きの参照で移動する。',
children: [
{
name: 'containment', description: '親の責務と子の寿命から、自然な内包単位を選ぶ。', title: '親子関係の根拠を書く',
body: `仕様上の意味
Treeの親子は所有または内包を表す。便利なリンク集を作るためだけに、依存先や関連先を子として並べない。Objectは公開用のprojectionであり、内部のデータ構造をそのまま露出する必要もない。
設計の手順
・候補の親ごとに「この親が何を内包するか」を一文で書く。
・親が消える場合、公開中の配下やscopeをどう終了させるか確認する。親不在の孤立subtreeを残さない。
・管理単位が違う関連先はOperationの結果へ分離する。
・同じ対象を複数配置するならnameと同一性の整合性を別途確認する。
実装の工夫
画面上の分類とHostの所有関係は別の図にすると、見せ方の都合で階層を増やす失敗を避けやすい。利用者が次の行動を選べるdescriptionを添え、階層を追加するたびに取得費用と説明責任が増えることをレビューする。`,
sources: [{ document: '2.1-object-tree-schema', label: 'Tree・Interface scope' }],
},
{
name: 'relations', description: '非所有の関係を、意味の明確な結果とentryで公開する。', title: '関連先を返す契約を作る',
body: `仕様上の必須事項
関連、依存、参照は親子と区別する。Worldspace上の位置を結果に含める場合はDescriptorでentry型として宣言する。普通のstringやjsonの中にあるパスらしい文字列を、Clientが参照として推測してはならない。
契約のチェック
・Operation名、field名、または関係を表す型で「なぜ関連するか」を説明する。
・返すentryは実行対象の子である必要はない。兄弟や別の位置への参照でも、内包関係を暗黙に追加しない。
・参照先の公開情報を取得するタイミングと、結果を提示するタイミングを分ける。
実装の工夫
関係の種類と絞り込み条件を明示すると、関連先の無制限取得を避けやすい。結果に含まれた全entryを即座に観測する必要はない。利用者が選んだ参照だけを必要な深さで取得し、取得失敗は元のOperationの成否と切り離して表示する。`,
sources: [{ document: '2.2-operation-and-interface', label: 'Operation Resultとエントリの発見' }, { document: '4-client', label: '一時的な発見とpin・Failure handling' }],
},
],
},
{
name: 'indexing', description: '安価に完全観測できる子だけをTreeへ置き、大量集合は検索で扱う。', title: '列挙の境界を決める',
body: `仕様上の境界
indexable childrenは、現実的な件数と費用で列挙でき、内包として自然な集合にする。大量の対象をすべてTreeへ展開することを前提にしない。検索、filter、paginationは集合を扱うObjectのOperationで設計し、必要なentryを返す。
設計チェック
・通常件数だけでなく、最大件数と取得時間を見積もる。
・observeの成功はrequested depthまで完全である。勝手に途中で切った一覧を成功として返さない。
・完全な観測をbudget内で作れない場合はResourceLimitExceededになる。observeに独自のlimitやtruncatedを足さない。
実装の工夫
浅い観測で探索の入口が分かるdescriptionを用意し、具体的な対象の発見は条件付きの検索へ誘導する。検索結果のcursorや件数上限はdomain契約として決める。最初の画面を速くするために全件を先読みするのではなく、利用者が必要とする範囲を測定する。`,
sources: [{ document: '2.1-object-tree-schema', label: 'Indexable children・observe' }, { document: '3-protocol', label: 'observe・Resource limit' }],
},
],
},
{
name: 'interfaces', description: '操作をcapabilityごとの契約へまとめ、scopeと型を明示する。',
children: [
{
name: 'scope', description: '公開位置とInterface参照の有効範囲を揃える。', title: 'scopeを配置から決める',
body: `仕様上の必須事項
Interface参照はscopeとnameの構造化recordである。scopeは対象の配置path自身か祖先とし、segment単位で判定する。文字列prefixが似ているだけでは祖先ではない。scopeの適合は自動継承でも、実行権限の証明でもない。
設計チェック
・対象Objectのinterfacesへ、利用する参照を明示する。
・共通の操作をどのscopeで共有するか決め、同じscope内のlocal name衝突を検出する。
・scope Objectの移動・削除・差し替えで旧参照の有効期間が終了することを確認する。
実装の工夫
公開座標と内部座標の対応表を持つと、Host合成時のscope変換を検証しやすい。HTTP endpointのprefixをWorldspace scopeへ加えない。scope_refが得られる場合は観測のmetadataとして保持し、識別子の第三成分にしない。refがない公開形態も有効であり、追跡不能な差し替えまで検出できるとは約束しない。`,
sources: [{ document: '2.1-object-tree-schema', label: 'Interface scope' }, { document: '2.2-operation-and-interface', label: 'Interfaceの識別とscope・Scope Objectと有効期間' }],
},
{
name: 'contracts', description: '名前付き引数、結果の型、domain failureを契約にする。', title: '操作の入出力を設計する',
body: `仕様上の必須事項
Operationは名前付きparameterとreturn typeを持つ。引数なしでもcallには空のrecordを渡す。位置引数、未知field、型に合わない値を受理しない。domain上の失敗はreturn typeのUnion caseで表し、Protocol failureへ混ぜない。
契約を決める手順
・利用者の目的を一文で書き、入力、成功結果、domain上の拒否理由を分ける。
・必須fieldと省略可能fieldを区別する。省略をnullや暗黙のdefaultと同一視しない。
・Worldspace参照はentry、任意の文章はstringとして宣言する。
・用途を短いdocumentationで説明し、長い本文をDescriptorへ詰め込まない。
実装の工夫
将来fieldを追加したい結果にはrecordを基本とする。実装コード上の宣言からDescriptorとvalidationを生成する方針を取り、手書き表示を契約の正本にしない。このガイドは具体的なSDKのAPIや導入commandを規定しない。`,
sources: [{ document: '2.2-operation-and-interface', label: 'Operation declaration・Protocol failureとdomain failure' }, { document: '3-protocol', label: 'Value validation' }],
},
],
},
{
name: 'publication', description: '見せる対象と実行を許す操作を、別々に設計する。', title: '公開と認可を分ける',
body: `仕様上の境界
Worldspaceへの公開は、主体が対象の存在と公開情報を認識できることを意味する。公開されないObjectは存在しないものとして扱い、観測はNotFoundにする。省略数やredaction markerで隠した対象を漏らさない。
設計チェック
・Objectを隠す、Interfaceを外す、Operationを宣言しない、公開した操作の実行を拒否する、の各判断を分ける。
・公開済み操作の実行拒否はPermissionDenied。存在を隠すためのObject観測には使わない。
・descriptionにはその主体へ開示できる情報だけを入れる。
実装の工夫
主体ごとの公開表と実行認可表を別々に作ると、画面で見えたことを許可と誤認しにくい。頻繁に変わるdomain状態をInterface集合へ同期させる設計は避け、必要な状態はdomain固有のOperationで読む。最終的な認可と実行条件はcall時にHostが確認する。`,
sources: [{ document: '2.1-object-tree-schema', label: 'Objectの可視性' }, { document: '2.2-operation-and-interface', label: 'Operationの可視性' }],
},
],
},
{
name: 'build', description: 'Host、Client、HTTPの境界を実装し、観測と実行の契約を守る。', open: true,
children: [
{
name: 'host', description: '完全な観測、固定した実行条件、opaqueなvalidatorを提供する。', open: true,
children: [
{
name: 'observations', description: 'requested depthとchildrenの省略規則を正しく実装する。', title: '観測responseを組み立てる',
body: `仕様上の必須事項
observeの起点はdepth 0。requested depth未満のnodeには完全な直接indexable childrenを含め、境界ではchildrenを省略する。空arrayは観測済みの空集合、省略は未観測であり、意味が違う。非rootの起点名はrequest pathの末尾segmentと一致させる。
実装チェック
・主体へ公開しないObjectと、そのObjectだけを経由する配下を除く。
・兄弟のname重複と各配置のscope制約を検証する。
・要求より深いnodeや、要求していないDescriptorを同梱しない。
・budget超過時に不完全な成功を返さない。
実装の工夫
観測を組み立てる処理と公開情報の取得処理を分けると、深さと可視性のテストを共有しやすい。テストはdepth 0、空の子、境界の省略、複数段の完全性を用意する。画面の折りたたみ状態とProtocolのchildren coverageを同じflagで管理しない。`,
sources: [{ document: '3-protocol', label: 'observe' }, { document: '2.1-object-tree-schema', label: 'Objectの可視性・childrenの意味' }],
},
{
name: 'dispatch', description: '呼出先と契約を一貫した時点で固定し、途中の再解決を防ぐ。', title: '実行条件を固定する',
body: `仕様上の必須事項
Hostは一つのlinearization pointで対象、Interface membership、scopeの紐付き、validator、Descriptor、implementation、backend対象を解決し、preconditionを確認する。call途中に別のObjectや新しい契約へ解決し直してはならない。
実装チェック
・失敗の判定順はProtocolに従い、古いvalidatorを引数エラーより先に扱う。
・argumentsとresultを、固定した同じOperation宣言で検証する。
・認可も固定した対象と主体contextへ適用する。
・条件付きcommitではvalidator比較とcommitをatomicにする。
実装の工夫
固定した観測をcall専用のcontextにまとめると、途中の設定変更との混線を避けやすい。effect後に結果の検証やencodingが失敗した場合、未commitを保証できなければOperationOutcomeUnknownにする。単にInternalで包んで安全な再実行を期待しない。`,
sources: [{ document: '3-protocol', label: 'call_operation' }],
},
{
name: 'validators', description: 'Object状態とDescriptorの版を別の条件として扱う。', title: 'validatorの責務を分ける',
body: `仕様上の区別
Object validatorは対象状態、Interface validatorはDescriptorの観測に対応する。opaque bytesの完全一致で比較し、値から新旧や内容を推測しない。HTTP ETag等のmetadataは別物であり、bodyのWIP validatorを代用しない。
実装チェック
・変更可能なInterfaceはvalidatorを返し、Descriptor変更時に以前と異なる値を発行する。
・validatorを返さないInterfaceのDescriptorは、その有効期間中immutableにする。
・Clientは取得したInterface validatorをcallへ必ずコピーする。
・scope_refはscope Objectとの紐付きであり、状態validatorの代わりに使わない。
実装の工夫
生成方法にはrevisionやhashを利用できるが、Clientへその意味を公開契約として要求しない。対応する参照と観測を一緒に保存し、値だけの全体cacheを作らない。Object validatorを省略する形態も許されるため、提供しない保証を実装側で勝手に補わない。`,
sources: [{ document: '3-protocol', label: 'Interface参照とvalidator・call_operation' }, { document: '5-transports', label: 'Validator' }],
},
],
},
{
name: 'client', description: '検証した観測を保持し、必要な取得だけを計画する。',
children: [
{
name: 'cache', description: 'Known Spaceを現在保持するcacheとして扱い、replicaと区別する。', title: '観測cacheを更新する',
body: `仕様上の境界
Known Spaceは現在保持しているエントリであり、過去の全発見を永久蓄積する集合でもHostのreplicaでもない。認証主体やtenant等で異なるeffective Worldspace viewの観測を混在させない。HostはClientのcacheを追跡・更新しない。
更新チェック
・childrenが存在する観測では、直接の観測済みedgeを完全な一覧で置き換える。
・children省略時には既存edgeを削除しない。
・pathのprefixだけから未観測の親子edgeを作らない。
・一覧からedgeが消えたことをObject削除の証拠にしない。
実装の工夫
取得、保持、提示の状態を分けると、表示を省いた情報まで失う事故を防げる。pinや実行中callの参照は必要に応じて保持し、不要なsubtreeは解放する。古い観測であることを利用者へ示し、最新性が必要な行動には明示的な再観測を用意する。`,
sources: [{ document: '4-client', label: 'Known Space・取得保持提示の分離・Objectとchildrenの観測' }],
},
{
name: 'descriptors', description: 'Descriptorを検証してからcacheし、実行中の版を保持する。', title: '操作契約を受理する',
body: `仕様上の必須事項
fetch_interfaceのresponseは要求した構造化参照と一致しなければならない。Descriptorの構造、意味、対応formatを検証してから使用する。validだが未対応のformatはUnsupportedDescriptorFormatであり、Protocol違反のInvalidResponseと分ける。
実装チェック
・cache keyは同じview内のscopeとnameの組。local nameや表示文字列だけを使わない。
・同じ有効期間で同じ参照とvalidatorに異なるDescriptorを受理しない。
・scopeの紐付きが確認できなくなった旧観測を、新しいcallへ付け替えない。
・実行中callの結果検証には開始時のDescriptorを保持する。
実装の工夫
検証済み観測と未検証responseを別の型で扱うと、UIやcallへの流入を防ぎやすい。新しい版を取得しても、古い版を使う進行中callが完了するまで保持する。祖先を順に探すlookupや同名参照へのfallbackは追加しない。`,
sources: [{ document: '3-protocol', label: 'fetch_interface' }, { document: '4-client', label: 'Interface cache' }],
},
{
name: 'scheduling', description: 'interest、並列取得、古いresponseの扱いに上限と所有者を設ける。', title: '取得を増幅させない',
body: `仕様上の必須事項
Clientは保持量、進行中request、並列度、request rateにboundedな上限を設ける。responseを元requestとeffective Worldspace viewへ対応付け、後続requestが所有する状態を古いresponseで上書きしない。終了したrequestを進行中のまま残さない。
実装チェック
・interestをcoverageと進行中requestに照合し、不足する取得だけを発行する。
・明示的な再観測と、同じinterestを満たす調整を区別する。
・cache解放と再取得が、利用者の変更なしに自己維持loopにならないか確認する。
・取得用retryをcall_operationへ適用しない。
実装の工夫
requestごとの世代や所有範囲を保持すると、重なる観測のmergeを検証しやすい。並列化は独立したinteractionを使い、全量先読みを前提にしない。上限で取得を開始できないinterestはProtocol failureではなく、容量解放後に再評価する。`,
sources: [{ document: '4-client', label: 'Interestと取得計画' }, { document: '5-transports', label: 'Logical interactionとphysical exchange' }],
},
],
},
{
name: 'http', description: '通信URL、JSON encoding、WIPの意味を別々に検証する。',
children: [
{
name: 'endpoint', description: '任意のbase URLから標準routeを解決し、Worldspace座標をbodyへ置く。', title: 'HTTPの接続先を扱う',
body: `仕様上の必須事項
endpointはauthorityとhostを持つabsolute URLで、queryとfragmentを持たず、path末尾は /。各routeはこのbaseから v1/observe、v1/fetch_interface、v1/call_operation を解決し、すべてPOSTを使う。origin直下や特定prefixを仮定しない。
実装チェック
・利用者入力の末尾 / を補うなら、route解決前にcanonical化する。
・Worldspace pathとscopeはbodyのlogical fieldに置き、URL prefixを加えない。
・query付きrouteや余分なpath segmentを標準routeとして受理しない。
・Content-TypeとAcceptを標準bindingの規則に合わせる。
実装の工夫
任意の深いendpoint prefixで接続テストを行うと、root固定の実装を見つけやすい。TLS、認証方式、proxy設定はdeploymentの選択として記録し、WIPが特定方式を必須にしているとは説明しない。endpointや主体が変わればcacheも分離する。`,
sources: [{ document: '5.1-wip-over-http', label: 'Endpoint・HTTP request metadata' }],
},
{
name: 'codec', description: 'JSONの厳密な入力境界とsuccess/errorの区別を実装する。', title: 'JSONを契約どおりに交換する',
body: `仕様上の必須事項
JSONはUTF-8。duplicate memberとschemaで許可されないfieldを拒否し、Protocolのoptional fieldはnullでなく省略する。bytesは標準alphabetと必要なpaddingを持つcanonical base64で表す。listの順序は維持し、object member順は意味にしない。
実装チェック
・成功は200で対応responseを直接返し、共通のsuccess envelopeを追加しない。
・非2xxのProtocol failureは所定のerror envelopeで返す。
・Interface参照はscopeとnameのrecordにし、短縮表記stringをwireへ入れない。
・HTTP statusだけでProtocolErrorCodeを推測しない。
実装の工夫
decode、logical validation、上位層への受け渡しを分けてテストする。通常のJSON parserがduplicate memberを消してしまわないか確認する。未知Descriptor formatやjson型には正本の例外があるため、すべてのobjectへ一律に同じ未知field規則を適用しない。`,
sources: [{ document: '5.1-wip-over-http', label: 'HTTP response・JSON共通規則・Interface Reference' }],
},
],
},
],
},
{
name: 'operate', description: '障害を層ごとに診断し、再観測、照会、負荷制御の手順を整える。', open: true,
children: [
{
name: 'recovery', description: 'エラー分類から回復先を決め、結果不明の操作を再送しない。',
children: [
{
name: 'codes', description: 'Protocol、Transport、Client-local、domainの失敗を区別する。', title: '失敗した層を特定する',
body: `仕様上の分類
回復actionはvalidなProtocol errorのcodeから決め、messageやHTTP statusから推測しない。HTTP認証失敗、未知route、proxyの非JSON応答はTransport側の失敗であり、PermissionDeniedやNotFoundへ変換しない。
診断の手順
・元interactionとviewを確認し、受信したenvelopeがvalidか調べる。
・successやerrorの契約違反はClient-localなInvalidResponseとして隔離する。
・validだが未対応のDescriptor formatはUnsupportedDescriptorFormatとして区別する。
・domain failureはOperationのUnion結果として読む。
運用の工夫
記録には層、interaction、機械可読code、dispatchの可能性、再観測対象を分けて残す。HTTPだけの監視で操作成否を決めない。画面にも「接続できない」と「公開された操作が実行拒否された」を別に示し、利用者が認証設定とdomain条件のどちらを確認すべきか分かるようにする。`,
sources: [{ document: '4-client', label: 'Failure handling' }, { document: '5.1-wip-over-http', label: 'Routeとmetadataのfailure・HTTP response' }],
},
{
name: 'unknown-outcomes', description: '結果が確定しないcallは停止し、再観測やdomain照会へ進む。', title: '結果不明の操作を扱う',
body: `仕様上の禁止事項
OperationOutcomeUnknownは未実行やrollbackを保証しない。response不達、timeout、connection loss、local cancellationもcommit取消の証拠ではない。dispatchされた可能性があるcall_operationを自動再実行しない。read等の名前から安全なretryを推測しない。
対応の手順
・自動retryとproxyのreplayを止め、結果が未確定であると提示する。
・対象をstaleとして再観測するか、domain固有の状態照会を案内する。
・再観測で分からないeffectを「なかった」と断定しない。
・Transport不達を、HostからOperationOutcomeUnknownを受信したことにしない。
運用の工夫
担当者が確認すべき記録と照会経路を事前に用意する。InternalでもClientはOperationを自動再実行しない。結果entryの追加取得だけが失敗した場合は、成功済みの元操作を繰り返さず、参照先の取得として別に回復する。`,
sources: [{ document: '4-client', label: 'Failure handling' }, { document: '5-transports', label: 'Dispatchとreplay' }],
},
{
name: 'refresh', description: 'mismatchの種類に応じて、更新する観測を選ぶ。', title: '再観測の対象を選ぶ',
body: `仕様上の手順
ValidatorRequiredとValidatorMismatchは対象Object、InterfaceValidatorRequiredとInterfaceValidatorMismatchは対象Interfaceを再観測する。InterfaceMismatchではObjectを再観測し、使用したInterface observationも無効化して必要なら取得し直す。
回復チェック
・NotFoundでは対象Entryをstaleにし、必要に応じて親を再観測する。
・InterfaceNotFoundでは参照元Objectの現在の集合を確認する。
・InvalidRequestやInvalidArgumentsは、同じ入力を変更せず再送しない。
・同じOperationを再試行する判断をした場合も、先に新しい観測を得る。
運用の工夫
再観測は状態の更新であり、元操作の自動再実行とは別actionとしてUIと手順書に分ける。変更後の引数や対象を利用者が確認できるようにし、validatorを人間に手入力させない。古いDescriptorへ新しいscope_refだけを付け足す修復は行わない。`,
sources: [{ document: '4-client', label: '観測整合性・Failure handling' }],
},
],
},
{
name: 'budgets', description: '負荷の上限と保持方針を決め、制限に達した層を記録する。',
children: [
{
name: 'limits', description: 'Protocol budgetと通信設備の上限を別々に扱う。', title: '負荷上限を運用する',
body: `仕様上の境界
logicalな観測やValue等のbudgetと、HTTP serverやproxyのmessage size、timeout、queue上限は別である。設備の制限をResourceLimitExceededで包まない。完全な観測を作れないHostは、途中までの成功ではなくProtocolの制限エラーを返す。
運用チェック
・node数、深さ、DescriptorやValueの規模と、通信の時間・sizeを別々に測る。
・ResourceLimitExceededでは観測depth等のlogical request shapeを減らす。
・大規模集合を全量列挙せず、domainの検索・pagination設計を見直す。
・Operationの制限エラーを根拠に自動再実行しない。
実装の工夫
上限値はdeploymentごとに定め、実際のresponse規模と待ち時間で見直す。取得retryには回数、間隔、並列度の上限を設ける。WIPが特定の運用値を標準化しているとは説明せず、通信設備の拒否とHostのlogical制限を診断で区別する。`,
sources: [{ document: '3-protocol', label: 'Resource limit' }, { document: '5-transports', label: 'Transport運用との境界' }, { document: '4-client', label: 'Failure handling' }],
},
{
name: 'retention', description: 'cacheを解放しても、pinと実行中の検証に必要な観測を守る。', title: '保持量を制御する',
body: `仕様上の境界
Clientは観測をcacheとして保持するが、HostのObject lifetimeを変更しない。pinはClient内部の保持方針であり、Protocol上の固定やHost側の存続保証ではない。cache evictionをObject削除と混同しない。
運用チェック
・一時的な結果を使い終えたら、不要な観測を解放できるか確認する。
・pinした対象と実行中callが参照する対象を把握する。
・実行中callのDescriptorは結果検証の完了まで保持する。
・取得とevictionが互いを繰り返し起動するloopを検出する。
実装の工夫
容量だけでなく、保持理由と進行中request数も計測すると増加原因を追いやすい。画面を閉じたときのinterest変更と、観測を解放する判断を別々にする。発見した全entryを永久保存する方針に頼らず、用途ごとに一時保持と継続保持を選び、viewの切替時に混在を防ぐ。`,
sources: [{ document: '4-client', label: '一時的な発見とpin・Interestと取得計画・Interface cache' }],
},
],
},
{
name: 'contexts', description: '接続先や主体の変更時に観測と進行中requestを分離する。', title: '公開viewの切替を安全にする',
body: `仕様上の必須事項
endpoint、認証主体、tenant、capability等によって異なるeffective Worldspace viewのKnown Space、Object、Interface観測、validatorを混在させない。connection reuseやmultiplexingを使っても、この分離は失ってはならない。
運用の手順
・認証更新やtenant切替がviewを変える条件をdeploymentとして記録する。
・切替前に開始したrequestのresponseが、切替後のcacheへ入らないことを確認する。
・HTTP authentication failureと、公開済みOperationのPermissionDeniedを分けて扱う。
・旧観測のrefを新しいviewの同一性判定に使わない。
実装の工夫
内部のcache namespaceとrequest contextにviewを識別する情報を持たせ、切替テストで遅延responseを注入する。認証方式やTLS設定の具体値は運用で選ぶものであり、WIPの標準profileではない。診断messageにはcredential、backend path、非公開Objectや内部stack traceを含めない。`,
sources: [{ document: '5-transports', label: 'Effective Worldspace view' }, { document: '3-protocol', label: 'Protocol responseとfailure' }],
},
],
},
{
name: 'review', description: '設計の根拠と境界条件を、読めるチェック項目と試験証拠で確認する。', open: true,
children: [
{
name: 'model-checks', description: '配置と公開方針が、利用者の探索と情報境界に合っているか確認する。',
children: [
{
name: 'navigation', description: '名前・親子・大量集合を、利用者の行動から点検する。', title: '探索設計をレビューする',
body: `確認する証拠
rootから目的の対象へ辿る手順を一つ選び、各階層のdescriptionだけで次の行動を判断できるか読む。分類を増やしただけで有用な深さになったとは扱わず、親子の内包根拠と取得費用を確認する。
仕様チェック
・root名、兄弟名の一意性、canonical pathの構成を試験する。
・関連先は親子へ濫用せず、entry型の結果として宣言する。
・大量集合を全件childrenへ展開しない。
・同じrefとvalidatorを持つObjectのrepresentationを比較する。
レビューの工夫
改名、複数配置、非indexableな対象の直接観測を検討し、住所と同一性を混同していないか確認する。このサイトのASCII命名や折りたたみ方は表示・編集の方針であり、Protocolの一般要件として押し付けない。指摘には該当する契約と再現手順を添える。`,
sources: [{ document: '2.1-object-tree-schema', label: 'Object / エントリ / path・Tree・Indexable children' }],
},
{
name: 'disclosure', description: '公開範囲と実行認可を、異なる主体と拒否条件で試験する。', title: '情報公開をレビューする',
body: `確認する証拠
異なる主体に同じ対象を観測させ、公開表と実行認可表に沿った結果になるか確認する。見えるOperationを実行できるとは限らない。scopeが適合するだけでmembershipや認可を追加していないか調べる。
仕様チェック
・非公開Objectの観測はNotFoundで、redaction markerや省略数を漏らさない。
・公開しないInterfaceやOperationをDescriptorの表示から推測で補わない。
・公開した操作の実行拒否はPermissionDeniedとして扱う。
・descriptionやerror messageへ秘密情報を含めない。
レビューの工夫
URLを知っている場合、cacheに残っている場合、権限変更後のcallをそれぞれ試す。存在の認識、過去の観測、現在の実行許可は別の事実として証拠を残す。HTTP 401/403をWIPのPermissionDeniedへ変換していないことも、接続境界の試験で確認する。`,
sources: [{ document: '2.1-object-tree-schema', label: 'Objectの可視性' }, { document: '2.2-operation-and-interface', label: 'Operationの可視性' }, { document: '5.1-wip-over-http', label: 'Routeとmetadataのfailure' }],
},
],
},
{
name: 'boundary-checks', description: '入力・観測・実行・表示の境界を、失敗注入と競合試験で確認する。',
children: [
{
name: 'validation', description: '不正responseがcache・UI・Applicationへ流れないことを確認する。', title: 'validationと表示をレビューする',
body: `確認する証拠
正しい型を受理する試験だけでなく、duplicate member、未知field、欠落した必須field、null、省略、型違反を別々に入力する。Protocol structの厳密さと、Descriptorの未知formatやjson型の例外を混同しない。
仕様チェック
・不正なsuccess resultをApplicationへ渡さず、InvalidResponseとして扱う。
・未対応だがvalidなformatをUnsupportedDescriptorFormatと区別する。
・typed entryだけを参照として扱い、stringから推測しない。
・Text Viewは検証済みデータから生成し、表示textを正本や実行requestへ逆変換しない。
レビューの工夫
制御文字や区切り記号を含む名前も試し、表示の引用・escapeを確認する。Interface signatureの型やOperationを都合よく省略しない。文字列一致をidentityやvalidatorの代わりにせず、表示用の短縮参照をwireやcache keyへ流用していないか点検する。`,
sources: [{ document: '5.1-wip-over-http', label: 'JSON共通規則' }, { document: '3-protocol', label: 'fetch_interface・call_operation' }, { document: '4.2-text-view', label: '位置付け・Interface signature・名前と文字列' }],
},
{
name: 'failure-tests', description: '競合、response不達、予算超過で実行契約が変わらないことを確認する。', title: '失敗と競合をレビューする',
body: `確認する証拠
正常完了だけでは実行境界を証明できない。観測後のObject更新、Descriptor更新、scope差し替え、遅いresponse、commit後の応答生成失敗を注入し、各層がどの事実を保証できるか記録する。
仕様チェック
・validator不一致では実行せず、Protocolの判定順を守る。
・開始時に固定したDescriptorでargumentsとresultを検証する。
・後続requestが所有する状態を古いresponseで上書きしない。
・結果不明やTransport不達でcallを自動再実行しない。
・budget超過を不完全な観測成功で隠さない。
レビューの工夫
取得retry、HTTP client、proxyの設定まで確認し、logicalな一回のcallが複数dispatchされない証拠を残す。結果entryの付随取得失敗と元操作の成否は別々に試験する。保証できないeffectを未実行と報告せず、利用者が照会へ進めることまで確認する。`,
sources: [{ document: '3-protocol', label: 'call_operation・observe' }, { document: '4-client', label: 'Interestと取得計画・Failure handling' }, { document: '5-transports', label: 'Dispatchとreplay' }],
},
],
},
],
},
],
};
-72
View File
@@ -1,72 +0,0 @@
// This Worldspace contains the site's authored implementation guide. The local
// read operation demonstrates the interaction model; it is not a network Host.
import { rootContent, type ContentNode } from './landing-content.ts';
export interface DemoObject extends Omit<ContentNode, 'children'> {
path: string;
parent?: string;
depth: number;
interface?: string;
}
function flatten(node: ContentNode, parent?: string, depth = 1): DemoObject[] {
const path = parent === undefined ? '/' : `${parent === '/' ? '' : parent}/${node.name}`;
const { children = [], ...content } = node;
return [
{ ...content, path, parent, depth, interface: node.body ? 'document.read' : undefined },
...children.flatMap((child) => flatten(child, path, depth + 1)),
];
}
export const objects = flatten(rootContent);
export const initialObject = objects.find((object) => object.path === '/design/object-tree/paths/names')!;
export const childrenOf = (path: string) => objects.filter((object) => object.parent === path);
export const initialCollapsedPaths = objects.filter((object) => childrenOf(object.path).length && !object.open && object.path !== '/').map((object) => object.path);
/** Search retains the actual ancestor chain; it does not invent tree edges. */
export function visibleObjects(collapsed: ReadonlySet<string>, query = ''): DemoObject[] {
const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean);
if (terms.length) {
const found = new Set(['/']);
for (const object of objects) {
const text = [object.path, object.title, object.description, object.body].join('\n').toLocaleLowerCase();
if (!terms.every((term) => text.includes(term))) continue;
let cursor: DemoObject | undefined = object;
while (cursor) {
found.add(cursor.path);
cursor = objects.find((item) => item.path === cursor!.parent);
}
}
return objects.filter((object) => found.has(object.path));
}
return objects.filter((object) => {
let parent = object.parent;
while (parent) {
if (collapsed.has(parent)) return false;
parent = objects.find((item) => item.path === parent)?.parent;
}
return true;
});
}
export const initialVisibleObjects = visibleObjects(new Set(initialCollapsedPaths));
/** Four mono cells per ancestor, with joins derived from the currently shown tree. */
export function treePrefix(object: DemoObject, visible: readonly DemoObject[] = objects): string {
if (!object.parent) return '';
const isLast = (item: DemoObject) => visible.filter((other) => other.parent === item.parent).at(-1)?.path === item.path;
let prefix = isLast(object) ? '└── ' : '├── ';
let parent = objects.find((item) => item.path === object.parent);
while (parent?.parent) {
prefix = (isLast(parent) ? ' ' : '│ ') + prefix;
parent = objects.find((item) => item.path === parent!.parent);
}
return prefix;
}
export const interfaceReference = (object: DemoObject) => object.interface ? `/::${object.interface}` : undefined;
export function objectSignature(object: DemoObject): string {
const references = object.interface ? JSON.stringify(interfaceReference(object)) : '';
return `object at ${JSON.stringify(object.path)} {\n name: ${JSON.stringify(object.name)};\n description: ${JSON.stringify(object.description)};\n interfaces: [${references}];\n}`;
}
export function interfaceSignature(object: DemoObject): string {
if (!object.interface) return '// この項目はコンテンツのまとまりです。\n// 子Objectを選んで内容を探してください。';
return `interface ${JSON.stringify(interfaceReference(object))} {\n operation read_body() -> { body: string };\n}`;
}
+63
View File
@@ -0,0 +1,63 @@
export interface DocumentSource {
id: string;
title: string;
href: string;
html: string;
}
export interface TreeNode {
name: string;
path: string;
parent?: string;
depth: number;
title?: string;
href?: string;
html?: string;
}
// Mirror the content directory. No authored taxonomy, summaries or demo data.
export function documentTree(documents: readonly DocumentSource[]): TreeNode[] {
const nodes: TreeNode[] = [{ name: '', path: '/', depth: 1 }];
for (const document of [...documents].sort((a, b) => a.id.localeCompare(b.id, 'en', { numeric: true }))) {
const segments = `${document.id}.md`.split('/');
let parent = '/';
segments.forEach((name, index) => {
const path = `${parent === '/' ? '' : parent}/${name}`;
const file = index === segments.length - 1;
if (!nodes.some((node) => node.path === path)) {
nodes.push({ name, path, parent, depth: index + 2, ...(file ? { title: document.title, href: document.href, html: document.html } : {}) });
}
parent = path;
});
}
const visit = (path: string): TreeNode[] => childrenOf(nodes, path).flatMap((node) => [node, ...visit(node.path)]);
return [nodes[0], ...visit('/')];
}
export const childrenOf = (nodes: readonly TreeNode[], path: string) => nodes.filter((node) => node.parent === path);
export function visibleNodes(nodes: readonly TreeNode[], collapsed: ReadonlySet<string>): TreeNode[] {
return nodes.filter((node) => {
let parent = node.parent;
while (parent) {
if (collapsed.has(parent)) return false;
parent = nodes.find((item) => item.path === parent)?.parent;
}
return true;
});
}
export function treePrefix(node: TreeNode, nodes: readonly TreeNode[], visible: readonly TreeNode[] = nodes): string {
if (!node.parent) return '';
const isLast = (item: TreeNode) => childrenOf(visible, item.parent!).at(-1)?.path === item.path;
let prefix = isLast(node) ? '└── ' : '├── ';
let parent = nodes.find((item) => item.path === node.parent);
while (parent?.parent) {
prefix = (isLast(parent) ? ' ' : '│ ') + prefix;
parent = nodes.find((item) => item.path === parent!.parent);
}
return prefix;
}
// Even literal </script> in a source code example must remain JSON, not markup.
export const serializeTree = (nodes: readonly TreeNode[]) => JSON.stringify(nodes).replace(/</g, '\\u003c');
-24
View File
@@ -1,33 +1,9 @@
---
import Site from '../layouts/Site.astro';
import Landing from '../components/Landing.astro';
import { href, bookHref } from '../lib/book';
const interactions = [
{ name: 'observe', label: '世界を観測する', text: 'pathからObjectと、指定した深さまでのindexableな子Objectを取得する。' },
{ name: 'fetch_interface', label: 'できることを知る', text: 'Interfaceの宣言を取得し、Operationの引数・結果・用途を理解する。' },
{ name: 'call_operation', label: '対象を操作する', text: '対象とInterfaceを明示して、観測した条件のもとでOperationを実行する。' },
];
---
<Site landing>
<main id="main">
<Landing />
<section class="hero landing-explanation">
<div class="hero-copy">
<p class="eyebrow"><span class="status-dot" aria-hidden="true"></span> WEB INTERFACE PROTOCOL</p>
<h1>ツールの一覧から、<br /><em>探索できる世界へ。</em></h1>
<p class="lead">Filesystemも、Issueも、Databaseも。<br />WIPはデジタル環境を、発見・観測・操作できる<br class="desktop-break" />ひとつの<strong>Worldspace</strong>として公開するProtocolです。</p>
<div class="hero-actions"><a class="button primary" href={href('book/')}>Bookを読む <span aria-hidden="true">→</span></a><a class="text-link" href={bookHref('1-concept')}>コンセプトから知る ↗</a></div>
<p class="hero-note">One worldspace. Three core interactions.</p>
</div>
</section>
<section class="interaction-section" aria-labelledby="interactions">
<div class="section-heading"><p class="eyebrow">A SMALL COMMON LANGUAGE</p><h2 id="interactions">共通の約束は、小さく。</h2><p>環境ごとの巨大なAPIではなく、探索と実行のための3つのCore interaction。</p></div>
<div class="interaction-grid">{interactions.map((item, index) => <a class="interaction-card" href={bookHref('3-protocol')}><span class="step">0{index + 1}</span><code>{item.name}</code><h3>{item.label}</h3><p>{item.text}</p><span class="card-arrow" aria-hidden="true">↗</span></a>)}</div>
</section>
<section class="principles" aria-labelledby="principles">
<div><p class="eyebrow">BOUNDARIES, NOT A BIGGER API</p><h2 id="principles">見えるものと、<br />扱えることを分ける。</h2></div>
<div class="principle-list"><div><h3>ObjectとInterface</h3><p>Objectは公開情報とInterface参照を持つ。Operationの型や説明は、共有できるDescriptorとして分離する。</p></div><div><h3>発見と保持</h3><p>結果から得たentryは、必要なときに観測する。ClientのKnown Spaceは過去の観測であり、Hostの状態のreplicaではない。</p></div><div><h3>意味論と通信</h3><p>Coreはtransportから独立。標準HTTP bindingが、同じ契約をHTTPとJSONへ対応付ける。</p></div></div>
</section>
<section class="reading-cta"><p class="eyebrow">START READING</p><h2>仕様の全体像を、Bookで。</h2><p>コンセプトからCore Model、Client、HTTP bindingまで。<br />正本と草案を分け、章を辿って読めます。</p><a class="button primary" href={href('book/')}>目次を開く →</a></section>
</main>
</Site>
+36 -111
View File
@@ -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<HTMLElement>('[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 = <T extends Element = HTMLElement>(selector: string) => stage.querySelector<T>(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<HTMLInputElement>('.tree-search');
const expandAll = get<HTMLButtonElement>('[data-expand-all]');
const collapseAll = get<HTMLButtonElement>('[data-collapse-all]');
const sources = get('[data-sources]');
const nodes = [...stage.querySelectorAll<HTMLButtonElement>('.demo-node')];
const nodes = [...stage.querySelectorAll<HTMLElement>('.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<SVGSVGElement>('.orbit');
const orbitPath = get<SVGPathElement>('#welcome-orbit');
const textPath = get<SVGTextPathElement>('.orbit textPath');
const invitation = get('.intro-invitation');
const skip = get<HTMLButtonElement>('.intro-skip');
const call = get<HTMLButtonElement>('.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<HTMLElement>('.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<typeof animate> | undefined;
const collapsed = new Set(initialCollapsedPaths);
const panels = [...stage.querySelectorAll<HTMLElement>('.ascii-panel')];
const glitchTargets = [...stage.querySelectorAll<HTMLElement>([
'.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<string>();
const glitchTargets = [...stage.querySelectorAll<HTMLElement>('.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<SVGTextElement>('.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<HTMLElement>('.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<SVGUseElement>('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: 先頭・末尾';
}
+20 -132
View File
@@ -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; }
}
+22
View File
@@ -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;
+8 -8
View File
@@ -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. コアモデル');
});
+199 -260
View File
@@ -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<SVGGElement>('.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('<h1');
}
for (const path of ['/', '/draft']) {
const folder = stored.find((document) => 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<string, string>();
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<HTMLElement>('[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('通信の約束まで');
});
+1 -1
View File
@@ -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();
-109
View File
@@ -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;
}
});
-49
View File
@@ -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 ? '└── ' : '├── '));
}
}
});
+75
View File
@@ -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: '<title>', href: '/book/source/', html: '</script><script>alert(1)</script>\u2028&' }]);
const serialized = serializeTree(input);
assert.equal(serialized.includes('<'), false);
assert.deepEqual(JSON.parse(serialized), input);
});