< 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 >