fix: rebuild landing with SVG motion and a monospace tree
Reference site / build (push) Canceled after 0s
Reference site / build (push) Canceled after 0s
This commit is contained in:
@@ -39,7 +39,18 @@ npm run test:browser
|
||||
# インストール済みChromeを使う場合:CHROME_PATH=/path/to/chrome npm run test:browser
|
||||
```
|
||||
|
||||
テストはクライアントJavaScriptを無効にして実行し、スクリーンショットを`site/test-results/`へ保存します。サブパスbuildの検証時は同じ`SITE_BASE`を設定してください。
|
||||
Bookの閲覧テストはJavaScript無効、ランディングのテストは有効にして実行します。スクリーンショットは`site/test-results/`に保存され、ランディングは6場面をデスクトップ/モバイルで記録します。サブパスbuildの検証時は同じ`SITE_BASE`を設定してください。テスト用previewは`127.0.0.1:4322`を使用し、4321のdev serverとは別に起動します。
|
||||
|
||||
### ランディング
|
||||
|
||||
トップはSVGのルートマークから始まります。クリック・ホイール/タッチスクロール・キー入力のいずれか一回で、約6.2秒のタイムラインを最後まで再生します。追加入力で段階を進めたり、再生を重ねたりはしません。Escapeまたは画面右下のスキップで直ちにデモへ移れます。ブラウザの修飾キー付きショートカットは妨げません。
|
||||
|
||||
- `src/components/Landing.astro` — SVGと、最終的に操作するツリー/パネルの静的HTML。
|
||||
- `src/scripts/landing.ts` — 単一の`requestAnimationFrame`時計、役割別の速度曲線、等弧長で円をほどくSVG path、ツリー操作。
|
||||
- `src/lib/landing-demo.ts` — ブラウザ内だけで使うサンプル。外部Hostへの接続、変更操作、通信はありません。Object signatureとInterface signatureを分け、本文は明示的なCallで表示します。
|
||||
- `src/styles/landing.css` — トップ全体を14pxのmonoフォント・24pxの行高で構成。フレーム幅を4ch単位に揃え、列・ガター・パディングもセルと行の整数倍にします。ツリーはSVGの線ではなく`├──`・`└──`・`│`などの罫線素片を使い、JavaScript無効時も同じ構造を表示します。モバイルはツリーとパネルを縦に配置します。
|
||||
|
||||
`prefers-reduced-motion: reduce`では演出を省略し、操作可能なデモを表示します。JavaScript無効時は静的なサンプルと説明・Bookリンクを表示し、モジュール取得失敗時も導入画面に閉じ込めません。再確認はページのリロードで行えます。
|
||||
|
||||
## 文書の編集
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ const base = process.env.SITE_BASE?.replace(/\/$/, '') || '';
|
||||
export default defineConfig({
|
||||
testDir: './tests/browser',
|
||||
use: {
|
||||
baseURL: `http://127.0.0.1:4321${base}/`,
|
||||
baseURL: `http://127.0.0.1:4322${base}/`,
|
||||
javaScriptEnabled: false,
|
||||
launchOptions: { executablePath: process.env.CHROME_PATH },
|
||||
},
|
||||
@@ -14,8 +14,8 @@ export default defineConfig({
|
||||
{ name: 'mobile', use: { viewport: { width: 390, height: 844 } } },
|
||||
],
|
||||
webServer: {
|
||||
command: 'npm run preview -- --host 127.0.0.1 --port 4321',
|
||||
url: `http://127.0.0.1:4321${base}/`,
|
||||
command: 'npm run preview -- --host 127.0.0.1 --port 4322 --ignore-lock',
|
||||
url: `http://127.0.0.1:4322${base}/`,
|
||||
reuseExistingServer: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
import { objects, initialObject, childrenOf, treePrefix, objectSignature, interfaceSignature } from '../lib/landing-demo';
|
||||
import { href } from '../lib/book';
|
||||
import '../styles/landing.css';
|
||||
---
|
||||
<section class="landing" aria-label="ルートからWorldspaceへ" data-landing-stage>
|
||||
<div class="intro-art" aria-hidden="true">
|
||||
<div class="tap-stream tap-stream-a"><span>Tap ↗ Tap ↗ Tap ↗</span></div>
|
||||
<div class="tap-stream tap-stream-b"><span>Tap ↗ Tap ↗ Tap ↗</span></div>
|
||||
<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>
|
||||
<text><textPath href="#welcome-orbit" textLength="1080" lengthAdjust="spacing">WELCOME TO WIP / DISCOVERY / UNDERSTANDING / ACTION / </textPath></text>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="intro-invitation"><span>EVERY WORLD BEGINS AT A ROOT.</span><p>Click · Scroll · Press any key</p></div>
|
||||
<button class="intro-skip" type="button">アニメーションをスキップ ↗</button>
|
||||
|
||||
<div class="landing-content">
|
||||
<div class="explorer-heading" data-reveal>
|
||||
<div><p class="eyebrow">FROM A ROOT, A WORLD.</p><h2>ひとつの入口から、<br class="mobile-only" />できることが見えてくる。</h2></div>
|
||||
<span class="demo-badge"><i></i> INTERACTIVE / LOCAL DEMO</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 デモ">
|
||||
{objects.map((object) => {
|
||||
const root = object.path === '/';
|
||||
const children = childrenOf(object.path);
|
||||
return <div class:list={['node-row', { 'root-slot': root }]}>
|
||||
{!root && <span class="branch-prefix" aria-hidden="true">{treePrefix(object)}</span>}
|
||||
<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-setsize={root ? 1 : childrenOf(object.parent!).length}
|
||||
aria-posinset={root ? 1 : childrenOf(object.parent!).findIndex((item) => item.path === object.path) + 1}
|
||||
aria-selected={object.path === initialObject.path ? 'true' : 'false'}
|
||||
tabindex={object.path === initialObject.path ? 0 : -1} disabled
|
||||
>
|
||||
{root ? <svg class="root-vector" viewBox="0 0 48 64" aria-hidden="true" focusable="false"><path class="root-shape" d="M31 4H41L17 60H7Z" /><path class="root-tether" d="M29 32H64" pathLength="1" vector-effect="non-scaling-stroke" /></svg> : <><span>{object.name}</span>{children.length > 0 && <span class="node-indicator" aria-hidden="true">−</span>}</>}
|
||||
</button>
|
||||
{root && <><span class="root-wire" aria-hidden="true">──┐</span><span class="root-caption" data-reveal>worldspace</span></>}
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<section class="ascii-panel text-panel" aria-labelledby="text-view-title" data-reveal>
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
<section class="ascii-panel call-panel" aria-labelledby="call-result-title" data-reveal>
|
||||
<div class="panel-title"><h3 id="call-result-title">03 / Call result</h3><span>EXPLICIT ACTION</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>
|
||||
<div class="result-shell">
|
||||
<div class="result-label"><span>RETURN / body</span><span data-call-state>READY</span></div>
|
||||
<pre data-result>// Operationを選び、Callで本文を取得。<br />// このデモはブラウザ内だけで動作します。</pre>
|
||||
</div>
|
||||
<p class="demo-status" role="status" aria-live="polite" aria-atomic="true">ローカルサンプル — 外部への通信や変更は行いません。</p>
|
||||
</section>
|
||||
</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>
|
||||
</div>
|
||||
</section>
|
||||
<script>
|
||||
import { initLanding } from '../scripts/landing';
|
||||
initLanding();
|
||||
</script>
|
||||
@@ -1,14 +1,23 @@
|
||||
---
|
||||
import '../styles/global.css';
|
||||
import { href, repository } from '../lib/book';
|
||||
interface Props { title?: string; description?: string; book?: boolean; }
|
||||
const { title = 'Web Interface Protocol', description = 'デジタル環境を、発見・観測・操作できるWorldspaceへ。WIPのコンセプトと仕様を読む。', book = false } = Astro.props;
|
||||
interface Props { title?: string; description?: string; book?: boolean; landing?: boolean; }
|
||||
const { title = 'Web Interface Protocol', description = 'デジタル環境を、発見・観測・操作できるWorldspaceへ。WIPのコンセプトと仕様を読む。', book = false, landing = false } = Astro.props;
|
||||
const canonical = Astro.site ? new URL(Astro.url.pathname, Astro.site) : undefined;
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
{landing && <script is:inline>
|
||||
// Progressive enhancement: a missing/blocked module must never strand the page.
|
||||
if (!matchMedia('(prefers-reduced-motion: reduce)').matches && !location.hash) {
|
||||
document.documentElement.dataset.landing = 'boot';
|
||||
setTimeout(() => {
|
||||
if (document.documentElement.dataset.landing === 'boot') delete document.documentElement.dataset.landing;
|
||||
}, 3500);
|
||||
}
|
||||
</script>}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{title} · WIP</title>
|
||||
<meta name="description" content={description} />
|
||||
@@ -19,7 +28,7 @@ const canonical = Astro.site ? new URL(Astro.url.pathname, Astro.site) : undefin
|
||||
<meta name="theme-color" content="#123c39" />
|
||||
<link rel="icon" type="image/svg+xml" href={href('favicon.svg')} />
|
||||
</head>
|
||||
<body>
|
||||
<body class:list={{ 'landing-page': landing }}>
|
||||
<a class="skip-link" href="#main">本文へスキップ</a>
|
||||
<header class="site-header">
|
||||
<a class="brand" href={href()} aria-label="WIP トップページ"><span class="brand-mark">W</span><span>WIP<span class="brand-caption">Web Interface Protocol</span></span></a>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// Deliberately local fixtures, not a network client or a protocol response codec.
|
||||
export interface DemoObject {
|
||||
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')!;
|
||||
export const childrenOf = (path: string) => objects.filter((object) => object.parent === path);
|
||||
|
||||
/** Each ancestor occupies four mono cells. Keep wires as text, including in SSR. */
|
||||
export function treePrefix(object: DemoObject): string {
|
||||
if (!object.parent) return '';
|
||||
const isLast = (item: DemoObject) => childrenOf(item.parent!).at(-1)?.path === item.path;
|
||||
let prefix = isLast(object) ? '└── ' : '├── ';
|
||||
let parent = objects.find((item) => item.path === object.parent);
|
||||
while (parent?.parent) {
|
||||
prefix = (isLast(parent) ? ' ' : '│ ') + prefix;
|
||||
parent = objects.find((item) => item.path === parent!.parent);
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
|
||||
export const interfaceReference = (object: DemoObject) => object.interface ? `/::${object.interface}` : undefined;
|
||||
export function objectSignature(object: DemoObject): string {
|
||||
const references = object.interface ? JSON.stringify(interfaceReference(object)) : '';
|
||||
return `object at ${JSON.stringify(object.path)} {\n name: ${JSON.stringify(object.name)};\n description: ${JSON.stringify(object.description)};\n interfaces: [${references}];\n}`;
|
||||
}
|
||||
export function interfaceSignature(object: DemoObject): string {
|
||||
if (!object.interface) return '// No interface on this object.\n// Explore its children in the tree.';
|
||||
return `interface ${JSON.stringify(interfaceReference(object))} {\n operation read_body() -> { body: string };\n}`;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
import Site from '../layouts/Site.astro';
|
||||
import Landing from '../components/Landing.astro';
|
||||
import { href, bookHref } from '../lib/book';
|
||||
const interactions = [
|
||||
{ name: 'observe', label: '世界を観測する', text: 'pathからObjectと、指定した深さまでのindexableな子Objectを取得する。' },
|
||||
@@ -7,9 +8,10 @@ const interactions = [
|
||||
{ name: 'call_operation', label: '対象を操作する', text: '対象とInterfaceを明示して、観測した条件のもとでOperationを実行する。' },
|
||||
];
|
||||
---
|
||||
<Site>
|
||||
<Site landing>
|
||||
<main id="main">
|
||||
<section class="hero">
|
||||
<Landing />
|
||||
<section class="hero landing-explanation">
|
||||
<div class="hero-copy">
|
||||
<p class="eyebrow"><span class="status-dot" aria-hidden="true"></span> WEB INTERFACE PROTOCOL</p>
|
||||
<h1>ツールの一覧から、<br /><em>探索できる世界へ。</em></h1>
|
||||
@@ -17,11 +19,6 @@ const interactions = [
|
||||
<div class="hero-actions"><a class="button primary" href={href('book/')}>Bookを読む <span aria-hidden="true">→</span></a><a class="text-link" href={bookHref('1-concept')}>コンセプトから知る ↗</a></div>
|
||||
<p class="hero-note">One worldspace. Three core interactions.</p>
|
||||
</div>
|
||||
<div class="world-card" aria-label="Worldspaceの例">
|
||||
<div class="card-top"><span class="status-dot" aria-hidden="true"></span><span>WORLDSPACE</span><span class="card-coordinate">/</span></div>
|
||||
<div class="world-tree"><div class="tree-root">/ <span>あなたに公開された世界</span></div><ul><li><span class="tree-symbol">◇</span> repository <small>source & history</small></li><li><span class="tree-symbol">◇</span> issues <small>query & resolve</small><ul><li><span class="tree-symbol">◈</span> 123 <span class="tree-tag">Object</span></li></ul></li><li><span class="tree-symbol">◇</span> knowledge <small>search & discover</small></li></ul></div>
|
||||
<div class="operation-example"><span class="code-label">SELECT AN OPERATION</span><code>/::issue.lifecycle#close</code><span class="example-note">参照を見つけ、意味を理解し、明示的に呼び出す。</span></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="interaction-section" aria-labelledby="interactions">
|
||||
<div class="section-heading"><p class="eyebrow">A SMALL COMMON LANGUAGE</p><h2 id="interactions">共通の約束は、小さく。</h2><p>環境ごとの巨大なAPIではなく、探索と実行のための3つのCore interaction。</p></div>
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
import { objects, initialObject, childrenOf, objectSignature, interfaceSignature, interfaceReference } from '../lib/landing-demo';
|
||||
|
||||
const clamp = (value: number) => Math.max(0, Math.min(1, value));
|
||||
const progress = (time: number, start: number, duration: number) => clamp((time - start) / duration);
|
||||
// Solve the timing curve's x coordinate, then evaluate y (not a Bézier
|
||||
// polynomial used directly as time). Curves are assigned by physical role.
|
||||
function bezier(x1: number, y1: number, x2: number, y2: number) {
|
||||
const at = (t: number, a: number, b: number) => 3 * (1 - t) ** 2 * t * a + 3 * (1 - t) * t * t * b + t ** 3;
|
||||
return (value: number) => {
|
||||
if (value <= 0 || value >= 1) return clamp(value);
|
||||
let low = 0, high = 1;
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const t = (low + high) / 2;
|
||||
if (at(t, x1, x2) < value) low = t; else high = t;
|
||||
}
|
||||
return at((low + high) / 2, y1, y2);
|
||||
};
|
||||
}
|
||||
const timing = {
|
||||
// Mass leaves the centre horizontally; a longer deceleration seats the root.
|
||||
travel: bezier(.58, 0, .22, 1),
|
||||
// Curvature releases after travel has begun, rather than a simultaneous zoom.
|
||||
unroll: bezier(.55, 0, .3, 1),
|
||||
scale: bezier(.42, 0, .18, 1),
|
||||
// The leading edge slows as it reaches a branch/node connection.
|
||||
trace: bezier(.22, .3, .25, 1),
|
||||
};
|
||||
const mix = (a: number, b: number, value: number) => a + (b - a) * value;
|
||||
const duration = 6200;
|
||||
// Preserve the arc length while reducing curvature. Unlike interpolating arbitrary
|
||||
// Bézier control points, this cannot fold or bunch the letters during unrolling.
|
||||
function unrolledOrbit(opening: number): string {
|
||||
const x = mix(-180, -150, opening);
|
||||
if (opening > .999) return `M${x} 0 H${x + 360 * Math.PI}`;
|
||||
const radius = 180 / (1 - opening);
|
||||
const tangent = mix(-Math.PI / 2, 0, opening);
|
||||
const point = (fraction: number) => {
|
||||
const angle = tangent + Math.PI * 2 * (1 - opening) * fraction;
|
||||
return `${x + radius * (Math.sin(angle) - Math.sin(tangent))} ${radius * (Math.cos(tangent) - Math.cos(angle))}`;
|
||||
};
|
||||
return `M${x} 0 A${radius} ${radius} 0 0 1 ${point(.5)} A${radius} ${radius} 0 0 1 ${point(1)}`;
|
||||
}
|
||||
|
||||
export function initLanding() {
|
||||
const element = document.querySelector<HTMLElement>('[data-landing-stage]');
|
||||
if (!element) return;
|
||||
const stage: HTMLElement = element;
|
||||
const html = document.documentElement;
|
||||
const motion = matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const get = <T extends Element = HTMLElement>(selector: string) => stage.querySelector<T>(selector)!;
|
||||
const root = get<HTMLButtonElement>('.root-mark');
|
||||
const tether = get<SVGPathElement>('.root-tether');
|
||||
const slot = get('.root-slot');
|
||||
const tree = get('.demo-tree');
|
||||
const nodes = [...stage.querySelectorAll<HTMLButtonElement>('.demo-node')];
|
||||
const rows = nodes.map((node) => node.parentElement!);
|
||||
const branches = [...stage.querySelectorAll<HTMLElement>('.branch-prefix')];
|
||||
const rootWire = get('.root-wire');
|
||||
const reveals = [...stage.querySelectorAll<HTMLElement>('[data-reveal]')];
|
||||
const orbit = get<SVGSVGElement>('.orbit');
|
||||
const orbitPath = get<SVGPathElement>('#welcome-orbit');
|
||||
const textPath = get<SVGTextPathElement>('.orbit textPath');
|
||||
const taps = [...stage.querySelectorAll<HTMLElement>('.tap-stream')];
|
||||
const invitation = get('.intro-invitation');
|
||||
const skip = get<HTMLButtonElement>('.intro-skip');
|
||||
const call = get<HTMLButtonElement>('.demo-call');
|
||||
const result = get('[data-result]');
|
||||
const status = get('.demo-status');
|
||||
const callState = get('[data-call-state]');
|
||||
const treeCaption = get('.tree-caption');
|
||||
const blocked = [...document.querySelectorAll<HTMLElement>('.site-header, .site-footer, main > :not(.landing)')];
|
||||
const abort = new AbortController();
|
||||
let state: 'armed' | 'playing' | 'complete' = 'complete';
|
||||
let selected = initialObject;
|
||||
let frame = 0;
|
||||
let startTime = 0;
|
||||
let keyboardStarted = false;
|
||||
let animation: Animation | undefined;
|
||||
const collapsed = new Set<string>();
|
||||
|
||||
function select(index: number, focus = false) {
|
||||
selected = objects[index];
|
||||
nodes.forEach((node, i) => {
|
||||
node.setAttribute('aria-selected', String(i === index));
|
||||
node.tabIndex = i === index ? 0 : -1;
|
||||
});
|
||||
get('[data-object-signature]').textContent = objectSignature(selected);
|
||||
get('[data-interface-signature]').textContent = interfaceSignature(selected);
|
||||
get('[data-target]').textContent = selected.path;
|
||||
get('[data-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 });
|
||||
}
|
||||
|
||||
function updateExpanded() {
|
||||
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;
|
||||
if (childrenOf(object.path).length) {
|
||||
nodes[index].setAttribute('aria-expanded', String(!collapsed.has(object.path)));
|
||||
const indicator = nodes[index].querySelector('.node-indicator');
|
||||
if (indicator) indicator.textContent = collapsed.has(object.path) ? '+' : '−';
|
||||
}
|
||||
});
|
||||
rootWire.textContent = collapsed.has('/') ? '──╴' : '──┐';
|
||||
}
|
||||
|
||||
nodes.forEach((node, index) => node.addEventListener('click', (event) => {
|
||||
if (state !== 'complete') return;
|
||||
select(index);
|
||||
if ((event.target as Element).closest('.node-indicator') && childrenOf(objects[index].path).length) {
|
||||
const path = objects[index].path;
|
||||
collapsed.has(path) ? collapsed.delete(path) : collapsed.add(path);
|
||||
updateExpanded();
|
||||
}
|
||||
}));
|
||||
|
||||
tree.addEventListener('keydown', (event) => {
|
||||
if (state !== 'complete') return;
|
||||
const index = nodes.indexOf(document.activeElement as HTMLButtonElement);
|
||||
if (index < 0) return;
|
||||
const visible = nodes.map((_, i) => i).filter((i) => !rows[i].hidden);
|
||||
const position = visible.indexOf(index);
|
||||
let target = index;
|
||||
switch (event.key) {
|
||||
case 'ArrowDown': target = visible[Math.min(position + 1, visible.length - 1)]; break;
|
||||
case 'ArrowUp': target = visible[Math.max(position - 1, 0)]; break;
|
||||
case 'Home': target = visible[0]; break;
|
||||
case 'End': target = visible[visible.length - 1]; break;
|
||||
case 'ArrowRight':
|
||||
if (childrenOf(objects[index].path).length) {
|
||||
if (collapsed.delete(objects[index].path)) updateExpanded();
|
||||
else target = visible[position + 1];
|
||||
}
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
if (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);
|
||||
break;
|
||||
default: return;
|
||||
}
|
||||
event.preventDefault();
|
||||
select(target, true);
|
||||
});
|
||||
|
||||
call.addEventListener('click', () => {
|
||||
if (state !== 'complete' || selected.body === undefined) return;
|
||||
animation?.cancel();
|
||||
result.textContent = selected.body;
|
||||
callState.textContent = 'RETURNED';
|
||||
callState.dataset.callState = 'returned';
|
||||
status.textContent = `${selected.path} の read_body が完了。ローカルサンプル / 外部通信なし。`;
|
||||
if (!motion.matches) animation = result.animate([{ opacity: .25, transform: 'translateY(5px)' }, { opacity: 1, transform: 'translateY(0)' }], { duration: 280, easing: 'cubic-bezier(.2,.7,.2,1)' });
|
||||
});
|
||||
|
||||
function finish() {
|
||||
cancelAnimationFrame(frame);
|
||||
state = 'complete';
|
||||
stage.dataset.phase = 'complete';
|
||||
delete html.dataset.landing;
|
||||
abort.abort();
|
||||
root.style.removeProperty('transform');
|
||||
tether.style.removeProperty('stroke-dasharray');
|
||||
tether.style.removeProperty('stroke-dashoffset');
|
||||
tether.style.removeProperty('opacity');
|
||||
root.setAttribute('aria-label', 'ルート /');
|
||||
invitation.style.removeProperty('opacity');
|
||||
nodes.forEach((node) => {
|
||||
node.disabled = false;
|
||||
node.style.removeProperty('opacity');
|
||||
node.style.removeProperty('transform');
|
||||
node.style.removeProperty('will-change');
|
||||
});
|
||||
reveals.forEach((element) => {
|
||||
element.inert = false;
|
||||
element.removeAttribute('aria-hidden');
|
||||
element.style.removeProperty('opacity');
|
||||
element.style.removeProperty('transform');
|
||||
element.style.removeProperty('clip-path');
|
||||
});
|
||||
blocked.forEach((element) => { element.inert = false; });
|
||||
[...branches, rootWire].forEach((wire) => {
|
||||
wire.style.removeProperty('opacity');
|
||||
wire.style.removeProperty('clip-path');
|
||||
});
|
||||
select(objects.indexOf(selected), keyboardStarted || document.activeElement === skip || document.activeElement === root);
|
||||
}
|
||||
|
||||
// One clock owns all phases. Inputs cannot advance, restart, or stack timelines.
|
||||
function render(time: number) {
|
||||
const target = slot.getBoundingClientRect();
|
||||
const endX = target.left + root.offsetWidth / 2;
|
||||
const endY = target.bottom - root.offsetHeight / 2;
|
||||
const travel = timing.travel(progress(time, 2320, 2330));
|
||||
const remaining = 1 - travel;
|
||||
// A continuous spatial Bézier: depart left, arrive vertically. The orbit is
|
||||
// attached to this very same moving origin, not animated on a separate track.
|
||||
const centreX = remaining ** 3 * innerWidth / 2
|
||||
+ 3 * remaining ** 2 * travel * (innerWidth * .26)
|
||||
+ 3 * remaining * travel ** 2 * endX + travel ** 3 * endX;
|
||||
const centreY = remaining ** 3 * innerHeight / 2
|
||||
+ 3 * remaining ** 2 * travel * innerHeight / 2
|
||||
+ 3 * remaining * travel ** 2 * (endY + innerHeight * .15) + travel ** 3 * endY;
|
||||
const scale = mix(7.2, 1, timing.scale(progress(time, 3120, 1530)));
|
||||
root.style.transform = `translate3d(${centreX - endX}px, ${centreY - endY}px, 0) scale(${scale})`;
|
||||
tether.style.strokeDasharray = '1';
|
||||
tether.style.strokeDashoffset = String(1 - timing.trace(progress(time, 3260, 980)));
|
||||
tether.style.opacity = String(1 - progress(time, 4200, 450));
|
||||
invitation.style.opacity = String(1 - progress(time, 0, 280));
|
||||
const tapOpacity = progress(time, 60, 180) * (1 - progress(time, 650, 350));
|
||||
taps.forEach((tap, index) => {
|
||||
tap.style.opacity = String(tapOpacity);
|
||||
tap.firstElementChild!.setAttribute('style', `transform: translateX(${mix(index ? -60 : 0, index ? 0 : -60, progress(time, 0, 1050))}px)`);
|
||||
});
|
||||
const opening = timing.unroll(progress(time, 2530, 1350));
|
||||
orbitPath.setAttribute('d', unrolledOrbit(opening));
|
||||
orbit.style.opacity = String(progress(time, 620, 480) * (1 - progress(time, 3500, 550)));
|
||||
const turn = mix(-36, 2, progress(time, 620, 2100)) * (1 - opening);
|
||||
orbit.style.transform = `translate(calc(-50% + ${centreX - innerWidth / 2}px), calc(-50% + ${centreY - innerHeight / 2}px)) rotate(${turn}deg)`;
|
||||
textPath.setAttribute('startOffset', String(12 + opening * 650));
|
||||
rootWire.style.opacity = String(progress(time, 4140, 320));
|
||||
branches.forEach((wire, index) => {
|
||||
const value = timing.trace(progress(time, 4140 + index * 85, 520));
|
||||
wire.style.opacity = String(clamp(value * 4));
|
||||
wire.style.clipPath = `inset(0 ${(1 - value) * 100}% 0 0)`;
|
||||
});
|
||||
nodes.slice(1).forEach((node, index) => {
|
||||
// Labels resolve where the growing line arrives; they do not slide in.
|
||||
node.style.opacity = String(progress(time, 4490 + index * 85, 220));
|
||||
});
|
||||
reveals.forEach((element, index) => {
|
||||
const isPanel = element.classList.contains('ascii-panel');
|
||||
const start = isPanel ? 5020 + (element.classList.contains('call-panel') ? 180 : 0) : 4680 + index * 55;
|
||||
const value = progress(time, start, isPanel ? 850 : 380);
|
||||
element.style.opacity = String(isPanel ? clamp(value * 3) : value);
|
||||
// Terminal panels materialize in reading order; no unrelated card motion.
|
||||
if (isPanel) element.style.clipPath = `inset(0 0 ${(1 - value) * 100}% 0)`;
|
||||
});
|
||||
stage.dataset.phase = time < 620 ? 'tap' : time < 2530 ? 'orbit' : time < 4080 ? 'unfold' : time < 5020 ? 'tree' : 'panels';
|
||||
}
|
||||
|
||||
function tick(now: number) {
|
||||
if (state !== 'playing') return;
|
||||
const time = now - startTime;
|
||||
if (time >= duration) return finish();
|
||||
try {
|
||||
render(time);
|
||||
frame = requestAnimationFrame(tick);
|
||||
} catch {
|
||||
// Fail open: the document and demo are more important than the animation.
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
function start(fromKeyboard = false) {
|
||||
if (state !== 'armed') return;
|
||||
if (motion.matches) return finish();
|
||||
keyboardStarted = fromKeyboard;
|
||||
state = 'playing';
|
||||
html.dataset.landing = 'playing';
|
||||
startTime = performance.now();
|
||||
root.style.willChange = 'transform';
|
||||
frame = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
if (html.dataset.landing === 'boot' && !motion.matches && !location.hash && scrollY < 20) {
|
||||
state = 'armed';
|
||||
html.dataset.landing = 'armed';
|
||||
stage.dataset.phase = 'armed';
|
||||
blocked.forEach((element) => { element.inert = true; });
|
||||
reveals.forEach((element) => { element.inert = true; element.setAttribute('aria-hidden', 'true'); });
|
||||
root.disabled = false;
|
||||
root.tabIndex = 0;
|
||||
root.setAttribute('aria-label', 'Worldspaceを開く');
|
||||
render(0);
|
||||
stage.dataset.phase = 'armed';
|
||||
invitation.style.opacity = '1';
|
||||
const signal = abort.signal;
|
||||
document.addEventListener('click', (event) => {
|
||||
if ((event.target as Element).closest('.intro-skip, .skip-link')) return finish();
|
||||
start();
|
||||
}, { signal });
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.metaKey || event.ctrlKey || event.altKey || ['Meta', 'Control', 'Alt', 'Shift'].includes(event.key)) return;
|
||||
if (event.key === 'Escape') { keyboardStarted = true; return finish(); }
|
||||
if (event.key === 'Tab') { start(true); return; }
|
||||
if ((event.target as Element).closest('.intro-skip, .skip-link') && event.key === 'Enter') return;
|
||||
event.preventDefault();
|
||||
start(true);
|
||||
}, { signal });
|
||||
const scrollTrigger = (event: Event) => {
|
||||
if (event instanceof WheelEvent && (event.ctrlKey || (event.deltaX === 0 && event.deltaY === 0))) return;
|
||||
if (event.cancelable) event.preventDefault();
|
||||
start();
|
||||
};
|
||||
document.addEventListener('wheel', scrollTrigger, { signal, passive: false });
|
||||
document.addEventListener('touchmove', scrollTrigger, { signal, passive: false });
|
||||
document.addEventListener('scroll', () => start(), { signal, passive: true });
|
||||
} else {
|
||||
finish();
|
||||
}
|
||||
|
||||
const resize = new ResizeObserver(() => {
|
||||
if (state === 'armed') { render(0); stage.dataset.phase = 'armed'; invitation.style.opacity = '1'; }
|
||||
});
|
||||
resize.observe(get('.tree-pane'));
|
||||
// Resuming a backgrounded page should never leave a half-finished intro.
|
||||
const onVisibility = () => { if (document.hidden && state === 'playing') finish(); };
|
||||
const onMotion = () => { if (motion.matches && state !== 'complete') finish(); };
|
||||
const onPageShow = (event: PageTransitionEvent) => { if (event.persisted && state !== 'complete') finish(); };
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
motion.addEventListener('change', onMotion);
|
||||
window.addEventListener('pageshow', onPageShow);
|
||||
window.addEventListener('pagehide', (event) => {
|
||||
if (state !== 'complete') finish();
|
||||
animation?.cancel();
|
||||
if (event.persisted) return;
|
||||
resize.disconnect();
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
motion.removeEventListener('change', onMotion);
|
||||
window.removeEventListener('pageshow', onPageShow);
|
||||
});
|
||||
treeCaption.title = '↑ ↓: 選択 / ←: 閉じる・親へ / →: 開く・子へ / Home・End: 先頭・末尾';
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/* One mono cell horizontally, one 24px baseline vertically. The frame is
|
||||
quantized to four cells so both columns and their gutter remain on-grid. */
|
||||
.landing-page {
|
||||
--paper: #fafaf8; --ink: #20221f; --muted: #686d64; --green: #283e25;
|
||||
--accent: #dcf9b0; --line: #d9ddd3; --row: 24px;
|
||||
--mono: 'SFMono-Regular', Consolas, 'Liberation Mono', 'Noto Sans Mono CJK JP', monospace;
|
||||
font: 14px/var(--row) var(--mono); background: var(--paper);
|
||||
}
|
||||
.landing-page :is(button, input, code, pre) { font: inherit; }
|
||||
.landing-page :is(h1, h2, h3, p) { letter-spacing: 0; }
|
||||
.landing-page :is(.site-header, .landing-content, .hero, .interaction-section, .principles, .site-footer) {
|
||||
width: calc(100% - 4ch); width: round(down, calc(100% - 4ch), 4ch);
|
||||
max-width: 132ch; 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; }
|
||||
.landing-page .brand { font: bold 14px/var(--row) var(--mono); letter-spacing: 0; }
|
||||
.landing-page .brand-mark { display: none; }
|
||||
.landing-page .brand-caption { font: inherit; font-weight: normal; }
|
||||
.landing-page .site-header nav { font: inherit; gap: 3ch; }
|
||||
.landing-page .site-header nav a { padding: 0; }
|
||||
.landing { position: relative; isolation: isolate; overflow: clip; min-height: 100svh; padding: calc(5 * var(--row)) 0 var(--row); background: var(--paper); }
|
||||
.landing button { color: inherit; }
|
||||
.explorer-heading { display: flex; align-items: end; justify-content: space-between; gap: 4ch; margin-bottom: var(--row); }
|
||||
.explorer-heading .eyebrow { margin: 0; font: inherit; letter-spacing: 0; }
|
||||
.explorer-heading h2 { font-size: 21px; line-height: calc(2 * var(--row)); font-weight: 500; margin: 0; }
|
||||
.demo-badge { font: inherit; color: var(--muted); }
|
||||
.demo-badge i { display: none; }
|
||||
.explorer { display: grid; grid-template-columns: 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; }
|
||||
.tree-pane { min-width: 0; }
|
||||
.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 { position: relative; }
|
||||
.node-row { position: relative; padding-left: 5ch; height: var(--row); display: flex; align-items: center; white-space: nowrap; }
|
||||
.node-row[hidden] { display: none; }
|
||||
.node-row.root-slot { padding: 0; height: calc(3 * var(--row)); align-items: end; }
|
||||
/* These are real Unicode box-drawing characters, not SVG paths or borders.
|
||||
Scale only their vertical ink to cover the leading between mono baselines. */
|
||||
.branch-prefix, .root-wire { display: inline-block; flex: 0 0 auto; white-space: pre; font: 14px/14px var(--mono); height: 14px; color: #8e9886; transform: scaleY(1.7142857143); transform-origin: center; pointer-events: none; }
|
||||
.root-wire { margin-bottom: 5px; }
|
||||
.demo-node { position: relative; flex: 0 0 auto; border: 0; border-radius: 0; padding: 0 1ch; margin: 0; height: var(--row); display: flex; align-items: center; gap: 1ch; cursor: pointer; background: transparent; text-align: left; transition: background .18s; }
|
||||
.demo-node[aria-selected="true"] { background: var(--accent); color: #273621; }
|
||||
.demo-node:hover { background: #eaf0e2; }
|
||||
.demo-node[aria-selected="true"]:hover { background: #d1f39b; }
|
||||
.demo-node:disabled { cursor: default; opacity: 1; }
|
||||
.node-indicator { width: 1ch; color: var(--muted); }
|
||||
.root-mark { width: 3ch; height: var(--row); display: block; padding: 0; background: transparent !important; transform-origin: center; z-index: 25; transition: none; }
|
||||
.root-vector { display: block; width: 100%; height: 100%; overflow: visible; }
|
||||
.root-shape { fill: currentColor; }
|
||||
.root-tether { fill: none; stroke: #8e9886; stroke-width: 1; opacity: 0; }
|
||||
html[data-landing="boot"] .root-tether { stroke-dasharray: 1; stroke-dashoffset: 1; }
|
||||
.root-mark[aria-selected="true"] { color: #4e752d; }
|
||||
.root-caption { margin-left: 2ch; height: var(--row); color: var(--muted); }
|
||||
.ascii-panel { position: relative; min-width: 0; outline: 1px solid var(--line); outline-offset: -1px; padding: var(--row) 2ch; }
|
||||
.ascii-panel::before, .ascii-panel::after { content: '+'; position: absolute; top: calc(var(--row) / -2); font: inherit; background: var(--paper); color: #89947f; width: 1ch; text-align: center; }
|
||||
.ascii-panel::before { left: -.5ch; }
|
||||
.ascii-panel::after { right: -.5ch; }
|
||||
.panel-title { display: flex; justify-content: space-between; gap: 2ch; align-items: baseline; min-height: var(--row); margin-bottom: var(--row); }
|
||||
.panel-title h3 { font: inherit; margin: 0; }
|
||||
.panel-title > span { color: var(--muted); }
|
||||
.ascii-panel pre { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; tab-size: 2; }
|
||||
.text-panel { grid-column: 1; grid-row: 2; }
|
||||
.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-target { display: grid; grid-template-columns: 7ch minmax(0, 1fr); margin: 0 0 var(--row); }
|
||||
.call-target > span { color: var(--muted); }
|
||||
.call-target code { overflow-wrap: anywhere; }
|
||||
.interface-signature { color: #58604f; }
|
||||
.call-controls { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--row) 2ch; margin-top: var(--row); padding-bottom: var(--row); box-shadow: 0 1px var(--line); }
|
||||
.call-controls > code { overflow-wrap: anywhere; }
|
||||
.demo-call { display: flex; justify-content: space-between; gap: 2ch; align-items: center; background: var(--ink); color: white !important; border: 0; padding: 0 2ch; height: calc(2 * var(--row)); cursor: pointer; transition: background .18s; }
|
||||
.demo-call:hover { background: #3f5036; }
|
||||
.demo-call:disabled { opacity: .4; cursor: default; }
|
||||
.result-shell { flex: 1; min-height: calc(9 * var(--row)); margin-top: var(--row); }
|
||||
.result-label { display: flex; justify-content: space-between; gap: 2ch; color: var(--muted); margin-bottom: var(--row); }
|
||||
.result-label [data-call-state="returned"] { color: #4d722a; }
|
||||
.demo-status { color: var(--muted); margin: var(--row) 0 0; min-height: var(--row); }
|
||||
.explorer-footer { display: flex; justify-content: space-between; gap: 4ch; align-items: start; margin-top: var(--row); padding-top: var(--row); box-shadow: 0 -1px var(--line); }
|
||||
.explorer-footer > span { color: var(--muted); }
|
||||
.explorer-footer a { text-decoration: none; white-space: nowrap; }
|
||||
.explorer-footer a span { margin-left: 2ch; }
|
||||
.no-script-note { color: var(--muted); margin-top: var(--row); }
|
||||
/* The explanatory sections use the same frame, baseline, and mono type. */
|
||||
.landing-page .hero, .landing-page .interaction-section, .landing-page .principles { padding: calc(3 * var(--row)) 0; }
|
||||
.landing-explanation { display: block; text-align: left; border-top: 1px solid var(--line); margin-top: calc(3 * var(--row)); }
|
||||
.landing-explanation .hero-actions { justify-content: start; }
|
||||
.landing-page :is(.eyebrow, .lead, .hero-note, .text-link, .step, .section-heading p, .principle-list p, .interaction-card p, .interaction-card code, .reading-cta p) { font: inherit; letter-spacing: 0; }
|
||||
.landing-page :is(.hero h1, .section-heading h2, .principles h2, .reading-cta h2) { font-size: 28px; line-height: calc(2 * var(--row)); margin: 0 0 var(--row); }
|
||||
.landing-page :is(.principle-list h3, .interaction-card h3) { font-size: inherit; line-height: var(--row); margin: 0 0 var(--row); }
|
||||
.landing-page p { margin-bottom: var(--row); }
|
||||
.landing-page .hero-actions { gap: 2ch; margin: var(--row) 0; }
|
||||
.landing-page .button { font: inherit; border-radius: 0; padding: var(--row) 2ch; border: 0; }
|
||||
.landing-page .section-heading { margin-bottom: var(--row); }
|
||||
.landing-page .interaction-grid { border-radius: 0; }
|
||||
.landing-page .interaction-card { padding: var(--row) 2ch; }
|
||||
.landing-page .step { margin-bottom: var(--row); }
|
||||
.landing-page .principles { gap: 4ch; }
|
||||
.landing-page .principle-list > div + div { padding-top: var(--row); }
|
||||
.landing-page .reading-cta { padding: calc(3 * var(--row)) 2ch; margin: 0; }
|
||||
.landing-page .site-footer { padding: var(--row) 0; font: inherit; gap: 2ch; }
|
||||
.mobile-only { display: none; }
|
||||
.intro-art, .intro-invitation, .intro-skip { display: none; }
|
||||
html[data-landing] { overflow: hidden; }
|
||||
html[data-landing] .landing { touch-action: none; }
|
||||
html[data-landing] .landing-page .site-header, html[data-landing] .landing-page .site-footer, html[data-landing] .landing-page main > :not(.landing) { visibility: hidden; }
|
||||
html[data-landing] [data-reveal], html[data-landing] .demo-node:not(.root-mark), html[data-landing] :is(.branch-prefix, .root-wire) { opacity: 0; }
|
||||
html[data-landing] .root-mark { color: var(--ink); }
|
||||
html[data-landing="boot"] .root-mark { position: fixed; left: calc(50vw - 1.5ch); top: calc(50svh - var(--row) / 2); transform: scale(7.2); }
|
||||
html[data-landing] .intro-art { display: block; position: fixed; z-index: 10; width: 100%; height: 100svh; inset: 0; pointer-events: none; }
|
||||
.orbit { position: absolute; left: 50%; top: 50%; width: min(640px, 112vw); height: min(640px, 112vw); overflow: visible; transform: translate(-50%, -50%); opacity: 0; }
|
||||
.orbit text { fill: var(--ink); font: 500 17px var(--mono); letter-spacing: 1.5px; }
|
||||
.tap-stream { position: absolute; left: 50%; top: 50%; width: 200px; overflow: hidden; font: 500 10px var(--mono); letter-spacing: 2px; opacity: 0; white-space: nowrap; }
|
||||
.tap-stream-a { transform: translate(-145px, -24px) rotate(-67deg); }
|
||||
.tap-stream-b { transform: translate(-50px, 22px) rotate(-67deg); }
|
||||
.tap-stream span { display: block; }
|
||||
html[data-landing] .intro-invitation { display: block; position: fixed; left: 2ch; right: 2ch; bottom: calc(4 * var(--row)); text-align: center; z-index: 11; pointer-events: none; color: var(--muted); }
|
||||
.intro-invitation p { margin: var(--row) 0 0; }
|
||||
html[data-landing] .intro-skip { display: block; position: fixed; right: 2ch; bottom: var(--row); z-index: 40; border: 0; padding: 0 1ch; height: var(--row); background: transparent; color: var(--muted); cursor: pointer; }
|
||||
html[data-landing="armed"] .root-mark { cursor: pointer; }
|
||||
@media (max-width: 1100px) {
|
||||
.explorer-heading { display: block; }
|
||||
.demo-badge { display: block; }
|
||||
.tree-caption span:last-child { display: none; }
|
||||
.panel-title > span { display: none; }
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.landing { padding-top: calc(4 * var(--row)); }
|
||||
.landing-page .brand-caption { display: none; }
|
||||
.explorer { grid-template-columns: minmax(0, 1fr); }
|
||||
.text-panel, .call-panel { grid-column: 1; grid-row: auto; }
|
||||
.call-panel { min-height: calc(20 * var(--row)); }
|
||||
.explorer-footer { flex-wrap: wrap; gap: var(--row); }
|
||||
.mobile-only { display: block; }
|
||||
.explorer-heading h2 { line-height: calc(2 * var(--row)); }
|
||||
.landing-page .principles { grid-template-columns: 1fr; }
|
||||
.landing-page .hero h1 { font-size: 21px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.landing-page *, .landing-page *::before, .landing-page *::after { transition: none !important; animation: none !important; }
|
||||
}
|
||||
@media print {
|
||||
.intro-art, .intro-invitation, .intro-skip { display: none !important; }
|
||||
.landing { padding: var(--row); }
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.use({ javaScriptEnabled: true });
|
||||
|
||||
const stage = '[data-landing-stage]';
|
||||
async function armed(page: import('@playwright/test').Page) {
|
||||
await page.goto('./');
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'armed');
|
||||
}
|
||||
async function complete(page: import('@playwright/test').Page) {
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'complete', { timeout: 10000 });
|
||||
await expect(page.locator('html')).not.toHaveAttribute('data-landing');
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||
await expect(page.getByRole('link', { name: 'Book', exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
for (const trigger of ['click', 'wheel', 'key']) {
|
||||
test(`one ${trigger} plays through to the interactive world without more input`, async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await armed(page);
|
||||
if (trigger === 'click') await page.getByRole('treeitem', { name: 'Worldspaceを開く' }).click();
|
||||
if (trigger === 'wheel') await page.mouse.wheel(0, 240);
|
||||
if (trigger === 'key') await page.keyboard.press('a');
|
||||
await expect(page.locator('html')).toHaveAttribute('data-landing', 'playing');
|
||||
await complete(page);
|
||||
await 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 expect(page.getByRole('status')).toContainText('read_body が完了');
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test('storyboard phases retain one root and ignore repeated triggers', async ({ page }, testInfo) => {
|
||||
await page.clock.install({ time: new Date('2026-01-01T00:00:00Z') });
|
||||
await page.clock.pauseAt(new Date('2026-01-01T00:00:01Z'));
|
||||
await armed(page);
|
||||
const root = page.locator('.root-mark');
|
||||
await expect(root.locator('svg .root-shape')).toHaveCount(1);
|
||||
await expect(root).toHaveText(''); // Geometry, never a font glyph.
|
||||
await expect(page.locator('.call-panel')).toHaveAttribute('inert', '');
|
||||
const initial = await root.boundingBox();
|
||||
expect(initial).not.toBeNull();
|
||||
expect(Math.abs(initial!.x + initial!.width / 2 - page.viewportSize()!.width / 2)).toBeLessThan(2);
|
||||
expect(Math.abs(initial!.y + initial!.height / 2 - page.viewportSize()!.height / 2)).toBeLessThan(2);
|
||||
await page.screenshot({ path: testInfo.outputPath('01-root.png') });
|
||||
await root.click();
|
||||
await page.clock.runFor(320);
|
||||
await page.screenshot({ path: testInfo.outputPath('02-tap.png') });
|
||||
await page.clock.runFor(1450);
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'orbit');
|
||||
const arcLength = await page.locator('#welcome-orbit').evaluate((path) => (path as SVGPathElement).getTotalLength());
|
||||
await page.screenshot({ path: testInfo.outputPath('03-orbit.png') });
|
||||
await page.mouse.wheel(0, 180);
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await page.mouse.click(20, 20);
|
||||
await page.clock.runFor(1430);
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'unfold');
|
||||
const unfoldingLength = await page.locator('#welcome-orbit').evaluate((path) => (path as SVGPathElement).getTotalLength());
|
||||
expect(Math.abs(unfoldingLength - arcLength)).toBeLessThan(2);
|
||||
await page.screenshot({ path: testInfo.outputPath('04-unfold.png') });
|
||||
await page.clock.runFor(1550);
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'tree');
|
||||
await page.screenshot({ path: testInfo.outputPath('05-tree.png') });
|
||||
await page.clock.runFor(1800);
|
||||
await complete(page);
|
||||
await expect(root).toHaveCount(1);
|
||||
await expect(page.locator('.call-panel')).not.toHaveAttribute('inert');
|
||||
await expect(page.locator('.branch-lines')).toHaveCount(0);
|
||||
await expect(page.locator('.branch-prefix')).toHaveText([
|
||||
'├── ', '│ ├── ', '│ └── ', '│ └── ',
|
||||
'├── ', '│ └── ', '└── ', ' └── ',
|
||||
]);
|
||||
const grid = await page.evaluate(() => {
|
||||
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());
|
||||
return {
|
||||
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),
|
||||
font: getComputedStyle(prefix).fontFamily,
|
||||
};
|
||||
});
|
||||
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);
|
||||
expect(grid.font).toContain('Mono');
|
||||
await page.screenshot({ path: testInfo.outputPath('06-world.png'), fullPage: true, animations: 'disabled' });
|
||||
await page.keyboard.press('z');
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'complete');
|
||||
});
|
||||
|
||||
test('tree selection, collapse and call results are keyboard accessible', 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 page.keyboard.press('ArrowUp');
|
||||
const issues = page.getByRole('treeitem', { name: 'issues', exact: true });
|
||||
await expect(issues).toBeFocused();
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await expect(issues).toHaveAttribute('aria-expanded', 'false');
|
||||
await expect(selected).toBeHidden();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(selected).toBeVisible();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(selected).toBeFocused();
|
||||
await page.keyboard.press('Home');
|
||||
await expect(page.getByRole('treeitem', { name: 'ルート /' })).toBeFocused();
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await expect(page.getByRole('treeitem')).toHaveCount(1);
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await page.keyboard.press('End');
|
||||
await expect(page.getByRole('treeitem', { name: 'protocol', exact: true })).toBeFocused();
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.getByRole('button', { name: 'Call', exact: false })).toBeFocused();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page.locator('[data-result]')).toContainText('fetch_interface');
|
||||
});
|
||||
|
||||
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.');
|
||||
});
|
||||
|
||||
test('changing motion preference and resizing cannot strand the intro', async ({ page }) => {
|
||||
await armed(page);
|
||||
await page.setViewportSize({ width: 360, height: 740 });
|
||||
await page.keyboard.press('Space');
|
||||
await expect(page.locator('html')).toHaveAttribute('data-landing', 'playing');
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await complete(page);
|
||||
await page.mouse.wheel(0, 650);
|
||||
await expect.poll(() => page.evaluate(() => scrollY)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('blocked animation module fails open to readable content', async ({ page }) => {
|
||||
await page.route('**/*.js', (route) => route.abort());
|
||||
await page.goto('./');
|
||||
await expect(page.locator('html')).not.toHaveAttribute('data-landing', { timeout: 6000 });
|
||||
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
||||
await page.getByRole('link', { name: 'Book', exact: true }).click();
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText('通信の約束まで');
|
||||
});
|
||||
|
||||
test('touch swipe starts the uninterrupted timeline', async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name !== 'mobile', 'Touch viewport regression');
|
||||
await armed(page);
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: 195, y: 550 }] });
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: 195, y: 400 }] });
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||
await expect(page.locator('html')).toHaveAttribute('data-landing', 'playing');
|
||||
await complete(page);
|
||||
await cdp.detach();
|
||||
});
|
||||
Reference in New Issue
Block a user