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.
394 lines
16 KiB
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>
|