fix: grow landing trunk downward into the tree
Reference site / build (push) Canceled after 0s

This commit is contained in:
2026-10-10 17:39:40 +09:00
parent 340df47e65
commit 651cc573be
6 changed files with 90 additions and 33 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ Bookの閲覧テストはJavaScript無効、ランディングのテストは有
トップはSVGのルートマークから始まります。コンテの2はホバー専用の「Tap」表現で、ポインタを離すと消え、待っていても本編には進みません。クリック・ホイール/タッチスクロール・キー入力のいずれか一回で、3〜6を約6.9秒で最後まで再生します。ホバーは再生の前提ではなく、追加入力で段階を進めたり、再生を重ねたりもしません。Escapeまたは画面右下のスキップで直ちにデモへ移れます。ブラウザの修飾キー付きショートカットは妨げません。 トップはSVGのルートマークから始まります。コンテの2はホバー専用の「Tap」表現で、ポインタを離すと消え、待っていても本編には進みません。クリック・ホイール/タッチスクロール・キー入力のいずれか一回で、3〜6を約6.9秒で最後まで再生します。ホバーは再生の前提ではなく、追加入力で段階を進めたり、再生を重ねたりもしません。Escapeまたは画面右下のスキップで直ちにデモへ移れます。ブラウザの修飾キー付きショートカットは妨げません。
コンテ4では、大きなルートを左へ移し、円周の上端から右の接線へ文字を流しながら横の幹を伸ばします。円の半径・曲率は変えません。幹を伸ばす区間を見せてからルートと幹を縮小・移動し、幹の先端を罫線`──┐`の角へ接続して枝を展開します。 コンテ4では、大きなルートを左へ移し、円周の上端から右の接線へ文字を流しながら、ルートの直下へ縦の幹を伸ばします。円の半径・曲率は変えません。幹を伸ばす区間を見せてからルートと幹を縮小・移動し、縦幹全体を同じ列の`├`・`│`・`└`へ引き継いで、右へ枝を展開します。主幹は連続したまま保ち、枝先とラベルにglitchを適用します。
ツリーとパネルはASCII glitchから確定した文字・罫線へ収束します。ノイズは局所的なセル置換を10Hzで更新し、全画面の点滅は使いません。空白・改行・日本語は保持し、完了/スキップ時には元の文字列を完全に復元します。 ツリーとパネルはASCII glitchから確定した文字・罫線へ収束します。ノイズは局所的なセル置換を10Hzで更新し、全画面の点滅は使いません。空白・改行・日本語は保持し、完了/スキップ時には元の文字列を完全に復元します。
+4 -3
View File
@@ -8,7 +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="intro-trunk" aria-hidden="true" focusable="false"><path class="trunk-line" d="M0 0V0" /></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>
@@ -30,8 +30,9 @@ import BoxFrame from './BoxFrame.astro';
{objects.map((object) => { {objects.map((object) => {
const root = object.path === '/'; const root = object.path === '/';
const children = childrenOf(object.path); const children = childrenOf(object.path);
const prefix = treePrefix(object);
return <div class:list={['node-row', { 'root-slot': root }]}> return <div class:list={['node-row', { 'root-slot': root }]}>
{!root && <span class="branch-prefix" aria-hidden="true">{treePrefix(object)}</span>} {!root && <span class="branch-prefix" aria-hidden="true"><span class="branch-spine">{prefix.slice(0, 1)}</span><span class="branch-arm">{prefix.slice(1)}</span></span>}
<button <button
type="button" role="treeitem" class:list={['demo-node', { 'root-mark': root }]} type="button" role="treeitem" class:list={['demo-node', { 'root-mark': root }]}
data-path={object.path} aria-label={root ? 'ルート /' : object.name} data-path={object.path} aria-label={root ? 'ルート /' : object.name}
@@ -43,7 +44,7 @@ import BoxFrame from './BoxFrame.astro';
> >
{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>}</>} {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-caption" data-reveal>worldspace</span>}
</div>; </div>;
})} })}
</div> </div>
+23 -18
View File
@@ -49,8 +49,9 @@ export function initLanding() {
const tree = get('.demo-tree'); const tree = get('.demo-tree');
const nodes = [...stage.querySelectorAll<HTMLButtonElement>('.demo-node')]; const nodes = [...stage.querySelectorAll<HTMLButtonElement>('.demo-node')];
const rows = nodes.map((node) => node.parentElement!); const rows = nodes.map((node) => node.parentElement!);
const branches = [...stage.querySelectorAll<HTMLElement>('.branch-prefix')]; const branches = [...stage.querySelectorAll<HTMLElement>('.branch-arm')];
const rootWire = get('.root-wire'); const spines = [...stage.querySelectorAll<HTMLElement>('.branch-spine')];
const lastRootBranch = nodes.findLast((node) => node.getAttribute('aria-level') === '2')!.parentElement!;
const reveals = [...stage.querySelectorAll<HTMLElement>('[data-reveal]')]; const reveals = [...stage.querySelectorAll<HTMLElement>('[data-reveal]')];
const orbit = get<SVGSVGElement>('.orbit'); const orbit = get<SVGSVGElement>('.orbit');
const orbitPath = get<SVGPathElement>('#welcome-orbit'); const orbitPath = get<SVGPathElement>('#welcome-orbit');
@@ -73,7 +74,7 @@ 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', '.node-label', '.box-fill', '.box-column', '.box-corner', '.branch-arm', '.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) => {
@@ -139,7 +140,6 @@ export function initLanding() {
if (indicator) indicator.textContent = collapsed.has(object.path) ? '+' : '−'; if (indicator) indicator.textContent = collapsed.has(object.path) ? '+' : '−';
} }
}); });
rootWire.textContent = collapsed.has('/') ? '──╴' : '──┐';
} }
nodes.forEach((node, index) => node.addEventListener('click', (event) => { nodes.forEach((node, index) => node.addEventListener('click', (event) => {
@@ -218,7 +218,7 @@ export function initLanding() {
element.style.removeProperty('clip-path'); element.style.removeProperty('clip-path');
}); });
blocked.forEach((element) => { element.inert = false; }); blocked.forEach((element) => { element.inert = false; });
[...branches, rootWire].forEach((wire) => { [...branches, ...spines].forEach((wire) => {
wire.style.removeProperty('opacity'); wire.style.removeProperty('opacity');
wire.style.removeProperty('clip-path'); wire.style.removeProperty('clip-path');
}); });
@@ -228,8 +228,9 @@ 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 lastBranchTarget = lastRootBranch.getBoundingClientRect();
const endX = target.left + root.offsetWidth / 2; // offsetWidth rounds fractional mono cells and would shift the SVG axis.
const endX = target.left + parseFloat(getComputedStyle(root).width) / 2;
const endY = target.bottom - root.offsetHeight / 2; const endY = target.bottom - root.offsetHeight / 2;
const orbitScale = parseFloat(getComputedStyle(orbit).width) / 600; const orbitScale = parseFloat(getComputedStyle(orbit).width) / 600;
const radius = orbitRadius * orbitScale; const radius = orbitRadius * orbitScale;
@@ -248,19 +249,22 @@ export function initLanding() {
const scale = mix(7.2, 1, timing.scale(progress(time, 4100, 1350))); 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})`;
const extension = timing.trace(progress(time, 2680, 1000)); // Let the last letters clear the circle's lower arc before drawing through it.
const longLength = Math.max(0, innerWidth * .92 - stagedX - radius); const extension = timing.trace(progress(time, 3040, 820));
const startX = centreX + mix(radius, wireTarget.left - endX, dock); // The trunk grows down from the root, independently of the rightward text.
const tipX = centreX + mix(radius + longLength * extension, wireTarget.right - wireTarget.width / 6 - endX, dock); // Keep its head below the slash throughout scaling, then match the entire
const lineY = centreY + mix(0, wireTarget.top + wireTarget.height / 2 - endY, dock); // first column of ├ / │ / └ rather than shrinking into a horizontal elbow.
// Crossfade only once both geometries coincide: the SVG tip becomes the const largeStart = root.offsetHeight * 7.2 / 2 + 8;
// corner of ──┐, then the literal box-drawing branches grow from that joint. 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); const handoff = progress(time, 5480, 140);
trunk.setAttribute('d', `M${startX} ${lineY} H${tipX}`); trunk.setAttribute('d', `M${centreX} ${startY} V${tipY}`);
trunk.style.strokeWidth = String(mix(10, 1, dock)); 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))); 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.stroke = `rgb(${ink.join(',')})`;
trunk.style.opacity = time < 2680 ? '0' : String(1 - handoff); trunk.style.opacity = time < 3040 ? '0' : String(1 - handoff);
invitation.style.opacity = String(1 - progress(time, playbackOffset, 280)); invitation.style.opacity = String(1 - progress(time, playbackOffset, 280));
const release = timing.release(progress(time, 2460, 1640)); const release = timing.release(progress(time, 2460, 1640));
@@ -272,7 +276,8 @@ export function initLanding() {
const turn = mix(-36, 0, progress(time, 620, 1700)); 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 + release * (circumference + exitLength))); textPath.setAttribute('startOffset', String(12 + release * (circumference + exitLength)));
rootWire.style.opacity = String(handoff); // Preserve the continuous spine during the handoff; only the arms glitch.
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, 5450 + index * 85, 520));
wire.style.opacity = String(clamp(value * 4)); wire.style.opacity = String(clamp(value * 4));
@@ -290,7 +295,7 @@ export function initLanding() {
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 < 2460 ? 'orbit' : time < 2680 ? 'unfold' : time < 4100 ? 'extend' : time < 6320 ? 'tree' : 'panels'; stage.dataset.phase = time < 2460 ? 'orbit' : time < 3040 ? 'unfold' : time < 4100 ? 'extend' : time < 6320 ? 'tree' : 'panels';
} }
function tick(now: number) { function tick(now: number) {
+5 -4
View File
@@ -32,13 +32,14 @@ html[data-landing] .landing-page .site-header { opacity: 0; }
.tree-caption { display: flex; justify-content: space-between; gap: 2ch; color: var(--muted); height: var(--row); margin: 0; white-space: nowrap; } .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; } .tree-caption span:last-child { font-size: inherit; }
.demo-tree { position: relative; } .demo-tree { position: relative; }
.node-row { position: relative; padding-left: 5ch; 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.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. */
.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; } .branch-prefix { display: inline-flex; 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; } .branch-spine { flex: 0 0 1ch; }
.branch-arm { display: block; }
.demo-node { position: relative; flex: 0 0 auto; border: 0; border-radius: 0; padding: 0 1ch; margin: 0; height: var(--row); display: flex; align-items: center; gap: 1ch; cursor: pointer; background: transparent; text-align: left; transition: background .18s; } .demo-node { position: relative; flex: 0 0 auto; border: 0; border-radius: 0; padding: 0 1ch; margin: 0; height: var(--row); display: flex; align-items: center; gap: 1ch; cursor: pointer; background: transparent; text-align: left; transition: background .18s; }
.demo-node[aria-selected="true"] { background: var(--accent); color: #273621; } .demo-node[aria-selected="true"] { background: var(--accent); color: #273621; }
.demo-node:hover { background: #eaf0e2; } .demo-node:hover { background: #eaf0e2; }
@@ -113,7 +114,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] [data-reveal], html[data-landing] .demo-node:not(.root-mark), html[data-landing] :is(.branch-prefix, .root-wire) { 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); }
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; }
+50 -7
View File
@@ -68,7 +68,7 @@ test('storyboard phases retain one root and ignore repeated triggers', async ({
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(1080); // Scene 4: early trunk growth, before docking. await page.clock.runFor(1330); // Scene 4: early trunk growth, before docking.
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'extend'); await expect(page.locator(stage)).toHaveAttribute('data-phase', 'extend');
expect(await page.locator('#welcome-orbit').getAttribute('d')).toMatch(new RegExp(`^${circlePath} H`)); 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) => {
@@ -80,28 +80,60 @@ 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);
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());
expect(earlyLength).toBeGreaterThan(10); 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(950); // Let the horizontal line finish extending. await page.clock.runFor(700); // Let the downward trunk finish extending.
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'extend'); 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());
expect(extendedLength).toBeGreaterThan(earlyLength * 1.4); expect(extendedLength).toBeGreaterThan(earlyLength * 1.4);
expect(extendedLength).toBeGreaterThan(page.viewportSize()!.width * .18); expect(extendedLength).toBeGreaterThan(page.viewportSize()!.height * .18);
await expect(page.locator('.trunk-line')).toHaveCSS('stroke-width', '10px'); await expect(page.locator('.trunk-line')).toHaveCSS('stroke-width', '10px');
const extendedRoot = await root.boundingBox(); const extendedRoot = await root.boundingBox();
expect(extendedRoot!.height).toBeCloseTo(initial!.height, 0); expect(extendedRoot!.height).toBeCloseTo(initial!.height, 0);
expect(extendedRoot!.y).toBeCloseTo(initial!.y, 0); 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 path = document.querySelector<SVGPathElement>('.trunk-line')!;
const start = path.getPointAtLength(0).matrixTransform(path.getScreenCTM()!);
const tip = path.getPointAtLength(path.getTotalLength()).matrixTransform(path.getScreenCTM()!);
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 };
});
const extended = await trunkGeometry();
expect(Math.abs(extended.dx)).toBeLessThan(.1);
expect(Math.abs(extended.axis)).toBeLessThan(.1);
expect(extended.dy).toBeGreaterThan(0);
expect(extended.gap).toBeCloseTo(8, 0);
expect(extended.bottom).toBeLessThan(page.viewportSize()!.height * .91);
await page.screenshot({ path: testInfo.outputPath('04-trunk.png') }); await page.screenshot({ path: testInfo.outputPath('04-trunk.png') });
await page.clock.runFor(1700); // SVG/box-drawing handoff, with neither removed. await page.clock.runFor(850);
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 tip = path.getPointAtLength(path.getTotalLength()).matrixTransform(path.getScreenCTM()!); const tip = path.getPointAtLength(path.getTotalLength()).matrixTransform(path.getScreenCTM()!);
const target = document.querySelector('.root-wire')!.getBoundingClientRect(); const first = document.querySelector('.branch-spine')!.getBoundingClientRect();
return Math.hypot(tip.x - (target.right - target.width / 6), tip.y - (target.top + target.height / 2)); const last = [...document.querySelectorAll('.demo-node[aria-level="2"]')].at(-1)!.parentElement!.querySelector('.branch-spine')!.getBoundingClientRect();
return {
start: Math.hypot(start.x - (first.left + first.width / 2), start.y - first.top),
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),
trunkOpacity: Number(getComputedStyle(path).opacity),
};
}); });
expect(handoff).toBeLessThan(1); expect(handoff.start).toBeLessThan(1);
expect(handoff.end).toBeLessThan(1);
expect(handoff.spineOpacity).toBeGreaterThan(0);
expect(handoff.trunkOpacity).toBeGreaterThan(0);
expect(handoff.spineOpacity + handoff.trunkOpacity).toBeCloseTo(1, 4);
await page.clock.runFor(550); 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') });
@@ -127,13 +159,19 @@ test('storyboard phases retain one root and ignore repeated triggers', async ({
'├── ', '│ ├── ', '│ └── ', '│ └── ', '├── ', '│ ├── ', '│ └── ', '│ └── ',
'├── ', '│ └── ', '└── ', ' └── ', '├── ', '│ └── ', '└── ', ' └── ',
]); ]);
await expect(page.locator('.root-wire')).toHaveCount(0);
await expect(page.locator('.branch-spine')).toHaveText(['├', '│', '│', '│', '├', '│', '└', ' ']);
const grid = await page.evaluate(() => { const grid = await page.evaluate(() => {
const root = document.querySelector('.root-mark')!.getBoundingClientRect();
const spine = document.querySelector('.branch-spine')!.getBoundingClientRect();
const prefix = document.querySelector<HTMLElement>('.branch-prefix')!; const prefix = document.querySelector<HTMLElement>('.branch-prefix')!;
const cell = prefix.getBoundingClientRect().width / 4; const cell = prefix.getBoundingClientRect().width / 4;
const frame = document.querySelector('.landing-content')!.getBoundingClientRect(); const frame = document.querySelector('.landing-content')!.getBoundingClientRect();
const rows = [...document.querySelectorAll<HTMLElement>('.node-row:not(.root-slot)')].map((row) => row.getBoundingClientRect()); const rows = [...document.querySelectorAll<HTMLElement>('.node-row:not(.root-slot)')].map((row) => row.getBoundingClientRect());
const nodes = [...document.querySelectorAll<HTMLElement>('.demo-node:not(.root-mark)')].map((node) => node.getBoundingClientRect()); const nodes = [...document.querySelectorAll<HTMLElement>('.demo-node:not(.root-mark)')].map((node) => node.getBoundingClientRect());
return { return {
rootColumnError: Math.abs(root.left + root.width / 2 - spine.left - spine.width / 2),
rootGap: Math.abs(root.bottom - spine.top),
frameCells: frame.width / cell, frameCells: frame.width / cell,
rowHeights: rows.map((row) => row.height), rowHeights: rows.map((row) => row.height),
nodeColumns: nodes.map((node) => (node.left - frame.left) / cell), nodeColumns: nodes.map((node) => (node.left - frame.left) / cell),
@@ -141,6 +179,8 @@ test('storyboard phases retain one root and ignore repeated triggers', async ({
font: getComputedStyle(prefix).fontFamily, font: getComputedStyle(prefix).fontFamily,
}; };
}); });
expect(grid.rootColumnError).toBeLessThan(1);
expect(grid.rootGap).toBeLessThan(1);
expect(Math.abs(grid.frameCells / 4 - Math.round(grid.frameCells / 4))).toBeLessThan(.02); expect(Math.abs(grid.frameCells / 4 - Math.round(grid.frameCells / 4))).toBeLessThan(.02);
expect(grid.rowHeights.every((height) => height === 24)).toBe(true); 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.nodeColumns.every((col) => Math.abs(col - Math.round(col)) < .02)).toBe(true);
@@ -192,7 +232,10 @@ test('tree selection, collapse and call results are keyboard accessible', async
await expect(page.getByRole('treeitem', { name: 'ルート /' })).toBeFocused(); await expect(page.getByRole('treeitem', { name: 'ルート /' })).toBeFocused();
await page.keyboard.press('ArrowLeft'); await page.keyboard.press('ArrowLeft');
await expect(page.getByRole('treeitem')).toHaveCount(1); await expect(page.getByRole('treeitem')).toHaveCount(1);
await expect(page.locator('.branch-spine').first()).toBeHidden();
await page.keyboard.press('ArrowRight'); await page.keyboard.press('ArrowRight');
await expect(page.locator('.branch-spine').first()).toBeVisible();
await expect(page.locator('.branch-prefix').first()).toHaveText('├── ');
await page.keyboard.press('End'); await page.keyboard.press('End');
await expect(page.getByRole('treeitem', { name: 'protocol', exact: true })).toBeFocused(); await expect(page.getByRole('treeitem', { name: 'protocol', exact: true })).toBeFocused();
await page.keyboard.press('Tab'); await page.keyboard.press('Tab');
+7
View File
@@ -6,6 +6,13 @@ test('home and book are usable without client JavaScript', async ({ page }, test
await page.goto('./'); await page.goto('./');
await expect(page.getByRole('heading', { level: 1 })).toContainText('探索できる世界へ'); await expect(page.getByRole('heading', { level: 1 })).toContainText('探索できる世界へ');
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await expect(page.locator('.branch-prefix').first()).toHaveText('├── ');
const root = await page.locator('.root-mark').boundingBox();
const spine = await page.locator('.branch-spine').first().boundingBox();
expect(root).not.toBeNull();
expect(spine).not.toBeNull();
expect(Math.abs(root!.x + root!.width / 2 - spine!.x - spine!.width / 2)).toBeLessThan(1);
expect(Math.abs(root!.y + root!.height - spine!.y)).toBeLessThan(1);
await page.screenshot({ path: testInfo.outputPath('home.png'), fullPage: true }); await page.screenshot({ path: testInfo.outputPath('home.png'), fullPage: true });
await page.getByRole('link', { name: 'Book', exact: true }).click(); await page.getByRole('link', { name: 'Book', exact: true }).click();
await expect(page.getByRole('heading', { level: 1 })).toContainText('通信の約束まで'); await expect(page.getByRole('heading', { level: 1 })).toContainText('通信の約束まで');