fix: mask hover text to the slash edges
Reference site / build (push) Canceled after 0s

This commit is contained in:
2026-10-10 19:33:17 +09:00
parent 5d418beeba
commit e03ff8e650
4 changed files with 78 additions and 11 deletions
+2
View File
@@ -45,6 +45,8 @@ Bookの閲覧テストはJavaScript無効、ランディングのテストは有
トップはSVGのルートマークから始まります。コンテの2はホバー専用の「Tap」表現で、ポインタを離すと消え、待っていても本編には進みません。クリック・ホイール/タッチスクロール・キー入力のいずれか一回で、全体を約3.9秒で最後まで再生します。ホバーは再生の前提ではなく、追加入力で段階を進めたり、再生を重ねたりもしません。Escapeまたは画面右下のスキップで直ちにデモへ移れます。ブラウザの修飾キー付きショートカットは妨げません。
ホバーの「Tap」はルートと同じSVG座標系に置き、スラッシュの上辺・下辺(`y=4`〜`60`)に一致するマスクで切り抜きます。左右の文字列は斜辺と平行に逆方向へ流れ、上下端は水平に揃います。
ルートは中間位置で停止せず、一つの弧状の軌道でツリーへ移動します。円周文字の上端接線への流出、ルートの縮小、直下への幹伸長を重ね、着地を待たずに右向きの枝とパネルのglitchを始めます。円の半径・曲率は変えません。移動中の枝もルートと同じ座標変換に乗せ、縦幹全体を`├`・`│`・`└`へずれなく引き継ぎます。主幹は連続したまま保ち、枝先とラベルにglitchを適用します。ページのヘッダーや周辺要素も末尾の収束に重ねて現れます。
ツリーとパネルはASCII glitchから確定した文字・罫線へ収束します。ノイズは局所的なセル置換を10Hzで更新し、全画面の点滅は使いません。空白・改行・日本語は保持し、完了/スキップ時には元の文字列を完全に復元します。
+12 -3
View File
@@ -6,8 +6,6 @@ import BoxFrame from './BoxFrame.astro';
---
<section class="landing" aria-label="ルートからWorldspaceへ" data-landing-stage>
<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 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>
@@ -42,7 +40,18 @@ import BoxFrame from './BoxFrame.astro';
aria-selected={object.path === initialObject.path ? 'true' : 'false'}
tabindex={object.path === initialObject.path ? 0 : -1} disabled
>
{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">
<defs>
<mask id="tap-edge-mask" maskUnits="userSpaceOnUse" x="-8" y="4" width="64" height="56" mask-type="alpha">
<rect x="-8" y="4" width="64" height="56" fill="white" />
</mask>
</defs>
<g class="tap-mask" mask="url(#tap-edge-mask)">
<g class="tap-stream tap-stream-a" transform="translate(5 60) rotate(-66.801409)"><text x="-18" y="0">{'Tap → '.repeat(8)}</text></g>
<g class="tap-stream tap-stream-b" transform="translate(22 60) rotate(-66.801409)"><text x="-18" y="0">{'Tap → '.repeat(8)}</text></g>
</g>
<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-caption" data-reveal>worldspace</span>}
</div>;
+8 -7
View File
@@ -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; }
+56 -1
View File
@@ -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();