fix: replace shallow landing fixture with a practical guide
Reference site / build (push) Canceled after 0s

This commit is contained in:
2026-10-11 15:25:15 +09:00
parent ae2c236df7
commit 107b59afb4
11 changed files with 1180 additions and 136 deletions
+37 -23
View File
@@ -1,13 +1,18 @@
---
import { objects, initialObject, childrenOf, treePrefix, objectSignature, interfaceSignature } from '../lib/landing-demo';
import { href } from '../lib/book';
import { objects, initialObject, childrenOf, treePrefix, objectSignature, interfaceSignature, interfaceReference, initialCollapsedPaths, initialVisibleObjects } from '../lib/landing-demo';
import { href, bookHref } from '../lib/book';
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 trunkLength = (objects.findLastIndex((object) => object.depth === 2) - 1) * 24 + branchY + branchThickness / 2;
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;
---
<section class="landing" aria-label="ルートからWorldspaceへ" data-landing-stage>
<section class="landing" aria-label="WIP実装ガイドのWorldspace" data-landing-stage data-book-base={href('book/')}>
<TreeGlyphs />
<div class="intro-art" aria-hidden="true">
<svg class="orbit" viewBox="-300 -300 600 600" focusable="false">
<defs><path id="welcome-orbit" d="M0 -180 C99 -180 180 -99 180 0 C180 99 99 180 0 180 C-99 180 -180 99 -180 0 C-180 -99 -99 -180 0 -180" /></defs>
@@ -19,25 +24,29 @@ const trunkLength = (objects.findLastIndex((object) => object.depth === 2) - 1)
<div class="landing-content">
<div class="explorer-heading" data-reveal>
<div><p class="eyebrow">FROM A ROOT, A WORLD.</p><h2>ひとつの入口から、<br class="mobile-only" />できることが見えてくる。</h2></div>
<span class="demo-badge"><i></i> INTERACTIVE / LOCAL DEMO</span>
<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="explorer-left">
<div class="tree-pane">
<div class="tree-caption" data-reveal><span>01 / WORLDSPACE</span><span>↑ ↓ navigate · ← → expand</span></div>
<div class="demo-tree" role="tree" aria-label="Worldspace デモ">
<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実装ガイド">
<div class="tree-scene">
{objects.map((object, index) => {
const root = object.path === '/';
const children = childrenOf(object.path);
const prefix = treePrefix(object);
return <div class:list={['node-row', { 'root-slot': root }]}>
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-level={object.depth} aria-expanded={children.length ? 'true' : undefined}
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'}
@@ -60,36 +69,41 @@ const trunkLength = (objects.findLastIndex((object) => object.depth === 2) - 1)
<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>}</>}
</svg> : <><span class="node-label">{object.name}</span>{children.length > 0 && <span class="node-indicator" aria-hidden="true">{collapsed.has(object.path) ? '+' : '−'}</span>}<span class="node-summary">{object.title ?? object.description}</span></>}
</button>
{root && <span class="root-caption" data-reveal>worldspace</span>}
{root && <span class="root-caption" data-reveal>wip / field-guide</span>}
</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 / Text View</h3><span>OBJECT SIGNATURE</span></div>
<pre data-object-signature>{objectSignature(initialObject)}</pre>
<p class="panel-footnote">選択したObjectの公開情報。本文はOperationで取得します。</p>
<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>
</div>
<section class="ascii-panel call-panel" aria-labelledby="call-result-title" data-reveal>
<BoxFrame />
<div class="panel-title"><h3 id="call-result-title">03 / Call result</h3><span>EXPLICIT ACTION</span></div>
<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>
<pre class="interface-signature" data-interface-signature>{interfaceSignature(initialObject)}</pre>
<div class="call-controls"><code data-operation>/::issue.read#read_body</code><button type="button" class="demo-call" disabled>Call <span aria-hidden="true">↗</span></button></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>
<pre data-result>{'// Operationを選び、Callで本文を取得。\n// このデモはブラウザ内だけで動作します。'}</pre>
<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>
<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><p class="no-script-note">これは静的なサンプル表示です。JavaScriptを有効にするとアニメーションとデモを操作できます。Bookはそのまま読めます。</p></noscript>
<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>
</div>
</section>
<script>
+9
View File
@@ -0,0 +1,9 @@
---
import { glyphPath } from '../lib/landing-glyphs.mjs';
// Shared, build-time Lyth Mono outlines. Filtering only changes glyph references;
// the font parser never ships to the browser and all joins share one row viewport.
const chars = [' ', '│', '├', '└', '─'];
---
<svg class="tree-glyphs" aria-hidden="true" focusable="false" width="0" height="0">
<defs>{chars.map((char) => <path id={`tree-glyph-${char.codePointAt(0)}`} d={glyphPath(char)} />)}</defs>
</svg>
+5 -9
View File
@@ -1,19 +1,15 @@
---
import { glyphPath, cellWidth, rowHeight, branchY, branchThickness } from '../lib/landing-glyphs.mjs';
import { cellWidth, rowHeight, branchY, branchThickness } from '../lib/landing-glyphs.mjs';
interface Props { prefix: string; id: number; }
const { prefix, id } = Astro.props;
const first = prefix[0];
const arm = prefix.slice(1);
const reference = (char: string) => `#tree-glyph-${char.codePointAt(0)}`;
---
<span class="branch-prefix" style={`width: ${prefix.length}ch`} aria-hidden="true" data-prefix={prefix}>
<svg class="branch-vector" viewBox={`0 0 ${cellWidth * prefix.length} ${rowHeight}`} data-cell-width={cellWidth} preserveAspectRatio="none" focusable="false">
<defs><clipPath id={`branch-join-${id}`}><rect x={cellWidth / 2} y={branchY - branchThickness / 2} width={cellWidth / 2} height={branchThickness} /></clipPath></defs>
{/* The persistent trunk supplies the first column's vertical ink. Retain
only the horizontal half of root-level joins, cut from the real glyph.
One viewport keeps adjacent glyph edges on the same raster grid. */}
<g class="branch-spine">
{(first === '├' || first === '└') && <path d={glyphPath(first)} clip-path={`url(#branch-join-${id})`} />}
</g>
<g class="branch-arm" transform={`translate(${cellWidth} 0)`}><path d={glyphPath(arm)} /></g>
{/* Only the persistent trunk supplies first-column vertical ink. */}
<g class="branch-spine"><use data-cell="0" href={reference(first === '├' || first === '└' ? first : ' ')} clip-path={`url(#branch-join-${id})`} /></g>
<g class="branch-arm">{[...prefix.slice(1)].map((char, index) => <use data-cell={index + 1} href={reference(char)} x={(index + 1) * cellWidth} />)}</g>
</svg>
</span>
+480
View File
@@ -0,0 +1,480 @@
// 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' }],
},
],
},
],
},
],
};
+47 -23
View File
@@ -1,33 +1,57 @@
// Deliberately local fixtures, not a network client or a protocol response codec.
export interface DemoObject {
// 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;
name: string;
parent?: string;
depth: number;
description: string;
interface?: string;
body?: string;
}
export const objects: DemoObject[] = [
{ path: '/', name: '', depth: 1, description: 'One worldspace. Many possibilities.' },
{ path: '/repository', name: 'repository', parent: '/', depth: 2, description: 'Source, made discoverable.' },
{ path: '/repository/README.md', name: 'README.md', parent: '/repository', depth: 3, description: 'A small introduction to WIP.', interface: 'file.read', body: '# Web Interface Protocol\n\nDiscover a world, not a list of tools.\nObserve an object. Understand its interfaces.\nThen choose what to do.' },
{ path: '/repository/src', name: 'src', parent: '/repository', depth: 3, description: 'The implementation lives here.' },
{ path: '/repository/src/lib.rs', name: 'lib.rs', parent: '/repository/src', depth: 4, description: 'A minimal entry point.', interface: 'file.read', body: 'pub fn hello() -> &\'static str {\n "Hello, Worldspace."\n}' },
{ path: '/issues', name: 'issues', parent: '/', depth: 2, description: 'Work waiting to become action.' },
{ path: '/issues/123', name: '123', parent: '/issues', depth: 3, description: 'Make the world discoverable.', interface: 'issue.read', body: 'Give every capability a place.\n\nAn agent can discover this object, inspect\nits interface, and explicitly call read_body.\n\nNo exhaustive tool list required.' },
{ path: '/knowledge', name: 'knowledge', parent: '/', depth: 2, description: 'Context, connected to the world.' },
{ path: '/knowledge/protocol', name: 'protocol', parent: '/knowledge', depth: 3, description: 'Three interactions. One shared language.', interface: 'document.read', body: 'observe\n Discover objects at a path.\n\nfetch_interface\n Understand the available operations.\n\ncall_operation\n Act on a chosen object.' },
];
export const initialObject = objects.find((object) => object.path === '/issues/123')!;
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);
/** Each ancestor occupies four mono cells; build-time glyph paths use this layout. */
export function treePrefix(object: DemoObject): string {
/** 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) => childrenOf(item.parent!).at(-1)?.path === item.path;
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) {
@@ -43,6 +67,6 @@ export function objectSignature(object: DemoObject): string {
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 '// No interface on this object.\n// Explore its children in the tree.';
if (!object.interface) return '// この項目はコンテンツのまとまりです。\n// 子Objectを選んで内容を探してください。';
return `interface ${JSON.stringify(interfaceReference(object))} {\n operation read_body() -> { body: string };\n}`;
}
+87 -25
View File
@@ -1,5 +1,5 @@
import { animate, createTimeline, cubicBezier } from 'animejs';
import { objects, initialObject, childrenOf, objectSignature, interfaceSignature, interfaceReference } from '../lib/landing-demo';
import { objects, initialObject, childrenOf, objectSignature, interfaceSignature, interfaceReference, initialCollapsedPaths, visibleObjects, treePrefix } from '../lib/landing-demo';
import { glitchText } from '../lib/landing-ascii.mjs';
const clamp = (value: number) => Math.max(0, Math.min(1, value));
@@ -48,8 +48,16 @@ export async function initLanding() {
const branchThickness = Number(trunkAnchor.dataset.branchThickness);
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 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));
const rowStart = (row: HTMLElement) => flow.branch + Math.max(0, introRows.indexOf(row) - 1) * stagger;
const branches = [...stage.querySelectorAll<SVGGElement>('.branch-arm')];
const spines = [...stage.querySelectorAll<SVGGElement>('.branch-spine')];
const reveals = [...stage.querySelectorAll<HTMLElement>('[data-reveal]')];
@@ -69,7 +77,7 @@ export async function initLanding() {
let selected = initialObject;
let keyboardStarted = false;
let animation: ReturnType<typeof animate> | undefined;
const collapsed = new Set<string>();
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',
@@ -78,9 +86,8 @@ export async function initLanding() {
].join(','))].map((element, seed) => {
const panel = element.closest('.ascii-panel');
const row = element.closest('.node-row');
const index = rows.indexOf(row as HTMLElement);
const start = panel ? (panel.classList.contains('call-panel') ? flow.callPanel : flow.textPanel)
: flow.branch + Math.max(0, index - 1) * flow.stagger;
: rowStart(row as HTMLElement);
return { element, text: element.textContent ?? '', start, duration: panel ? flow.panelDuration : 580, seed };
});
let lastGlitchFrame = -1;
@@ -131,12 +138,12 @@ export async function initLanding() {
.add(orbit, { opacity: [0, 1], duration: 300 }, 0)
.add(html, { '--landing-chrome': [0, 1], duration: 600 }, 3000);
branches.forEach((wire, index) => {
const start = flow.branch + index * flow.stagger;
const start = rowStart(rows[index + 1]);
timeline.add([wire, spines[index]], { opacity: [0, 1], duration: 160 }, start)
.add(wire, { clipPath: ['inset(0 100% 0 0)', 'inset(0 0% 0 0)'], duration: 520, ease: timing.trace }, start);
});
nodes.slice(1).forEach((node, index) => {
timeline.add(node, { opacity: [0, 1], duration: 220 }, flow.branch + 180 + index * flow.stagger);
timeline.add(node, { opacity: [0, 1], duration: 220 }, rowStart(rows[index + 1]) + 180);
});
reveals.forEach((element, index) => {
const isPanel = element.classList.contains('ascii-panel');
@@ -166,6 +173,10 @@ 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));
@@ -174,37 +185,78 @@ export async function initLanding() {
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';
delete callState.dataset.callState;
result.textContent = selected.interface ? '// Callで本文を取得。\n// Objectの観測とOperationの実行は別の操作です。' : '// このObjectには公開Operationがありません。\n// ツリーから子Objectを選んでください。';
status.textContent = `${selected.path} を選択。ローカルサンプル / 外部通信なし。`;
if (focus) nodes[index].focus({ preventScroll: true });
callState.dataset.callState = 'ready';
result.textContent = selected.interface
? '「本文を読む」で、この項目の判断基準とチェックリストを取得します。公開情報の観測と本文の取得は別の操作です。'
: 'このまとまりには ' + childrenOf(selected.path).length + ' 項目があります。子Objectを選び、目的に合うガイドを探してください。';
sources.hidden = true;
status.textContent = selected.path + ' を選択。本文はまだ取得していません。';
if (focus) {
nodes[index].focus({ preventScroll: true });
const box = nodes[index].getBoundingClientRect(), bounds = viewport.getBoundingClientRect();
if (box.top < bounds.top) viewport.scrollTop -= bounds.top - box.top;
if (box.bottom > bounds.bottom) viewport.scrollTop += box.bottom - bounds.bottom;
if (box.left < bounds.left) viewport.scrollLeft -= bounds.left - box.left;
if (box.right > bounds.right) viewport.scrollLeft += box.right - bounds.right;
}
}
function updateExpanded() {
const filtering = Boolean(search.value.trim());
const shown = visibleObjects(collapsed, search.value);
const paths = new Set(shown.map((object) => object.path));
objects.forEach((object, index) => {
let parent = object.parent;
let hidden = false;
while (parent) {
if (collapsed.has(parent)) hidden = true;
parent = objects.find((candidate) => candidate.path === parent)?.parent;
}
rows[index].hidden = hidden;
rows[index].hidden = !paths.has(object.path);
if (childrenOf(object.path).length) {
nodes[index].setAttribute('aria-expanded', String(!collapsed.has(object.path)));
const expanded = filtering ? shown.some((child) => child.parent === object.path) : !collapsed.has(object.path);
nodes[index].setAttribute('aria-expanded', String(expanded));
const indicator = nodes[index].querySelector('.node-indicator');
if (indicator) indicator.textContent = collapsed.has(object.path) ? '+' : '−';
if (indicator) indicator.textContent = expanded ? '−' : '+';
}
const prefixElement = rows[index].querySelector<HTMLElement>('.branch-prefix');
if (prefixElement && paths.has(object.path)) {
const prefix = treePrefix(object, shown);
prefixElement.dataset.prefix = prefix;
prefixElement.querySelectorAll<SVGUseElement>('use[data-cell]').forEach((use) => {
const cell = Number(use.dataset.cell);
const char = cell === 0 && !['├', '└'].includes(prefix[0]) ? ' ' : prefix[cell];
use.setAttribute('href', '#tree-glyph-' + char.codePointAt(0));
});
}
});
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];
select(objects.indexOf(next));
}
}
nodes.forEach((node, index) => node.addEventListener('click', (event) => {
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', () => {
if (state !== 'complete') return;
select(index);
if ((event.target as Element).closest('.node-indicator') && childrenOf(objects[index].path).length) {
if (!search.value.trim() && childrenOf(objects[index].path).length) {
const path = objects[index].path;
collapsed.has(path) ? collapsed.delete(path) : collapsed.add(path);
updateExpanded();
@@ -225,12 +277,12 @@ export async function initLanding() {
case 'End': target = visible[visible.length - 1]; break;
case 'ArrowRight':
if (childrenOf(objects[index].path).length) {
if (collapsed.delete(objects[index].path)) updateExpanded();
else target = visible[position + 1];
if (!search.value.trim() && collapsed.delete(objects[index].path)) updateExpanded();
else target = visible.find((i) => objects[i].parent === objects[index].path) ?? index;
}
break;
case 'ArrowLeft':
if (childrenOf(objects[index].path).length && !collapsed.has(objects[index].path)) {
if (!search.value.trim() && childrenOf(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);
@@ -245,9 +297,17 @@ export async function initLanding() {
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 が完了。ローカルサンプル / 外部通信なし。`;
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) });
});
@@ -287,6 +347,8 @@ 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);
}
+35 -7
View File
@@ -9,7 +9,7 @@
.landing-page :is(h1, h2, h3, p) { letter-spacing: 0; }
.landing-page :is(.site-header, .landing-content, .hero, .interaction-section, .principles, .site-footer) {
width: calc(100% - 4ch); width: round(down, calc(100% - 4ch), 4ch);
max-width: 132ch; margin-inline: auto;
max-width: 160ch; margin-inline: auto;
}
.landing-page .site-header { position: absolute; inset: 0 0 auto; z-index: 30; background: transparent; border: 0; min-height: calc(3 * var(--row)); padding: var(--row) 0; opacity: 1; transition: opacity .6s; }
html[data-landing] .landing-page .site-header { opacity: 0; }
@@ -25,9 +25,23 @@ html[data-landing] .landing-page .site-header { opacity: 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: repeat(2, minmax(0, 1fr)); gap: var(--row) 4ch; align-items: start; }
.explorer-left { display: grid; grid-template-rows: max-content 1fr; align-self: stretch; gap: var(--row); min-width: 0; }
.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; }
.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; }
html[data-landing] .tree-viewport { overflow: visible; }
.tree-help { margin: var(--row) 0 0; color: var(--muted); }
.node-summary { max-width: 40ch; overflow: hidden; text-overflow: ellipsis; font-size: 12px; 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; }
@@ -75,10 +89,10 @@ html[data-landing="boot"] .slash-art { --slash-scale: 1; }
.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 { grid-column: 1; grid-row: 2; }
.text-panel { min-width: 0; }
.text-panel pre { color: #425238; }
.panel-footnote { color: var(--muted); margin: var(--row) 0 0; }
.call-panel { grid-column: 2; grid-row: 1; align-self: stretch; display: flex; flex-direction: column; }
.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; }
@@ -88,7 +102,20 @@ html[data-landing="boot"] .slash-art { --slash-scale: 1; }
.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 { flex: 1; min-height: calc(9 * var(--row)); margin-top: var(--row); }
.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); }
@@ -150,7 +177,7 @@ html[data-landing="armed"] .root-mark { cursor: pointer; }
.landing { padding-top: calc(4 * var(--row)); }
.landing-page .brand-caption { display: none; }
.explorer { grid-template-columns: minmax(0, 1fr); }
.text-panel, .call-panel { grid-column: 1; grid-row: auto; }
.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; }
@@ -164,4 +191,5 @@ html[data-landing="armed"] .root-mark { cursor: pointer; }
@media print {
.intro-art, .intro-invitation, .intro-skip { display: none !important; }
.landing { padding: var(--row); }
.tree-viewport { height: auto; overflow: visible; }
}
+19 -6
View File
@@ -28,6 +28,12 @@ test('self-hosted Lyth Mono and EB Garamond load with the configured base', asyn
expect(fonts.stack).toContain('Lyth Mono');
expect(fonts.prefix).toBe(''); // SSR uses the same glyph paths, not HTML wires.
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 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') });
@@ -35,17 +41,21 @@ test('self-hosted Lyth Mono and EB Garamond load with the configured base', asyn
test.describe('static font and tree rendering', () => {
test.use({ javaScriptEnabled: false, deviceScaleFactor: 4 });
test('glyph joins paint continuously across SVG cell boundaries', async ({ page }) => {
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.
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) => {
const svg = node.parentElement!.querySelector<SVGSVGElement>('.branch-vector')!;
const p = new DOMPoint(Number(svg.dataset.cellWidth) / 2, 12).matrixTransform(svg.getScreenCTM()!);
return { x: p.x, y: p.y };
}));
for (const join of joins) {
expect(joins).toHaveLength(4);
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 })).toString('base64');
const png = (await page.screenshot({ clip, path: testInfo.outputPath(`glyph-join-${index}.png`) })).toString('base64');
const columns = await page.evaluate(async (png) => {
const image = new Image(); image.src = 'data:image/png;base64,' + png;
await image.decode();
@@ -73,7 +83,10 @@ test.describe('static font and tree rendering', () => {
await expect(page.locator('.trunk-line')).toBeVisible();
await expect(page.locator('.trunk-line')).toHaveCSS('stroke', 'none');
await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '├── ');
await expect(page.locator('.branch-prefix').first().locator('path')).toHaveCount(2);
await expect(page.locator('.branch-prefix').first().locator('path')).toHaveCount(0);
await expect(page.locator('.branch-prefix').first().locator('use')).toHaveCount(4);
await expect(page.locator('.branch-prefix').first().locator('.branch-spine use')).toHaveAttribute('href', '#tree-glyph-9500');
await expect(page.locator('.tree-glyphs defs path')).toHaveCount(5);
await expect(page.getByRole('link', { name: 'Book', exact: true })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await page.screenshot({ path: testInfo.outputPath('lyth-mono-static.png') });
@@ -86,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: 'Call', exact: false }).click();
await expect(page.locator('[data-result]')).toContainText('Give every capability a place.');
await page.getByRole('button', { name: '本文を読む' }).click();
await expect(page.locator('[data-result]')).toContainText('Unicode正規化');
});
+303 -43
View File
@@ -3,6 +3,64 @@ import { expect, test } from '@playwright/test';
test.use({ javaScriptEnabled: true });
const stage = '[data-landing-stage]';
const initialPath = '/design/object-tree/paths/names';
const nodeAt = (page: import('@playwright/test').Page, path: string) => page.locator(`.demo-node[data-path="${path}"]`);
const visiblePrefixes = '.node-row:not([hidden]) .branch-prefix';
// 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) {
await page.setViewportSize({ width: page.viewportSize()!.width, height: 2000 });
await page.locator('.tree-viewport').evaluate((el) => {
(el as HTMLElement).style.height = 'auto';
el.scrollTop = el.scrollLeft = 0;
});
await page.evaluate(() => scrollTo(0, 0));
}
async function checkGlyphRefs(page: import('@playwright/test').Page) {
const rendered = await page.locator(visiblePrefixes).evaluateAll((prefixes) => {
const paths = [...document.querySelectorAll<HTMLElement>('.node-row:not([hidden]) .demo-node')].map((node) => node.dataset.path!);
const parent = (path: string) => path.slice(0, path.lastIndexOf('/')) || '/';
const last = (path: string) => paths.filter((other) => other !== '/' && parent(other) === parent(path)).at(-1) === path;
return prefixes.map((prefix) => {
const path = prefix.parentElement!.querySelector<HTMLElement>('.demo-node')!.dataset.path!;
let expected = last(path) ? '└── ' : '├── ';
let ancestor = parent(path);
while (ancestor !== '/') {
expected = (last(ancestor) ? ' ' : '│ ') + expected;
ancestor = parent(ancestor);
}
const refs = [...prefix.querySelectorAll<SVGUseElement>('use[data-cell]')].map((use) => ({
cell: Number(use.dataset.cell), href: use.getAttribute('href'),
resolves: Boolean(document.querySelector(use.getAttribute('href')!)),
}));
return { actual: prefix.getAttribute('data-prefix'), expected, refs };
});
});
expect(rendered.length).toBeGreaterThan(0);
for (const { actual, expected, refs } of rendered) {
expect(actual).toBe(expected);
expect(refs.map(({ cell }) => cell)).toEqual([...expected].map((_, index) => index));
for (const { cell, href, resolves } of refs) {
const char = cell === 0 && !['├', '└'].includes(expected[0]) ? ' ' : expected[cell];
expect(href).toBe('#tree-glyph-' + char.codePointAt(0));
expect(resolves).toBe(true);
}
}
await expect(page.locator('.branch-prefix path')).toHaveCount(0);
await expect(page.locator('.tree-glyphs defs path')).toHaveCount(5);
const endpoint = await page.evaluate(() => {
const anchor = document.querySelector<SVGGElement>('.trunk-anchor')!;
const last = [...document.querySelectorAll<HTMLElement>('.node-row:not([hidden]) .demo-node[aria-level="2"]')].at(-1)!;
const svg = last.parentElement!.querySelector<SVGSVGElement>('.branch-vector')!;
const join = new DOMPoint(Number(svg.dataset.cellWidth) / 2, Number(anchor.dataset.branchY) + Number(anchor.dataset.branchThickness) / 2).matrixTransform(svg.getScreenCTM()!);
return { x: join.x, y: join.y };
});
const trunk = await trunkGeometry(page);
expect(Math.abs(trunk.x - endpoint.x)).toBeLessThan(.1);
expect(Math.abs(trunk.bottom - endpoint.y)).toBeLessThan(.1);
}
// Filled glyph bounds describe the ink; the anchor describes its origin.
// Path length/start points describe an outline perimeter, not the trunk axis.
async function trunkGeometry(page: import('@playwright/test').Page) {
@@ -94,11 +152,13 @@ for (const trigger of ['click', 'wheel', 'key']) {
if (trigger === 'key') await page.keyboard.press('a');
await expect(page.locator('html')).toHaveAttribute('data-landing', 'playing');
await complete(page);
await page.getByRole('treeitem', { name: 'README.md', exact: true }).click();
await expect(page.locator('[data-object-signature]')).toContainText('object at "/repository/README.md"');
await expect(page.locator('[data-result]')).not.toContainText('# Web Interface Protocol');
await page.getByRole('button', { name: 'Call', exact: false }).click();
await expect(page.locator('[data-result]')).toContainText('# Web Interface Protocol');
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 が完了');
expect(errors).toEqual([]);
});
@@ -397,7 +457,7 @@ test('continuous motion overlaps growth, travel and reveal without intermediate
await expect(root).toHaveCount(1);
await expect(page.locator('.call-panel')).not.toHaveAttribute('inert');
await expect(page.locator('[data-glitching]')).toHaveCount(0);
await expect(page.locator('[data-object-signature]')).toContainText('object at "/issues/123"');
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');
@@ -406,22 +466,22 @@ test('continuous motion overlaps growth, travel and reveal without intermediate
for (const side of await panel.locator('.box-column').all()) await expect(side).toHaveText(/^[│\s]+$/);
}
await expect(page.locator('.branch-lines')).toHaveCount(0);
expect(await page.locator('.branch-prefix').evaluateAll((prefixes) => prefixes.map((prefix) => prefix.getAttribute('data-prefix')))).toEqual([
'├── ', '│ ├── ', '│ └── ', '│ └── ',
'├── ', '│ └── ', '└── ', ' └── ',
]);
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 checkGlyphRefs(page);
await expect(page.locator('.root-wire')).toHaveCount(0);
await expect(page.locator('.branch-prefix svg')).toHaveCount(8);
await expect(page.locator('.branch-spine path')).toHaveCount(3);
await expect(page.locator('.branch-arm path')).toHaveCount(8);
await expect(page.locator(visiblePrefixes + ' svg')).toHaveCount(23);
await expect(page.locator(visiblePrefixes + ' .branch-spine use')).toHaveCount(23);
await expect(page.locator('.tree-scene')).toHaveCSS('transform', 'none');
const grid = await page.evaluate(() => {
const root = document.querySelector('.root-mark')!.getBoundingClientRect();
const prefix = document.querySelector<HTMLElement>('.branch-prefix')!;
const cell = prefix.getBoundingClientRect().width / 4;
const frame = document.querySelector('.landing-content')!.getBoundingClientRect();
const rows = [...document.querySelectorAll<HTMLElement>('.node-row:not(.root-slot)')].map((row) => row.getBoundingClientRect());
const nodes = [...document.querySelectorAll<HTMLElement>('.demo-node:not(.root-mark)')].map((node) => node.getBoundingClientRect());
const rows = [...document.querySelectorAll<HTMLElement>('.node-row:not(.root-slot):not([hidden])')].map((row) => row.getBoundingClientRect());
const nodes = [...document.querySelectorAll<HTMLElement>('.node-row:not([hidden]) .demo-node:not(.root-mark)')].map((node) => node.getBoundingClientRect());
const foot = new DOMPoint(12, 60).matrixTransform(document.querySelector<SVGPathElement>('.root-shape')!.getScreenCTM()!);
return {
rootColumnError: Math.abs(foot.x - prefix.getBoundingClientRect().left - cell / 2),
@@ -429,7 +489,7 @@ 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),
panelRows: [...document.querySelectorAll('.ascii-panel')].map((panel) => (panel.getBoundingClientRect().top - frame.top) / 24),
panels: [...document.querySelectorAll('.ascii-panel')].map((panel) => panel.getBoundingClientRect().toJSON()),
font: getComputedStyle(prefix).fontFamily,
};
});
@@ -438,7 +498,9 @@ 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);
expect(grid.panelRows.every((row) => Math.abs(row - Math.round(row)) < .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');
@@ -478,45 +540,194 @@ test('skipping an active glitch restores exact text and frames', async ({ page }
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 / Call result');
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(/^─+$/);
await page.clock.runFor(2000);
await expect(page.locator('[data-object-signature]')).toContainText('object at "/issues/123"');
await expect(page.locator('[data-object-signature]')).toContainText('object at "/design/object-tree/paths/names"');
await page.locator('.root-mark').hover();
await expect(page.locator(stage)).not.toHaveAttribute('data-hover');
});
test('tree selection, collapse and call results are keyboard accessible', async ({ page }) => {
test('tree selection, collapse and reading are keyboard accessible in the scrollable viewport', async ({ page }) => {
await armed(page);
await page.keyboard.press('Escape');
await complete(page);
const selected = page.getByRole('treeitem', { name: '123', exact: true });
await expect(selected).toBeFocused();
await expect(nodeAt(page, initialPath)).toBeFocused();
await page.keyboard.press('ArrowUp');
const issues = page.getByRole('treeitem', { name: 'issues', exact: true });
await expect(issues).toBeFocused();
const paths = nodeAt(page, '/design/object-tree/paths');
await expect(paths).toBeFocused();
await page.keyboard.press('ArrowLeft');
await expect(issues).toHaveAttribute('aria-expanded', 'false');
await expect(selected).toBeHidden();
await expect(paths).toHaveAttribute('aria-expanded', 'false');
await expect(nodeAt(page, initialPath)).toBeHidden();
await page.keyboard.press('ArrowRight');
await expect(selected).toBeVisible();
await expect(nodeAt(page, initialPath)).toBeVisible();
await page.keyboard.press('ArrowRight');
await expect(selected).toBeFocused();
await expect(nodeAt(page, initialPath)).toBeFocused();
await page.keyboard.press('ArrowLeft');
await expect(paths).toBeFocused();
await page.keyboard.press('Home');
await expect(page.getByRole('treeitem', { name: 'ルート /' })).toBeFocused();
const root = nodeAt(page, '/');
await expect(root).toBeFocused();
await page.keyboard.press('ArrowLeft');
await expect(page.getByRole('treeitem')).toHaveCount(1);
await expect(page.locator('.branch-spine').first()).toBeHidden();
expect((await trunkGeometry(page)).length).toBe(0);
await page.keyboard.press('ArrowRight');
await expect(page.locator('.branch-spine').first()).toBeVisible();
await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '├── ');
await expect(page.getByRole('treeitem')).toHaveCount(24);
await checkGlyphRefs(page);
await page.getByRole('button', { name: 'すべて展開', exact: true }).click();
await root.focus();
await page.keyboard.press('End');
await expect(page.getByRole('treeitem', { name: 'protocol', exact: true })).toBeFocused();
const finalLeaf = nodeAt(page, '/review/boundary-checks/failure-tests');
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 };
});
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 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.getByRole('button', { name: 'Call', exact: false })).toBeFocused();
await expect(page.locator('.text-panel summary')).toBeFocused();
await page.keyboard.press('Enter');
await expect(page.locator('[data-result]')).toContainText('fetch_interface');
await expect(page.locator('[data-object-signature]')).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');
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');
});
test.describe('glyph trunk regressions', () => {
@@ -556,7 +767,12 @@ test.describe('glyph trunk regressions', () => {
await armed(page);
await page.keyboard.press('Escape');
await complete(page);
await exposeTree(page);
const checkEnd = async (name: string) => {
// Keyboard selection can scroll wide prose summaries 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));
const trunk = await trunkGeometry(page);
const join = await page.evaluate(() => {
const anchor = document.querySelector<SVGGElement>('.trunk-anchor')!;
@@ -602,7 +818,7 @@ test.describe('glyph trunk regressions', () => {
return trunk.length;
};
let previous = await checkEnd('expanded');
for (const name of ['repository', 'issues']) {
for (const name of ['design', 'build', 'operate']) {
const node = page.getByRole('treeitem', { name, exact: true });
await node.focus();
await page.keyboard.press('ArrowLeft');
@@ -611,17 +827,17 @@ test.describe('glyph trunk regressions', () => {
expect(length).toBeLessThan(previous);
previous = length;
}
const knowledge = page.getByRole('treeitem', { name: 'knowledge', exact: true });
await knowledge.focus();
const review = page.getByRole('treeitem', { name: 'review', exact: true });
await review.focus();
await page.keyboard.press('ArrowLeft');
expect(await checkEnd('knowledge')).toBe(previous); // Last child never extends the first-column spine.
expect(await checkEnd('review')).toBe(previous); // Last child never extends the first-column spine.
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 ['repository', 'issues', 'knowledge']) {
for (const name of ['design', 'build', 'operate', 'review']) {
await page.getByRole('treeitem', { name, exact: true }).focus();
await page.keyboard.press('ArrowRight');
}
@@ -632,6 +848,28 @@ test.describe('glyph trunk regressions', () => {
test.describe('JavaScript-free glyph tree', () => {
test.use({ javaScriptEnabled: false });
test('all 26 authored bodies are accessible 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 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');
@@ -640,7 +878,7 @@ test.describe('JavaScript-free glyph tree', () => {
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('.branch-prefix svg')).toHaveCount(8);
await expect(page.locator(visiblePrefixes + ' svg')).toHaveCount(23);
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);
@@ -653,13 +891,35 @@ 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 }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto('./');
await complete(page);
await expect(page.locator('.intro-skip')).toBeHidden();
await page.getByRole('button', { name: 'Call', exact: false }).click();
await expect(page.locator('[data-result]')).toContainText('Give every capability a place.');
await page.getByRole('button', { name: '本文を読む' }).click();
await expect(page.locator('[data-result]')).toContainText('Unicode正規化');
});
test('changing motion preference and resizing cannot strand the intro', async ({ page }) => {
+109
View File
@@ -0,0 +1,109 @@
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
@@ -0,0 +1,49 @@
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 ? '└── ' : '├── '));
}
}
});