feat(scroll-frames): adapt to framework + demo; add genie-class motion presets

ScrollFrames (eidos) rebuilt as a first-class component -- it was draft WIP using
bare native APIs.

- No native globals: window scroll/resize -> dom.listen + dom.observeResize;
  requestAnimationFrame -> dom.raf (disposer); window.innerHeight / devicePixelRatio
  -> dom.getWindow(node); new Image() -> dom.getDocument().createElement('img').
- lang="ts" + typed props (types.ts) + index.ts + data-attrs + recipe CSS (no
  scoped <style>), registered in index.css.
- New `root` prop: drive progress from a scroll container instead of the window
  (makes it embeddable + demoable).
- Morfo (morfo/components/scroll-frames.ts): scope ['eidos'], 4 parts, 0 events.
- Demo (uix/components/scroll-frames): locked 6-tab template, self-contained
  generated frames (canvas -> data URL), scrollable stage via `root`. + README.
- Fixes the 12 errors the earlier draft pushed (branch back to the 1 pre-existing
  grafito error).

Motion demo (temas/animations): "genie y cia" section -- 4 expressive JS presets
played via eidos.motion.enter: genie (waapi warp), flip-3d (rotateY), swing
(pendulum), elastic (overshoot spring).

Verify: npm run check -> 1 pre-existing error (grafito), 0 new; recipe-css +
component-api contracts clean (3 pre-existing words failures); morfo:check
scroll-frames PASS (1/108 fail = pre-existing words).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 0b7757d027
commit ba74b6b416

@ -0,0 +1,70 @@
# ScrollFrames
Eidos-only primitive for **scroll-driven media scrubbing**. The frame shown is a
pure function of scroll progress (0 → 1) over a tall track with a sticky viewport.
Two mutually-exclusive modes:
- **`frames`** — an ordered array of image URLs drawn onto a `<canvas>` (cover
fit, HiDPI-aware).
- **`video`** — a clip's `currentTime` eased toward the scroll target (`smooth`).
```svelte
<ScrollFrames frames={urls} height="400vh" />
<ScrollFrames video="/clip.mp4" smooth={0.1} />
<!-- inside a fixed-height scroller instead of the page: -->
<div bind:this={box} style="height: 320px; overflow-y: auto;
--_scroll-frames-viewport-block-size: 320px;">
<ScrollFrames {frames} root={box} height="300%" />
</div>
```
## Adapted to the framework
Every DOM + timing touchpoint goes through **`ActiveDom`** (`eidos.dom`), so the
component is iframe / popup / happy-dom safe and its lifecycle is tracked — no bare
globals:
| Native (draft) | Framework |
| --- | --- |
| `window.addEventListener('scroll'/'resize')` | `dom.listen` + `dom.observeResize` |
| `requestAnimationFrame` / `cancelAnimationFrame` | `dom.raf` (disposer) |
| `window.innerHeight` / `devicePixelRatio` | `dom.getWindow(node)` |
| `new Image()` | `dom.getDocument().createElement('img')` |
The `root` prop lets the scroll come from a container instead of the window;
without it the window drives progress (the default).
## Morfo
`scope: ['eidos']`, **0 events**. Four structural parts:
| part | marker | role |
| --- | --- | --- |
| `provider` | `data-scroll-frames` | the tall track (scroll travel) |
| `viewport` | `data-scroll-frames-viewport` | sticky child that pins the media |
| `media` | `data-scroll-frames-media` | `<canvas>` / `<video>` — `aria-hidden` |
| `loader` | `data-scroll-frames-loader` | optional load-progress readout |
## Sema events
**None — by design.** ScrollFrames is passive: the displayed frame is a function
of scroll position, not of a discrete user act, so there is no semantic occurrence
to emit. The component carries no `sema` participation (`scope: ['eidos']`). The
demo's Sema tab states this explicitly per the demo template.
## Recipe
Layout only (`scroll-frames.css`): `[data-scroll-frames]` (relative track),
`[data-scroll-frames-viewport]` (sticky, height via
`--_scroll-frames-viewport-block-size`, default `100vh`),
`[data-scroll-frames-media]` (full-bleed `object-fit: cover`),
`[data-scroll-frames-loader]` (`--font-size-xs` / `--color-neutral-text`). Every
selector is morfo-backed; no eidos-only, intent or variant selectors.
## A11y
- The media is **decorative** (`aria-hidden="true"`) — meaning lives in the
surrounding page, not the canvas/video.
- Scroll-linked: the user controls pace directly, so it never auto-animates. For a
reduced-motion path, render a single representative frame.

@ -0,0 +1,8 @@
// ScrollFrames — scroll-driven media scrubbing (video scrub / canvas frame
// sequence). See `./scroll-frames.svelte` + `./types.ts`.
//
// import ScrollFrames from '$uix/eidos/components/scroll-frames';
// <ScrollFrames frames={urls} height="400vh" />
// <ScrollFrames video="/clip.mp4" smooth={0.1} />
export { default } from './scroll-frames.svelte';
export type { ScrollFramesProps } from './types';

@ -0,0 +1,39 @@
/**
* ScrollFrames recipe — scroll-driven media scrubbing.
*
* Layout only: a tall track (`[data-scroll-frames]`) whose sticky viewport
* (`[data-scroll-frames-viewport]`) pins the media (`[data-scroll-frames-media]`)
* full-bleed while the track scrolls. The only token is the loader's text.
*/
[data-scroll-frames] {
position: relative;
inline-size: 100%;
}
[data-scroll-frames-viewport] {
position: sticky;
inset-block-start: 0;
/* Window scroll → full viewport. Inside a `root` container, override the
internal token to the container height (e.g. `100%`). */
block-size: var(--_scroll-frames-viewport-block-size, 100vh);
display: grid;
place-items: center;
overflow: hidden;
}
[data-scroll-frames-media] {
inline-size: 100%;
block-size: 100%;
object-fit: cover; /* affects <video>; the <canvas> does its own cover draw */
display: block;
}
[data-scroll-frames-loader] {
position: absolute;
inset-block-end: 1.5rem;
font-size: var(--font-size-xs);
line-height: 1;
letter-spacing: 0.1em;
color: var(--color-neutral-text);
opacity: 0.5;
}

@ -1,53 +1,145 @@
<script>
<script lang="ts">
/**
* ScrollFrames — secuencia de imágenes sincronizada con el scroll.
* ScrollFrames — scroll-driven media scrubbing. See `./types.ts` for the
* two modes (`video` scrub / `frames` canvas sequence).
*
* Técnica: un contenedor alto ("track") con un hijo `position: sticky` que
* permanece fijo en pantalla mientras se recorre el track. El progreso del
* scroll dentro del track (0 → 1) se mapea a un índice de fotograma, y ese
* fotograma se dibuja en un <canvas> (sin parpadeos, fluido).
*
* Props:
* - frames: string[] URLs de los fotogramas, EN ORDEN. (obligatorio)
* - height: string Altura del track. Más alto = giro más lento. (def "300vh")
* - background: string Color de fondo del canvas. (def "transparent")
* - class: string Clases extra para el track.
* Adapted to the framework: every native API the original drafted by hand
* (`window.addEventListener`, `requestAnimationFrame`, `window.innerHeight`,
* `devicePixelRatio`, `new Image()`) now goes through `eidos.dom` (ActiveDom),
* so it is iframe / popup / happy-dom safe and its lifecycle is tracked.
*/
import './scroll-frames.css';
import { ActiveEidos } from '$uix/eidos';
import type { ScrollFramesProps } from './types';
let {
frames,
video = null,
frames = null,
root = null,
height = '300vh',
smooth = 0.12,
background = 'transparent',
class: className = ''
} = $props();
class: className = '',
...rest
}: ScrollFramesProps = $props();
const eidos = ActiveEidos.require();
const dom = eidos.dom;
const mode = $derived<'video' | 'frames'>(video ? 'video' : 'frames');
let track = $state<HTMLDivElement | null>(null);
let progress = $state(0);
// ── video mode ──────────────────────────────────────────────────────────
let videoEl = $state<HTMLVideoElement | null>(null);
let duration = $state(0);
// ── frames mode ─────────────────────────────────────────────────────────
let canvas = $state<HTMLCanvasElement | null>(null);
let loaded = $state(0);
let size = $state({ w: 0, h: 0 });
let images: HTMLImageElement[] = [];
const loading = $derived(
mode === 'frames' ? Boolean(frames && loaded < frames.length) : duration === 0
);
// 1) scroll → progress (shared) — listener + rAF coalescing via ActiveDom.
// Driven by `root` when given (a scroll container), else the window.
$effect(() => {
const el = track;
if (!el) return;
const win = dom.getWindow(el);
const scroller: HTMLElement | Window = root ?? win;
let cancelFrame: (() => void) | null = null;
const compute = () => {
cancelFrame = null;
const trackRect = el.getBoundingClientRect();
// Viewport top + height of whatever we scroll within (window or container).
const viewTop = root ? root.getBoundingClientRect().top : 0;
const viewHeight = root ? root.clientHeight : win.innerHeight;
const distance = trackRect.height - viewHeight; // usable travel
const traveled = viewTop - trackRect.top;
progress = distance > 0 ? Math.min(1, Math.max(0, traveled / distance)) : 0;
};
const onScroll = () => {
if (!cancelFrame) cancelFrame = dom.raf(compute);
};
compute();
const stopScroll = dom.listen(scroller, 'scroll', onScroll, { passive: true });
const stopResize = dom.observeResize(el, onScroll);
return () => {
cancelFrame?.();
stopScroll();
stopResize();
};
});
// 2) video mode — ease currentTime toward the scroll target via rAF.
let stopLoop: (() => void) | null = null;
let displayed = 0;
const loop = () => {
stopLoop = null;
const v = videoEl;
if (!v || !duration) return;
const target = progress * duration;
displayed = smooth > 0 ? displayed + (target - displayed) * smooth : target;
if (Math.abs(v.currentTime - displayed) > 0.01) {
try {
v.currentTime = displayed;
} catch {
/* browser can't seek yet; retried next frame */
}
}
if (Math.abs(target - displayed) > 0.001) stopLoop = dom.raf(loop);
};
const kick = () => {
if (!stopLoop) stopLoop = dom.raf(loop);
};
$effect(() => {
if (mode !== 'video') return;
const v = videoEl;
if (!v) return;
const onMeta = () => {
duration = v.duration || 0;
kick();
};
const stop = dom.listen(v, 'loadedmetadata', onMeta);
if (v.readyState >= 1 && v.duration) duration = v.duration;
return stop;
});
// --- Estado reactivo (runas) ---
let track = $state(null); // contenedor alto (define el recorrido)
let canvas = $state(null); // lienzo donde se pinta el fotograma
let progress = $state(0); // 0..1 según el scroll dentro del track
let loaded = $state(0); // nº de imágenes ya precargadas
let size = $state({ w: 0, h: 0 }); // tamaño del canvas en píxeles de dispositivo
$effect(() => {
if (mode !== 'video') return;
void progress;
void duration;
kick();
});
let images = []; // objetos Image (no necesita ser reactivo)
$effect(() => () => stopLoop?.());
// Índice de fotograma derivado del progreso. round() reparte cada frame
// en una "franja" de scroll de igual tamaño.
// 3) frames mode — preload + canvas draw.
const frameIndex = $derived(
frames.length
? Math.min(
frames.length - 1,
Math.max(0, Math.round(progress * (frames.length - 1)))
)
mode === 'frames' && frames && frames.length
? Math.min(frames.length - 1, Math.max(0, Math.round(progress * (frames.length - 1))))
: 0
);
// --- 1) Precarga de todas las imágenes ---
$effect(() => {
if (mode !== 'frames' || !frames) return;
const list = frames;
let cancelled = false;
loaded = 0;
images = frames.map((src) => {
const img = new Image();
const doc = dom.getDocument();
images = list.map((src) => {
const img = doc.createElement('img');
img.onload = () => {
if (!cancelled) loaded += 1; // dispara un redibujado al cargar
if (!cancelled) loaded += 1;
};
img.src = src;
return img;
@ -57,77 +149,38 @@
};
});
// --- 2) Cálculo del progreso al hacer scroll (throttle con rAF) ---
$effect(() => {
const el = track;
if (!el) return;
let ticking = false;
function compute() {
ticking = false;
const rect = el.getBoundingClientRect();
const vh = window.innerHeight;
const distancia = rect.height - vh; // recorrido útil mientras está sticky
const recorrido = -rect.top; // cuánto hemos avanzado dentro del track
progress =
distancia > 0
? Math.min(1, Math.max(0, recorrido / distancia))
: 0;
}
function onScroll() {
if (!ticking) {
ticking = true;
requestAnimationFrame(compute);
}
}
compute();
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll);
return () => {
window.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onScroll);
};
});
// --- 3) Ajuste del canvas al tamaño real (nitidez en pantallas HiDPI) ---
// canvas size (HiDPI sharpness)
$effect(() => {
if (mode !== 'frames') return;
const c = canvas;
if (!c) return;
function resize() {
const dpr = window.devicePixelRatio || 1;
const w = Math.round(c.clientWidth * dpr);
const h = Math.round(c.clientHeight * dpr);
c.width = w;
c.height = h;
size = { w, h }; // dispara redibujado
}
const win = dom.getWindow(c);
const resize = () => {
const dpr = win.devicePixelRatio || 1;
c.width = Math.round(c.clientWidth * dpr);
c.height = Math.round(c.clientHeight * dpr);
size = { w: c.width, h: c.height };
};
resize();
window.addEventListener('resize', resize);
return () => window.removeEventListener('resize', resize);
return dom.observeResize(c, resize);
});
// --- 4) Dibujar el fotograma actual (cover) ---
// draw the current frame ("cover" fit)
$effect(() => {
// Dependencias reactivas que fuerzan el redibujado:
frameIndex;
size;
loaded;
if (mode !== 'frames') return;
void frameIndex;
void size;
void loaded;
const c = canvas;
const img = images[frameIndex];
if (!c || !img || !img.complete || !img.naturalWidth) return;
const ctx = c.getContext('2d');
const { width: cw, height: ch } = c;
if (!ctx) return;
const cw = c.width;
const ch = c.height;
const ir = img.naturalWidth / img.naturalHeight;
const cr = cw / ch;
let dw, dh, dx, dy;
let dw: number, dh: number, dx: number, dy: number;
if (ir > cr) {
dh = ch;
dw = ch * ir;
@ -139,50 +192,45 @@
dx = 0;
dy = (ch - dh) / 2;
}
ctx.clearRect(0, 0, cw, ch);
ctx.drawImage(img, dx, dy, dw, dh);
});
</script>
<div class="sfs-track {className}" bind:this={track} style:height>
<div class="sfs-sticky">
<canvas
class="sfs-canvas"
bind:this={canvas}
style:background
aria-hidden="true"
></canvas>
{#if frames.length && loaded < frames.length}
<div class="sfs-loader">{Math.round((loaded / frames.length) * 100)}%</div>
<div
data-scroll-frames
data-mode={mode}
class={className}
bind:this={track}
style:height
{...rest}
>
<div data-scroll-frames-viewport>
{#if mode === 'video'}
<!-- muted + playsinline + preload are required to scrub without playing -->
<video
data-scroll-frames-media
bind:this={videoEl}
src={video}
muted
playsinline
preload="auto"
style:background
aria-hidden="true"
></video>
{:else}
<canvas
data-scroll-frames-media
bind:this={canvas}
style:background
aria-hidden="true"
></canvas>
{/if}
{#if loading}
<div data-scroll-frames-loader>
{#if mode === 'frames' && frames}{Math.round((loaded / frames.length) * 100)}%{/if}
</div>
{/if}
</div>
</div>
<style>
.sfs-track {
position: relative;
width: 100%;
}
.sfs-sticky {
position: sticky;
top: 0;
height: 100vh;
display: grid;
place-items: center;
overflow: hidden;
}
.sfs-canvas {
width: 100%;
height: 100%;
display: block;
}
.sfs-loader {
position: absolute;
bottom: 1.5rem;
font: 0.75rem/1 system-ui, sans-serif;
letter-spacing: 0.1em;
opacity: 0.5;
}
</style>

@ -0,0 +1,39 @@
import type { HTMLAttributes } from 'svelte/elements';
/**
* `ScrollFrames` — scroll-driven media scrubbing. Two mutually-exclusive modes
* (pick one by which prop you pass):
*
* - `video` → scrubs a `<video>`'s `currentTime` by scroll progress.
* - `frames` → draws an ordered image sequence onto a `<canvas>` by progress.
*
* A tall track holds a `position: sticky` child that stays pinned while the
* track scrolls; progress within the track (0 → 1) drives the animation.
*
* All DOM + timing goes through `ActiveDom` (scroll/resize listeners, rAF,
* window/document resolution, image creation) so the component is iframe /
* popup / happy-dom safe — no bare `window` / `requestAnimationFrame` / `new
* Image()`.
*/
export interface ScrollFramesProps extends Omit<HTMLAttributes<HTMLDivElement>, 'class'> {
/** Video URL — scrub mode. Mutually exclusive with `frames`. */
video?: string | null;
/** Ordered frame image URLs — canvas sequence mode. */
frames?: readonly string[] | null;
/**
* Scroll container that drives progress. Default: the window. Pass an
* overflow-scrolling element to scrub from ITS scroll instead — this lets the
* component live inside a fixed-height region (a demo stage, a panel) rather
* than the page. When set, size the sticky viewport with the internal token
* `--_scroll-frames-viewport-block-size` (defaults to `100vh`).
*/
root?: HTMLElement | null;
/** Track height; taller = more scroll distance = slower scrub. Default `'300vh'`. */
height?: string;
/** Video scrub smoothing 0..1 (`0` = exact to scroll, `0.12` ≈ soft inertia). Default `0.12`. */
smooth?: number;
/** Canvas / video background. Default `'transparent'`. */
background?: string;
/** Extra classes for the track element. */
class?: string;
}

@ -80,6 +80,7 @@
@import './components/text/text.css';
@import './components/s-text/s-text.css';
@import './components/s-text-virtual-list/s-text-virtual-list.css';
@import './components/scroll-frames/scroll-frames.css';
@import './components/heading/heading.css';
@import './components/display/display.css';
@import './components/code/code.css';

@ -0,0 +1,62 @@
import type { Morfo } from '../types';
import { v } from '../types';
/**
* ScrollFrames — scroll-driven media scrubbing (eidos-only, passive).
*
* No events: the component reacts to scroll position, it does not emit semantic
* occurrences. Four structural parts:
* - `provider` — the tall track that defines the scroll travel.
* - `viewport` — the sticky child pinned while the track scrolls.
* - `media` — the `<canvas>` / `<video>` (decorative → `aria-hidden`).
* - `loader` — optional load-progress readout.
*/
export const scrollFramesMorfo = {
name: 'ScrollFrames',
kebab: 'scroll-frames',
scope: ['eidos'],
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'div',
optional: false,
data: [],
aria: []
},
{
name: 'Viewport',
kebab: 'viewport',
archetype: 'viewport',
kind: 'public',
defaultElement: 'div',
optional: false,
data: [],
aria: []
},
{
name: 'Media',
kebab: 'media',
archetype: 'image',
kind: 'public',
defaultElement: 'img',
optional: false,
data: [],
// The media is purely decorative — the canvas/video carries no
// accessible content of its own; the page around it provides meaning.
aria: [{ attr: 'aria-hidden', value: v.literal('true') }]
},
{
name: 'Loader',
kebab: 'loader',
archetype: 'indicator',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
}
]
} as const satisfies Morfo;

@ -17,7 +17,7 @@
*/
import { tick } from 'svelte'
import { ActiveEidos } from '$uix/eidos'
import { rect, spring } from '$motion'
import { rect, spring, waapi } from '$motion'
import { Presence } from '$soma/layers/presence.svelte'
import { readableActive } from '$libs/reactive'
import { Dialog } from '$uix/eidos/components/dialog'
@ -138,6 +138,58 @@
enter: spring({ values: { opacity: [0, 1], scale: [0.85, 1] }, stiffness: 240, damping: 20 }),
exit: spring({ values: { opacity: [1, 0], scale: [1, 0.92] }, stiffness: 300, damping: 30 })
})
// Expressive JS presets — dramatic, physical entrances at the genie level.
// `waapi` keyframes (transform/filter) need no per-box CSS; `spring` (elastic)
// overshoots. `eidos.motion.enter(el, name)` plays each on demand; all end at
// the rest state so they settle in place.
eidos.motion.register('genie', {
driver: 'waapi',
enter: waapi(
[
{ transform: 'translateY(70px) scale(0.18)', opacity: 0, filter: 'blur(6px)' },
{ transform: 'none', opacity: 1, filter: 'blur(0)' }
],
{ duration: 650, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' }
)
})
eidos.motion.register('flip-3d', {
driver: 'waapi',
enter: waapi(
[
{ transform: 'perspective(700px) rotateY(85deg)', opacity: 0 },
{ transform: 'perspective(700px) rotateY(0deg)', opacity: 1 }
],
{ duration: 520, easing: 'cubic-bezier(0.2, 0, 0, 1)' }
)
})
eidos.motion.register('swing', {
driver: 'waapi',
enter: waapi(
[
{ transform: 'rotate(-14deg) translateY(-28px)', opacity: 0, transformOrigin: 'top center' },
{ transform: 'rotate(9deg)', opacity: 1, offset: 0.55, transformOrigin: 'top center' },
{ transform: 'rotate(0deg)', opacity: 1, transformOrigin: 'top center' }
],
{ duration: 820, easing: 'cubic-bezier(0.2, 0, 0.2, 1)' }
)
})
eidos.motion.register('elastic', {
driver: 'spring',
enter: spring({ values: { scale: [0.3, 1], opacity: [0, 1] }, stiffness: 700, damping: 13 })
})
const jsPresets = [
{ name: 'genie', label: 'genie' },
{ name: 'flip-3d', label: 'flip 3D' },
{ name: 'swing', label: 'swing' },
{ name: 'elastic', label: 'elastic' }
]
const jsBoxes = $state<Record<string, HTMLElement | null>>({})
function playJs(name: string): void {
const el = jsBoxes[name]
if (el) eidos.motion.enter(el, name)
}
let panelOpen = $state(false)
let panelEl = $state<HTMLElement | null>(null)
const panelPresence = new Presence({
@ -498,6 +550,39 @@ await tick()
eidos.motion.enter(el, 'flip', { dom, sourceRect: first })`}</pre>
</section>
<!-- Expressive JS presets — genie & friends -->
<section class="preset">
<div class="head">
<span class="eyebrow">eidos.motion.enter · presets JS expresivos</span>
<h2>genie y cía <small>(entradas dramáticas)</small></h2>
<p>
Más presets de driver JS al nivel de <code>genie</code>: warp tipo minimizar
(<code>genie</code>, scale + translate + blur), giro 3D (<code>flip-3d</code>,
<code>rotateY</code>), péndulo (<code>swing</code>) y muelle elástico con overshoot real
(<code>elastic</code>). Pulsa <strong>Play</strong> y el motor corre cada entrada vía
<code>eidos.motion.enter</code>; asientan en su sitio. (En el preview en segundo plano el
rAF está congelado; enfócalo en un browser para verlas.)
</p>
</div>
<div class="demo">
<div class="grid">
{#each jsPresets as p (p.name)}
<div class="stage small">
<button class="btn" type="button" onclick={() => playJs(p.name)}>▶ {p.label}</button>
<div class="box" bind:this={jsBoxes[p.name]}>{p.label}</div>
</div>
{/each}
</div>
</div>
<pre class="code">{`eidos.motion.register('genie', {
driver: 'waapi',
enter: waapi([{ transform: 'translateY(70px) scale(0.18)', opacity: 0, filter: 'blur(6px)' },
{ transform: 'none', opacity: 1, filter: 'blur(0)' }],
{ duration: 650, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' })
})
eidos.motion.enter(boxEl, 'genie') // ▶ play`}</pre>
</section>
<!-- Stagger — Material list choreography (declarative, zero JS) -->
<section class="preset">
<div class="head">

@ -0,0 +1,369 @@
<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('');
// 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}}`,
'/>'
]
.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}
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>6</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>paste a muted clip</span></span
>
<input type="text" bind:value={videoUrl} placeholder="/clip.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>
{/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">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>
Loading…
Cancel
Save

Powered by TurnKey Linux.