diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..efc52c1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,68 @@ +# Changelog + +All notable changes to `@zablab/solar` are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.2.0] — chantier Solar action runner + +### Added + +- **`Patch.action` descriptor** on the wire protocol — Solar now + reconstructs dense patches locally rather than receiving them + frame-by-frame. Six built-in kinds : `count-up`, `curve-path`, + `text-reveal`, `stagger-group`, `reorder`, `mask-reveal`. + Patches without `action` flow through the existing + `transitions.ts` mapper unchanged — fully backward compatible. +- **`animate/action-runner.ts`** dispatcher + per-kind sub-runners + in `animate/runners/`. Unknown kinds raise + `UnknownActionKindError` ; hosts can register custom kinds via + `registerActionRunner(kind, fn)`. +- **`animate/flip.ts`** — single source of truth for FLIP. Solar's + `reorder` runner consumes it directly ; Prism's preview + flip-runtime imports it via `@zablab/solar/animate/flip`. +- **`animate/easing-resolver.ts`** — resolve `EasingRef` (string id or + inline spring) into a CSS easing string plus a `t → eased t` + function. +- **`PrismScene` public class** at `scene/prism-scene.ts` exposing + `mount`, `unmount`, `playAnimation`, `stopAnimation`, `on`/`off`, + `connectToOrion`, `disconnectFromOrion`, `setScene`. Lets any web + host run a Prism-authored scene without Pulsar, CEF, or Electron. +- **DOM binder** (`scene/binder.ts`) — one-way bindings via + `data-anim-path` / `data-anim-attr`. +- **Examples** : `examples/embed-vanilla/` (UMD ` + + + + +
+ + + + +``` + +A working copy lives at `examples/embed-vanilla/`. + +## 3. Quickstart — React + +```tsx +import { useEffect, useRef } from "react"; +import { PrismScene, type SceneJson } from "@zablab/solar"; + +const SCENE: SceneJson = { + state: { "score.value": 0 }, + html: `

0

`, + animations: { + "Score Update": { + patches: [ + { + path: "score.value", + value: "${param.score_to}", + action: { + kind: "count-up", + params: { from: 0, to: "${param.score_to}" }, + duration_ms: 800, + }, + }, + ], + }, + }, +}; + +export function PrismSceneEmbed({ score }: { score: number }) { + const ref = useRef(null); + const sceneRef = useRef(null); + + useEffect(() => { + if (!ref.current) return; + const scene = new PrismScene({ sceneJson: SCENE }); + scene.mount(ref.current); + sceneRef.current = scene; + return () => scene.unmount(); + }, []); + + useEffect(() => { + sceneRef.current?.playAnimation("Score Update", { score_to: score }); + }, [score]); + + return
; +} +``` + +A working copy lives at `examples/embed-react/`. + +## 4. Public API — `PrismScene` + +```ts +class PrismScene { + constructor(opts: { sceneJson: SceneJson; mockMode?: boolean }); + + mount(target: HTMLElement): void; + unmount(): void; + + playAnimation(assetId: string, params?: Record): Promise; + stopAnimation(assetId: string): void; + + on(event: PrismSceneEvent, handler: AnimationHandler): void; + off(event: PrismSceneEvent, handler: AnimationHandler): void; + + connectToOrion(opts: { url: string; token: string }): void; + disconnectFromOrion(): void; + + setScene(sceneJson: SceneJson): void; +} +``` + +### Events + +| Event | Payload | Fires… | +| --------------------- | ------------------------------------ | ----------------------------------- | +| `animation:start` | `{ asset_id, params? }` | …when `playAnimation()` begins. | +| `animation:completed` | `{ asset_id, params? }` | …when playback finishes cleanly. | +| `animation:error` | `{ asset_id, params?, error }` | …on a failure (unknown asset, runner error, double-play). | + +### Concurrency + +A given `assetId` cannot play twice at once — the second call +rejects with `code: "ALREADY_PLAYING"`. Use `stopAnimation(assetId)` +to abort an in-flight run before re-playing it. Different +`assetId`s play independently. + +### `${param.*}` interpolation + +`playAnimation("Score Update", { score_to: 1891 })` substitutes +`${param.score_to}` everywhere it appears in `patches[*].path`, +`patches[*].value`, and `patches[*].action.params`. Whole-string +tokens (`"${param.score_to}"`) round-trip the raw param value, so +numbers stay numbers. + +### Hot-reload + +`setScene(json)` swaps the scene without dismounting React. State +resets to the new `state` map and DOM rebinds against the new +`html` if provided. + +### Connecting to Orion (optional) + +`connectToOrion({ url, token })` opens an authenticated WS to +`wss:///orion/api/v1/show/stream`. Snapshots reseed the store, +deltas patch it. The embed runs fully without this connection — call +it only when you need live triggers from a running show. + +`mockMode: true` makes `connectToOrion()` a no-op (useful for offline +previews in CI). + +## 5. DOM contract + +Solar binds **one-way** : your DOM declares anchor points, Solar +writes into them. + +| Attribute | Behaviour | +| ---------------------- | --------------------------------------------------------------- | +| `data-anim-path="x.y"` | The element's `textContent` mirrors the store value at `x.y`. | +| `data-anim-attr="src"` | Combined with `data-anim-path`, the named attribute (here `src`) is updated instead of `textContent`. | +| `data-anim-id="foo"` | Action runners (text-reveal, mask-reveal) use this as a target alias. | +| `data-anim-unit` | Marks a child unit for `text-reveal`. | +| `data-anim-child` | Marks a child unit for `stagger-group`. | +| `data-flip-id="k"` | Identifies a list item for `reorder` (FLIP). | + +Two-way bindings (user input) are out of scope for the v1 embed API. + +## 6. Action descriptors + +Each patch can optionally carry an `action` descriptor that triggers +a richer animation (count-up, FLIP reorder, mask reveal …). The full +reference lives in [`action-descriptors.md`](./action-descriptors.md). + +## 7. Bundle layout + +| Path | Format | Purpose | +| --------------------------------- | ------ | ------------------------------------------------ | +| `dist/solar.js` | ESM | Main bundle — `import { PrismScene } from "@zablab/solar"`. | +| `dist/solar.esm.js` | ESM | Alias of `solar.js` for ESM-friendly tooling. | +| `dist/solar.umd.js` | UMD | ` + + + + + + + diff --git a/package.json b/package.json index f45ac17..ccfab44 100644 --- a/package.json +++ b/package.json @@ -1,21 +1,30 @@ { "name": "@zablab/solar", - "version": "0.1.1", - "description": "Solar — scene runtime bundle for the Zablab broadcast platform (Pulsar CEF + Prism webview + editor preview)", + "version": "0.2.0", + "description": "Solar — scene runtime bundle for the Zablab broadcast platform (Pulsar CEF + Prism webview + web embed)", "type": "module", "private": true, "main": "./dist/solar.js", - "module": "./dist/solar.js", - "types": "./dist/index.d.ts", + "module": "./dist/solar.esm.js", + "unpkg": "./dist/solar.umd.js", + "jsdelivr": "./dist/solar.umd.js", + "types": "./dist/solar.d.ts", "exports": { ".": { - "types": "./dist/index.d.ts", - "import": "./dist/solar.js" + "types": "./dist/solar.d.ts", + "import": "./dist/solar.js", + "require": "./dist/solar.umd.js" + }, + "./animate/flip": { + "types": "./dist/animate/flip.d.ts", + "import": "./dist/animate/flip.js" }, "./style.css": "./dist/solar.css" }, "files": [ - "dist" + "dist", + "README.md", + "CHANGELOG.md" ], "author": { "name": "Zablab", @@ -26,7 +35,7 @@ }, "scripts": { "dev": "vite", - "build": "vite build && node scripts/build-host-html.mjs", + "build": "vite build && vite build -c vite.config.umd.ts && node scripts/finalise-dist.mjs && node scripts/build-host-html.mjs", "lint": "eslint --max-warnings 0 .", "typecheck": "tsc -b --pretty", "format": "prettier --write .", @@ -35,12 +44,14 @@ "test:e2e": "playwright test", "check:bundle": "node scripts/check-bundle-size.mjs" }, - "dependencies": { - "@preact/signals-react": "^3.2.1", + "peerDependencies": { "framer-motion": "^12.0.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, + "dependencies": { + "@preact/signals-react": "^3.2.1" + }, "devDependencies": { "@playwright/test": "^1.49.1", "@tailwindcss/postcss": "^4.0.7", @@ -54,10 +65,13 @@ "eslint-config-prettier": "^10.0.1", "eslint-plugin-react": "^7.37.4", "eslint-plugin-react-hooks": "^5.1.0", + "framer-motion": "^12.0.0", "globals": "^17.6.0", "happy-dom": "^20.9.0", "postcss": "^8.5.2", "prettier": "^3.5.2", + "react": "^19.0.0", + "react-dom": "^19.0.0", "tailwindcss": "^4.0.7", "typescript": "^5.7.3", "typescript-eslint": "^8.24.0", diff --git a/scripts/finalise-dist.mjs b/scripts/finalise-dist.mjs new file mode 100644 index 0000000..7fee3f6 --- /dev/null +++ b/scripts/finalise-dist.mjs @@ -0,0 +1,89 @@ +#!/usr/bin/env node +/** + * Finalises the published `dist/` shape required by the chantier + * `Solar action runner` criterion 8 : + * + * - dist/solar.js — ESM bundle (already emitted) + * - dist/solar.esm.js — ESM alias (copy of solar.js) + * - dist/solar.umd.js — UMD bundle (emitted by vite.config.umd.ts) + * - dist/solar.d.ts — public types (alias of dist/index.d.ts) + * - dist/animate/flip.js — FLIP subpath consumed by Prism + * + * The alias copies (solar.esm.js, solar.d.ts) are tiny and keep the + * external contract stable while leaving the dts plugin's default + * naming alone. + */ +import { + copyFileSync, + existsSync, + readFileSync, + writeFileSync, + rmSync, + mkdirSync, +} from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const dist = resolve(here, "..", "dist"); + +function must(path) { + if (!existsSync(path)) { + console.error(`finalise-dist: expected artefact missing — ${path}`); + process.exit(1); + } +} + +must(resolve(dist, "solar.js")); +must(resolve(dist, "animate", "flip.js")); +must(resolve(dist, "solar.d.ts")); + +// 1. ESM alias. +copyFileSync(resolve(dist, "solar.js"), resolve(dist, "solar.esm.js")); + +// 2. Per-entry type stubs for the subpath exports. +mkdirSync(resolve(dist, "animate"), { recursive: true }); +const flipDts = resolve(dist, "animate", "flip.d.ts"); +const rolledFlipDts = resolve(dist, "flip.d.ts"); +if (existsSync(rolledFlipDts)) { + copyFileSync(rolledFlipDts, flipDts); + rmSync(rolledFlipDts); +} else if (!existsSync(flipDts)) { + // Fallback : re-export the relevant subset from the rolled bundle. + writeFileSync( + flipDts, + `export {\n captureFlip,\n playFlip,\n withFlip,\n} from "../solar";\nexport type { FlipSnapshot, FlipPlayOptions } from "../solar";\n`, + ); +} + +// 4. Validate UMD was emitted. +const umd = resolve(dist, "solar.umd.js"); +if (!existsSync(umd)) { + console.error("finalise-dist: dist/solar.umd.js missing — run vite -c vite.config.umd.ts"); + process.exit(1); +} + +// 5. Drop the source maps for the alias copies (they reference the +// original chunk hash and adding a duplicate map only inflates the +// tarball without buying anything). +for (const candidate of ["solar.esm.js.map"]) { + const p = resolve(dist, candidate); + if (existsSync(p)) rmSync(p); +} + +console.log( + "finalise-dist: ok — solar.js / solar.esm.js / solar.umd.js / solar.d.ts / animate/flip.js", +); +// Read and surface gzipped sizes for transparency. +import("node:zlib").then(({ gzipSync }) => { + const fmt = (n) => `${(n / 1024).toFixed(2)} KiB`; + for (const f of [ + "solar.js", + "solar.esm.js", + "solar.umd.js", + "animate/flip.js", + ]) { + const buf = readFileSync(resolve(dist, f)); + console.log(` ${f.padEnd(20)} raw=${fmt(buf.length)} gzip=${fmt(gzipSync(buf).length)}`); + } +}); diff --git a/src/animate/action-runner.ts b/src/animate/action-runner.ts new file mode 100644 index 0000000..6a7a8e2 --- /dev/null +++ b/src/animate/action-runner.ts @@ -0,0 +1,71 @@ +// Action runner dispatcher — routes Patch.action descriptors to the +// matching sub-runner. Patches without `action` are out of scope here ; +// callers fall back to the existing transitions.ts pipeline. +// +// Lifecycle : +// runAction({ store, patch, root, signal }) +// → look up RUNNERS[patch.action.kind] +// → await the runner +// → throw if kind is unknown so callers can surface the error +// +// All runners are async. Implementations may operate against the +// `store` (count-up, curve-path), the DOM (text-reveal, stagger-group, +// reorder, mask-reveal), or both. + +import type { Patch } from "../transport/protocol"; +import type { Store } from "../state/store"; +import { runCountUp } from "./runners/count-up"; +import { runCurvePath } from "./runners/curve-path"; +import { runTextReveal } from "./runners/text-reveal"; +import { runStaggerGroup } from "./runners/stagger-group"; +import { runReorder } from "./runners/reorder"; +import { runMaskReveal } from "./runners/mask-reveal"; + +export interface ActionContext { + store: Store; + patch: Patch; + root?: HTMLElement | null; + signal?: AbortSignal; +} + +export type ActionRunner = (ctx: ActionContext) => Promise; + +const RUNNERS: Record = { + "count-up": runCountUp, + "curve-path": runCurvePath, + "text-reveal": runTextReveal, + "stagger-group": runStaggerGroup, + reorder: runReorder, + "mask-reveal": runMaskReveal, +}; + +export function hasAction(patch: Patch): boolean { + return Boolean(patch.action); +} + +export class UnknownActionKindError extends Error { + readonly kind: string; + constructor(kind: string) { + super(`Solar action-runner : unknown kind '${kind}'`); + this.kind = kind; + this.name = "UnknownActionKindError"; + } +} + +export async function runAction(ctx: ActionContext): Promise { + const action = ctx.patch.action; + if (!action) return; + const runner = RUNNERS[action.kind]; + if (!runner) throw new UnknownActionKindError(action.kind); + await runner(ctx); +} + +/** Register or override a runner — exposed for hosts that ship custom + * action kinds. Use sparingly ; the built-in kinds are the contract + * Prism's compiler targets. */ +export function registerActionRunner( + kind: string, + runner: ActionRunner, +): void { + RUNNERS[kind] = runner; +} diff --git a/src/animate/easing-resolver.ts b/src/animate/easing-resolver.ts new file mode 100644 index 0000000..528819b --- /dev/null +++ b/src/animate/easing-resolver.ts @@ -0,0 +1,57 @@ +// Easing resolver — turn an EasingRef descriptor into a usable easing. +// +// We return two facets : a CSS easing string (consumed by WAAPI/CSS +// transitions and the FLIP runtime) and a normalised-time function `t +// → eased t` for runners that compute values in JS (count-up, +// curve-path). +// +// Spring easings can't be reduced to a closed-form CSS string ; we +// fall back to `ease-out` for the CSS facet and use a critically- +// damped approximation for the JS facet. That's deliberately +// minimal — the spring authoring path goes through framer-motion when +// fidelity matters. + +import type { EasingRef } from "../transport/protocol"; + +export interface ResolvedEasing { + /** CSS / WAAPI `easing` value. */ + css: string; + /** `t ∈ [0,1] → eased t ∈ [0,1]`. */ + fn: (t: number) => number; +} + +const LINEAR = (t: number): number => t; +const EASE_IN = (t: number): number => t * t * t; +const EASE_OUT = (t: number): number => 1 - Math.pow(1 - t, 3); +const EASE_IN_OUT = (t: number): number => + t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; + +const STRING_EASINGS: Record = { + linear: { css: "linear", fn: LINEAR }, + "ease-in": { css: "cubic-bezier(0.42, 0, 1, 1)", fn: EASE_IN }, + "ease-out": { css: "cubic-bezier(0, 0, 0.58, 1)", fn: EASE_OUT }, + "ease-in-out": { css: "cubic-bezier(0.42, 0, 0.58, 1)", fn: EASE_IN_OUT }, + "cubic-in": { css: "cubic-bezier(0.32, 0, 0.67, 0)", fn: EASE_IN }, + "cubic-out": { css: "cubic-bezier(0.33, 1, 0.68, 1)", fn: EASE_OUT }, + "cubic-in-out": { css: "cubic-bezier(0.65, 0, 0.35, 1)", fn: EASE_IN_OUT }, +}; + +const DEFAULT: ResolvedEasing = { + css: "cubic-bezier(0, 0, 0.58, 1)", + fn: EASE_OUT, +}; + +export function resolveEasing(ref: EasingRef | undefined): ResolvedEasing { + if (!ref) return DEFAULT; + if (typeof ref === "string") { + return STRING_EASINGS[ref] ?? DEFAULT; + } + // Inline spring → approximate. We damp toward 1 using the ratio. + const { stiffness, damping } = ref; + const ratio = damping > 0 ? Math.min(1, damping / Math.max(1, stiffness)) : 1; + const fn = (t: number): number => { + const decay = Math.exp(-5 * (1 - ratio) * t); + return 1 - decay * Math.cos(t * Math.PI * (1 - ratio)); + }; + return { css: "cubic-bezier(0.22, 1, 0.36, 1)", fn }; +} diff --git a/src/animate/flip.ts b/src/animate/flip.ts new file mode 100644 index 0000000..a863bf4 --- /dev/null +++ b/src/animate/flip.ts @@ -0,0 +1,106 @@ +// FLIP (First-Last-Invert-Play) — shared between Solar's reorder +// runner and Prism's preview flip-runtime. +// +// This module is the **single source of truth** for FLIP behaviour +// across the platform : Solar's action-runner imports it directly, +// Prism imports it via `@zablab/solar/animate/flip`. Once published, +// the API is contract — additive changes only without a major bump. +// +// Operating mode : +// 1. `captureFlip(root)` — measure FIRST positions of every node +// matching the selector (default `[data-flip-id]`). +// 2. The host mutates the DOM (insertion, reorder, removal …). +// 3. `playFlip(root, prev, options)` — measure LAST positions, +// INVERT each delta (translate node back to its old position +// with no transition), then PLAY (animate translate(0,0)). +// +// All animations run via the Web Animations API on `transform` only +// (GPU-friendly, no layout cost). Nodes without a previous rect are +// skipped — they were just inserted and have no "first" to interpolate +// from. Removals are out of scope of FLIP itself (handled by the +// host's exit transition). + +export interface FlipSnapshot { + rects: Map; +} + +export interface FlipPlayOptions { + duration?: number; + /** CSS / WAAPI easing string. */ + easing?: string; + /** Override of the FLIP marker selector (default `[data-flip-id]`). */ + selector?: string; +} + +const DEFAULT_SELECTOR = "[data-flip-id]"; + +function flipIdOf(el: HTMLElement, attr: string): string | null { + if (attr === "[data-flip-id]") { + return el.dataset.flipId ?? null; + } + return el.getAttribute(attr.replace(/^\[|\]$/g, "")) ?? null; +} + +export function captureFlip( + root: HTMLElement, + selector: string = DEFAULT_SELECTOR, +): FlipSnapshot { + const rects = new Map(); + const nodes = root.querySelectorAll(selector); + nodes.forEach((el) => { + const id = flipIdOf(el, selector); + if (id) rects.set(id, el.getBoundingClientRect()); + }); + return { rects }; +} + +export async function playFlip( + root: HTMLElement, + prev: FlipSnapshot, + options: FlipPlayOptions = {}, +): Promise { + const duration = options.duration ?? 400; + const easing = options.easing ?? "cubic-bezier(0.22, 1, 0.36, 1)"; + const selector = options.selector ?? DEFAULT_SELECTOR; + + const animations: Animation[] = []; + const nodes = root.querySelectorAll(selector); + nodes.forEach((el) => { + const id = flipIdOf(el, selector); + if (!id) return; + const prevRect = prev.rects.get(id); + if (!prevRect) return; + const nextRect = el.getBoundingClientRect(); + const dx = prevRect.left - nextRect.left; + const dy = prevRect.top - nextRect.top; + if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) return; + if (typeof el.animate !== "function") return; + const anim = el.animate( + [ + { transform: `translate(${dx}px, ${dy}px)` }, + { transform: "translate(0, 0)" }, + ], + { duration, easing, fill: "both" }, + ); + animations.push(anim); + }); + await Promise.all( + animations.map((a) => + a.finished.then( + () => undefined, + () => undefined, + ), + ), + ); +} + +/** Convenience helper for runners that own the mutation themselves. */ +export async function withFlip( + root: HTMLElement, + mutate: () => void | Promise, + options?: FlipPlayOptions, +): Promise { + const snapshot = captureFlip(root, options?.selector); + await mutate(); + await playFlip(root, snapshot, options); +} diff --git a/src/animate/runners/count-up.ts b/src/animate/runners/count-up.ts new file mode 100644 index 0000000..4d36765 --- /dev/null +++ b/src/animate/runners/count-up.ts @@ -0,0 +1,93 @@ +// count-up — tween a numeric path from `from` to `to` over `duration_ms`. +// +// The runner steps via requestAnimationFrame and writes the in-flight +// value through `store.set()` on every tick. Components reading the +// signal re-render with the latest number ; no patches are pushed on +// the wire — by design. +// +// Params (all optional, defaults in code) : +// - from : starting value (default 0) +// - to : ending value (default 0) +// - decimals : rounding (default 0) +// - formatter : "integer" | "decimal" — informational only. + +import type { ActionRunner } from "../action-runner"; +import { resolveEasing } from "../easing-resolver"; + +interface CountUpParams { + from?: number; + to?: number; + decimals?: number; +} + +const DEFAULT_DURATION_MS = 800; + +export const runCountUp: ActionRunner = async (ctx) => { + const { store, patch, signal } = ctx; + const action = patch.action; + if (!action) return; + const params = (action.params ?? {}) as CountUpParams; + const from = Number.isFinite(params.from) ? (params.from as number) : 0; + const toCandidate = + typeof patch.value === "number" + ? patch.value + : Number.isFinite(params.to) + ? (params.to as number) + : 0; + const to = toCandidate; + const decimals = + typeof params.decimals === "number" && params.decimals >= 0 + ? Math.floor(params.decimals) + : 0; + const duration = action.duration_ms ?? DEFAULT_DURATION_MS; + const easing = resolveEasing(action.easing); + + // Edge cases — zero duration or no-op : commit immediately. + if (duration <= 0 || from === to) { + store.set(patch.path, round(to, decimals), patch.transition); + return; + } + + const start = nowMs(); + const raf = pickRaf(); + + await new Promise((resolve) => { + function tick() { + if (signal?.aborted) { + store.set(patch.path, round(to, decimals)); + resolve(); + return; + } + const elapsed = nowMs() - start; + const t = Math.min(1, elapsed / duration); + const eased = easing.fn(t); + const value = from + (to - from) * eased; + store.set(patch.path, round(value, decimals)); + if (t < 1) raf(tick); + else resolve(); + } + raf(tick); + }); +}; + +function round(v: number, decimals: number): number { + if (decimals === 0) return Math.round(v); + const f = Math.pow(10, decimals); + return Math.round(v * f) / f; +} + +function nowMs(): number { + if (typeof performance !== "undefined" && typeof performance.now === "function") { + return performance.now(); + } + return Date.now(); +} + +type RafFn = (cb: FrameRequestCallback) => number; + +function pickRaf(): RafFn { + if (typeof requestAnimationFrame === "function") { + return requestAnimationFrame; + } + return (cb) => setTimeout(() => cb(nowMs()), 16) as unknown as number; +} diff --git a/src/animate/runners/curve-path.ts b/src/animate/runners/curve-path.ts new file mode 100644 index 0000000..16c7172 --- /dev/null +++ b/src/animate/runners/curve-path.ts @@ -0,0 +1,104 @@ +// curve-path — sample a curve defined by anchored Bézier handles. +// +// The descriptor carries `curve.anchors` (t_pct, value, optional +// tangents) and a `sample_hz` cadence. We pre-sample once, then walk +// the sample buffer at the configured rate, writing each sampled +// value to the store. This avoids any per-frame `getPointAtLength` +// cost ; for v1, payloads stay short enough that pre-sampling is +// orders of magnitude under any perceptible cost. + +import type { ActionRunner } from "../action-runner"; + +interface CurveAnchor { + t_pct: number; + value: number; + in_tangent?: { dt: number; dv: number }; + out_tangent?: { dt: number; dv: number }; +} + +const DEFAULT_DURATION_MS = 1000; + +export const runCurvePath: ActionRunner = async (ctx) => { + const { store, patch, signal } = ctx; + const action = patch.action; + if (!action?.curve) return; + + const anchors = (action.curve.anchors ?? []).slice().sort( + (a, b) => a.t_pct - b.t_pct, + ); + if (anchors.length === 0) return; + if (anchors.length === 1) { + store.set(patch.path, anchors[0]!.value, patch.transition); + return; + } + + const duration = action.duration_ms ?? DEFAULT_DURATION_MS; + const hz = action.curve.sample_hz ?? 60; + const frameMs = 1000 / hz; + const totalFrames = Math.max(1, Math.round(duration / frameMs)); + + const start = nowMs(); + const raf = pickRaf(); + let frame = 0; + + await new Promise((resolve) => { + function tick() { + if (signal?.aborted) { + store.set(patch.path, anchors[anchors.length - 1]!.value); + resolve(); + return; + } + const elapsed = nowMs() - start; + const t = Math.min(1, elapsed / duration); + const value = sample(anchors, t * 100); + store.set(patch.path, value); + frame++; + if (t < 1 && frame < totalFrames * 4) raf(tick); + else { + store.set(patch.path, anchors[anchors.length - 1]!.value); + resolve(); + } + } + raf(tick); + }); +}; + +function sample(anchors: CurveAnchor[], pct: number): number { + if (pct <= anchors[0]!.t_pct) return anchors[0]!.value; + const last = anchors[anchors.length - 1]!; + if (pct >= last.t_pct) return last.value; + for (let i = 0; i < anchors.length - 1; i++) { + const a = anchors[i]!; + const b = anchors[i + 1]!; + if (pct >= a.t_pct && pct <= b.t_pct) { + const localT = (pct - a.t_pct) / (b.t_pct - a.t_pct); + // Cubic hermite using tangents when present, else linear. + const out = a.out_tangent ?? { dt: 0, dv: 0 }; + const inn = b.in_tangent ?? { dt: 0, dv: 0 }; + const h00 = 2 * localT ** 3 - 3 * localT ** 2 + 1; + const h10 = localT ** 3 - 2 * localT ** 2 + localT; + const h01 = -2 * localT ** 3 + 3 * localT ** 2; + const h11 = localT ** 3 - localT ** 2; + const m0 = out.dv; + const m1 = inn.dv; + return h00 * a.value + h10 * m0 + h01 * b.value + h11 * m1; + } + } + return last.value; +} + +function nowMs(): number { + if (typeof performance !== "undefined" && typeof performance.now === "function") { + return performance.now(); + } + return Date.now(); +} + +type RafFn = (cb: FrameRequestCallback) => number; + +function pickRaf(): RafFn { + if (typeof requestAnimationFrame === "function") { + return requestAnimationFrame; + } + return (cb) => setTimeout(() => cb(nowMs()), 16) as unknown as number; +} diff --git a/src/animate/runners/mask-reveal.ts b/src/animate/runners/mask-reveal.ts new file mode 100644 index 0000000..995f72d --- /dev/null +++ b/src/animate/runners/mask-reveal.ts @@ -0,0 +1,99 @@ +// mask-reveal — animate a CSS `clip-path` from a hidden state to a +// fully revealed state. Implementation is GPU-friendly (clip-path +// composites on the same layer as transform). +// +// Params : +// - direction : "left-to-right" | "right-to-left" | "top-to-bottom" +// | "bottom-to-top" | "center-out" (default +// "left-to-right") + +import type { ActionRunner } from "../action-runner"; +import { resolveEasing } from "../easing-resolver"; + +interface MaskParams { + direction?: + | "left-to-right" + | "right-to-left" + | "top-to-bottom" + | "bottom-to-top" + | "center-out"; +} + +const DEFAULT_DURATION_MS = 600; + +const FRAMES: Record, [string, string]> = { + "left-to-right": [ + "inset(0 100% 0 0)", + "inset(0 0 0 0)", + ], + "right-to-left": [ + "inset(0 0 0 100%)", + "inset(0 0 0 0)", + ], + "top-to-bottom": [ + "inset(0 0 100% 0)", + "inset(0 0 0 0)", + ], + "bottom-to-top": [ + "inset(100% 0 0 0)", + "inset(0 0 0 0)", + ], + "center-out": [ + "inset(50% 50% 50% 50%)", + "inset(0 0 0 0)", + ], +}; + +export const runMaskReveal: ActionRunner = async (ctx) => { + const { patch, root, signal } = ctx; + const action = patch.action; + if (!action) return; + const params = (action.params ?? {}) as MaskParams; + const dir = params.direction ?? "left-to-right"; + const duration = action.duration_ms ?? DEFAULT_DURATION_MS; + const easing = resolveEasing(action.easing).css; + + const target = resolveTarget(root, patch.path); + if (!target) return; + + const frames = FRAMES[dir]; + if (typeof target.animate !== "function") { + target.style.clipPath = frames[1]; + return; + } + const anim = target.animate( + [{ clipPath: frames[0] }, { clipPath: frames[1] }], + { duration, easing, fill: "both" }, + ); + signal?.addEventListener("abort", () => anim.cancel()); + await anim.finished.then( + () => undefined, + () => undefined, + ); +}; + +function resolveTarget( + root: HTMLElement | null | undefined, + path: string, +): HTMLElement | null { + if (!root) return null; + const exact = root.querySelector( + `[data-anim-path="${cssEscape(path)}"]`, + ); + if (exact) return exact; + const last = path.split(/[.[\]]/).filter(Boolean).pop(); + if (last) { + const byId = root.querySelector( + `[data-anim-id="${cssEscape(last)}"]`, + ); + if (byId) return byId; + } + return root; +} + +function cssEscape(s: string): string { + if (typeof CSS !== "undefined" && typeof CSS.escape === "function") { + return CSS.escape(s); + } + return s.replace(/["\\]/g, "\\$&"); +} diff --git a/src/animate/runners/reorder.ts b/src/animate/runners/reorder.ts new file mode 100644 index 0000000..ed21f3a --- /dev/null +++ b/src/animate/runners/reorder.ts @@ -0,0 +1,62 @@ +// reorder — FLIP animate the children of a list when their order +// changes. Two consumption patterns : +// +// 1. The patch carries a new ordering as `patch.value` (an array of +// ids). The runner captures FIRST, writes the value to the store +// (component re-renders with new order), then PLAYs. +// 2. The host mutates the DOM imperatively in `mutate` (rare in +// Solar — exposed for symmetry with Prism's preview). +// +// Either way the FLIP technique itself comes from `../flip.ts`, the +// canonical implementation shared with Prism. + +import type { ActionRunner } from "../action-runner"; +import { captureFlip, playFlip } from "../flip"; +import { resolveEasing } from "../easing-resolver"; + +interface ReorderParams { + /** Override the FLIP-id selector (default `[data-flip-id]`). */ + selector?: string; + /** Total animation duration in ms (overrides action.duration_ms). */ + duration_ms?: number; +} + +const DEFAULT_DURATION_MS = 400; + +export const runReorder: ActionRunner = async (ctx) => { + const { store, patch, root } = ctx; + const action = patch.action; + if (!action) return; + if (!root) { + // No DOM access — fall back to a plain state write. The host's + // primitive will reconcile order without animation. + store.set(patch.path, patch.value, patch.transition); + return; + } + const params = (action.params ?? {}) as ReorderParams; + const selector = params.selector ?? "[data-flip-id]"; + const duration = + params.duration_ms ?? action.duration_ms ?? DEFAULT_DURATION_MS; + const easing = resolveEasing(action.easing).css; + + const snapshot = captureFlip(root, selector); + + // Trigger the reorder by writing the new value into the store. We + // wait one microtask + one animation frame to let React commit the + // DOM mutation before we measure LAST. + store.set(patch.path, patch.value, patch.transition); + await flushFrame(); + + await playFlip(root, snapshot, { duration, easing, selector }); +}; + +function flushFrame(): Promise { + return new Promise((resolve) => { + const raf = + typeof requestAnimationFrame === "function" + ? requestAnimationFrame + : (cb: FrameRequestCallback) => + setTimeout(() => cb(Date.now()), 16) as unknown as number; + raf(() => raf(() => resolve())); + }); +} diff --git a/src/animate/runners/stagger-group.ts b/src/animate/runners/stagger-group.ts new file mode 100644 index 0000000..7a787a6 --- /dev/null +++ b/src/animate/runners/stagger-group.ts @@ -0,0 +1,128 @@ +// stagger-group / text-reveal — animate each child of the targeted +// node with a staggered start. Works entirely against the DOM via +// WAAPI so the host doesn't need to wire signals for every unit. +// +// The runner selects children with `child_selector` (default +// `[data-anim-unit]`), then schedules an opacity+transform animation +// for each one staggered by `stagger_ms`. Children that lack +// `el.animate` are upgraded synchronously (final state applied) so +// SSR / static fallbacks stay functional. + +import type { ActionRunner, ActionContext } from "../action-runner"; +import { resolveEasing } from "../easing-resolver"; + +interface StaggerParams { + stagger_ms?: number; + per_unit_ms?: number; + /** Initial opacity (default 0). */ + from_opacity?: number; + /** Initial translateY in px (default 8). */ + from_y?: number; +} + +interface StaggerDefaults { + defaultStaggerMs: number; + defaultPerUnitMs: number; + defaultSelector: string; + stateAttr?: string; +} + +export async function runChildStagger( + ctx: ActionContext, + defaults: StaggerDefaults, +): Promise { + const { patch, root, signal } = ctx; + const action = patch.action; + if (!action) return; + const params = (action.params ?? {}) as StaggerParams; + const staggerMs = params.stagger_ms ?? defaults.defaultStaggerMs; + const perUnitMs = params.per_unit_ms ?? defaults.defaultPerUnitMs; + const fromOpacity = params.from_opacity ?? 0; + const fromY = params.from_y ?? 8; + const easing = resolveEasing(action.easing).css; + + const target = resolveTarget(root, patch.path); + if (!target) return; + + const selector = + action.child_selector?.kind === "css-selector" && + typeof action.child_selector.value === "string" + ? action.child_selector.value + : defaults.defaultSelector; + const children = Array.from( + target.querySelectorAll(selector), + ); + if (children.length === 0) return; + + const animations: Animation[] = []; + children.forEach((el, i) => { + if (defaults.stateAttr) el.setAttribute(defaults.stateAttr, "in"); + const delay = i * staggerMs; + if (typeof el.animate !== "function") { + el.style.opacity = "1"; + el.style.transform = "translateY(0)"; + return; + } + const anim = el.animate( + [ + { opacity: fromOpacity, transform: `translateY(${fromY}px)` }, + { opacity: 1, transform: "translateY(0)" }, + ], + { duration: perUnitMs, delay, easing, fill: "both" }, + ); + animations.push(anim); + }); + + if (signal) { + signal.addEventListener("abort", () => { + animations.forEach((a) => a.cancel()); + }); + } + + await Promise.all( + animations.map((a) => + a.finished.then( + () => undefined, + () => undefined, + ), + ), + ); +} + +export const runStaggerGroup: ActionRunner = async (ctx) => { + await runChildStagger(ctx, { + defaultStaggerMs: 60, + defaultPerUnitMs: 320, + defaultSelector: "[data-anim-child]", + }); +}; + +function resolveTarget( + root: HTMLElement | null | undefined, + path: string, +): HTMLElement | null { + if (!root) return null; + // Resolution order : + // 1. exact `[data-anim-path=""]` + // 2. `[data-anim-id=""]` + // 3. root itself (fallback — stagger over its children) + const exact = root.querySelector( + `[data-anim-path="${cssEscape(path)}"]`, + ); + if (exact) return exact; + const last = path.split(/[.[\]]/).filter(Boolean).pop(); + if (last) { + const byId = root.querySelector( + `[data-anim-id="${cssEscape(last)}"]`, + ); + if (byId) return byId; + } + return root; +} + +function cssEscape(s: string): string { + if (typeof CSS !== "undefined" && typeof CSS.escape === "function") { + return CSS.escape(s); + } + return s.replace(/["\\]/g, "\\$&"); +} diff --git a/src/animate/runners/text-reveal.ts b/src/animate/runners/text-reveal.ts new file mode 100644 index 0000000..40cba33 --- /dev/null +++ b/src/animate/runners/text-reveal.ts @@ -0,0 +1,23 @@ +// text-reveal — stagger child elements that the host marked with the +// configured selector. The runner toggles `data-anim-state` on each +// child and animates opacity + transform via WAAPI ; CSS authored by +// the host can hook into the state attribute for richer effects. +// +// Params : +// - unit : "letter" | "word" — informational, the host +// decides how to split. Default "letter". +// - stagger_ms : delay between consecutive children. Default 30. +// - per_unit_ms : duration of each unit's animation. Default 240. + +import type { ActionRunner } from "../action-runner"; +import { runChildStagger } from "./stagger-group"; + +export const runTextReveal: ActionRunner = async (ctx) => { + // text-reveal is a stagger-group with text-friendly defaults. + await runChildStagger(ctx, { + defaultStaggerMs: 30, + defaultPerUnitMs: 240, + defaultSelector: "[data-anim-unit]", + stateAttr: "data-anim-state", + }); +}; diff --git a/src/index.ts b/src/index.ts index 2163a69..1e19913 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,3 +11,45 @@ export type { SolarError, SolarErrorCode, } from "./types"; + +// --- chantier Solar action runner ------------------------------------ + +export { PrismScene } from "./scene/prism-scene"; +export type { + PrismSceneOptions, + PrismSceneEvent, + SceneJson, + AnimationDef, + AnimationEventPayload, + AnimationHandler, + OrionConnectOptions, +} from "./scene/prism-scene"; + +export type { + Patch, + Transition, + TweenTransition, + SpringTransition, + CrossfadeTransition, + NoTransition, + ActionDescriptor, + ActionKind, + EasingRef, +} from "./transport/protocol"; + +// Action-runner pieces — exported for hosts that want to build on +// the same primitives (Prism preview, custom integrations). +export { + runAction, + hasAction, + registerActionRunner, + UnknownActionKindError, +} from "./animate/action-runner"; +export type { + ActionContext, + ActionRunner, +} from "./animate/action-runner"; + +// FLIP — single source of truth shared with Prism preview. +export { captureFlip, playFlip, withFlip } from "./animate/flip"; +export type { FlipSnapshot, FlipPlayOptions } from "./animate/flip"; diff --git a/src/scene/binder.ts b/src/scene/binder.ts new file mode 100644 index 0000000..86df30b --- /dev/null +++ b/src/scene/binder.ts @@ -0,0 +1,54 @@ +// Scene binder — wire the host's static HTML to the store. +// +// Any element marked with `data-anim-path=""` has its textContent +// updated whenever the matching signal changes. Elements with +// `data-anim-attr=""` receive an attribute update instead. +// +// This is intentionally a thin one-way binding ; the host owns the +// DOM structure, Solar owns the values. Two-way input binding is out +// of scope for the public embed API in v1. + +import { effect } from "@preact/signals-react"; +import type { Store } from "../state/store"; + +export interface SceneBinder { + dispose(): void; +} + +const PATH_ATTR = "data-anim-path"; + +export function bindScene(root: HTMLElement, store: Store): SceneBinder { + const disposers: Array<() => void> = []; + const nodes = root.querySelectorAll(`[${PATH_ATTR}]`); + nodes.forEach((el) => { + const path = el.getAttribute(PATH_ATTR); + if (!path) return; + const targetAttr = el.getAttribute("data-anim-attr"); + const sig = store.signal(path); + const dispose = effect(() => { + const value = sig.value; + if (targetAttr) { + if (value === null || value === undefined) { + el.removeAttribute(targetAttr); + } else { + el.setAttribute(targetAttr, String(value)); + } + } else { + el.textContent = value === undefined || value === null ? "" : String(value); + } + }); + disposers.push(dispose); + }); + + return { + dispose() { + for (const d of disposers) { + try { + d(); + } catch { + /* noop */ + } + } + }, + }; +} diff --git a/src/scene/mount.tsx b/src/scene/mount.tsx new file mode 100644 index 0000000..be208d4 --- /dev/null +++ b/src/scene/mount.tsx @@ -0,0 +1,41 @@ +// Minimal React mount for PrismScene. The "render tree" here is +// intentionally just an invisible probe : PrismScene v1 binds against +// host-authored DOM via data attributes (see ./binder.ts) rather than +// owning the layout tree. The React root exists so future versions +// can swap in the full Solar primitives renderer without a public- +// API break. + +import { createRoot, type Root } from "react-dom/client"; +import { createElement, type ReactElement } from "react"; +import type { Store } from "../state/store"; + +export interface SceneRoot { + dispose(): void; +} + +function ProbeMarker(): ReactElement { + return createElement("template", { + "data-solar-scene": "1", + "aria-hidden": "true", + }); +} + +export function renderScene(target: HTMLElement, _store: Store): SceneRoot { + // Attach a tiny React island so consumers can confirm the bundle is + // active without touching their own DOM. The probe is a