diff --git a/README.md b/README.md index f7c21e3..8c7ce4a 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,8 @@ Bookの閲覧テストはJavaScript無効、ランディングのテストは有 トップはSVGのルートマークから始まります。コンテの2はホバー専用の「Tap」表現で、ポインタを離すと消え、待っていても本編には進みません。クリック・ホイール/タッチスクロール・キー入力のいずれか一回で、全体を約3.9秒で最後まで再生します。ホバーは再生の前提ではなく、追加入力で段階を進めたり、再生を重ねたりもしません。Escapeまたは画面右下のスキップで直ちにデモへ移れます。ブラウザの修飾キー付きショートカットは妨げません。 +ホバーの「Tap」はルートと同じSVG座標系に置き、スラッシュの上辺・下辺(`y=4`〜`60`)に一致するマスクで切り抜きます。左右の文字列は斜辺と平行に逆方向へ流れ、上下端は水平に揃います。 + ルートは中間位置で停止せず、一つの弧状の軌道でツリーへ移動します。円周文字の上端接線への流出、ルートの縮小、直下への幹伸長を重ね、着地を待たずに右向きの枝とパネルのglitchを始めます。円の半径・曲率は変えません。移動中の枝もルートと同じ座標変換に乗せ、縦幹全体を`├`・`│`・`└`へずれなく引き継ぎます。主幹は連続したまま保ち、枝先とラベルにglitchを適用します。ページのヘッダーや周辺要素も末尾の収束に重ねて現れます。 ツリーとパネルはASCII glitchから確定した文字・罫線へ収束します。ノイズは局所的なセル置換を10Hzで更新し、全画面の点滅は使いません。空白・改行・日本語は保持し、完了/スキップ時には元の文字列を完全に復元します。 diff --git a/site/src/components/Landing.astro b/site/src/components/Landing.astro index e6e0084..3d7bf68 100644 --- a/site/src/components/Landing.astro +++ b/site/src/components/Landing.astro @@ -6,8 +6,6 @@ import BoxFrame from './BoxFrame.astro'; ---
; diff --git a/site/src/styles/landing.css b/site/src/styles/landing.css index 994c714..e1e66cf 100644 --- a/site/src/styles/landing.css +++ b/site/src/styles/landing.css @@ -124,14 +124,15 @@ html[data-landing] .intro-art { display: block; position: fixed; z-index: 10; wi .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; } -.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-b { transform: translate(-50px, 22px) rotate(-67deg); } -.tap-stream span { display: block; white-space: pre; } +/* The mask stays in the slash's coordinate system (y=4…60), outside the + rotated text lanes. Thus both streams enter/leave at horizontal edges. */ +.tap-mask { pointer-events: none; } +.tap-stream { fill: currentColor; font: 500 3.7px var(--mono); letter-spacing: .74px; opacity: 0; } +.tap-stream text { white-space: pre; } .landing[data-hover="true"] .tap-stream { opacity: 1; } -.landing[data-hover="true"] .tap-stream-a span { animation: tap-forward 1.4s linear infinite; } -.landing[data-hover="true"] .tap-stream-b span { animation: tap-forward 1.4s linear infinite reverse; } -@keyframes tap-forward { from { transform: translateX(calc(-6ch - 12px)); } to { transform: translateX(0); } } +.landing[data-hover="true"] .tap-stream-a text { animation: tap-forward 1.4s linear infinite; } +.landing[data-hover="true"] .tap-stream-b text { animation: tap-forward 1.4s linear infinite reverse; } +@keyframes tap-forward { from { transform: translateX(calc(-6ch - 4.44px)); } to { transform: translateX(0); } } html[data-landing] .intro-invitation { display: block; position: fixed; left: 2ch; right: 2ch; bottom: calc(4 * var(--row)); text-align: center; z-index: 11; pointer-events: none; color: var(--muted); } .intro-invitation p { margin: var(--row) 0 0; } html[data-landing] .intro-skip { display: block; position: fixed; right: 2ch; bottom: var(--row); z-index: 40; border: 0; padding: 0 1ch; height: var(--row); background: transparent; color: var(--muted); cursor: pointer; } diff --git a/site/tests/browser/landing.spec.ts b/site/tests/browser/landing.spec.ts index 7fcdf05..a83283b 100644 --- a/site/tests/browser/landing.spec.ts +++ b/site/tests/browser/landing.spec.ts @@ -34,13 +34,68 @@ for (const trigger of ['click', 'wheel', 'key']) { }); } +test('hover streams are masked at the actual slash edges throughout the loop', async ({ page }, testInfo) => { + await armed(page); + const root = page.locator('.root-mark'); + const box = (await root.boundingBox())!; + const shape = (await root.locator('.root-shape').boundingBox())!; + const clip = { x: Math.floor(box.x) - 8, y: Math.floor(box.y) - 8, width: Math.ceil(box.width) + 16, height: Math.ceil(box.height) + 16 }; + const before = (await page.screenshot({ clip })).toString('base64'); + await root.hover(); + await expect(page.locator('.tap-mask')).toHaveAttribute('mask', 'url(#tap-edge-mask)'); + const mask = await page.locator('#tap-edge-mask rect').evaluate((rect) => { + const svg = rect.closest('svg')!; + const matrix = svg.getScreenCTM()!; + return { top: new DOMPoint(0, 4).matrixTransform(matrix).y, bottom: new DOMPoint(0, 60).matrixTransform(matrix).y }; + }); + expect(mask.top).toBeCloseTo(shape.y, 1); + expect(mask.bottom).toBeCloseTo(shape.y + shape.height, 1); + for (const time of [0, 700, 1399]) { + await page.locator('.tap-stream text').evaluateAll((texts, time) => { + texts.forEach((text) => text.getAnimations().forEach((animation) => { + animation.pause(); + animation.currentTime = time; + })); + }, time); + const after = (await page.screenshot({ clip, path: testInfo.outputPath(`hover-mask-${time}.png`) })).toString('base64'); + const ink = await page.evaluate(async ({ before, after }) => { + const pixels = async (base64: string) => { + const image = new Image(); + image.src = 'data:image/png;base64,' + base64; + await image.decode(); + const canvas = document.createElement('canvas'); + canvas.width = image.width; canvas.height = image.height; + const ctx = canvas.getContext('2d')!; + ctx.drawImage(image, 0, 0); + return { data: ctx.getImageData(0, 0, image.width, image.height).data, width: image.width }; + }; + const a = await pixels(before), b = await pixels(after); + let minY = Infinity, maxY = -Infinity, count = 0; + for (let i = 0; i < a.data.length; i += 4) { + if (Math.abs(a.data[i] - b.data[i]) + Math.abs(a.data[i + 1] - b.data[i + 1]) + Math.abs(a.data[i + 2] - b.data[i + 2]) > 30) { + const y = Math.floor(i / 4 / a.width); + minY = Math.min(minY, y); maxY = Math.max(maxY, y); count++; + } + } + return { minY, maxY, count }; + }, { before, after }); + expect(ink.count).toBeGreaterThan(50); + expect(ink.minY + clip.y).toBeGreaterThanOrEqual(Math.floor(shape.y)); + expect(ink.maxY + clip.y).toBeLessThanOrEqual(Math.ceil(shape.y + shape.height)); + } + await expect(page.locator(stage)).toHaveAttribute('data-phase', 'armed'); + await page.mouse.move(0, 0); + await expect(page.locator('.tap-stream-a')).toHaveCSS('opacity', '0'); + await expect(page.locator('.tap-stream-b')).toHaveCSS('opacity', '0'); +}); + test('continuous motion overlaps growth, travel and reveal without intermediate stops', async ({ page }, testInfo) => { await page.clock.install({ time: new Date('2026-01-01T00:00:00Z') }); await page.clock.pauseAt(new Date('2026-01-01T00:00:01Z')); await armed(page); const root = page.locator('.root-mark'); await expect(root.locator('svg .root-shape')).toHaveCount(1); - await expect(root).toHaveText(''); // Geometry, never a font glyph. + await expect(root.locator('.root-shape')).toHaveAttribute('d', 'M31 4H41L17 60H7Z'); // The slash is geometry, not a font glyph. await expect(page.locator('.call-panel')).toHaveAttribute('inert', ''); const initial = await root.boundingBox(); expect(initial).not.toBeNull();