This commit is contained in:
@@ -45,6 +45,8 @@ Bookの閲覧テストはJavaScript無効、ランディングのテストは有
|
||||
|
||||
トップはSVGのルートマークから始まります。コンテの2はホバー専用の「Tap」表現で、ポインタを離すと消え、待っていても本編には進みません。クリック・ホイール/タッチスクロール・キー入力のいずれか一回で、全体を約3.9秒で最後まで再生します。ホバーは再生の前提ではなく、追加入力で段階を進めたり、再生を重ねたりもしません。Escapeまたは画面右下のスキップで直ちにデモへ移れます。ブラウザの修飾キー付きショートカットは妨げません。
|
||||
|
||||
ホバーの「Tap」はルートと同じSVG座標系に置き、スラッシュの上辺・下辺(`y=4`〜`60`)に一致するマスクで切り抜きます。左右の文字列は斜辺と平行に逆方向へ流れ、上下端は水平に揃います。
|
||||
|
||||
ルートは中間位置で停止せず、一つの弧状の軌道でツリーへ移動します。円周文字の上端接線への流出、ルートの縮小、直下への幹伸長を重ね、着地を待たずに右向きの枝とパネルのglitchを始めます。円の半径・曲率は変えません。移動中の枝もルートと同じ座標変換に乗せ、縦幹全体を`├`・`│`・`└`へずれなく引き継ぎます。主幹は連続したまま保ち、枝先とラベルにglitchを適用します。ページのヘッダーや周辺要素も末尾の収束に重ねて現れます。
|
||||
|
||||
ツリーとパネルはASCII glitchから確定した文字・罫線へ収束します。ノイズは局所的なセル置換を10Hzで更新し、全画面の点滅は使いません。空白・改行・日本語は保持し、完了/スキップ時には元の文字列を完全に復元します。
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user