From 651cc573be498cf572844c6f18c7b504d488c0cc Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 10 Oct 2026 17:39:40 +0900 Subject: [PATCH] fix: grow landing trunk downward into the tree --- README.md | 2 +- site/src/components/Landing.astro | 7 ++-- site/src/scripts/landing.ts | 41 +++++++++++---------- site/src/styles/landing.css | 9 ++--- site/tests/browser/landing.spec.ts | 57 ++++++++++++++++++++++++++---- site/tests/browser/site.spec.ts | 7 ++++ 6 files changed, 90 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 2bf0bff..90104da 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Bookの閲覧テストはJavaScript無効、ランディングのテストは有 トップはSVGのルートマークから始まります。コンテの2はホバー専用の「Tap」表現で、ポインタを離すと消え、待っていても本編には進みません。クリック・ホイール/タッチスクロール・キー入力のいずれか一回で、3〜6を約6.9秒で最後まで再生します。ホバーは再生の前提ではなく、追加入力で段階を進めたり、再生を重ねたりもしません。Escapeまたは画面右下のスキップで直ちにデモへ移れます。ブラウザの修飾キー付きショートカットは妨げません。 -コンテ4では、大きなルートを左へ移し、円周の上端から右の接線へ文字を流しながら横の幹を伸ばします。円の半径・曲率は変えません。幹を伸ばす区間を見せてからルートと幹を縮小・移動し、幹の先端を罫線`──┐`の角へ接続して枝を展開します。 +コンテ4では、大きなルートを左へ移し、円周の上端から右の接線へ文字を流しながら、ルートの直下へ縦の幹を伸ばします。円の半径・曲率は変えません。幹を伸ばす区間を見せてからルートと幹を縮小・移動し、縦幹全体を同じ列の`├`・`│`・`└`へ引き継いで、右へ枝を展開します。主幹は連続したまま保ち、枝先とラベルにglitchを適用します。 ツリーとパネルはASCII glitchから確定した文字・罫線へ収束します。ノイズは局所的なセル置換を10Hzで更新し、全画面の点滅は使いません。空白・改行・日本語は保持し、完了/スキップ時には元の文字列を完全に復元します。 diff --git a/site/src/components/Landing.astro b/site/src/components/Landing.astro index 92e50de..e6e0084 100644 --- a/site/src/components/Landing.astro +++ b/site/src/components/Landing.astro @@ -8,7 +8,7 @@ import BoxFrame from './BoxFrame.astro'; diff --git a/site/src/scripts/landing.ts b/site/src/scripts/landing.ts index e88e88e..b2e74e5 100644 --- a/site/src/scripts/landing.ts +++ b/site/src/scripts/landing.ts @@ -49,8 +49,9 @@ export function initLanding() { const tree = get('.demo-tree'); const nodes = [...stage.querySelectorAll('.demo-node')]; const rows = nodes.map((node) => node.parentElement!); - const branches = [...stage.querySelectorAll('.branch-prefix')]; - const rootWire = get('.root-wire'); + const branches = [...stage.querySelectorAll('.branch-arm')]; + const spines = [...stage.querySelectorAll('.branch-spine')]; + const lastRootBranch = nodes.findLast((node) => node.getAttribute('aria-level') === '2')!.parentElement!; const reveals = [...stage.querySelectorAll('[data-reveal]')]; const orbit = get('.orbit'); const orbitPath = get('#welcome-orbit'); @@ -73,7 +74,7 @@ export function initLanding() { const collapsed = new Set(); const panels = [...stage.querySelectorAll('.ascii-panel')]; const glitchTargets = [...stage.querySelectorAll([ - '.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) { diff --git a/site/src/styles/landing.css b/site/src/styles/landing.css index ce1d1e3..98003e9 100644 --- a/site/src/styles/landing.css +++ b/site/src/styles/landing.css @@ -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; } diff --git a/site/tests/browser/landing.spec.ts b/site/tests/browser/landing.spec.ts index 4247cbf..dc83613 100644 --- a/site/tests/browser/landing.spec.ts +++ b/site/tests/browser/landing.spec.ts @@ -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('.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('.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('.branch-prefix')!; const cell = prefix.getBoundingClientRect().width / 4; const frame = document.querySelector('.landing-content')!.getBoundingClientRect(); const rows = [...document.querySelectorAll('.node-row:not(.root-slot)')].map((row) => row.getBoundingClientRect()); const nodes = [...document.querySelectorAll('.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'); diff --git a/site/tests/browser/site.spec.ts b/site/tests/browser/site.spec.ts index 17a0ad9..ec65ebb 100644 --- a/site/tests/browser/site.spec.ts +++ b/site/tests/browser/site.spec.ts @@ -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('通信の約束まで');