You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/web/routes/uix/components/scroll-frames/+page.svelte

394 lines
16 KiB

<script lang="ts">
import ScrollFrames from '$uix/eidos/components/scroll-frames';
import type { ScrollFramesProps } from '$uix/eidos/components/scroll-frames';
import { compileMorfo } from '$uix/morfo';
import { scrollFramesMorfo } from '@/uix/morfo/components/scroll-frames';
import { getActiveUix } from '$active-uix';
const uix = getActiveUix();
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type TraceEntry = { event: string; family: string; intent?: string; at: number };
let tab = $state<Tab>('live');
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
// ── Live state ────────────────────────────────────────────────────────
type Mode = 'frames' | 'video';
let mode = $state<Mode>('frames');
let frameCount = $state(40);
let trackHeight = $state(300); // % of the scroll container
let smooth = $state(0.12);
let videoUrl = $state('/demos/video.mp4');
let videoStart = $state(0);
let videoEnd = $state<number | null>(null);
// The scroll container that drives progress (the component's `root`). Demoed
// inside a fixed-height region so it scrubs without scrolling the whole page.
let scrollRoot = $state<HTMLElement | null>(null);
const STAGE_H = 320;
// ── Self-contained frames: draw a progress dial + index per frame to a
// canvas and snapshot it. No external assets — the sequence IS the demo.
function generateFrames(n: number): string[] {
const doc = uix.dom.getDocument();
const out: string[] = [];
const W = 480;
const H = 270;
for (let i = 0; i < n; i++) {
const c = doc.createElement('canvas');
c.width = W;
c.height = H;
const ctx = c.getContext('2d');
if (!ctx) continue;
const t = n > 1 ? i / (n - 1) : 0;
const hue = Math.round(t * 300);
ctx.fillStyle = `hsl(${hue} 55% 13%)`;
ctx.fillRect(0, 0, W, H);
ctx.strokeStyle = `hsl(${hue} 80% 62%)`;
ctx.lineWidth = 16;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.arc(W / 2, H / 2, 74, -Math.PI / 2, -Math.PI / 2 + Math.max(0.0001, t) * Math.PI * 2);
ctx.stroke();
ctx.fillStyle = '#fff';
ctx.font = '600 44px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(String(i + 1), W / 2, H / 2);
out.push(c.toDataURL());
}
return out;
}
let generatedFrames = $state<string[]>([]);
$effect(() => {
generatedFrames = generateFrames(frameCount);
});
// ── Compiled morfo ────────────────────────────────────────────────────
const compiled = compileMorfo(scrollFramesMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
// Trace strip (0 events — kept per the demo template; stays empty).
$effect(() => {
const el = stageRef;
if (!el) return;
const obs = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.attributeName !== 'data-event') continue;
const target = m.target as Element;
const ev = target.getAttribute('data-event');
if (!ev) continue;
trace = [
{
event: ev,
family: target.getAttribute('data-event-family') ?? '—',
intent: target.getAttribute('data-event-intent') ?? undefined,
at: Date.now()
},
...trace
].slice(0, 6);
}
});
obs.observe(el, { attributes: true, subtree: true, attributeFilter: ['data-event'] });
return () => obs.disconnect();
});
// ── Snippets ──────────────────────────────────────────────────────────
const somaSnippet = [
'<!-- ScrollFrames is an eidos-only primitive — no soma layer. -->',
"<!-- The morfo declares scope:['eidos'] + 0 events (purely scroll-driven). -->"
].join('\n');
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import ScrollFrames from '$uix/eidos/components/scroll-frames';",
mode === 'frames' ? ' const frames = [/* ordered image URLs */];' : '',
'</' + 'script>',
'',
'<ScrollFrames',
mode === 'frames' ? ' {frames}' : ` video="${videoUrl || '/clip.mp4'}"`,
trackHeight !== 300 && ` height="${trackHeight}vh"`,
mode === 'video' && smooth !== 0.12 && ` smooth={${smooth}}`,
mode === 'video' && videoStart !== 0 && ` start={${videoStart}}`,
mode === 'video' && videoEnd !== null && ` end={${videoEnd}}`,
'/>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Media · ScrollFrames</div>
<h1 data-uix-page-title>ScrollFrames</h1>
<p data-uix-page-lede>
Eidos-only primitive for scroll-driven media scrubbing. Two modes —
<code>frames</code> (an ordered image sequence drawn onto a <code>canvas</code>) and
<code>video</code> (a clip's <code>currentTime</code> eased by scroll). A tall track with a
sticky viewport pins the media while progress (0 → 1) drives the frame. All DOM + timing go
through <code>ActiveDom</code> — no bare <code>window</code> / <code>requestAnimationFrame</code
>. Four morfo parts, zero events.
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill>
<span data-uix-meta-key>parts</span>{compiled.parts.order.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>events</span>0
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>modes</span>2
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>scope</span>eidos
</span>
</div>
</header>
<!-- Live preview always rendered. The component is driven by `root` (this
fixed-height scroller) so it scrubs without scrolling the whole page. -->
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef}>
<div
bind:this={scrollRoot}
style="inline-size: 100%; block-size: {STAGE_H}px; overflow-y: auto; border-radius: var(--uix-radius-3, 12px); --_scroll-frames-viewport-block-size: {STAGE_H}px;"
>
{#if scrollRoot}
{#if mode === 'frames'}
<ScrollFrames
frames={generatedFrames}
root={scrollRoot}
height="{trackHeight}%"
background="#0b0b10"
/>
{:else}
<ScrollFrames
video={videoUrl || null}
root={scrollRoot}
{smooth}
start={videoStart}
end={videoEnd}
height="{trackHeight}%"
background="#0b0b10"
/>
{/if}
{/if}
</div>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
<span>{trace.length === 0 ? 'no semantic events (scroll-driven)' : `${trace.length} event(s)`}</span>
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>mode</span>
{mode}{mode === 'frames' ? ` · ${frameCount} frames` : ''}
</span>
</div>
</div>
<div data-uix-tabs role="tablist">
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>
API <span data-uix-tab-count>8</span>
</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p · 0e</span>
</button>
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
<span data-uix-layer-badge="sema">sema</span>
<span data-uix-tab-count>{events.length}</span>
</button>
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
{#if tab === 'live'}
<section data-uix-section>
<h2 data-uix-section-title>Controls</h2>
<p data-uix-section-desc>
ScrollFrames is eidos-only — there is no <span data-uix-layer-badge="soma">soma</span> layer.
Scroll the preview above to scrub; the dial + index are auto-generated frames (no external
assets). All knobs are visual / behavioral treatment.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · visual treatment
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>mode</span>
<span data-uix-chips role="radiogroup">
{#each ['frames', 'video'] as m}
<button data-uix-chip data-active={mode === m} onclick={() => (mode = m as Mode)}
>{m}</button
>
{/each}
</span>
</label>
{#if mode === 'frames'}
<label data-uix-control>
<span data-uix-control-label>frames <span data-uix-control-hint>{frameCount}</span></span>
<input type="range" min="6" max="80" step="1" bind:value={frameCount} />
</label>
{:else}
<label data-uix-control>
<span data-uix-control-label
>video URL <span data-uix-control-hint>same-origin clip (range requests)</span></span
>
<input type="text" bind:value={videoUrl} placeholder="/demos/video.mp4" />
</label>
<label data-uix-control>
<span data-uix-control-label>smooth <span data-uix-control-hint>{smooth}</span></span>
<input type="range" min="0" max="0.4" step="0.02" bind:value={smooth} />
</label>
<label data-uix-control>
<span data-uix-control-label>start <span data-uix-control-hint>{videoStart}s</span></span>
<input type="number" min="0" step="0.5" bind:value={videoStart} />
</label>
<label data-uix-control>
<span data-uix-control-label>end <span data-uix-control-hint>{videoEnd ?? 'full'}</span></span>
<input
type="number"
min="0"
step="0.5"
placeholder="full"
value={videoEnd ?? ''}
oninput={(e) =>
(videoEnd = e.currentTarget.value === '' ? null : Number(e.currentTarget.value))}
/>
</label>
{/if}
<label data-uix-control>
<span data-uix-control-label
>height <span data-uix-control-hint>{trackHeight}% · taller = slower</span></span
>
<input type="range" min="150" max="600" step="10" bind:value={trackHeight} />
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>headless · none — eidos-only primitive</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{somaSnippet}</code></pre>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>visual · scroll-driven media</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{eidosSnippet}</code></pre>
</div>
</section>
{:else if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>Props</h2>
<table data-uix-table>
<thead><tr><th>prop</th><th>type</th><th>default</th><th>notes</th></tr></thead>
<tbody>
<tr><td class="name">video</td><td class="type">{`string | null`}</td><td>null</td><td>Clip URL — scrub mode (mutually exclusive with frames).</td></tr>
<tr><td class="name">frames</td><td class="type">{`readonly string[] | null`}</td><td>null</td><td>Ordered image URLs — canvas sequence mode.</td></tr>
<tr><td class="name">root</td><td class="type">{`HTMLElement | null`}</td><td>null</td><td>Scroll container. Default: the window.</td></tr>
<tr><td class="name">height</td><td class="type">string</td><td>'300vh'</td><td>Track height; taller = more travel = slower scrub.</td></tr>
<tr><td class="name">smooth</td><td class="type">number</td><td>0.12</td><td>Video scrub easing 0..1 (0 = exact, 0.12 ≈ inertia).</td></tr>
<tr><td class="name">start</td><td class="type">number</td><td>0</td><td>Segment start (s) — progress 0 maps here (video mode).</td></tr>
<tr><td class="name">end</td><td class="type">{`number | null`}</td><td>null</td><td>Segment end (s) — progress 1 maps here; null = full duration.</td></tr>
<tr><td class="name">background</td><td class="type">string</td><td>'transparent'</td><td>Canvas / video background.</td></tr>
</tbody>
</table>
</section>
{:else if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>Declarative contract</h2>
<table data-uix-table>
<thead><tr><th>field</th><th>value</th></tr></thead>
<tbody>
<tr><td class="name">name</td><td class="type">"{scrollFramesMorfo.name}"</td></tr>
<tr><td class="name">kebab</td><td class="type">"{scrollFramesMorfo.kebab}"</td></tr>
<tr><td class="name">scope</td><td class="type">[{scrollFramesMorfo.scope.map((s) => `"${s}"`).join(', ')}]</td></tr>
<tr><td class="name">parts</td><td class="default">{scrollFramesMorfo.parts.length}</td></tr>
<tr><td class="name">events</td><td class="default">0</td></tr>
</tbody>
</table>
<h2 data-uix-section-title>Parts</h2>
<table data-uix-table>
<thead><tr><th>kebab</th><th>marker</th><th>element</th><th>archetype</th><th>optional</th></tr></thead>
<tbody>
{#each scrollFramesMorfo.parts as part}
<tr>
<td class="name">{part.kebab}</td>
<td class="type">data-{part.kebab === 'provider' ? 'scroll-frames' : `scroll-frames-${part.kebab}`}</td>
<td>{part.defaultElement}</td>
<td>{part.archetype}</td>
<td>{part.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
<h2 data-uix-section-title>media · ARIA</h2>
<table data-uix-table>
<thead><tr><th>attr</th><th>value</th><th>note</th></tr></thead>
<tbody>
<tr><td class="name">aria-hidden</td><td class="type">"true"</td><td>Media is decorative — meaning lives in the surrounding page.</td></tr>
</tbody>
</table>
<h2 data-uix-section-title>Events</h2>
<p data-uix-section-desc>None. ScrollFrames is passive — it reacts to scroll position and emits no semantic occurrences.</p>
</section>
{:else if tab === 'sema'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span> events
</h2>
<p data-uix-section-desc>
ScrollFrames declares <strong>0 events</strong>. It is a passive, scroll-driven visual: the
frame shown is a pure function of scroll progress, not of a discrete user act, so there is
no semantic occurrence to emit. Justified in the component README — the component carries no
<span data-uix-layer-badge="sema">sema</span> participation (scope is <code>['eidos']</code>).
</p>
</section>
{:else if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Recipe selectors</h2>
<table data-uix-table>
<thead><tr><th>selector</th><th>source</th><th>role</th></tr></thead>
<tbody>
<tr><td class="type">[data-scroll-frames]</td><td>morfo</td><td>track — relative, full width</td></tr>
<tr><td class="type">[data-scroll-frames-viewport]</td><td>morfo</td><td>sticky, pins the media; height via <code>--_scroll-frames-viewport-block-size</code></td></tr>
<tr><td class="type">[data-scroll-frames-media]</td><td>morfo</td><td>full-bleed <code>object-fit: cover</code></td></tr>
<tr><td class="type">[data-scroll-frames-loader]</td><td>morfo</td><td>load-progress readout</td></tr>
</tbody>
</table>
<p data-uix-section-desc>
Every selector is morfo-backed (a part's <code>data-*</code> marker). No eidos-only or
intent/variant selectors — the component is structural.
</p>
</section>
{:else if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<table data-uix-table>
<thead><tr><th>concern</th><th>treatment</th></tr></thead>
<tbody>
<tr><td class="name">decorative media</td><td>The canvas / video carries <code>aria-hidden="true"</code> — it conveys no content of its own; the surrounding page provides meaning.</td></tr>
<tr><td class="name">reduced motion</td><td>Scroll-linked; the user controls pace directly, so it does not auto-animate. For a reduced-motion path, render a single representative frame.</td></tr>
<tr><td class="name">keyboard</td><td>Driven by the scroll container — standard scroll keys (Space, PageDown, arrows) advance it.</td></tr>
</tbody>
</table>
</section>
{/if}
</div>

Powered by TurnKey Linux.