fix: carry landing motion through overlapping reveals
Reference site / build (push) Canceled after 0s

This commit is contained in:
2026-10-10 18:01:52 +09:00
parent 651cc573be
commit 5d418beeba
4 changed files with 146 additions and 103 deletions
+3 -3
View File
@@ -39,13 +39,13 @@ npm run test:browser
# インストール済みChromeを使う場合:CHROME_PATH=/path/to/chrome npm run test:browser # インストール済みChromeを使う場合:CHROME_PATH=/path/to/chrome npm run test:browser
``` ```
Bookの閲覧テストはJavaScript無効、ランディングのテストは有効にして実行します。スクリーンショットは`site/test-results/`に保存され、ランディングは6場面をデスクトップ/モバイルで記録します。サブパスbuildの検証時は同じ`SITE_BASE`を設定してください。テスト用previewは`127.0.0.1:4322`を使用し、4321のdev serverとは別に起動します。 Bookの閲覧テストはJavaScript無効、ランディングのテストは有効にして実行します。スクリーンショットは`site/test-results/`に保存され、ランディングは動きが重なる途中フレームをデスクトップ/モバイルで記録し、移動・縮小・幹伸長の同時進行と、移動中のSVG/罫線の接続位置を検証します。サブパスbuildの検証時は同じ`SITE_BASE`を設定してください。テスト用previewは`127.0.0.1:4322`を使用し、4321のdev serverとは別に起動します。
### ランディング ### ランディング
トップはSVGのルートマークから始まります。コンテの2はホバー専用の「Tap」表現で、ポインタを離すと消え、待っていても本編には進みません。クリック・ホイール/タッチスクロール・キー入力のいずれか一回で、3〜6を約6.9秒で最後まで再生します。ホバーは再生の前提ではなく、追加入力で段階を進めたり、再生を重ねたりもしません。Escapeまたは画面右下のスキップで直ちにデモへ移れます。ブラウザの修飾キー付きショートカットは妨げません。 トップはSVGのルートマークから始まります。コンテの2はホバー専用の「Tap」表現で、ポインタを離すと消え、待っていても本編には進みません。クリック・ホイール/タッチスクロール・キー入力のいずれか一回で、全体を約3.9秒で最後まで再生します。ホバーは再生の前提ではなく、追加入力で段階を進めたり、再生を重ねたりもしません。Escapeまたは画面右下のスキップで直ちにデモへ移れます。ブラウザの修飾キー付きショートカットは妨げません。
コンテ4では、大きなルートを左へ移し、円周の上端から右の接線へ文字を流しながら、ルートの直下へ縦の幹を伸ばします。円の半径・曲率は変えません。幹を伸ばす区間を見せてからルートと幹を縮小・移動し、縦幹全体を同じ列の`├`・`│`・`└`へ引き継いで、右へ枝を展開します。主幹は連続したまま保ち、枝先とラベルにglitchを適用します。 ルートは中間位置で停止せず、一つの弧状の軌道でツリーへ移動します。円周文字の上端接線への流出、ルートの縮小、直下への幹伸長を重ね、着地を待たずに右向きの枝とパネルのglitchを始めます。円の半径・曲率は変えません。移動中の枝もルートと同じ座標変換に乗せ、縦幹全体を`├`・`│`・`└`へずれなく引き継ぎます。主幹は連続したまま保ち、枝先とラベルにglitchを適用します。ページのヘッダーや周辺要素も末尾の収束に重ねて現れます。
ツリーとパネルはASCII glitchから確定した文字・罫線へ収束します。ノイズは局所的なセル置換を10Hzで更新し、全画面の点滅は使いません。空白・改行・日本語は保持し、完了/スキップ時には元の文字列を完全に復元します。 ツリーとパネルはASCII glitchから確定した文字・罫線へ収束します。ノイズは局所的なセル置換を10Hzで更新し、全画面の点滅は使いません。空白・改行・日本語は保持し、完了/スキップ時には元の文字列を完全に復元します。
+77 -65
View File
@@ -18,18 +18,22 @@ function bezier(x1: number, y1: number, x2: number, y2: number) {
}; };
} }
const timing = { const timing = {
// Mass leaves the centre horizontally; a longer deceleration seats the root. // One acceleration and one landing, with no intermediate resting pose.
travel: bezier(.58, 0, .22, 1), travel: bezier(.42, 0, .32, 1),
// Typography accelerates along a fixed track, not a deforming circle. // Typography accelerates along a fixed track, not a deforming circle.
release: bezier(.55, 0, .3, 1), release: bezier(.55, 0, .3, 1),
scale: bezier(.42, 0, .18, 1), scale: bezier(.6, 0, .2, 1),
// The leading edge slows as it reaches a branch/node connection. // The leading edge slows as it reaches a branch/node connection.
trace: bezier(.22, .3, .25, 1), trace: bezier(.22, .3, .25, 1),
}; };
const mix = (a: number, b: number, value: number) => a + (b - a) * value; const mix = (a: number, b: number, value: number) => a + (b - a) * value;
// Scene 2 is an independent hover affordance. Playback begins at scene 3. // Overlapping cues on one clock, not a sequence of completed storyboard poses.
const playbackOffset = 620; // Hover is independent; every playback starts this same continuous flow.
const duration = 7500 - playbackOffset; const flow = {
duration: 3900, release: 560, grow: 1320, converge: 1900,
handoff: 2460, branch: 2320, stagger: 55,
textPanel: 2520, callPanel: 2700, panelDuration: 900,
};
const orbitRadius = 180; const orbitRadius = 180;
const circumference = 2 * Math.PI * orbitRadius; const circumference = 2 * Math.PI * orbitRadius;
// The clockwise circle ends at its top, tangent to a rightward straight line. // The clockwise circle ends at its top, tangent to a rightward straight line.
@@ -81,9 +85,9 @@ export function initLanding() {
const panel = element.closest('.ascii-panel'); const panel = element.closest('.ascii-panel');
const row = element.closest('.node-row'); const row = element.closest('.node-row');
const index = rows.indexOf(row as HTMLElement); const index = rows.indexOf(row as HTMLElement);
const start = panel ? (panel.classList.contains('call-panel') ? 6500 : 6320) const start = panel ? (panel.classList.contains('call-panel') ? flow.callPanel : flow.textPanel)
: 5450 + Math.max(0, index - 1) * 85; : flow.branch + Math.max(0, index - 1) * flow.stagger;
return { element, text: element.textContent ?? '', start, duration: panel ? 850 : 580, seed }; return { element, text: element.textContent ?? '', start, duration: panel ? flow.panelDuration : 580, seed };
}); });
let lastGlitchFrame = -1; let lastGlitchFrame = -1;
@@ -97,8 +101,8 @@ export function initLanding() {
target.element.textContent = glitchText(target.text, value, frame, target.seed); target.element.textContent = glitchText(target.text, value, frame, target.seed);
} }
panels.forEach((panel) => { panels.forEach((panel) => {
const start = panel.classList.contains('call-panel') ? 6500 : 6320; const start = panel.classList.contains('call-panel') ? flow.callPanel : flow.textPanel;
panel.toggleAttribute('data-glitching', time >= start && time < start + 850); panel.toggleAttribute('data-glitching', time >= start && time < start + flow.panelDuration);
}); });
} }
@@ -201,6 +205,8 @@ export function initLanding() {
delete stage.dataset.hover; delete stage.dataset.hover;
restoreGlitch(); restoreGlitch();
root.style.removeProperty('transform'); root.style.removeProperty('transform');
rows.slice(1).forEach((row) => { row.style.removeProperty('transform'); });
html.style.removeProperty('--landing-chrome');
trunk.style.opacity = '0'; trunk.style.opacity = '0';
root.setAttribute('aria-label', 'ルート /'); root.setAttribute('aria-label', 'ルート /');
invitation.style.removeProperty('opacity'); invitation.style.removeProperty('opacity');
@@ -225,85 +231,91 @@ export function initLanding() {
select(objects.indexOf(selected), keyboardStarted || document.activeElement === skip || document.activeElement === root); select(objects.indexOf(selected), keyboardStarted || document.activeElement === skip || document.activeElement === root);
} }
// One clock owns all phases. Inputs cannot advance, restart, or stack timelines. // One trajectory carries the root AND emerging branches, so growth can begin
// before docking without disconnecting the SVG trunk from the text grid.
function render(time: number) { function render(time: number) {
const target = slot.getBoundingClientRect(); const target = slot.getBoundingClientRect();
const lastBranchTarget = lastRootBranch.getBoundingClientRect(); const treeTarget = tree.getBoundingClientRect();
// offsetWidth rounds fractional mono cells and would shift the SVG axis. // offsetWidth rounds fractional mono cells and would shift the SVG axis.
const endX = target.left + parseFloat(getComputedStyle(root).width) / 2; const endX = target.left + parseFloat(getComputedStyle(root).width) / 2;
const endY = target.bottom - root.offsetHeight / 2; const endY = target.bottom - root.offsetHeight / 2;
const travel = timing.travel(progress(time, 0, 3000));
const remaining = 1 - travel;
const startX = innerWidth / 2, startY = innerHeight / 2;
const centreX = remaining ** 3 * startX
+ 3 * remaining ** 2 * travel * (startX - Math.min(180, innerWidth * .2))
+ 3 * remaining * travel ** 2 * (endX + Math.min(96, innerWidth * .12)) + travel ** 3 * endX;
const centreY = remaining ** 3 * startY
+ 3 * remaining ** 2 * travel * (startY + Math.min(48, innerHeight * .08))
+ 3 * remaining * travel ** 2 * (endY + Math.min(90, innerHeight * .12)) + travel ** 3 * endY;
const scale = mix(7.2, 1, timing.scale(progress(time, 350, 2650)));
const dx = centreX - endX, dy = centreY - endY;
root.style.transform = `translate3d(${dx}px, ${dy}px, 0) scale(${scale})`;
rows.slice(1).forEach((row) => {
// offsetTop stays in layout coordinates, unaffected by last frame's transform.
const x = dx + (scale - 1) * (treeTarget.left - endX);
const y = dy + (scale - 1) * (treeTarget.top + row.offsetTop - endY);
row.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
});
// Growth overlaps the moving/shrinking root. It crosses the old circle only
// after the lower letters have passed, then converges on the moving grid.
const extension = timing.trace(progress(time, flow.grow, 1000));
const converge = timing.travel(progress(time, flow.converge, 560));
const longLength = Math.max(0, innerHeight * .4 - root.offsetHeight * 7.2 / 2 - 8);
const lineStartY = centreY + root.offsetHeight * scale / 2 + 8 * (1 - converge);
const finalLength = lastRootBranch.offsetTop + lastRootBranch.offsetHeight / 2 - slot.offsetTop - slot.offsetHeight;
const tipY = lineStartY + mix(longLength * extension, finalLength * scale, converge);
const handoff = progress(time, flow.handoff, 280);
trunk.setAttribute('d', `M${centreX} ${lineStartY} V${tipY}`);
trunk.style.strokeWidth = String(mix(10, scale, converge));
const ink = [[32, 142], [34, 152], [31, 134]].map(([from, to]) => Math.round(mix(from, to, converge)));
trunk.style.stroke = `rgb(${ink.join(',')})`;
trunk.style.opacity = String(progress(time, flow.grow, 100) * (1 - handoff));
invitation.style.opacity = String(1 - progress(time, 0, 220));
// Keep the circle centred on the moving root until the narrow viewport
// needs room for its trailing letters; its radius is never scaled or warped.
const orbitScale = parseFloat(getComputedStyle(orbit).width) / 600; const orbitScale = parseFloat(getComputedStyle(orbit).width) / 600;
const radius = orbitRadius * orbitScale; const radius = orbitRadius * orbitScale;
const stagedX = Math.max(innerWidth * .3, radius + 16); const drift = 120 * (1 - Math.exp(-time / 600));
const shiftedX = mix(innerWidth / 2, stagedX, timing.travel(progress(time, 2320, 980))); const release = timing.release(progress(time, flow.release, 1620));
// First slide the large composition left and grow its trunk. Only after
// that readable beat do we dock and shrink it into the terminal grid.
const dock = timing.travel(progress(time, 4100, 1350));
const remaining = 1 - dock;
const centreX = remaining ** 3 * shiftedX
+ 3 * remaining ** 2 * dock * mix(shiftedX, endX, .3)
+ 3 * remaining * dock ** 2 * endX + dock ** 3 * endX;
const centreY = remaining ** 3 * innerHeight / 2
+ 3 * remaining ** 2 * dock * innerHeight / 2
+ 3 * remaining * dock ** 2 * mix(endY, innerHeight / 2, .2) + dock ** 3 * endY;
const scale = mix(7.2, 1, timing.scale(progress(time, 4100, 1350)));
root.style.transform = `translate3d(${centreX - endX}px, ${centreY - endY}px, 0) scale(${scale})`;
// Let the last letters clear the circle's lower arc before drawing through it.
const extension = timing.trace(progress(time, 3040, 820));
// The trunk grows down from the root, independently of the rightward text.
// Keep its head below the slash throughout scaling, then match the entire
// first column of ├ / │ / └ rather than shrinking into a horizontal elbow.
const largeStart = root.offsetHeight * 7.2 / 2 + 8;
const longLength = Math.max(0, innerHeight * .9 - innerHeight / 2 - largeStart);
const startY = centreY + root.offsetHeight * scale / 2 + 8 * remaining;
const finalLength = lastBranchTarget.top + lastBranchTarget.height / 2 - target.bottom;
const tipY = startY + mix(longLength * extension, finalLength, dock);
const handoff = progress(time, 5480, 140);
trunk.setAttribute('d', `M${centreX} ${startY} V${tipY}`);
trunk.style.strokeWidth = String(mix(10, 1, dock));
const ink = [[32, 142], [34, 152], [31, 134]].map(([from, to]) => Math.round(mix(from, to, dock)));
trunk.style.stroke = `rgb(${ink.join(',')})`;
trunk.style.opacity = time < 3040 ? '0' : String(1 - handoff);
invitation.style.opacity = String(1 - progress(time, playbackOffset, 280));
const release = timing.release(progress(time, 2460, 1640));
const exitLength = innerWidth / orbitScale + circumference; const exitLength = innerWidth / orbitScale + circumference;
orbitPath.setAttribute('d', circularTrack + (time >= 2460 ? ` H${exitLength}` : '')); orbitPath.setAttribute('d', circularTrack + (time >= flow.release ? ` H${exitLength}` : ''));
orbit.style.opacity = String(progress(time, 620, 480)); orbit.style.opacity = String(progress(time, 0, 300));
// Rotation comes to rest before release. The exit is always horizontal const orbitX = Math.max(radius + 16, centreX);
// at the circle's top, never the expanding-radius curve used previously. const orbitY = centreY;
const turn = mix(-36, 0, progress(time, 620, 1700)); const turn = mix(-28, 0, timing.trace(progress(time, 0, flow.release)));
orbit.style.transform = `translate(calc(-50% + ${centreX - innerWidth / 2}px), calc(-50% + ${centreY - innerHeight / 2}px)) rotate(${turn}deg)`; orbit.style.transform = `translate(calc(-50% + ${orbitX - startX}px), calc(-50% + ${orbitY - startY}px)) rotate(${turn}deg)`;
textPath.setAttribute('startOffset', String(12 + release * (circumference + exitLength))); textPath.setAttribute('startOffset', String(12 + drift + release * (circumference + exitLength)));
// Preserve the continuous spine during the handoff; only the arms glitch.
spines.forEach((wire) => { wire.style.opacity = String(handoff); }); spines.forEach((wire) => { wire.style.opacity = String(handoff); });
branches.forEach((wire, index) => { branches.forEach((wire, index) => {
const value = timing.trace(progress(time, 5450 + index * 85, 520)); const value = timing.trace(progress(time, flow.branch + index * flow.stagger, 520));
wire.style.opacity = String(clamp(value * 4)); wire.style.opacity = String(clamp(value * 4));
wire.style.clipPath = `inset(0 ${(1 - value) * 100}% 0 0)`; wire.style.clipPath = `inset(0 ${(1 - value) * 100}% 0 0)`;
}); });
nodes.slice(1).forEach((node, index) => { nodes.slice(1).forEach((node, index) => {
// Labels resolve where the growing line arrives; they do not slide in. node.style.opacity = String(progress(time, flow.branch + 180 + index * flow.stagger, 220));
node.style.opacity = String(progress(time, 5790 + index * 85, 220));
}); });
reveals.forEach((element, index) => { reveals.forEach((element, index) => {
const isPanel = element.classList.contains('ascii-panel'); const isPanel = element.classList.contains('ascii-panel');
const start = isPanel ? 6320 + (element.classList.contains('call-panel') ? 180 : 0) : 5980 + index * 55; const start = isPanel ? (element.classList.contains('call-panel') ? flow.callPanel : flow.textPanel) : 2540 + index * 35;
const value = progress(time, start, isPanel ? 850 : 380); const value = progress(time, start, isPanel ? flow.panelDuration : 420);
// Noise resolves into the panel, not a wipe or a sliding card.
element.style.opacity = String(isPanel ? clamp(value * 6) : value); element.style.opacity = String(isPanel ? clamp(value * 6) : value);
}); });
html.style.setProperty('--landing-chrome', String(progress(time, 3000, 600)));
renderGlitch(time); renderGlitch(time);
stage.dataset.phase = time < 2460 ? 'orbit' : time < 3040 ? 'unfold' : time < 4100 ? 'extend' : time < 6320 ? 'tree' : 'panels'; // A diagnostic label, not a playback gate or a storyboard step.
stage.dataset.phase = 'flow';
} }
function tick(now: number) { function tick(now: number) {
if (state !== 'playing') return; if (state !== 'playing') return;
const time = now - startTime; const time = now - startTime;
if (time >= duration) return finish(); if (time >= flow.duration) return finish();
try { try {
render(time + playbackOffset); render(time);
frame = requestAnimationFrame(tick); frame = requestAnimationFrame(tick);
} catch { } catch {
// Fail open: the document and demo are more important than the animation. // Fail open: the document and demo are more important than the animation.
@@ -319,7 +331,7 @@ export function initLanding() {
html.dataset.landing = 'playing'; html.dataset.landing = 'playing';
delete stage.dataset.hover; delete stage.dataset.hover;
startTime = performance.now(); startTime = performance.now();
render(playbackOffset); render(0);
root.style.willChange = 'transform'; root.style.willChange = 'transform';
frame = requestAnimationFrame(tick); frame = requestAnimationFrame(tick);
} }
+2
View File
@@ -34,6 +34,7 @@ html[data-landing] .landing-page .site-header { opacity: 0; }
.demo-tree { position: relative; } .demo-tree { position: relative; }
.node-row { position: relative; padding-left: 1ch; height: var(--row); display: flex; align-items: center; white-space: nowrap; } .node-row { position: relative; padding-left: 1ch; height: var(--row); display: flex; align-items: center; white-space: nowrap; }
.node-row[hidden] { display: none; } .node-row[hidden] { display: none; }
.node-row:not(.root-slot) { transform-origin: 0 0; }
.node-row.root-slot { padding: 0; height: calc(3 * var(--row)); align-items: end; } .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. /* These are real Unicode box-drawing characters, not SVG paths or borders.
Scale only their vertical ink to cover the leading between mono baselines. */ Scale only their vertical ink to cover the leading between mono baselines. */
@@ -114,6 +115,7 @@ html[data-landing] .landing-page .site-header { opacity: 0; }
html[data-landing] { overflow: hidden; } html[data-landing] { overflow: hidden; }
html[data-landing] .landing { touch-action: none; } 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] .landing-page .site-header, html[data-landing] .landing-page .site-footer, html[data-landing] .landing-page main > :not(.landing) { visibility: hidden; }
html[data-landing="playing"] .landing-page :is(.site-header, .site-footer), html[data-landing="playing"] .landing-page main > :not(.landing) { visibility: visible; opacity: var(--landing-chrome, 0); transition: none; }
html[data-landing] [data-reveal], html[data-landing] .demo-node:not(.root-mark), html[data-landing] :is(.branch-spine, .branch-arm) { opacity: 0; } html[data-landing] [data-reveal], html[data-landing] .demo-node:not(.root-mark), html[data-landing] :is(.branch-spine, .branch-arm) { opacity: 0; }
html[data-landing] .root-mark { color: var(--ink); } html[data-landing] .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="boot"] .root-mark { position: fixed; left: calc(50vw - 1.5ch); top: calc(50svh - var(--row) / 2); transform: scale(7.2); }
+64 -35
View File
@@ -34,7 +34,7 @@ for (const trigger of ['click', 'wheel', 'key']) {
}); });
} }
test('storyboard phases retain one root and ignore repeated triggers', async ({ page }, testInfo) => { test('continuous motion overlaps growth, travel and reveal without intermediate stops', async ({ page }, testInfo) => {
await page.clock.install({ time: new Date('2026-01-01T00:00:00Z') }); await page.clock.install({ time: new Date('2026-01-01T00:00:00Z') });
await page.clock.pauseAt(new Date('2026-01-01T00:00:01Z')); await page.clock.pauseAt(new Date('2026-01-01T00:00:01Z'));
await armed(page); await armed(page);
@@ -58,19 +58,26 @@ test('storyboard phases retain one root and ignore repeated triggers', async ({
await expect(page.locator(stage)).not.toHaveAttribute('data-hover'); await expect(page.locator(stage)).not.toHaveAttribute('data-hover');
await expect(page.locator('.tap-stream-a')).toHaveCSS('opacity', '0'); await expect(page.locator('.tap-stream-a')).toHaveCSS('opacity', '0');
await root.click(); await root.click();
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'orbit'); await expect(page.locator(stage)).toHaveAttribute('data-phase', 'flow');
await expect(page.locator(stage)).not.toHaveAttribute('data-hover'); await expect(page.locator(stage)).not.toHaveAttribute('data-hover');
await page.clock.runFor(1150); await page.clock.runFor(420);
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'orbit');
const arcLength = await page.locator('#welcome-orbit').evaluate((path) => (path as SVGPathElement).getTotalLength()); const arcLength = await page.locator('#welcome-orbit').evaluate((path) => (path as SVGPathElement).getTotalLength());
const circlePath = await page.locator('#welcome-orbit').getAttribute('d'); const circlePath = await page.locator('#welcome-orbit').getAttribute('d');
await page.screenshot({ path: testInfo.outputPath('03-orbit.png') }); await page.screenshot({ path: testInfo.outputPath('03-orbit.png') });
await page.mouse.wheel(0, 180); await page.mouse.wheel(0, 180);
await page.keyboard.press('ArrowDown'); await page.keyboard.press('ArrowDown');
await page.mouse.click(20, 20); await page.mouse.click(20, 20);
await page.clock.runFor(1330); // Scene 4: early trunk growth, before docking. // No parked pose between orbit, release and growth: every sample keeps moving.
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'extend'); let previous = (await root.boundingBox())!;
expect(await page.locator('#welcome-orbit').getAttribute('d')).toMatch(new RegExp(`^${circlePath} H`)); for (let i = 0; i < 10; i++) {
await page.clock.runFor(100);
const current = (await root.boundingBox())!;
expect(Math.hypot(current.x - previous.x, current.y - previous.y)).toBeGreaterThan(.1);
expect(current.height).toBeLessThan(previous.height);
previous = current;
}
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'flow');
expect(await page.locator('#welcome-orbit').getAttribute('d')).toMatch(new RegExp('^' + circlePath + ' H'));
const tangent = await page.locator('#welcome-orbit').evaluate((element, length) => { const tangent = await page.locator('#welcome-orbit').evaluate((element, length) => {
const path = element as SVGPathElement; const path = element as SVGPathElement;
const first = path.getPointAtLength(length + 40); const first = path.getPointAtLength(length + 40);
@@ -80,42 +87,34 @@ test('storyboard phases retain one root and ignore repeated triggers', async ({
expect(tangent.dx).toBeCloseTo(40, 1); expect(tangent.dx).toBeCloseTo(40, 1);
expect(tangent.dy).toBeCloseTo(0, 1); expect(tangent.dy).toBeCloseTo(0, 1);
expect(tangent.y).toBeCloseTo(-180, 1); expect(tangent.y).toBeCloseTo(-180, 1);
// The trunk must not cut through letters still on the bottom of the circle.
expect(Number(await page.locator('.orbit textPath').getAttribute('startOffset'))).toBeGreaterThan(arcLength / 2 + 100); expect(Number(await page.locator('.orbit textPath').getAttribute('startOffset'))).toBeGreaterThan(arcLength / 2 + 100);
const earlyLength = await page.locator('.trunk-line').evaluate((path) => (path as SVGPathElement).getTotalLength()); const earlyLength = await page.locator('.trunk-line').evaluate((path) => (path as SVGPathElement).getTotalLength());
const earlyRoot = (await root.boundingBox())!;
expect(earlyLength).toBeGreaterThan(10); expect(earlyLength).toBeGreaterThan(10);
await page.screenshot({ path: testInfo.outputPath('04-unfold.png') }); await page.screenshot({ path: testInfo.outputPath('04-release-growth.png') });
await page.clock.runFor(700); // Let the downward trunk finish extending. await page.clock.runFor(260);
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'extend');
const extendedLength = await page.locator('.trunk-line').evaluate((path) => (path as SVGPathElement).getTotalLength()); const extendedLength = await page.locator('.trunk-line').evaluate((path) => (path as SVGPathElement).getTotalLength());
const extendedRoot = (await root.boundingBox())!;
expect(extendedLength).toBeGreaterThan(earlyLength * 1.4); expect(extendedLength).toBeGreaterThan(earlyLength * 1.4);
expect(extendedLength).toBeGreaterThan(page.viewportSize()!.height * .18); expect(extendedLength).toBeGreaterThan(page.viewportSize()!.height * .15);
await expect(page.locator('.trunk-line')).toHaveCSS('stroke-width', '10px'); // The trunk grows WHILE the root travels and shrinks, never after a hold.
const extendedRoot = await root.boundingBox(); expect(extendedRoot.height).toBeLessThan(earlyRoot.height - 5);
expect(extendedRoot!.height).toBeCloseTo(initial!.height, 0); expect(extendedRoot.x + extendedRoot.width / 2).toBeLessThan(earlyRoot.x + earlyRoot.width / 2 - 2);
expect(extendedRoot!.y).toBeCloseTo(initial!.y, 0);
await expect(page.locator('.demo-node:not(.root-mark)').first()).toHaveCSS('opacity', '0'); await expect(page.locator('.demo-node:not(.root-mark)').first()).toHaveCSS('opacity', '0');
const trunkGeometry = () => page.evaluate(() => { const extended = await page.evaluate(() => {
const path = document.querySelector<SVGPathElement>('.trunk-line')!; const path = document.querySelector<SVGPathElement>('.trunk-line')!;
const start = path.getPointAtLength(0).matrixTransform(path.getScreenCTM()!); const start = path.getPointAtLength(0).matrixTransform(path.getScreenCTM()!);
const tip = path.getPointAtLength(path.getTotalLength()).matrixTransform(path.getScreenCTM()!); const tip = path.getPointAtLength(path.getTotalLength()).matrixTransform(path.getScreenCTM()!);
const root = document.querySelector('.root-mark')!.getBoundingClientRect(); const root = document.querySelector('.root-mark')!.getBoundingClientRect();
return { dx: tip.x - start.x, dy: tip.y - start.y, axis: start.x - (root.left + root.width / 2), gap: start.y - root.bottom, bottom: tip.y }; return { dx: tip.x - start.x, dy: tip.y - start.y, axis: start.x - (root.left + root.width / 2), gap: start.y - root.bottom, bottom: tip.y };
}); });
const extended = await trunkGeometry();
expect(Math.abs(extended.dx)).toBeLessThan(.1); expect(Math.abs(extended.dx)).toBeLessThan(.1);
expect(Math.abs(extended.axis)).toBeLessThan(.1); expect(Math.abs(extended.axis)).toBeLessThan(.1);
expect(extended.dy).toBeGreaterThan(0); expect(extended.dy).toBeGreaterThan(0);
expect(extended.gap).toBeCloseTo(8, 0); expect(extended.gap).toBeCloseTo(8, 0);
expect(extended.bottom).toBeLessThan(page.viewportSize()!.height * .91); expect(extended.bottom).toBeLessThan(page.viewportSize()!.height);
await page.screenshot({ path: testInfo.outputPath('04-trunk.png') }); await page.screenshot({ path: testInfo.outputPath('04-travelling-trunk.png') });
await page.clock.runFor(850); await page.clock.runFor(920); // 2600ms: moving tree, SVG handoff AND panel onset.
const docking = await trunkGeometry();
expect(Math.abs(docking.dx)).toBeLessThan(.1);
expect(Math.abs(docking.axis)).toBeLessThan(.1);
expect(docking.gap).toBeGreaterThanOrEqual(0);
await page.screenshot({ path: testInfo.outputPath('05-docking.png') });
await page.clock.runFor(850); // SVG/box-drawing handoff, with neither removed.
const handoff = await page.evaluate(() => { const handoff = await page.evaluate(() => {
const path = document.querySelector<SVGPathElement>('.trunk-line')!; const path = document.querySelector<SVGPathElement>('.trunk-line')!;
const start = path.getPointAtLength(0).matrixTransform(path.getScreenCTM()!); const start = path.getPointAtLength(0).matrixTransform(path.getScreenCTM()!);
@@ -127,6 +126,8 @@ test('storyboard phases retain one root and ignore repeated triggers', async ({
end: Math.hypot(tip.x - (last.left + last.width / 2), tip.y - (last.top + last.height / 2)), end: Math.hypot(tip.x - (last.left + last.width / 2), tip.y - (last.top + last.height / 2)),
spineOpacity: Number(getComputedStyle(document.querySelector('.branch-spine')!).opacity), spineOpacity: Number(getComputedStyle(document.querySelector('.branch-spine')!).opacity),
trunkOpacity: Number(getComputedStyle(path).opacity), trunkOpacity: Number(getComputedStyle(path).opacity),
labelOpacity: Number(getComputedStyle(document.querySelector('.demo-node:not(.root-mark)')!).opacity),
panelOpacity: Number(getComputedStyle(document.querySelector('.text-panel')!).opacity),
}; };
}); });
expect(handoff.start).toBeLessThan(1); expect(handoff.start).toBeLessThan(1);
@@ -134,14 +135,22 @@ test('storyboard phases retain one root and ignore repeated triggers', async ({
expect(handoff.spineOpacity).toBeGreaterThan(0); expect(handoff.spineOpacity).toBeGreaterThan(0);
expect(handoff.trunkOpacity).toBeGreaterThan(0); expect(handoff.trunkOpacity).toBeGreaterThan(0);
expect(handoff.spineOpacity + handoff.trunkOpacity).toBeCloseTo(1, 4); expect(handoff.spineOpacity + handoff.trunkOpacity).toBeCloseTo(1, 4);
await page.clock.runFor(550); expect(handoff.labelOpacity).toBeGreaterThan(0);
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'tree'); expect(handoff.panelOpacity).toBeGreaterThan(0);
await page.screenshot({ path: testInfo.outputPath('05-tree.png') }); const growingTreeRoot = (await root.boundingBox())!;
await page.clock.runFor(600); expect(growingTreeRoot.height).toBeGreaterThan(24.1);
await page.screenshot({ path: testInfo.outputPath('05-moving-tree.png') });
await page.clock.runFor(200);
expect((await root.boundingBox())!.height).toBeLessThan(growingTreeRoot.height);
await expect(page.locator('.call-panel')).toHaveAttribute('data-glitching'); await expect(page.locator('.call-panel')).toHaveAttribute('data-glitching');
expect(await page.locator('.call-panel .box-fill').first().textContent()).not.toMatch(/^─+$/); expect(await page.locator('.call-panel .box-fill').first().textContent()).not.toMatch(/^─+$/);
await page.screenshot({ path: testInfo.outputPath('06-glitch.png') }); await page.screenshot({ path: testInfo.outputPath('06-overlapping-glitch.png') });
await page.clock.runFor(1200); await page.clock.runFor(400);
const chromeOpacity = await page.locator('.site-header').evaluate((el) => Number(getComputedStyle(el).opacity));
expect(chromeOpacity).toBeGreaterThan(0);
expect(chromeOpacity).toBeLessThan(1);
await expect(page.locator('.site-header')).toHaveAttribute('inert', '');
await page.clock.runFor(800);
await complete(page); await complete(page);
await expect(root).toHaveCount(1); await expect(root).toHaveCount(1);
await expect(page.locator('.call-panel')).not.toHaveAttribute('inert'); await expect(page.locator('.call-panel')).not.toHaveAttribute('inert');
@@ -191,14 +200,34 @@ test('storyboard phases retain one root and ignore repeated triggers', async ({
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'complete'); await expect(page.locator(stage)).toHaveAttribute('data-phase', 'complete');
}); });
for (const time of [1500, 2600]) {
test(`skipping at ${time}ms removes the moving tree transforms`, 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('a');
await page.clock.runFor(time);
await expect(page.locator('.node-row').last()).not.toHaveCSS('transform', 'none');
await page.keyboard.press('Escape');
await complete(page);
for (const row of await page.locator('.node-row').all()) await expect(row).toHaveCSS('transform', 'none');
await expect(page.locator('.root-mark')).toHaveCSS('transform', 'none');
await expect(page.locator('.site-header')).toHaveCSS('opacity', '1');
await expect(page.locator('.branch-prefix').first()).toHaveText('├── ');
await expect(page.locator('.intro-art')).toBeHidden();
await page.clock.runFor(4000);
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'complete');
});
}
test('skipping an active glitch restores exact text and frames', async ({ page }) => { test('skipping an active glitch restores exact text and frames', async ({ page }) => {
await page.clock.install({ time: new Date('2026-01-01T00:00:00Z') }); await page.clock.install({ time: new Date('2026-01-01T00:00:00Z') });
await page.clock.pauseAt(new Date('2026-01-01T00:00:01Z')); await page.clock.pauseAt(new Date('2026-01-01T00:00:01Z'));
await armed(page); await armed(page);
await page.keyboard.press('a'); await page.keyboard.press('a');
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'orbit'); await expect(page.locator(stage)).toHaveAttribute('data-phase', 'flow');
await expect(page.locator('.tap-stream-a')).toHaveCSS('opacity', '0'); await expect(page.locator('.tap-stream-a')).toHaveCSS('opacity', '0');
await page.clock.runFor(6100); await page.clock.runFor(3000);
await expect(page.locator('.call-panel')).toHaveAttribute('data-glitching'); await expect(page.locator('.call-panel')).toHaveAttribute('data-glitching');
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
await complete(page); await complete(page);