fix: separate hover and use ASCII glitch transitions
Reference site / build (push) Canceled after 0s

This commit is contained in:
2026-10-10 03:00:08 +09:00
parent 5c6ccc3677
commit d5a0879142
8 changed files with 185 additions and 27 deletions
+5 -1
View File
@@ -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無効時も同じ構造を表示します。モバイルはツリーとパネルを縦に配置します。
+12
View File
@@ -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>
+7 -4
View File
@@ -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';
---
<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 ↗</span></div>
<div class="tap-stream tap-stream-b"><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 → Tap → Tap → </span></div>
<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>
<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'}
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>
{root && <><span class="root-wire" aria-hidden="true">──┐</span><span class="root-caption" data-reveal>worldspace</span></>}
</div>;
@@ -47,19 +48,21 @@ import '../styles/landing.css';
</div>
</div>
<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>
<pre data-object-signature>{objectSignature(initialObject)}</pre>
<p class="panel-footnote">選択したObjectの公開情報。本文はOperationで取得します。</p>
</section>
</div>
<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="call-target"><span>TARGET</span><code data-target>{initialObject.path}</code></div>
<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="result-shell">
<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>
<p class="demo-status" role="status" aria-live="polite" aria-atomic="true">ローカルサンプル — 外部への通信や変更は行いません。</p>
</section>
+21
View File
@@ -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
View File
@@ -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<SVGSVGElement>('.orbit');
const orbitPath = get<SVGPathElement>('#welcome-orbit');
const textPath = get<SVGTextPathElement>('.orbit textPath');
const taps = [...stage.querySelectorAll<HTMLElement>('.tap-stream')];
const invitation = get('.intro-invitation');
const skip = get<HTMLButtonElement>('.intro-skip');
const call = get<HTMLButtonElement>('.demo-call');
@@ -77,6 +79,40 @@ export function initLanding() {
let keyboardStarted = false;
let animation: Animation | undefined;
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) {
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();
+19 -5
View File
@@ -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; }
+48 -4
View File
@@ -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');
+21
View File
@@ -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), '');
});