fix: separate hover and use ASCII glitch transitions
Reference site / build (push) Canceled after 0s
Reference site / build (push) Canceled after 0s
This commit is contained in:
@@ -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/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/scripts/landing.ts` — 単一の`requestAnimationFrame`時計、役割別の速度曲線、等弧長で円をほどくSVG path、ツリー操作。
|
||||||
- `src/lib/landing-demo.ts` — ブラウザ内だけで使うサンプル。外部Hostへの接続、変更操作、通信はありません。Object signatureとInterface signatureを分け、本文は明示的なCallで表示します。
|
- `src/lib/landing-demo.ts` — ブラウザ内だけで使うサンプル。外部Hostへの接続、変更操作、通信はありません。Object signatureとInterface signatureを分け、本文は明示的なCallで表示します。
|
||||||
- `src/styles/landing.css` — トップ全体を14pxのmonoフォント・24pxの行高で構成。フレーム幅を4ch単位に揃え、列・ガター・パディングもセルと行の整数倍にします。ツリーはSVGの線ではなく`├──`・`└──`・`│`などの罫線素片を使い、JavaScript無効時も同じ構造を表示します。モバイルはツリーとパネルを縦に配置します。
|
- `src/styles/landing.css` — トップ全体を14pxのmonoフォント・24pxの行高で構成。フレーム幅を4ch単位に揃え、列・ガター・パディングもセルと行の整数倍にします。ツリーはSVGの線ではなく`├──`・`└──`・`│`などの罫線素片を使い、JavaScript無効時も同じ構造を表示します。モバイルはツリーとパネルを縦に配置します。
|
||||||
|
|||||||
@@ -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();
|
||||||
|
---
|
||||||
|
<div class="box-frame" aria-hidden="true">
|
||||||
|
<span class="box-edge box-top"><span class="box-corner">┌</span><span class="box-fill">{horizontal}</span><span class="box-corner">┐</span></span>
|
||||||
|
<span class="box-side box-left"><span class="box-column">{vertical}</span></span>
|
||||||
|
<span class="box-side box-right"><span class="box-column">{vertical}</span></span>
|
||||||
|
<span class="box-edge box-bottom"><span class="box-corner">└</span><span class="box-fill">{horizontal}</span><span class="box-corner">┘</span></span>
|
||||||
|
</div>
|
||||||
@@ -2,11 +2,12 @@
|
|||||||
import { objects, initialObject, childrenOf, treePrefix, objectSignature, interfaceSignature } from '../lib/landing-demo';
|
import { objects, initialObject, childrenOf, treePrefix, objectSignature, interfaceSignature } from '../lib/landing-demo';
|
||||||
import { href } from '../lib/book';
|
import { href } from '../lib/book';
|
||||||
import '../styles/landing.css';
|
import '../styles/landing.css';
|
||||||
|
import BoxFrame from './BoxFrame.astro';
|
||||||
---
|
---
|
||||||
<section class="landing" aria-label="ルートからWorldspaceへ" data-landing-stage>
|
<section class="landing" aria-label="ルートからWorldspaceへ" data-landing-stage>
|
||||||
<div class="intro-art" aria-hidden="true">
|
<div class="intro-art" aria-hidden="true">
|
||||||
<div class="tap-stream tap-stream-a"><span>Tap ↗ Tap ↗ Tap ↗</span></div>
|
<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 ↗</span></div>
|
<div class="tap-stream tap-stream-b"><span>Tap → Tap → Tap → Tap → Tap → </span></div>
|
||||||
<svg class="orbit" viewBox="-300 -300 600 600" focusable="false">
|
<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>
|
<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>
|
||||||
<text><textPath href="#welcome-orbit" textLength="1080" lengthAdjust="spacing">WELCOME TO WIP / DISCOVERY / UNDERSTANDING / ACTION / </textPath></text>
|
<text><textPath href="#welcome-orbit" textLength="1080" lengthAdjust="spacing">WELCOME TO WIP / DISCOVERY / UNDERSTANDING / ACTION / </textPath></text>
|
||||||
@@ -39,7 +40,7 @@ import '../styles/landing.css';
|
|||||||
aria-selected={object.path === initialObject.path ? 'true' : 'false'}
|
aria-selected={object.path === initialObject.path ? 'true' : 'false'}
|
||||||
tabindex={object.path === initialObject.path ? 0 : -1} disabled
|
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" /><path class="root-tether" d="M29 32H64" pathLength="1" vector-effect="non-scaling-stroke" /></svg> : <><span>{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"><path class="root-shape" d="M31 4H41L17 60H7Z" /><path class="root-tether" d="M29 32H64" pathLength="1" vector-effect="non-scaling-stroke" /></svg> : <><span class="node-label">{object.name}</span>{children.length > 0 && <span class="node-indicator" aria-hidden="true">−</span>}</>}
|
||||||
</button>
|
</button>
|
||||||
{root && <><span class="root-wire" aria-hidden="true">──┐</span><span class="root-caption" data-reveal>worldspace</span></>}
|
{root && <><span class="root-wire" aria-hidden="true">──┐</span><span class="root-caption" data-reveal>worldspace</span></>}
|
||||||
</div>;
|
</div>;
|
||||||
@@ -47,19 +48,21 @@ import '../styles/landing.css';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<section class="ascii-panel text-panel" aria-labelledby="text-view-title" data-reveal>
|
<section class="ascii-panel text-panel" aria-labelledby="text-view-title" data-reveal>
|
||||||
|
<BoxFrame />
|
||||||
<div class="panel-title"><h3 id="text-view-title">02 / Text View</h3><span>OBJECT SIGNATURE</span></div>
|
<div class="panel-title"><h3 id="text-view-title">02 / Text View</h3><span>OBJECT SIGNATURE</span></div>
|
||||||
<pre data-object-signature>{objectSignature(initialObject)}</pre>
|
<pre data-object-signature>{objectSignature(initialObject)}</pre>
|
||||||
<p class="panel-footnote">選択したObjectの公開情報。本文はOperationで取得します。</p>
|
<p class="panel-footnote">選択したObjectの公開情報。本文はOperationで取得します。</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<section class="ascii-panel call-panel" aria-labelledby="call-result-title" data-reveal>
|
<section class="ascii-panel call-panel" aria-labelledby="call-result-title" data-reveal>
|
||||||
|
<BoxFrame />
|
||||||
<div class="panel-title"><h3 id="call-result-title">03 / Call result</h3><span>EXPLICIT ACTION</span></div>
|
<div class="panel-title"><h3 id="call-result-title">03 / Call result</h3><span>EXPLICIT ACTION</span></div>
|
||||||
<div class="call-target"><span>TARGET</span><code data-target>{initialObject.path}</code></div>
|
<div class="call-target"><span>TARGET</span><code data-target>{initialObject.path}</code></div>
|
||||||
<pre class="interface-signature" data-interface-signature>{interfaceSignature(initialObject)}</pre>
|
<pre class="interface-signature" data-interface-signature>{interfaceSignature(initialObject)}</pre>
|
||||||
<div class="call-controls"><code data-operation>/::issue.read#read_body</code><button type="button" class="demo-call" disabled>Call <span aria-hidden="true">↗</span></button></div>
|
<div class="call-controls"><code data-operation>/::issue.read#read_body</code><button type="button" class="demo-call" disabled>Call <span aria-hidden="true">↗</span></button></div>
|
||||||
<div class="result-shell">
|
<div class="result-shell">
|
||||||
<div class="result-label"><span>RETURN / body</span><span data-call-state>READY</span></div>
|
<div class="result-label"><span>RETURN / body</span><span data-call-state>READY</span></div>
|
||||||
<pre data-result>// Operationを選び、Callで本文を取得。<br />// このデモはブラウザ内だけで動作します。</pre>
|
<pre data-result>{'// Operationを選び、Callで本文を取得。\n// このデモはブラウザ内だけで動作します。'}</pre>
|
||||||
</div>
|
</div>
|
||||||
<p class="demo-status" role="status" aria-live="polite" aria-atomic="true">ローカルサンプル — 外部への通信や変更は行いません。</p>
|
<p class="demo-status" role="status" aria-live="polite" aria-atomic="true">ローカルサンプル — 外部への通信や変更は行いません。</p>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -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('');
|
||||||
|
}
|
||||||
+52
-13
@@ -1,4 +1,5 @@
|
|||||||
import { objects, initialObject, childrenOf, objectSignature, interfaceSignature, interfaceReference } from '../lib/landing-demo';
|
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 clamp = (value: number) => Math.max(0, Math.min(1, value));
|
||||||
const progress = (time: number, start: number, duration: number) => clamp((time - start) / duration);
|
const progress = (time: number, start: number, duration: number) => clamp((time - start) / duration);
|
||||||
@@ -26,7 +27,9 @@ const timing = {
|
|||||||
trace: bezier(.22, .3, .25, 1),
|
trace: bezier(.22, .3, .25, 1),
|
||||||
};
|
};
|
||||||
const mix = (a: number, b: number, value: number) => a + (b - a) * value;
|
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
|
// Preserve the arc length while reducing curvature. Unlike interpolating arbitrary
|
||||||
// Bézier control points, this cannot fold or bunch the letters during unrolling.
|
// Bézier control points, this cannot fold or bunch the letters during unrolling.
|
||||||
function unrolledOrbit(opening: number): string {
|
function unrolledOrbit(opening: number): string {
|
||||||
@@ -60,7 +63,6 @@ export function initLanding() {
|
|||||||
const orbit = get<SVGSVGElement>('.orbit');
|
const orbit = get<SVGSVGElement>('.orbit');
|
||||||
const orbitPath = get<SVGPathElement>('#welcome-orbit');
|
const orbitPath = get<SVGPathElement>('#welcome-orbit');
|
||||||
const textPath = get<SVGTextPathElement>('.orbit textPath');
|
const textPath = get<SVGTextPathElement>('.orbit textPath');
|
||||||
const taps = [...stage.querySelectorAll<HTMLElement>('.tap-stream')];
|
|
||||||
const invitation = get('.intro-invitation');
|
const invitation = get('.intro-invitation');
|
||||||
const skip = get<HTMLButtonElement>('.intro-skip');
|
const skip = get<HTMLButtonElement>('.intro-skip');
|
||||||
const call = get<HTMLButtonElement>('.demo-call');
|
const call = get<HTMLButtonElement>('.demo-call');
|
||||||
@@ -77,6 +79,40 @@ export function initLanding() {
|
|||||||
let keyboardStarted = false;
|
let keyboardStarted = false;
|
||||||
let animation: Animation | undefined;
|
let animation: Animation | undefined;
|
||||||
const collapsed = new Set<string>();
|
const collapsed = new Set<string>();
|
||||||
|
const panels = [...stage.querySelectorAll<HTMLElement>('.ascii-panel')];
|
||||||
|
const glitchTargets = [...stage.querySelectorAll<HTMLElement>([
|
||||||
|
'.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) {
|
function select(index: number, focus = false) {
|
||||||
selected = objects[index];
|
selected = objects[index];
|
||||||
@@ -170,6 +206,8 @@ export function initLanding() {
|
|||||||
stage.dataset.phase = 'complete';
|
stage.dataset.phase = 'complete';
|
||||||
delete html.dataset.landing;
|
delete html.dataset.landing;
|
||||||
abort.abort();
|
abort.abort();
|
||||||
|
delete stage.dataset.hover;
|
||||||
|
restoreGlitch();
|
||||||
root.style.removeProperty('transform');
|
root.style.removeProperty('transform');
|
||||||
tether.style.removeProperty('stroke-dasharray');
|
tether.style.removeProperty('stroke-dasharray');
|
||||||
tether.style.removeProperty('stroke-dashoffset');
|
tether.style.removeProperty('stroke-dashoffset');
|
||||||
@@ -217,12 +255,7 @@ export function initLanding() {
|
|||||||
tether.style.strokeDasharray = '1';
|
tether.style.strokeDasharray = '1';
|
||||||
tether.style.strokeDashoffset = String(1 - timing.trace(progress(time, 3260, 980)));
|
tether.style.strokeDashoffset = String(1 - timing.trace(progress(time, 3260, 980)));
|
||||||
tether.style.opacity = String(1 - progress(time, 4200, 450));
|
tether.style.opacity = String(1 - progress(time, 4200, 450));
|
||||||
invitation.style.opacity = String(1 - progress(time, 0, 280));
|
invitation.style.opacity = String(1 - progress(time, playbackOffset, 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)`);
|
|
||||||
});
|
|
||||||
const opening = timing.unroll(progress(time, 2530, 1350));
|
const opening = timing.unroll(progress(time, 2530, 1350));
|
||||||
orbitPath.setAttribute('d', unrolledOrbit(opening));
|
orbitPath.setAttribute('d', unrolledOrbit(opening));
|
||||||
orbit.style.opacity = String(progress(time, 620, 480) * (1 - progress(time, 3500, 550)));
|
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 isPanel = element.classList.contains('ascii-panel');
|
||||||
const start = isPanel ? 5020 + (element.classList.contains('call-panel') ? 180 : 0) : 4680 + index * 55;
|
const start = isPanel ? 5020 + (element.classList.contains('call-panel') ? 180 : 0) : 4680 + index * 55;
|
||||||
const value = progress(time, start, isPanel ? 850 : 380);
|
const value = progress(time, start, isPanel ? 850 : 380);
|
||||||
element.style.opacity = String(isPanel ? clamp(value * 3) : value);
|
// Noise resolves into the panel, not a wipe or a sliding card.
|
||||||
// Terminal panels materialize in reading order; no unrelated card motion.
|
element.style.opacity = String(isPanel ? clamp(value * 6) : value);
|
||||||
if (isPanel) element.style.clipPath = `inset(0 0 ${(1 - value) * 100}% 0)`;
|
|
||||||
});
|
});
|
||||||
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) {
|
function tick(now: number) {
|
||||||
@@ -255,7 +288,7 @@ export function initLanding() {
|
|||||||
const time = now - startTime;
|
const time = now - startTime;
|
||||||
if (time >= duration) return finish();
|
if (time >= duration) return finish();
|
||||||
try {
|
try {
|
||||||
render(time);
|
render(time + playbackOffset);
|
||||||
frame = requestAnimationFrame(tick);
|
frame = requestAnimationFrame(tick);
|
||||||
} catch {
|
} catch {
|
||||||
// Fail open: the document and demo are more important than the animation.
|
// Fail open: the document and demo are more important than the animation.
|
||||||
@@ -269,7 +302,9 @@ export function initLanding() {
|
|||||||
keyboardStarted = fromKeyboard;
|
keyboardStarted = fromKeyboard;
|
||||||
state = 'playing';
|
state = 'playing';
|
||||||
html.dataset.landing = 'playing';
|
html.dataset.landing = 'playing';
|
||||||
|
delete stage.dataset.hover;
|
||||||
startTime = performance.now();
|
startTime = performance.now();
|
||||||
|
render(playbackOffset);
|
||||||
root.style.willChange = 'transform';
|
root.style.willChange = 'transform';
|
||||||
frame = requestAnimationFrame(tick);
|
frame = requestAnimationFrame(tick);
|
||||||
}
|
}
|
||||||
@@ -287,6 +322,10 @@ export function initLanding() {
|
|||||||
stage.dataset.phase = 'armed';
|
stage.dataset.phase = 'armed';
|
||||||
invitation.style.opacity = '1';
|
invitation.style.opacity = '1';
|
||||||
const signal = abort.signal;
|
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) => {
|
document.addEventListener('click', (event) => {
|
||||||
if ((event.target as Element).closest('.intro-skip, .skip-link')) return finish();
|
if ((event.target as Element).closest('.intro-skip, .skip-link')) return finish();
|
||||||
start();
|
start();
|
||||||
|
|||||||
@@ -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; }
|
html[data-landing="boot"] .root-tether { stroke-dasharray: 1; stroke-dashoffset: 1; }
|
||||||
.root-mark[aria-selected="true"] { color: #4e752d; }
|
.root-mark[aria-selected="true"] { color: #4e752d; }
|
||||||
.root-caption { margin-left: 2ch; height: var(--row); color: var(--muted); }
|
.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 { position: relative; min-width: 0; padding: calc(2 * 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; }
|
.box-frame { position: absolute; inset: 0; pointer-events: none; user-select: none; color: #8e9886; }
|
||||||
.ascii-panel::before { left: -.5ch; }
|
.box-edge { position: absolute; inset-inline: 0; height: var(--row); display: grid; grid-template-columns: 1ch minmax(0, 1fr) 1ch; align-items: center; }
|
||||||
.ascii-panel::after { right: -.5ch; }
|
.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 { 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 h3 { font: inherit; margin: 0; }
|
||||||
.panel-title > span { color: var(--muted); }
|
.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 { 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-a { transform: translate(-145px, -24px) rotate(-67deg); }
|
||||||
.tap-stream-b { transform: translate(-50px, 22px) 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); }
|
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; }
|
.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; }
|
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; }
|
||||||
|
|||||||
@@ -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!.x + initial!.width / 2 - page.viewportSize()!.width / 2)).toBeLessThan(2);
|
||||||
expect(Math.abs(initial!.y + initial!.height / 2 - page.viewportSize()!.height / 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 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 root.click();
|
||||||
await page.clock.runFor(320);
|
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'orbit');
|
||||||
await page.screenshot({ path: testInfo.outputPath('02-tap.png') });
|
await expect(page.locator(stage)).not.toHaveAttribute('data-hover');
|
||||||
await page.clock.runFor(1450);
|
await page.clock.runFor(1150);
|
||||||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'orbit');
|
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'orbit');
|
||||||
const arcLength = await page.locator('#welcome-orbit').evaluate((path) => (path as SVGPathElement).getTotalLength());
|
const arcLength = await page.locator('#welcome-orbit').evaluate((path) => (path as SVGPathElement).getTotalLength());
|
||||||
await page.screenshot({ path: testInfo.outputPath('03-orbit.png') });
|
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 page.clock.runFor(1550);
|
||||||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'tree');
|
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'tree');
|
||||||
await page.screenshot({ path: testInfo.outputPath('05-tree.png') });
|
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 complete(page);
|
||||||
await expect(root).toHaveCount(1);
|
await expect(root).toHaveCount(1);
|
||||||
await expect(page.locator('.call-panel')).not.toHaveAttribute('inert');
|
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-lines')).toHaveCount(0);
|
||||||
await expect(page.locator('.branch-prefix')).toHaveText([
|
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');
|
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 }) => {
|
test('tree selection, collapse and call results are keyboard accessible', async ({ page }) => {
|
||||||
await armed(page);
|
await armed(page);
|
||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
|
|||||||
@@ -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), '');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user