fix: restore trunk growth and tangent text release
Reference site / build (push) Canceled after 0s

This commit is contained in:
2026-10-10 03:14:39 +09:00
parent d5a0879142
commit 340df47e65
5 changed files with 101 additions and 58 deletions
+4 -2
View File
@@ -43,14 +43,16 @@ Bookの閲覧テストはJavaScript無効、ランディングのテストは有
### ランディング ### ランディング
トップはSVGのルートマークから始まります。コンテの2はホバー専用の「Tap」表現で、ポインタを離すと消え、待っていても本編には進みません。クリック・ホイール/タッチスクロール・キー入力のいずれか一回で、3〜6を約5.6秒で最後まで再生します。ホバーは再生の前提ではなく、追加入力で段階を進めたり、再生を重ねたりもしません。Escapeまたは画面右下のスキップで直ちにデモへ移れます。ブラウザの修飾キー付きショートカットは妨げません。 トップはSVGのルートマークから始まります。コンテの2はホバー専用の「Tap」表現で、ポインタを離すと消え、待っていても本編には進みません。クリック・ホイール/タッチスクロール・キー入力のいずれか一回で、3〜6を約6.9秒で最後まで再生します。ホバーは再生の前提ではなく、追加入力で段階を進めたり、再生を重ねたりもしません。Escapeまたは画面右下のスキップで直ちにデモへ移れます。ブラウザの修飾キー付きショートカットは妨げません。
コンテ4では、大きなルートを左へ移し、円周の上端から右の接線へ文字を流しながら横の幹を伸ばします。円の半径・曲率は変えません。幹を伸ばす区間を見せてからルートと幹を縮小・移動し、幹の先端を罫線`──┐`の角へ接続して枝を展開します。
ツリーとパネルはASCII glitchから確定した文字・罫線へ収束します。ノイズは局所的なセル置換を10Hzで更新し、全画面の点滅は使いません。空白・改行・日本語は保持し、完了/スキップ時には元の文字列を完全に復元します。 ツリーとパネルはASCII glitchから確定した文字・罫線へ収束します。ノイズは局所的なセル置換を10Hzで更新し、全画面の点滅は使いません。空白・改行・日本語は保持し、完了/スキップ時には元の文字列を完全に復元します。
- `src/components/Landing.astro` — SVGと、最終的に操作するツリー/パネルの静的HTML。 - `src/components/Landing.astro` — SVGと、最終的に操作するツリー/パネルの静的HTML。
- `src/components/BoxFrame.astro` — `┌─┐`・`│`・`└─┘`によるText View/Call resultの枠。CSS borderやSVGで枠を代用せず、幅・高さに合わせて罫線素片をクリップします。JavaScript無効時も表示されます。 - `src/components/BoxFrame.astro` — `┌─┐`・`│`・`└─┘`によるText View/Call resultの枠。CSS borderやSVGで枠を代用せず、幅・高さに合わせて罫線素片をクリップします。JavaScript無効時も表示されます。
- `src/lib/landing-ascii.mjs` — セル幅を保つ決定的なglitch文字置換。 - `src/lib/landing-ascii.mjs` — セル幅を保つ決定的なglitch文字置換。
- `src/scripts/landing.ts` — 単一の`requestAnimationFrame`時計、役割別の速度曲線、等弧長で円をほどくSVG path、ツリー操作。 - `src/scripts/landing.ts` — 単一の`requestAnimationFrame`時計、役割別の速度曲線、固定円周+上端接線の文字経路、幹の伸長から罫線への接続、ツリー操作。
- `src/lib/landing-demo.ts` — ブラウザ内だけで使うサンプル。外部Hostへの接続、変更操作、通信はありません。Object signatureとInterface signatureを分け、本文は明示的なCallで表示します。 - `src/lib/landing-demo.ts` — ブラウザ内だけで使うサンプル。外部Hostへの接続、変更操作、通信はありません。Object signatureとInterface signatureを分け、本文は明示的なCallで表示します。
- `src/styles/landing.css` — トップ全体を14pxのmonoフォント・24pxの行高で構成。フレーム幅を4ch単位に揃え、列・ガター・パディングもセルと行の整数倍にします。ツリーはSVGの線ではなく`├──`・`└──`・`│`などの罫線素片を使い、JavaScript無効時も同じ構造を表示します。モバイルはツリーとパネルを縦に配置します。 - `src/styles/landing.css` — トップ全体を14pxのmonoフォント・24pxの行高で構成。フレーム幅を4ch単位に揃え、列・ガター・パディングもセルと行の整数倍にします。ツリーはSVGの線ではなく`├──`・`└──`・`│`などの罫線素片を使い、JavaScript無効時も同じ構造を表示します。モバイルはツリーとパネルを縦に配置します。
+2 -1
View File
@@ -8,6 +8,7 @@ import BoxFrame from './BoxFrame.astro';
<div class="intro-art" aria-hidden="true"> <div class="intro-art" aria-hidden="true">
<div class="tap-stream tap-stream-a"><span>Tap → Tap → Tap → Tap → Tap → </span></div> <div class="tap-stream tap-stream-a"><span>Tap → Tap → Tap → Tap → Tap → </span></div>
<div class="tap-stream tap-stream-b"><span>Tap → Tap → Tap → Tap → Tap → </span></div> <div class="tap-stream tap-stream-b"><span>Tap → Tap → Tap → Tap → Tap → </span></div>
<svg class="intro-trunk" aria-hidden="true" focusable="false"><path class="trunk-line" d="M0 0H0" /></svg>
<svg class="orbit" viewBox="-300 -300 600 600" focusable="false"> <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> <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> <text><textPath href="#welcome-orbit" textLength="1080" lengthAdjust="spacing">WELCOME TO WIP  /  DISCOVERY  /  UNDERSTANDING  /  ACTION  / </textPath></text>
@@ -40,7 +41,7 @@ import BoxFrame from './BoxFrame.astro';
aria-selected={object.path === initialObject.path ? 'true' : 'false'} aria-selected={object.path === initialObject.path ? 'true' : 'false'}
tabindex={object.path === initialObject.path ? 0 : -1} disabled 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 class="node-label">{object.name}</span>{children.length > 0 && <span class="node-indicator" aria-hidden="true">−</span>}</>} {root ? <svg class="root-vector" viewBox="0 0 48 64" aria-hidden="true" focusable="false"><path class="root-shape" d="M31 4H41L17 60H7Z" /></svg> : <><span class="node-label">{object.name}</span>{children.length > 0 && <span class="node-indicator" aria-hidden="true">−</span>}</>}
</button> </button>
{root && <><span class="root-wire" aria-hidden="true">──┐</span><span class="root-caption" data-reveal>worldspace</span></>} {root && <><span class="root-wire" aria-hidden="true">──┐</span><span class="root-caption" data-reveal>worldspace</span></>}
</div>; </div>;
+57 -47
View File
@@ -20,8 +20,8 @@ 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. // Mass leaves the centre horizontally; a longer deceleration seats the root.
travel: bezier(.58, 0, .22, 1), travel: bezier(.58, 0, .22, 1),
// Curvature releases after travel has begun, rather than a simultaneous zoom. // Typography accelerates along a fixed track, not a deforming circle.
unroll: bezier(.55, 0, .3, 1), release: bezier(.55, 0, .3, 1),
scale: bezier(.42, 0, .18, 1), scale: bezier(.42, 0, .18, 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),
@@ -29,20 +29,12 @@ const timing = {
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. // Scene 2 is an independent hover affordance. Playback begins at scene 3.
const playbackOffset = 620; const playbackOffset = 620;
const duration = 6200 - playbackOffset; const duration = 7500 - playbackOffset;
// Preserve the arc length while reducing curvature. Unlike interpolating arbitrary const orbitRadius = 180;
// Bézier control points, this cannot fold or bunch the letters during unrolling. const circumference = 2 * Math.PI * orbitRadius;
function unrolledOrbit(opening: number): string { // The clockwise circle ends at its top, tangent to a rightward straight line.
const x = mix(-180, -150, opening); // Scene 4 feeds the text down this rail; the circle's geometry never changes.
if (opening > .999) return `M${x} 0 H${x + 360 * Math.PI}`; const circularTrack = 'M0 -180 A180 180 0 1 1 0 180 A180 180 0 1 1 0 -180';
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() { export function initLanding() {
const element = document.querySelector<HTMLElement>('[data-landing-stage]'); const element = document.querySelector<HTMLElement>('[data-landing-stage]');
@@ -52,7 +44,7 @@ export function initLanding() {
const motion = matchMedia('(prefers-reduced-motion: reduce)'); const motion = matchMedia('(prefers-reduced-motion: reduce)');
const get = <T extends Element = HTMLElement>(selector: string) => stage.querySelector<T>(selector)!; const get = <T extends Element = HTMLElement>(selector: string) => stage.querySelector<T>(selector)!;
const root = get<HTMLButtonElement>('.root-mark'); const root = get<HTMLButtonElement>('.root-mark');
const tether = get<SVGPathElement>('.root-tether'); const trunk = get<SVGPathElement>('.trunk-line');
const slot = get('.root-slot'); const slot = get('.root-slot');
const tree = get('.demo-tree'); const tree = get('.demo-tree');
const nodes = [...stage.querySelectorAll<HTMLButtonElement>('.demo-node')]; const nodes = [...stage.querySelectorAll<HTMLButtonElement>('.demo-node')];
@@ -81,15 +73,15 @@ export function initLanding() {
const collapsed = new Set<string>(); const collapsed = new Set<string>();
const panels = [...stage.querySelectorAll<HTMLElement>('.ascii-panel')]; const panels = [...stage.querySelectorAll<HTMLElement>('.ascii-panel')];
const glitchTargets = [...stage.querySelectorAll<HTMLElement>([ const glitchTargets = [...stage.querySelectorAll<HTMLElement>([
'.branch-prefix', '.root-wire', '.node-label', '.box-fill', '.box-column', '.box-corner', '.branch-prefix', '.node-label', '.box-fill', '.box-column', '.box-corner',
'.panel-title h3', '.panel-title > span', '[data-object-signature]', '[data-interface-signature]', '.panel-title h3', '.panel-title > span', '[data-object-signature]', '[data-interface-signature]',
'[data-target]', '[data-operation]', '.result-label > span', '[data-result]', '[data-target]', '[data-operation]', '.result-label > span', '[data-result]',
].join(','))].map((element, seed) => { ].join(','))].map((element, seed) => {
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') ? 5200 : 5020) const start = panel ? (panel.classList.contains('call-panel') ? 6500 : 6320)
: element === rootWire ? 4140 : 4140 + Math.max(0, index - 1) * 85; : 5450 + Math.max(0, index - 1) * 85;
return { element, text: element.textContent ?? '', start, duration: panel ? 850 : 580, seed }; return { element, text: element.textContent ?? '', start, duration: panel ? 850 : 580, seed };
}); });
let lastGlitchFrame = -1; let lastGlitchFrame = -1;
@@ -104,7 +96,7 @@ 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') ? 5200 : 5020; const start = panel.classList.contains('call-panel') ? 6500 : 6320;
panel.toggleAttribute('data-glitching', time >= start && time < start + 850); panel.toggleAttribute('data-glitching', time >= start && time < start + 850);
}); });
} }
@@ -209,9 +201,7 @@ export function initLanding() {
delete stage.dataset.hover; delete stage.dataset.hover;
restoreGlitch(); restoreGlitch();
root.style.removeProperty('transform'); root.style.removeProperty('transform');
tether.style.removeProperty('stroke-dasharray'); trunk.style.opacity = '0';
tether.style.removeProperty('stroke-dashoffset');
tether.style.removeProperty('opacity');
root.setAttribute('aria-label', 'ルート /'); root.setAttribute('aria-label', 'ルート /');
invitation.style.removeProperty('opacity'); invitation.style.removeProperty('opacity');
nodes.forEach((node) => { nodes.forEach((node) => {
@@ -238,49 +228,69 @@ export function initLanding() {
// One clock owns all phases. Inputs cannot advance, restart, or stack timelines. // One clock owns all phases. Inputs cannot advance, restart, or stack timelines.
function render(time: number) { function render(time: number) {
const target = slot.getBoundingClientRect(); const target = slot.getBoundingClientRect();
const wireTarget = rootWire.getBoundingClientRect();
const endX = target.left + root.offsetWidth / 2; const endX = target.left + root.offsetWidth / 2;
const endY = target.bottom - root.offsetHeight / 2; const endY = target.bottom - root.offsetHeight / 2;
const travel = timing.travel(progress(time, 2320, 2330)); const orbitScale = parseFloat(getComputedStyle(orbit).width) / 600;
const remaining = 1 - travel; const radius = orbitRadius * orbitScale;
// A continuous spatial Bézier: depart left, arrive vertically. The orbit is const stagedX = Math.max(innerWidth * .3, radius + 16);
// attached to this very same moving origin, not animated on a separate track. const shiftedX = mix(innerWidth / 2, stagedX, timing.travel(progress(time, 2320, 980)));
const centreX = remaining ** 3 * innerWidth / 2 // First slide the large composition left and grow its trunk. Only after
+ 3 * remaining ** 2 * travel * (innerWidth * .26) // that readable beat do we dock and shrink it into the terminal grid.
+ 3 * remaining * travel ** 2 * endX + travel ** 3 * endX; 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 const centreY = remaining ** 3 * innerHeight / 2
+ 3 * remaining ** 2 * travel * innerHeight / 2 + 3 * remaining ** 2 * dock * innerHeight / 2
+ 3 * remaining * travel ** 2 * (endY + innerHeight * .15) + travel ** 3 * endY; + 3 * remaining * dock ** 2 * mix(endY, innerHeight / 2, .2) + dock ** 3 * endY;
const scale = mix(7.2, 1, timing.scale(progress(time, 3120, 1530))); 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})`; 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))); const extension = timing.trace(progress(time, 2680, 1000));
tether.style.opacity = String(1 - progress(time, 4200, 450)); const longLength = Math.max(0, innerWidth * .92 - stagedX - radius);
const startX = centreX + mix(radius, wireTarget.left - endX, dock);
const tipX = centreX + mix(radius + longLength * extension, wireTarget.right - wireTarget.width / 6 - endX, dock);
const lineY = centreY + mix(0, wireTarget.top + wireTarget.height / 2 - endY, dock);
// Crossfade only once both geometries coincide: the SVG tip becomes the
// corner of ──┐, then the literal box-drawing branches grow from that joint.
const handoff = progress(time, 5480, 140);
trunk.setAttribute('d', `M${startX} ${lineY} H${tipX}`);
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 < 2680 ? '0' : String(1 - handoff);
invitation.style.opacity = String(1 - progress(time, playbackOffset, 280)); invitation.style.opacity = String(1 - progress(time, playbackOffset, 280));
const opening = timing.unroll(progress(time, 2530, 1350));
orbitPath.setAttribute('d', unrolledOrbit(opening)); const release = timing.release(progress(time, 2460, 1640));
orbit.style.opacity = String(progress(time, 620, 480) * (1 - progress(time, 3500, 550))); const exitLength = innerWidth / orbitScale + circumference;
const turn = mix(-36, 2, progress(time, 620, 2100)) * (1 - opening); orbitPath.setAttribute('d', circularTrack + (time >= 2460 ? ` H${exitLength}` : ''));
orbit.style.opacity = String(progress(time, 620, 480));
// Rotation comes to rest before release. The exit is always horizontal
// at the circle's top, never the expanding-radius curve used previously.
const turn = mix(-36, 0, progress(time, 620, 1700));
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% + ${centreX - innerWidth / 2}px), calc(-50% + ${centreY - innerHeight / 2}px)) rotate(${turn}deg)`;
textPath.setAttribute('startOffset', String(12 + opening * 650)); textPath.setAttribute('startOffset', String(12 + release * (circumference + exitLength)));
rootWire.style.opacity = String(progress(time, 4140, 320)); rootWire.style.opacity = String(handoff);
branches.forEach((wire, index) => { branches.forEach((wire, index) => {
const value = timing.trace(progress(time, 4140 + index * 85, 520)); const value = timing.trace(progress(time, 5450 + index * 85, 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. // Labels resolve where the growing line arrives; they do not slide in.
node.style.opacity = String(progress(time, 4490 + index * 85, 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 ? 5020 + (element.classList.contains('call-panel') ? 180 : 0) : 4680 + index * 55; const start = isPanel ? 6320 + (element.classList.contains('call-panel') ? 180 : 0) : 5980 + index * 55;
const value = progress(time, start, isPanel ? 850 : 380); const value = progress(time, start, isPanel ? 850 : 380);
// Noise resolves into the panel, not a wipe or a sliding card. // 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);
}); });
renderGlitch(time); renderGlitch(time);
stage.dataset.phase = time < 2530 ? 'orbit' : time < 4080 ? 'unfold' : time < 5020 ? 'tree' : 'panels'; stage.dataset.phase = time < 2460 ? 'orbit' : time < 2680 ? 'unfold' : time < 4100 ? 'extend' : time < 6320 ? 'tree' : 'panels';
} }
function tick(now: number) { function tick(now: number) {
+2 -2
View File
@@ -48,8 +48,6 @@ html[data-landing] .landing-page .site-header { opacity: 0; }
.root-mark { width: 3ch; height: var(--row); display: block; padding: 0; background: transparent !important; transform-origin: center; z-index: 25; transition: none; } .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-vector { display: block; width: 100%; height: 100%; overflow: visible; }
.root-shape { fill: currentColor; } .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-mark[aria-selected="true"] { color: #4e752d; }
.root-caption { margin-left: 2ch; height: var(--row); color: var(--muted); } .root-caption { margin-left: 2ch; height: var(--row); color: var(--muted); }
.ascii-panel { position: relative; min-width: 0; padding: calc(2 * var(--row)) 2ch; } .ascii-panel { position: relative; min-width: 0; padding: calc(2 * var(--row)) 2ch; }
@@ -120,6 +118,8 @@ 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); }
html[data-landing] .intro-art { display: block; position: fixed; z-index: 10; width: 100%; height: 100svh; inset: 0; pointer-events: none; } 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 { position: absolute; left: 50%; top: 50%; width: min(640px, 112vw); height: min(640px, 112vw); overflow: visible; transform: translate(-50%, -50%); opacity: 0; }
.intro-trunk { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.trunk-line { fill: none; stroke: var(--ink); stroke-width: 10; stroke-linecap: butt; opacity: 0; }
.orbit text { fill: var(--ink); font: 500 17px var(--mono); letter-spacing: 1.5px; } .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 { 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-a { transform: translate(-145px, -24px) rotate(-67deg); }
+36 -6
View File
@@ -63,16 +63,46 @@ test('storyboard phases retain one root and ignore repeated triggers', async ({
await page.clock.runFor(1150); await page.clock.runFor(1150);
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'orbit'); 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');
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(1430); await page.clock.runFor(1080); // Scene 4: early trunk growth, before docking.
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'unfold'); await expect(page.locator(stage)).toHaveAttribute('data-phase', 'extend');
const unfoldingLength = await page.locator('#welcome-orbit').evaluate((path) => (path as SVGPathElement).getTotalLength()); expect(await page.locator('#welcome-orbit').getAttribute('d')).toMatch(new RegExp(`^${circlePath} H`));
expect(Math.abs(unfoldingLength - arcLength)).toBeLessThan(2); const tangent = await page.locator('#welcome-orbit').evaluate((element, length) => {
const path = element as SVGPathElement;
const first = path.getPointAtLength(length + 40);
const second = path.getPointAtLength(length + 80);
return { dx: second.x - first.x, dy: second.y - first.y, y: first.y };
}, arcLength);
expect(tangent.dx).toBeCloseTo(40, 1);
expect(tangent.dy).toBeCloseTo(0, 1);
expect(tangent.y).toBeCloseTo(-180, 1);
const earlyLength = await page.locator('.trunk-line').evaluate((path) => (path as SVGPathElement).getTotalLength());
expect(earlyLength).toBeGreaterThan(10);
await page.screenshot({ path: testInfo.outputPath('04-unfold.png') }); await page.screenshot({ path: testInfo.outputPath('04-unfold.png') });
await page.clock.runFor(1550); await page.clock.runFor(950); // Let the horizontal line finish extending.
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'extend');
const extendedLength = await page.locator('.trunk-line').evaluate((path) => (path as SVGPathElement).getTotalLength());
expect(extendedLength).toBeGreaterThan(earlyLength * 1.4);
expect(extendedLength).toBeGreaterThan(page.viewportSize()!.width * .18);
await expect(page.locator('.trunk-line')).toHaveCSS('stroke-width', '10px');
const extendedRoot = await root.boundingBox();
expect(extendedRoot!.height).toBeCloseTo(initial!.height, 0);
expect(extendedRoot!.y).toBeCloseTo(initial!.y, 0);
await expect(page.locator('.demo-node:not(.root-mark)').first()).toHaveCSS('opacity', '0');
await page.screenshot({ path: testInfo.outputPath('04-trunk.png') });
await page.clock.runFor(1700); // SVG/box-drawing handoff, with neither removed.
const handoff = await page.evaluate(() => {
const path = document.querySelector<SVGPathElement>('.trunk-line')!;
const tip = path.getPointAtLength(path.getTotalLength()).matrixTransform(path.getScreenCTM()!);
const target = document.querySelector('.root-wire')!.getBoundingClientRect();
return Math.hypot(tip.x - (target.right - target.width / 6), tip.y - (target.top + target.height / 2));
});
expect(handoff).toBeLessThan(1);
await page.clock.runFor(550);
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'tree'); await expect(page.locator(stage)).toHaveAttribute('data-phase', 'tree');
await page.screenshot({ path: testInfo.outputPath('05-tree.png') }); await page.screenshot({ path: testInfo.outputPath('05-tree.png') });
await page.clock.runFor(600); await page.clock.runFor(600);
@@ -128,7 +158,7 @@ test('skipping an active glitch restores exact text and frames', async ({ 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', 'orbit');
await expect(page.locator('.tap-stream-a')).toHaveCSS('opacity', '0'); await expect(page.locator('.tap-stream-a')).toHaveCSS('opacity', '0');
await page.clock.runFor(4800); await page.clock.runFor(6100);
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);