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または画面右下のスキップで直ちにデモへ移れます。ブラウザの修飾キー付きショートカットは妨げません。
コンテ4では、大きなルートを左へ移し、円周の上端から右の接線へ文字を流しながら横の幹を伸ばします。円の半径・曲率は変えません。幹を伸ばす区間を見せてからルートと幹を縮小・移動し、幹の先端を罫線`──┐`の角へ接続して枝を展開します。
コンテ4では、大きなルートを左へ移し、円周の上端から右の接線へ文字を流しながら、ルートの直下へ縦の幹を伸ばします。円の半径・曲率は変えません。幹を伸ばす区間を見せてからルートと幹を縮小・移動し、縦幹全体を同じ列の`├`・`│`・`└`へ引き継いで、右へ枝を展開します。主幹は連続したまま保ち、枝先とラベルにglitchを適用します。
ツリーとパネルは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="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>
<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">
<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>
@@ -30,8 +30,9 @@ import BoxFrame from './BoxFrame.astro';
{objects.map((object) => {
const root = object.path === '/';
const children = childrenOf(object.path);
const prefix = treePrefix(object);
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
type="button" role="treeitem" class:list={['demo-node', { 'root-mark': root }]}
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>}</>}
</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>
+23 -18
View File
@@ -49,8 +49,9 @@ export function initLanding() {
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 branches = [...stage.querySelectorAll<HTMLElement>('.branch-arm')];
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 orbit = get<SVGSVGElement>('.orbit');
const orbitPath = get<SVGPathElement>('#welcome-orbit');
@@ -73,7 +74,7 @@ export function initLanding() {
const collapsed = new Set<string>();
const panels = [...stage.querySelectorAll<HTMLElement>('.ascii-panel')];
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]',
'[data-target]', '[data-operation]', '.result-label > span', '[data-result]',
].join(','))].map((element, seed) => {
@@ -139,7 +140,6 @@ export function initLanding() {
if (indicator) indicator.textContent = collapsed.has(object.path) ? '+' : '−';
}
});
rootWire.textContent = collapsed.has('/') ? '──╴' : '──┐';
}
nodes.forEach((node, index) => node.addEventListener('click', (event) => {
@@ -218,7 +218,7 @@ export function initLanding() {
element.style.removeProperty('clip-path');
});
blocked.forEach((element) => { element.inert = false; });
[...branches, rootWire].forEach((wire) => {
[...branches, ...spines].forEach((wire) => {
wire.style.removeProperty('opacity');
wire.style.removeProperty('clip-path');
});
@@ -228,8 +228,9 @@ export function initLanding() {
// One clock owns all phases. Inputs cannot advance, restart, or stack timelines.
function render(time: number) {
const target = slot.getBoundingClientRect();
const wireTarget = rootWire.getBoundingClientRect();
const endX = target.left + root.offsetWidth / 2;
const lastBranchTarget = lastRootBranch.getBoundingClientRect();
// 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 orbitScale = parseFloat(getComputedStyle(orbit).width) / 600;
const radius = orbitRadius * orbitScale;
@@ -248,19 +249,22 @@ export function initLanding() {
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})`;
const extension = timing.trace(progress(time, 2680, 1000));
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.
// 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${startX} ${lineY} H${tipX}`);
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 < 2680 ? '0' : String(1 - handoff);
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));
@@ -272,7 +276,8 @@ export function initLanding() {
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)`;
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) => {
const value = timing.trace(progress(time, 5450 + index * 85, 520));
wire.style.opacity = String(clamp(value * 4));
@@ -290,7 +295,7 @@ export function initLanding() {
element.style.opacity = String(isPanel ? clamp(value * 6) : value);
});
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) {
+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 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 { position: relative; padding-left: 1ch; 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; }
.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; }
.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[aria-selected="true"] { background: var(--accent); color: #273621; }
.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] .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] [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="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; }
+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.keyboard.press('ArrowDown');
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');
expect(await page.locator('#welcome-orbit').getAttribute('d')).toMatch(new RegExp(`^${circlePath} H`));
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.dy).toBeCloseTo(0, 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());
expect(earlyLength).toBeGreaterThan(10);
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');
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);
expect(extendedLength).toBeGreaterThan(page.viewportSize()!.height * .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');
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.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 path = document.querySelector<SVGPathElement>('.trunk-line')!;
const start = path.getPointAtLength(0).matrixTransform(path.getScreenCTM()!);
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));
const first = document.querySelector('.branch-spine')!.getBoundingClientRect();
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 expect(page.locator(stage)).toHaveAttribute('data-phase', 'tree');
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 root = document.querySelector('.root-mark')!.getBoundingClientRect();
const spine = document.querySelector('.branch-spine')!.getBoundingClientRect();
const prefix = document.querySelector<HTMLElement>('.branch-prefix')!;
const cell = prefix.getBoundingClientRect().width / 4;
const frame = document.querySelector('.landing-content')!.getBoundingClientRect();
const rows = [...document.querySelectorAll<HTMLElement>('.node-row:not(.root-slot)')].map((row) => row.getBoundingClientRect());
const nodes = [...document.querySelectorAll<HTMLElement>('.demo-node:not(.root-mark)')].map((node) => node.getBoundingClientRect());
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,
rowHeights: rows.map((row) => row.height),
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,
};
});
expect(grid.rootColumnError).toBeLessThan(1);
expect(grid.rootGap).toBeLessThan(1);
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);
@@ -192,7 +232,10 @@ test('tree selection, collapse and call results are keyboard accessible', async
await expect(page.getByRole('treeitem', { name: 'ルート /' })).toBeFocused();
await page.keyboard.press('ArrowLeft');
await expect(page.getByRole('treeitem')).toHaveCount(1);
await expect(page.locator('.branch-spine').first()).toBeHidden();
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 expect(page.getByRole('treeitem', { name: 'protocol', exact: true })).toBeFocused();
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 expect(page.getByRole('heading', { level: 1 })).toContainText('探索できる世界へ');
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.getByRole('link', { name: 'Book', exact: true }).click();
await expect(page.getByRole('heading', { level: 1 })).toContainText('通信の約束まで');