From d5a08791421c9bfeca6433ba64a36f17386875f0 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 10 Oct 2026 03:00:08 +0900 Subject: [PATCH] fix: separate hover and use ASCII glitch transitions --- README.md | 6 ++- site/src/components/BoxFrame.astro | 12 ++++++ site/src/components/Landing.astro | 11 +++-- site/src/lib/landing-ascii.mjs | 21 ++++++++++ site/src/scripts/landing.ts | 65 ++++++++++++++++++++++++------ site/src/styles/landing.css | 24 ++++++++--- site/tests/browser/landing.spec.ts | 52 ++++++++++++++++++++++-- site/tests/landing-ascii.test.mjs | 21 ++++++++++ 8 files changed, 185 insertions(+), 27 deletions(-) create mode 100644 site/src/components/BoxFrame.astro create mode 100644 site/src/lib/landing-ascii.mjs create mode 100644 site/tests/landing-ascii.test.mjs diff --git a/README.md b/README.md index f13f15e..334e905 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,13 @@ Bookの閲覧テストはJavaScript無効、ランディングのテストは有 ### ランディング -トップはSVGのルートマークから始まります。クリック・ホイール/タッチスクロール・キー入力のいずれか一回で、約6.2秒のタイムラインを最後まで再生します。追加入力で段階を進めたり、再生を重ねたりはしません。Escapeまたは画面右下のスキップで直ちにデモへ移れます。ブラウザの修飾キー付きショートカットは妨げません。 +トップはSVGのルートマークから始まります。コンテの2はホバー専用の「Tap」表現で、ポインタを離すと消え、待っていても本編には進みません。クリック・ホイール/タッチスクロール・キー入力のいずれか一回で、3〜6を約5.6秒で最後まで再生します。ホバーは再生の前提ではなく、追加入力で段階を進めたり、再生を重ねたりもしません。Escapeまたは画面右下のスキップで直ちにデモへ移れます。ブラウザの修飾キー付きショートカットは妨げません。 + +ツリーとパネルはASCII glitchから確定した文字・罫線へ収束します。ノイズは局所的なセル置換を10Hzで更新し、全画面の点滅は使いません。空白・改行・日本語は保持し、完了/スキップ時には元の文字列を完全に復元します。 - `src/components/Landing.astro` — SVGと、最終的に操作するツリー/パネルの静的HTML。 +- `src/components/BoxFrame.astro` — `┌─┐`・`│`・`└─┘`によるText View/Call resultの枠。CSS borderやSVGで枠を代用せず、幅・高さに合わせて罫線素片をクリップします。JavaScript無効時も表示されます。 +- `src/lib/landing-ascii.mjs` — セル幅を保つ決定的なglitch文字置換。 - `src/scripts/landing.ts` — 単一の`requestAnimationFrame`時計、役割別の速度曲線、等弧長で円をほどくSVG path、ツリー操作。 - `src/lib/landing-demo.ts` — ブラウザ内だけで使うサンプル。外部Hostへの接続、変更操作、通信はありません。Object signatureとInterface signatureを分け、本文は明示的なCallで表示します。 - `src/styles/landing.css` — トップ全体を14pxのmonoフォント・24pxの行高で構成。フレーム幅を4ch単位に揃え、列・ガター・パディングもセルと行の整数倍にします。ツリーはSVGの線ではなく`├──`・`└──`・`│`などの罫線素片を使い、JavaScript無効時も同じ構造を表示します。モバイルはツリーとパネルを縦に配置します。 diff --git a/site/src/components/BoxFrame.astro b/site/src/components/BoxFrame.astro new file mode 100644 index 0000000..bcdfeb5 --- /dev/null +++ b/site/src/components/BoxFrame.astro @@ -0,0 +1,12 @@ +--- +// Overrun is clipped inside complete mono cells. No JS measurement, SVG, +// borders, or background gradients are needed, including for static HTML. +const horizontal = '─'.repeat(160); +const vertical = '│\n'.repeat(160).trimEnd(); +--- + diff --git a/site/src/components/Landing.astro b/site/src/components/Landing.astro index c38afc8..0f31bb0 100644 --- a/site/src/components/Landing.astro +++ b/site/src/components/Landing.astro @@ -2,11 +2,12 @@ import { objects, initialObject, childrenOf, treePrefix, objectSignature, interfaceSignature } from '../lib/landing-demo'; import { href } from '../lib/book'; import '../styles/landing.css'; +import BoxFrame from './BoxFrame.astro'; ---
; @@ -47,19 +48,21 @@ import '../styles/landing.css';
+

02 / Text View

OBJECT SIGNATURE
{objectSignature(initialObject)}

選択したObjectの公開情報。本文はOperationで取得します。

+

03 / Call result

EXPLICIT ACTION
TARGET{initialObject.path}
{interfaceSignature(initialObject)}
/::issue.read#read_body
RETURN / bodyREADY
-
// Operationを選び、Callで本文を取得。
// このデモはブラウザ内だけで動作します。
+
{'// Operationを選び、Callで本文を取得。\n// このデモはブラウザ内だけで動作します。'}

ローカルサンプル — 外部への通信や変更は行いません。

diff --git a/site/src/lib/landing-ascii.mjs b/site/src/lib/landing-ascii.mjs new file mode 100644 index 0000000..ddc323c --- /dev/null +++ b/site/src/lib/landing-ascii.mjs @@ -0,0 +1,21 @@ +// Deterministic cell substitutions: no layout jitter, timers, DOM parsing, +// full-screen flashes, or random characters left behind after a transition. +const noise = '#%/+*?[]{}01:='; +function hash(index, salt) { + let value = Math.imul(index + 1, 374761393) ^ Math.imul(salt + 1, 668265263); + value = Math.imul(value ^ (value >>> 13), 1274126177); + return (value ^ (value >>> 16)) >>> 0; +} + +export function glitchText(text, amount, frame, seed = 0) { + if (amount >= 1) return text; + return Array.from(text, (character, index) => { + const code = character.codePointAt(0); + // Preserve whitespace and non-ASCII prose. Replacing full-width Japanese + // with single-cell noise would change wrapping, frame height and baselines. + if (!((code >= 33 && code <= 126) || (code >= 0x2500 && code <= 0x257f))) return character; + const threshold = (hash(index, seed) % 1000) / 1000; + if (threshold < Math.max(0, amount)) return character; + return noise[hash(index, seed + frame * 31) % noise.length]; + }).join(''); +} diff --git a/site/src/scripts/landing.ts b/site/src/scripts/landing.ts index 9f56712..55321d0 100644 --- a/site/src/scripts/landing.ts +++ b/site/src/scripts/landing.ts @@ -1,4 +1,5 @@ import { objects, initialObject, childrenOf, objectSignature, interfaceSignature, interfaceReference } from '../lib/landing-demo'; +import { glitchText } from '../lib/landing-ascii.mjs'; const clamp = (value: number) => Math.max(0, Math.min(1, value)); const progress = (time: number, start: number, duration: number) => clamp((time - start) / duration); @@ -26,7 +27,9 @@ const timing = { trace: bezier(.22, .3, .25, 1), }; const mix = (a: number, b: number, value: number) => a + (b - a) * value; -const duration = 6200; +// Scene 2 is an independent hover affordance. Playback begins at scene 3. +const playbackOffset = 620; +const duration = 6200 - playbackOffset; // Preserve the arc length while reducing curvature. Unlike interpolating arbitrary // Bézier control points, this cannot fold or bunch the letters during unrolling. function unrolledOrbit(opening: number): string { @@ -60,7 +63,6 @@ export function initLanding() { const orbit = get('.orbit'); const orbitPath = get('#welcome-orbit'); const textPath = get('.orbit textPath'); - const taps = [...stage.querySelectorAll('.tap-stream')]; const invitation = get('.intro-invitation'); const skip = get('.intro-skip'); const call = get('.demo-call'); @@ -77,6 +79,40 @@ export function initLanding() { let keyboardStarted = false; let animation: Animation | undefined; const collapsed = new Set(); + const panels = [...stage.querySelectorAll('.ascii-panel')]; + const glitchTargets = [...stage.querySelectorAll([ + '.branch-prefix', '.root-wire', '.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) => { + const panel = element.closest('.ascii-panel'); + const row = element.closest('.node-row'); + const index = rows.indexOf(row as HTMLElement); + const start = panel ? (panel.classList.contains('call-panel') ? 5200 : 5020) + : element === rootWire ? 4140 : 4140 + Math.max(0, index - 1) * 85; + return { element, text: element.textContent ?? '', start, duration: panel ? 850 : 580, seed }; + }); + let lastGlitchFrame = -1; + + function renderGlitch(time: number) { + // Cell noise changes at 10Hz; transforms still run at display refresh rate. + const frame = Math.floor(time / 100); + if (lastGlitchFrame === frame) return; + lastGlitchFrame = frame; + for (const target of glitchTargets) { + const value = progress(time, target.start, target.duration); + target.element.textContent = glitchText(target.text, value, frame, target.seed); + } + panels.forEach((panel) => { + const start = panel.classList.contains('call-panel') ? 5200 : 5020; + panel.toggleAttribute('data-glitching', time >= start && time < start + 850); + }); + } + + function restoreGlitch() { + glitchTargets.forEach(({ element, text }) => { element.textContent = text; }); + panels.forEach((panel) => panel.removeAttribute('data-glitching')); + } function select(index: number, focus = false) { selected = objects[index]; @@ -170,6 +206,8 @@ export function initLanding() { stage.dataset.phase = 'complete'; delete html.dataset.landing; abort.abort(); + delete stage.dataset.hover; + restoreGlitch(); root.style.removeProperty('transform'); tether.style.removeProperty('stroke-dasharray'); tether.style.removeProperty('stroke-dashoffset'); @@ -217,12 +255,7 @@ export function initLanding() { tether.style.strokeDasharray = '1'; tether.style.strokeDashoffset = String(1 - timing.trace(progress(time, 3260, 980))); tether.style.opacity = String(1 - progress(time, 4200, 450)); - invitation.style.opacity = String(1 - progress(time, 0, 280)); - const tapOpacity = progress(time, 60, 180) * (1 - progress(time, 650, 350)); - taps.forEach((tap, index) => { - tap.style.opacity = String(tapOpacity); - tap.firstElementChild!.setAttribute('style', `transform: translateX(${mix(index ? -60 : 0, index ? 0 : -60, progress(time, 0, 1050))}px)`); - }); + invitation.style.opacity = String(1 - progress(time, playbackOffset, 280)); const opening = timing.unroll(progress(time, 2530, 1350)); orbitPath.setAttribute('d', unrolledOrbit(opening)); orbit.style.opacity = String(progress(time, 620, 480) * (1 - progress(time, 3500, 550))); @@ -243,11 +276,11 @@ export function initLanding() { const isPanel = element.classList.contains('ascii-panel'); const start = isPanel ? 5020 + (element.classList.contains('call-panel') ? 180 : 0) : 4680 + index * 55; const value = progress(time, start, isPanel ? 850 : 380); - element.style.opacity = String(isPanel ? clamp(value * 3) : value); - // Terminal panels materialize in reading order; no unrelated card motion. - if (isPanel) element.style.clipPath = `inset(0 0 ${(1 - value) * 100}% 0)`; + // Noise resolves into the panel, not a wipe or a sliding card. + element.style.opacity = String(isPanel ? clamp(value * 6) : value); }); - stage.dataset.phase = time < 620 ? 'tap' : time < 2530 ? 'orbit' : time < 4080 ? 'unfold' : time < 5020 ? 'tree' : 'panels'; + renderGlitch(time); + stage.dataset.phase = time < 2530 ? 'orbit' : time < 4080 ? 'unfold' : time < 5020 ? 'tree' : 'panels'; } function tick(now: number) { @@ -255,7 +288,7 @@ export function initLanding() { const time = now - startTime; if (time >= duration) return finish(); try { - render(time); + render(time + playbackOffset); frame = requestAnimationFrame(tick); } catch { // Fail open: the document and demo are more important than the animation. @@ -269,7 +302,9 @@ export function initLanding() { keyboardStarted = fromKeyboard; state = 'playing'; html.dataset.landing = 'playing'; + delete stage.dataset.hover; startTime = performance.now(); + render(playbackOffset); root.style.willChange = 'transform'; frame = requestAnimationFrame(tick); } @@ -287,6 +322,10 @@ export function initLanding() { stage.dataset.phase = 'armed'; invitation.style.opacity = '1'; const signal = abort.signal; + root.addEventListener('pointerenter', (event) => { + if (state === 'armed' && event.pointerType !== 'touch') stage.dataset.hover = 'true'; + }, { signal }); + root.addEventListener('pointerleave', () => { delete stage.dataset.hover; }, { signal }); document.addEventListener('click', (event) => { if ((event.target as Element).closest('.intro-skip, .skip-link')) return finish(); start(); diff --git a/site/src/styles/landing.css b/site/src/styles/landing.css index dc60141..774331b 100644 --- a/site/src/styles/landing.css +++ b/site/src/styles/landing.css @@ -52,10 +52,20 @@ html[data-landing] .landing-page .site-header { opacity: 0; } html[data-landing="boot"] .root-tether { stroke-dasharray: 1; stroke-dashoffset: 1; } .root-mark[aria-selected="true"] { color: #4e752d; } .root-caption { margin-left: 2ch; height: var(--row); color: var(--muted); } -.ascii-panel { position: relative; min-width: 0; outline: 1px solid var(--line); outline-offset: -1px; padding: var(--row) 2ch; } -.ascii-panel::before, .ascii-panel::after { content: '+'; position: absolute; top: calc(var(--row) / -2); font: inherit; background: var(--paper); color: #89947f; width: 1ch; text-align: center; } -.ascii-panel::before { left: -.5ch; } -.ascii-panel::after { right: -.5ch; } +.ascii-panel { position: relative; min-width: 0; padding: calc(2 * var(--row)) 2ch; } +.box-frame { position: absolute; inset: 0; pointer-events: none; user-select: none; color: #8e9886; } +.box-edge { position: absolute; inset-inline: 0; height: var(--row); display: grid; grid-template-columns: 1ch minmax(0, 1fr) 1ch; align-items: center; } +.box-top { top: 0; } +.box-bottom { bottom: 0; } +.box-corner, .box-fill { display: block; height: 14px; font: 14px/14px var(--mono); white-space: pre; transform: scaleY(1.7142857143); } +.box-fill { overflow: hidden; } +.box-side { position: absolute; top: var(--row); bottom: var(--row); width: 1ch; overflow: hidden; } +.box-left { left: 0; } +.box-right { right: 0; } +.box-column { display: block; white-space: pre; font: 14px/14px var(--mono); transform: scaleY(1.7142857143); transform-origin: top; } +/* Mutated glyphs are decorative during the intro; opacity remains steady. + The actual content is restored atomically before the controls are enabled. */ +.ascii-panel[data-glitching] .box-frame { color: #68745c; } .panel-title { display: flex; justify-content: space-between; gap: 2ch; align-items: baseline; min-height: var(--row); margin-bottom: var(--row); } .panel-title h3 { font: inherit; margin: 0; } .panel-title > span { color: var(--muted); } @@ -114,7 +124,11 @@ html[data-landing] .intro-art { display: block; position: fixed; z-index: 10; wi .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; } +.tap-stream span { display: block; 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); } } 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 edd47ca..9452994 100644 --- a/site/tests/browser/landing.spec.ts +++ b/site/tests/browser/landing.spec.ts @@ -47,10 +47,20 @@ test('storyboard phases retain one root and ignore repeated triggers', async ({ expect(Math.abs(initial!.x + initial!.width / 2 - page.viewportSize()!.width / 2)).toBeLessThan(2); expect(Math.abs(initial!.y + initial!.height / 2 - page.viewportSize()!.height / 2)).toBeLessThan(2); await page.screenshot({ path: testInfo.outputPath('01-root.png') }); + await root.hover(); + await expect(page.locator(stage)).toHaveAttribute('data-hover', 'true'); + await page.clock.runFor(8000); + await expect(page.locator(stage)).toHaveAttribute('data-phase', 'armed'); + await expect(page.locator('.tap-stream-a')).toHaveCSS('opacity', '1'); + expect(await root.boundingBox()).toEqual(initial); + await page.screenshot({ path: testInfo.outputPath('02-hover.png') }); + await page.mouse.move(0, 0); + await expect(page.locator(stage)).not.toHaveAttribute('data-hover'); + await expect(page.locator('.tap-stream-a')).toHaveCSS('opacity', '0'); await root.click(); - await page.clock.runFor(320); - await page.screenshot({ path: testInfo.outputPath('02-tap.png') }); - await page.clock.runFor(1450); + await expect(page.locator(stage)).toHaveAttribute('data-phase', 'orbit'); + await expect(page.locator(stage)).not.toHaveAttribute('data-hover'); + await page.clock.runFor(1150); await expect(page.locator(stage)).toHaveAttribute('data-phase', 'orbit'); const arcLength = await page.locator('#welcome-orbit').evaluate((path) => (path as SVGPathElement).getTotalLength()); await page.screenshot({ path: testInfo.outputPath('03-orbit.png') }); @@ -65,10 +75,23 @@ test('storyboard phases retain one root and ignore repeated triggers', async ({ await page.clock.runFor(1550); await expect(page.locator(stage)).toHaveAttribute('data-phase', 'tree'); await page.screenshot({ path: testInfo.outputPath('05-tree.png') }); - await page.clock.runFor(1800); + await page.clock.runFor(600); + await expect(page.locator('.call-panel')).toHaveAttribute('data-glitching'); + expect(await page.locator('.call-panel .box-fill').first().textContent()).not.toMatch(/^─+$/); + await page.screenshot({ path: testInfo.outputPath('06-glitch.png') }); + await page.clock.runFor(1200); await complete(page); await expect(root).toHaveCount(1); await expect(page.locator('.call-panel')).not.toHaveAttribute('inert'); + await expect(page.locator('[data-glitching]')).toHaveCount(0); + await expect(page.locator('[data-object-signature]')).toContainText('object at "/issues/123"'); + for (const panel of await page.locator('.ascii-panel').all()) { + await expect(panel).toHaveCSS('outline-style', 'none'); + await expect(panel).toHaveCSS('border-top-width', '0px'); + await expect(panel.locator('.box-corner')).toHaveText(['┌', '┐', '└', '┘']); + for (const fill of await panel.locator('.box-fill').all()) await expect(fill).toHaveText(/^─+$/); + for (const side of await panel.locator('.box-column').all()) await expect(side).toHaveText(/^[│\s]+$/); + } await expect(page.locator('.branch-lines')).toHaveCount(0); await expect(page.locator('.branch-prefix')).toHaveText([ '├── ', '│ ├── ', '│ └── ', '│ └── ', @@ -98,6 +121,27 @@ test('storyboard phases retain one root and ignore repeated triggers', async ({ await expect(page.locator(stage)).toHaveAttribute('data-phase', 'complete'); }); +test('skipping an active glitch restores exact text and frames', async ({ page }) => { + 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); + await page.keyboard.press('a'); + await expect(page.locator(stage)).toHaveAttribute('data-phase', 'orbit'); + await expect(page.locator('.tap-stream-a')).toHaveCSS('opacity', '0'); + await page.clock.runFor(4800); + await expect(page.locator('.call-panel')).toHaveAttribute('data-glitching'); + await page.keyboard.press('Escape'); + await complete(page); + await expect(page.locator('[data-glitching]')).toHaveCount(0); + await expect(page.locator('#call-result-title')).toHaveText('03 / Call result'); + await expect(page.locator('.call-panel .box-corner')).toHaveText(['┌', '┐', '└', '┘']); + await expect(page.locator('.call-panel .box-fill').first()).toHaveText(/^─+$/); + await page.clock.runFor(2000); + await expect(page.locator('[data-object-signature]')).toContainText('object at "/issues/123"'); + await page.locator('.root-mark').hover(); + await expect(page.locator(stage)).not.toHaveAttribute('data-hover'); +}); + test('tree selection, collapse and call results are keyboard accessible', async ({ page }) => { await armed(page); await page.keyboard.press('Escape'); diff --git a/site/tests/landing-ascii.test.mjs b/site/tests/landing-ascii.test.mjs new file mode 100644 index 0000000..ad59ad9 --- /dev/null +++ b/site/tests/landing-ascii.test.mjs @@ -0,0 +1,21 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { glitchText } from '../src/lib/landing-ascii.mjs'; + +const text = '┌────────┐\n│ object │\n└────────┘\n 本文: "sample";'; +test('ASCII glitch preserves cells, whitespace and Japanese prose', () => { + const output = glitchText(text, .2, 9, 2); + assert.equal(output.length, text.length); + assert.equal(output.match(/\s/g).join(''), text.match(/\s/g).join('')); + assert.ok(output.includes('本文')); + assert.notEqual(output, text); +}); +test('noise is reproducible and changes only with its explicit clock', () => { + assert.equal(glitchText(text, .3, 1, 7), glitchText(text, .3, 1, 7)); + assert.notEqual(glitchText(text, .3, 1, 7), glitchText(text, .3, 2, 7)); +}); +test('transition restores the exact source text at its boundary', () => { + assert.equal(glitchText(text, 1, 99), text); + assert.equal(glitchText(text, 2, 99), text); + assert.equal(glitchText('', .5, 1), ''); +});