diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index efc52c1..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,68 +0,0 @@ -# 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-lock.json b/package-lock.json index 49a7d05..788840c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "@zablab/solar", "version": "0.1.1", "dependencies": { + "@lumencast/runtime": "^0.1.0", "@preact/signals-react": "^3.2.1", "framer-motion": "^12.0.0", "react": "^19.0.0", @@ -1131,6 +1132,38 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lumencast/protocol": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@lumencast/protocol/-/protocol-0.1.0.tgz", + "integrity": "sha512-Bex8lI7LeE9UEMCkro/ZvxNgX8j5U/e0/MwzA2LdTBzGoB7DxL6DWfpEqKaTqKjGvHxOauM2Fli/KymADJ4Qog==", + "license": "Apache-2.0", + "dependencies": { + "ws": "^8.18.0", + "yaml": "^2.6.1" + }, + "bin": { + "lumencast-js-conformance": "dist/cli.js" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@lumencast/runtime": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@lumencast/runtime/-/runtime-0.1.0.tgz", + "integrity": "sha512-EbiW2xqujH6Q/MG9cmf0mDJG9azXLOMEd+4LtPf8tSCT/ix+bKiC3YMj2e83LhWvZXezcMu3YIGWwJOv2JKfGg==", + "license": "Apache-2.0", + "dependencies": { + "@lumencast/protocol": "0.1.0", + "@preact/signals-react": "^3.2.1", + "framer-motion": "^12.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/@microsoft/api-extractor": { "version": "7.58.7", "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.58.7.tgz", @@ -7467,7 +7500,6 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -7492,6 +7524,21 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index ccfab44..a31650b 100644 --- a/package.json +++ b/package.json @@ -1,30 +1,21 @@ { "name": "@zablab/solar", - "version": "0.2.0", - "description": "Solar — scene runtime bundle for the Zablab broadcast platform (Pulsar CEF + Prism webview + web embed)", + "version": "0.1.1", + "description": "Solar — scene runtime bundle for the Zablab broadcast platform (Pulsar CEF + Prism webview + editor preview)", "type": "module", "private": true, "main": "./dist/solar.js", - "module": "./dist/solar.esm.js", - "unpkg": "./dist/solar.umd.js", - "jsdelivr": "./dist/solar.umd.js", - "types": "./dist/solar.d.ts", + "module": "./dist/solar.js", + "types": "./dist/index.d.ts", "exports": { ".": { - "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" + "types": "./dist/index.d.ts", + "import": "./dist/solar.js" }, "./style.css": "./dist/solar.css" }, "files": [ - "dist", - "README.md", - "CHANGELOG.md" + "dist" ], "author": { "name": "Zablab", @@ -35,7 +26,7 @@ }, "scripts": { "dev": "vite", - "build": "vite build && vite build -c vite.config.umd.ts && node scripts/finalise-dist.mjs && node scripts/build-host-html.mjs", + "build": "vite build && node scripts/build-host-html.mjs", "lint": "eslint --max-warnings 0 .", "typecheck": "tsc -b --pretty", "format": "prettier --write .", @@ -44,14 +35,13 @@ "test:e2e": "playwright test", "check:bundle": "node scripts/check-bundle-size.mjs" }, - "peerDependencies": { + "dependencies": { + "@lumencast/runtime": "^0.1.0", + "@preact/signals-react": "^3.2.1", "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", @@ -65,13 +55,10 @@ "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/playwright.config.ts b/playwright.config.ts index aa0c61c..b78bbdf 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -12,8 +12,11 @@ export default defineConfig({ reporter: process.env.CI ? "github" : "list", timeout: 30_000, expect: { timeout: 5_000 }, - globalSetup: "./tests/e2e/global-setup.ts", - globalTeardown: "./tests/e2e/global-teardown.ts", + // ADR 007 sub-chantier B : the bespoke mock-orion global setup was + // removed with Solar's home-grown protocol. The LSDP/1.1 E2E harness + // (a stub Lumencast server + the runtime's server kit) is Probe's + // follow-up — acceptance (b)/(c)/(e). Until then `test:e2e` has no + // specs and is a no-op (E2E is not in the push gate). use: { baseURL: `http://localhost:${VITE_PORT}`, headless: true, diff --git a/scripts/check-bundle-size.mjs b/scripts/check-bundle-size.mjs index 359e5c9..836be77 100644 --- a/scripts/check-bundle-size.mjs +++ b/scripts/check-bundle-size.mjs @@ -16,15 +16,34 @@ const BROADCAST_BUDGET = 200 * 1024; const CONTROL_BUDGET = 280 * 1024; const TEST_BUDGET = 360 * 1024; +// Since ADR 007 (Solar = thin adapter over @lumencast/runtime), the +// per-mode chunks come from the runtime and may carry TWO hash segments +// (the runtime's own chunk hash + Vite's re-bundle hash), e.g. +// `broadcast-BqOhSNsY-8nj7XQpl.js`. Match against the known source names +// rather than trying to guess where the hash boundary is. +const KNOWN_PREFIXES = [ + "solar", + "index", + "tree", + "broadcast", + "control", + "test", + "status-pill", +]; + const files = readdirSync(DIST).filter((f) => f.endsWith(".js")); -const fileMap = new Map(); // basename prefix → { full, raw, gzip } +const fileMap = new Map(); // source name → { full, raw, gzip } for (const f of files) { const buf = readFileSync(join(DIST, f)); const gzip = gzipSync(buf).length; - // strip the hash suffix to get the source name (e.g. - // broadcast-CcsEAg11.js → broadcast) - const prefix = f.replace(/-[A-Za-z0-9_]+\.js$/, "").replace(/\.js$/, ""); - fileMap.set(prefix, { full: f, raw: buf.length, gzip }); + // A chunk maps to a known prefix when its name is exactly the prefix + // (`solar.js`) or the prefix followed by a hash segment + // (`broadcast-XXXX[-YYYY].js`). Longest match wins so `status-pill` + // isn't shadowed by a shorter prefix. + const match = KNOWN_PREFIXES.filter( + (p) => f === `${p}.js` || f.startsWith(`${p}-`), + ).sort((a, b) => b.length - a.length)[0]; + if (match) fileMap.set(match, { full: f, raw: buf.length, gzip }); } function need(prefix) { diff --git a/scripts/finalise-dist.mjs b/scripts/finalise-dist.mjs deleted file mode 100644 index 7fee3f6..0000000 --- a/scripts/finalise-dist.mjs +++ /dev/null @@ -1,89 +0,0 @@ -#!/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 deleted file mode 100644 index 6a7a8e2..0000000 --- a/src/animate/action-runner.ts +++ /dev/null @@ -1,71 +0,0 @@ -// 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/crossfade.tsx b/src/animate/crossfade.tsx deleted file mode 100644 index 4c08e9b..0000000 --- a/src/animate/crossfade.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { type ReactNode } from "react"; -import { AnimatePresence, motion } from "framer-motion"; - -export interface CrossfadeProps { - /** Scene id or any stable key — children remount on key change. */ - trackKey: string; - /** Duration in milliseconds. */ - durationMs?: number; - children: ReactNode; -} - -/** Crossfade two scene roots at key change. Both children are mounted - * during the transition window, one fading out as the other fades in. - * Animates opacity only (GPU-friendly). */ -export function Crossfade({ - trackKey, - durationMs = 400, - children, -}: CrossfadeProps) { - const transition = { duration: durationMs / 1000, ease: "easeInOut" } as const; - return ( - - - {children} - - - ); -} diff --git a/src/animate/easing-resolver.ts b/src/animate/easing-resolver.ts deleted file mode 100644 index 528819b..0000000 --- a/src/animate/easing-resolver.ts +++ /dev/null @@ -1,57 +0,0 @@ -// 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 deleted file mode 100644 index a863bf4..0000000 --- a/src/animate/flip.ts +++ /dev/null @@ -1,106 +0,0 @@ -// 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 deleted file mode 100644 index 4d36765..0000000 --- a/src/animate/runners/count-up.ts +++ /dev/null @@ -1,93 +0,0 @@ -// 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 deleted file mode 100644 index 16c7172..0000000 --- a/src/animate/runners/curve-path.ts +++ /dev/null @@ -1,104 +0,0 @@ -// 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 deleted file mode 100644 index 995f72d..0000000 --- a/src/animate/runners/mask-reveal.ts +++ /dev/null @@ -1,99 +0,0 @@ -// 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 deleted file mode 100644 index ed21f3a..0000000 --- a/src/animate/runners/reorder.ts +++ /dev/null @@ -1,62 +0,0 @@ -// 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 deleted file mode 100644 index 7a787a6..0000000 --- a/src/animate/runners/stagger-group.ts +++ /dev/null @@ -1,128 +0,0 @@ -// 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 deleted file mode 100644 index 40cba33..0000000 --- a/src/animate/runners/text-reveal.ts +++ /dev/null @@ -1,23 +0,0 @@ -// 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/animate/transitions.ts b/src/animate/transitions.ts deleted file mode 100644 index 36125fc..0000000 --- a/src/animate/transitions.ts +++ /dev/null @@ -1,52 +0,0 @@ -// Translate an ADR 002 `Transition` into a Framer Motion transition. -// -// We deliberately animate only GPU-friendly properties — transform, -// opacity, filter on a separate layer. Primitives enforce this at -// the DOM level by exposing those props as motion-bindable values -// rather than raw CSS. - -import type { Transition as Tx } from "../transport/protocol"; - -export type FramerEasing = "linear" | "easeIn" | "easeOut" | "easeInOut"; - -export interface FramerTransition { - duration?: number; - ease?: FramerEasing; - type?: "tween" | "spring"; - stiffness?: number; - damping?: number; -} - -const NO_ANIMATION: FramerTransition = { duration: 0 }; - -const EASE_MAP: Record = { - linear: "linear", - "cubic-in": "easeIn", - "cubic-out": "easeOut", - "cubic-in-out": "easeInOut", -}; - -export function toFramer(t: Tx | undefined): FramerTransition { - if (!t || t.kind === "none") return NO_ANIMATION; - if (t.kind === "tween") { - return { - type: "tween", - duration: (t.duration_ms ?? 0) / 1000, - ease: t.ease ? (EASE_MAP[t.ease] ?? "easeOut") : "easeOut", - }; - } - if (t.kind === "spring") { - return { - type: "spring", - ...(t.stiffness !== undefined ? { stiffness: t.stiffness } : {}), - ...(t.damping !== undefined ? { damping: t.damping } : {}), - }; - } - // crossfade is handled at scene-tree level (animate/crossfade.tsx) - // — at the per-prop level it degenerates into a tween on opacity. - return { - type: "tween", - duration: (t.duration_ms ?? 400) / 1000, - ease: "easeInOut", - }; -} diff --git a/src/app.tsx b/src/app.tsx deleted file mode 100644 index b0a3747..0000000 --- a/src/app.tsx +++ /dev/null @@ -1,96 +0,0 @@ -// Top-level React component for a mounted Solar instance. Reads the -// runtime signals (bundle / status) and dispatches to the right mode. -// -// Per-mode code splitting : the BroadcastMode / ControlMode / TestMode -// components live in separate chunks loaded only when the -// corresponding mode is requested. A broadcast-mode mount never -// downloads the overlay or test code — the broadcast chunk is the -// bare minimum Pulsar CEF needs to render the scene. This realises -// the "tree-shakable overlay" guarantee from chantier-solar.md -// criterion 6 (a.k.a. 5b in the working summary) at the bundle-stat -// level, not just at the runtime level. -// -// Crossfade-correctness note : AnimatePresence freezes the props of -// an exiting child so its render tree keeps using the values it held -// at the moment it started exiting. We embed `SolarRuntimeProvider` -// inside the motion.div so the exiting view keeps its OLD bundle -// while AnimatePresence animates it out. - -import { useSignals } from "@preact/signals-react/runtime"; -import type { Signal } from "@preact/signals-react"; -import { AnimatePresence, motion } from "framer-motion"; -import { lazy, Suspense } from "react"; -import type { Store } from "./state/store"; -import type { RenderBundle } from "./render/bundle"; -import type { ConnectionStatus } from "./transport/ws"; -import { SolarRuntimeProvider } from "./overlay/runtime-context"; -import type { SolarMode } from "./types"; - -const LazyBroadcastMode = lazy(() => - import("./modes/broadcast").then((m) => ({ default: m.BroadcastMode })), -); -const LazyControlMode = lazy(() => - import("./modes/control").then((m) => ({ default: m.ControlMode })), -); -const LazyTestMode = lazy(() => - import("./modes/test").then((m) => ({ default: m.TestMode })), -); - -export interface SolarAppProps { - mode: SolarMode; - store: Store; - bundleSignal: Signal; - statusSignal: Signal; - crossfadeKeySignal: Signal; - sendInput: (path: string, value: unknown, clientMsgId?: string) => void; -} - -export function SolarApp({ - mode, - store, - bundleSignal, - statusSignal, - crossfadeKeySignal, - sendInput, -}: SolarAppProps) { - useSignals(); - - const bundle = bundleSignal.value; - const status = statusSignal.value; - const trackKey = crossfadeKeySignal.value; - if (!bundle) return null; - - const ModeComponent = - mode === "broadcast" - ? LazyBroadcastMode - : mode === "control" - ? LazyControlMode - : LazyTestMode; - - return ( - - - - - - - - - - ); -} diff --git a/src/dev-entry.tsx b/src/dev-entry.tsx index f0a0848..a589d1d 100644 --- a/src/dev-entry.tsx +++ b/src/dev-entry.tsx @@ -1,22 +1,27 @@ -// Dev / e2e harness entry. NOT shipped in the library bundle. +// Dev / E2E harness entry — NOT part of the published bundle. // -// Reads URL query params and mounts Solar against the requested -// Orion endpoint. Used by `npm run dev` and by Playwright e2e tests -// (which set the params to point at the test mock-orion). +// `index.html` loads this module so `npm run dev` and the Playwright +// harness can mount() Solar against a mock LSDP server. Reads URL query +// params (orion / token / mode / scene / session), falls back to sane +// defaults, and mounts. The production counterpart is generated by +// scripts/build-host-html.mjs (inlined into dist/index.html). +// +// Excluded from tsconfig.lib.json + the dts build + the Vite library +// entry — it never ships in solar.js. -import { mount } from "./index"; +import { mount } from "./mount"; import type { SolarMode } from "./types"; const params = new URLSearchParams(window.location.search); - const orionUrl = - params.get("orion") ?? "ws://127.0.0.1:8080/orion/api/v1/show/stream"; -const token = params.get("token") ?? "dev-token"; -const modeParam = params.get("mode") ?? "control"; -const mode: SolarMode = - modeParam === "broadcast" || modeParam === "test" || modeParam === "control" - ? (modeParam as SolarMode) - : "control"; + params.get("orion") ?? `ws://${location.host}/lsdp/v1/show/stream`; +const token = params.get("token") ?? ""; +const modeParam = params.get("mode") ?? "broadcast"; +const mode: SolarMode = ( + ["broadcast", "control", "test"] as const +).includes(modeParam as SolarMode) + ? (modeParam as SolarMode) + : "broadcast"; const scene = params.get("scene") ?? undefined; const testSession = params.get("session") ?? undefined; @@ -33,6 +38,9 @@ mount({ ...(mode === "test" && scene ? { scene } : {}), ...(mode === "test" && testSession ? { testSession } : {}), onError: (err) => { - console.error("[solar]", err); + // Dev only — surface to the console. Broadcast hosts must not render + // chrome ; the operator overlay (control/test modes) is the runtime's + // own concern now. + console.error("[solar dev]", err); }, }); diff --git a/src/index.ts b/src/index.ts index 1e19913..2163a69 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,45 +11,3 @@ 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/modes/broadcast.tsx b/src/modes/broadcast.tsx deleted file mode 100644 index 4da072b..0000000 --- a/src/modes/broadcast.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { Tree } from "../render/tree"; -import { useSolarRuntime } from "../overlay/runtime-context"; - -/** Broadcast mode : pure scene render, no UI chrome. */ -export function BroadcastMode() { - const { store, bundle } = useSolarRuntime(); - return ; -} diff --git a/src/modes/control.tsx b/src/modes/control.tsx deleted file mode 100644 index 2598676..0000000 --- a/src/modes/control.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Tree } from "../render/tree"; -import { ControlPanel } from "../overlay/control"; -import { StatusPill } from "../overlay/status-pill"; -import { useSolarRuntime } from "../overlay/runtime-context"; - -/** Control mode : scene + operator overlay (status pill + fields - * panel from operator_inputs). */ -export function ControlMode() { - const { store, bundle } = useSolarRuntime(); - return ( - <> - - - - - ); -} diff --git a/src/modes/test.tsx b/src/modes/test.tsx deleted file mode 100644 index 41c0b52..0000000 --- a/src/modes/test.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { Tree } from "../render/tree"; -import { ControlPanel } from "../overlay/control"; -import { TestPanel } from "../overlay/test"; -import { StatusPill } from "../overlay/status-pill"; -import { useSolarRuntime } from "../overlay/runtime-context"; - -/** Test mode : scene + operator overlay + test extensions (adapter - * mocker, state inspector, time controls). */ -export function TestMode() { - const { store, bundle } = useSolarRuntime(); - return ( - <> - - - - - - ); -} diff --git a/src/mount.ts b/src/mount.ts index 803519f..af9d568 100644 --- a/src/mount.ts +++ b/src/mount.ts @@ -1,195 +1,86 @@ -import { signal } from "@preact/signals-react"; -import { createRoot, type Root } from "react-dom/client"; -import { createElement } from "react"; -import type { MountOptions, SolarHandle, SolarError, SolarToken } from "./types"; -import { createStore } from "./state/store"; -import { applySnapshot } from "./state/apply-snapshot"; -import { applyDelta } from "./state/apply-delta"; -import { - createBundleFetcher, - type BundleFetcher, - type RenderBundle, -} from "./render/bundle"; -import { TransportError, WsClient, type ConnectionStatus } from "./transport/ws"; -import { SolarApp } from "./app"; +// Solar's public mount() — a thin adapter over @lumencast/runtime. +// +// Since ADR 007 (Lumencast convergence, sub-chantier B), Solar no longer +// carries its own render tree, LSDP transport or leaf-grain state. Those +// were duplicates of what `@lumencast/runtime` already ships to spec +// (LSML 1.1 render + LSDP/1.1 wire). Solar's reason to exist is now the +// Zab-facing contract : a stable `mount()` + `SolarError` taxonomy the +// three hosts (Pulsar CEF / Prism webview / editor preview) depend on, +// mapped onto the runtime's `mount()`. +// +// Contract mapping (Solar → runtime) : +// - `orionUrl` → `serverUrl` (Zab keeps the Orion-named field) +// - `token` (SolarToken) → `token` (structurally identical) +// - `mode` → `mode` (same broadcast/control/test union) +// - `testSession`/`scene` → idem (only meaningful in test mode) +// - `onStatus` → `onStatus` (identical disconnected/connecting/live) +// - `onError` → `onError` (LumencastError ≡ SolarError ; the +// protocol ErrorCode union is byte- +// equal to SolarErrorCode) +// - return SolarHandle ← LumencastHandle ({ disconnect, setToken } same shape) +// +// The runtime owns the lifecycle (subscribe → snapshot → bundle fetch → +// delta → scene_changed → crossfade → token rotation → teardown). Solar +// validates options up-front (host-friendly errors with the `solar.mount:` +// prefix the hosts assert on) and delegates. + +import { mount as mountRuntime } from "@lumencast/runtime"; +import type { + LumencastError, + LumencastStatus, + MountOptions as RuntimeMountOptions, +} from "@lumencast/runtime"; import { validateOptions } from "./internal/validate-options"; +import type { MountOptions, SolarError, SolarHandle, SolarStatus } from "./types"; -/** - * Mount Solar against an Orion WS endpoint and render the active scene - * (or a test session's scene) into `target`. - * - * Lifecycle : - * 1. Open the WS, send subscribe (handled by WsClient). - * 2. On snapshot : fetch render bundle by `scene_version`, seed - * store, render React tree. - * 3. On delta : apply patches to store ; bound signals update, - * bound primitives re-render. - * 4. On scene_changed : fetch new bundle, swap tree (the Crossfade - * wrapper fades old → new based on `crossfadeKey` change). - * 5. setToken() rotates the WS auth without re-mounting React. - * 6. disconnect() tears down the WS, unmounts the React root. - */ export function mount(options: MountOptions): SolarHandle { + // Host-friendly validation with Solar's own message prefix. The runtime + // validates too, but its messages say "Lumencast" — hosts assert on + // "solar.mount:". validateOptions(options); - options.onStatus?.("disconnected"); - const store = createStore(); - const baseUrl = deriveBaseUrl(options.orionUrl); - const bundleFetcher = createBundleFetcher({ baseUrl }); - - const bundleSignal = signal(null); - const statusSignal = signal("disconnected"); - const crossfadeKeySignal = signal("__initial__"); - - // Forward status to the host without dropping the operator-overlay - // signal-driven updates. - const setStatus = (status: ConnectionStatus): void => { - statusSignal.value = status; - options.onStatus?.(status); - }; - - // Plumb the host's onError through to a typed SolarError. - const reportError = (err: SolarError): void => { - options.onError?.(err); - }; - - let active = true; - - const ws = new WsClient({ - url: options.orionUrl, + const runtimeOptions: RuntimeMountOptions = { + target: options.target, + serverUrl: options.orionUrl, token: options.token, - onStatus: setStatus, - onSnapshot: (msg) => { - if (!active) return; - void onSnapshot( - bundleFetcher, - store, - bundleSignal, - crossfadeKeySignal, - msg.scene_id, - msg.scene_version, - () => { - applySnapshot(store, msg); - }, - reportError, - ); - }, - onDelta: (msg) => { - if (!active) return; - applyDelta(store, msg); - }, - onSceneChanged: (_msg) => { - if (!active) return; - // The fresh snapshot that follows will carry the new - // scene_version, drive the bundle fetch, and flip the - // crossfade key. We don't act eagerly here — the snapshot is - // always the source of truth (ADR 002 § 11). Server-declared - // transition duration is honoured by Crossfade's default for - // v1 ; per-event duration override lands in a follow-up. - }, - onServerError: (msg) => { - reportError({ - code: msg.code, - message: msg.message, - recoverable: msg.recoverable, - }); - }, - onTransportError: (err) => { - reportError(transportToSolarError(err)); - }, - }); - - void (async function bootstrap() { - if (options.mode === "test") { - // Test sessions are scene-scoped — their WS URL already - // identifies the scene, so the server's first snapshot fixes - // the active scene. - } - ws.start(); - })(); + mode: options.mode, + ...(options.testSession !== undefined + ? { testSession: options.testSession } + : {}), + ...(options.scene !== undefined ? { scene: options.scene } : {}), + ...(options.onStatus + ? { onStatus: (status: LumencastStatus): void => options.onStatus?.(toSolarStatus(status)) } + : {}), + ...(options.onError + ? { onError: (err: LumencastError): void => options.onError?.(toSolarError(err)) } + : {}), + }; - // React root. - const root: Root = createRoot(options.target); - root.render( - createElement(SolarApp, { - mode: options.mode, - store, - bundleSignal, - statusSignal, - crossfadeKeySignal, - sendInput: (path, value, clientMsgId) => - ws.sendInput(path, value, clientMsgId), - }), - ); + const handle = mountRuntime(runtimeOptions); return { - disconnect() { - if (!active) return; - active = false; - ws.close(); - root.unmount(); - }, - setToken(token: SolarToken) { - if (!active) return; - ws.setToken(token); - }, + disconnect: () => handle.disconnect(), + setToken: (token) => handle.setToken(token), }; +} - // --- helpers (closures over outer scope) ---------------------- +// --- contract mapping helpers ----------------------------------------- - async function onSnapshot( - fetcher: BundleFetcher, - _store: typeof store, - bSignal: typeof bundleSignal, - cSignal: typeof crossfadeKeySignal, - sceneId: string, - sceneVersion: string, - applyState: () => void, - onErr: (err: SolarError) => void, - ): Promise { - let bundle: RenderBundle; - try { - bundle = await fetcher.get(sceneId, sceneVersion); - } catch (err) { - onErr({ - code: "BUNDLE_FETCH_FAILED", - message: - err instanceof Error ? err.message : "render bundle fetch failed", - recoverable: true, - }); - return; - } - if (!active) return; - applyState(); - bSignal.value = bundle; - // Trigger the crossfade : a fresh key drives AnimatePresence to - // mount the new tree with an opacity tween. - cSignal.value = `${sceneId}::${sceneVersion}`; - } +// The two status unions are identical ("disconnected" | "connecting" | +// "live") ; this keeps the boundary explicit and would fail to compile if +// either side drifted. +function toSolarStatus(status: LumencastStatus): SolarStatus { + return status; } -// --- error mapping -------------------------------------------- - -function transportToSolarError(err: TransportError): SolarError { - // The transport reports its own typed reason ; we map a few well- - // known cases to dedicated Solar codes and fall back to INTERNAL. +// `LumencastError` and `SolarError` are structurally identical and the +// protocol `ErrorCode` union is byte-equal to `SolarErrorCode`, so the +// forward is lossless. The explicit object construction documents the +// contract boundary and pins it at compile time. +function toSolarError(err: LumencastError): SolarError { return { - code: "INTERNAL", + code: err.code, message: err.message, recoverable: err.recoverable, }; } - -// --- URL helpers ---------------------------------------------- - -function deriveBaseUrl(wsUrl: string): string { - // wss:///orion/api/v1/show/stream → https:// - // ws:///orion/api/v1/show/stream → http:// - try { - const u = new URL(wsUrl); - const httpScheme = u.protocol === "wss:" ? "https:" : "http:"; - return `${httpScheme}//${u.host}`; - } catch { - return ""; - } -} diff --git a/src/overlay/control.tsx b/src/overlay/control.tsx deleted file mode 100644 index c86daf5..0000000 --- a/src/overlay/control.tsx +++ /dev/null @@ -1,237 +0,0 @@ -import { useSignals } from "@preact/signals-react/runtime"; -import type { OperatorInput } from "../render/bundle"; -import { useSolarRuntime } from "./runtime-context"; - -const PANEL_STYLE: React.CSSProperties = { - position: "fixed", - bottom: 12, - left: 12, - zIndex: 100_000, - width: 320, - maxHeight: "70vh", - overflowY: "auto", - padding: 12, - fontFamily: - "system-ui, -apple-system, BlinkMacSystemFont, sans-serif", - fontSize: 12, - color: "#e5e7eb", - background: "rgba(17, 24, 39, 0.92)", - border: "1px solid rgba(75, 85, 99, 0.6)", - borderRadius: 10, - boxShadow: "0 8px 32px rgba(0, 0, 0, 0.45)", -}; - -const ROW_STYLE: React.CSSProperties = { - display: "flex", - flexDirection: "column", - gap: 4, - padding: "6px 0", - borderBottom: "1px solid rgba(75, 85, 99, 0.35)", -}; - -const LABEL_STYLE: React.CSSProperties = { - color: "#9ca3af", - fontSize: 10.5, - letterSpacing: "0.02em", - textTransform: "uppercase", -}; - -const INPUT_STYLE: React.CSSProperties = { - background: "rgba(31, 41, 55, 0.8)", - border: "1px solid rgba(75, 85, 99, 0.6)", - borderRadius: 6, - color: "#f9fafb", - padding: "4px 6px", - fontSize: 12, - width: "100%", -}; - -export function ControlPanel() { - const { bundle, store, sendInput } = useSolarRuntime(); - useSignals(); - - const inputs = bundle.operator_inputs ?? []; - if (inputs.length === 0) return null; - - // Group entries by `group` field for readability. - const groups = new Map(); - for (const entry of inputs) { - const g = entry.group ?? "General"; - const list = groups.get(g) ?? []; - list.push(entry); - groups.set(g, list); - } - - return ( -
-
- Operator inputs -
- {[...groups.entries()].map(([group, entries]) => ( -
-
- {group} -
- {entries.map((entry) => ( - sendInput(entry.path, v)} - /> - ))} -
- ))} -
- ); -} - -function InputRow({ - entry, - currentValue, - onCommit, -}: { - entry: OperatorInput; - currentValue: unknown; - onCommit: (value: unknown) => void; -}) { - return ( -
- {entry.label} - -
- ); -} - -function Editor({ - entry, - currentValue, - onCommit, -}: { - entry: OperatorInput; - currentValue: unknown; - onCommit: (value: unknown) => void; -}) { - switch (entry.type) { - case "boolean": { - const checked = currentValue === true; - return ( - - ); - } - case "number": { - const min = entry.min as number | undefined; - const max = entry.max as number | undefined; - const step = entry.step as number | undefined; - return ( - { - const n = Number(e.target.value); - if (Number.isFinite(n)) onCommit(n); - }} - /> - ); - } - case "text": { - const max = entry.max_length as number | undefined; - return ( - onCommit(e.target.value)} - /> - ); - } - case "colour": { - return ( - onCommit(e.target.value)} - /> - ); - } - case "duration": { - return ( - { - const n = Number(e.target.value); - if (Number.isFinite(n) && n >= 0) onCommit(n); - }} - /> - ); - } - case "select": - case "enum": { - const options = - (entry.enum_values as string[] | undefined) ?? - (entry.options as string[] | undefined) ?? - []; - return ( - - ); - } - case "path-ref": - default: - // FIXME (v2) — `path-ref` UX is deferred ; for now show a plain - // text entry so the value is still editable. - return ( - onCommit(e.target.value)} - /> - ); - } -} diff --git a/src/overlay/runtime-context.tsx b/src/overlay/runtime-context.tsx deleted file mode 100644 index abbe402..0000000 --- a/src/overlay/runtime-context.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { createContext, useContext, type ReactNode } from "react"; -import type { Store } from "../state/store"; -import type { RenderBundle } from "../render/bundle"; -import type { ConnectionStatus } from "../transport/ws"; -import type { SolarMode } from "../types"; - -export interface SolarRuntime { - mode: SolarMode; - store: Store; - bundle: RenderBundle; - status: ConnectionStatus; - sendInput: (path: string, value: unknown, clientMsgId?: string) => void; -} - -const Ctx = createContext(null); - -export function SolarRuntimeProvider({ - value, - children, -}: { - value: SolarRuntime; - children: ReactNode; -}) { - return {children}; -} - -export function useSolarRuntime(): SolarRuntime { - const v = useContext(Ctx); - if (!v) { - throw new Error( - "Solar overlay components must be rendered inside SolarRuntimeProvider", - ); - } - return v; -} diff --git a/src/overlay/status-pill.tsx b/src/overlay/status-pill.tsx deleted file mode 100644 index 8e8e1b6..0000000 --- a/src/overlay/status-pill.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { useSolarRuntime } from "./runtime-context"; - -const COLOURS: Record = { - live: "rgba(34, 197, 94, 0.85)", - connecting: "rgba(234, 179, 8, 0.85)", - disconnected: "rgba(239, 68, 68, 0.85)", -}; - -const LABELS: Record = { - live: "live", - connecting: "reconnecting", - disconnected: "disconnected", -}; - -export function StatusPill() { - const { status } = useSolarRuntime(); - return ( -
- {LABELS[status] ?? status} -
- ); -} diff --git a/src/overlay/test.tsx b/src/overlay/test.tsx deleted file mode 100644 index 3172467..0000000 --- a/src/overlay/test.tsx +++ /dev/null @@ -1,211 +0,0 @@ -import { useSignals } from "@preact/signals-react/runtime"; -import { useState } from "react"; -import { useSolarRuntime } from "./runtime-context"; - -const PANEL_STYLE: React.CSSProperties = { - position: "fixed", - bottom: 12, - right: 12, - zIndex: 100_001, - width: 360, - maxHeight: "70vh", - overflowY: "auto", - padding: 12, - fontFamily: - "system-ui, -apple-system, BlinkMacSystemFont, sans-serif", - fontSize: 12, - color: "#e5e7eb", - background: "rgba(8, 47, 73, 0.92)", - border: "1px solid rgba(56, 189, 248, 0.4)", - borderRadius: 10, - boxShadow: "0 8px 32px rgba(0, 0, 0, 0.45)", -}; - -const SECTION_TITLE: React.CSSProperties = { - fontWeight: 600, - fontSize: 11, - letterSpacing: "0.06em", - color: "#7dd3fc", - textTransform: "uppercase", - marginBottom: 6, -}; - -const BUTTON_STYLE: React.CSSProperties = { - background: "rgba(14, 165, 233, 0.4)", - border: "1px solid rgba(125, 211, 252, 0.5)", - borderRadius: 6, - color: "#f0f9ff", - padding: "3px 8px", - fontSize: 11, - cursor: "pointer", -}; - -const ADAPTER_ROW: React.CSSProperties = { - display: "flex", - flexDirection: "column", - gap: 4, - padding: "6px 0", - borderBottom: "1px solid rgba(56, 189, 248, 0.2)", -}; - -/** Test-mode overlay : adapter mocker + state inspector + time - * controls. Drives Orion's __test.* family via the same `sendInput` - * channel. */ -export function TestPanel() { - const { bundle, store, sendInput } = useSolarRuntime(); - useSignals(); - const [filter, setFilter] = useState(""); - - const adapters = bundle.external_adapters ?? []; - const stateRecord = store.toRecord(); - const filteredEntries = Object.entries(stateRecord).filter( - ([k]) => filter === "" || k.includes(filter), - ); - - return ( -
- {/* Time controls */} -
Time
-
- - - -
- - {/* Adapter mocker */} -
External adapters
- {adapters.length === 0 && ( -
- No external adapters declared in this scene. -
- )} - {adapters.map((adapter) => ( - - sendInput("__test.mock_adapter", { - key: adapter.key, - payload, - }) - } - /> - ))} - - {/* State inspector */} -
State
- setFilter(e.target.value)} - style={{ - background: "rgba(8, 47, 73, 0.6)", - border: "1px solid rgba(125, 211, 252, 0.4)", - borderRadius: 6, - color: "#e0f2fe", - padding: "4px 6px", - fontSize: 11, - width: "100%", - marginBottom: 6, - }} - /> -
- {filteredEntries.map(([path, value]) => ( -
- {path} - {formatValue(value)} -
- ))} -
-
- ); -} - -function AdapterRow({ - adapter, - onMock, -}: { - adapter: { key: string; label: string; kind: string }; - onMock: (payload: unknown) => void; -}) { - const [draft, setDraft] = useState("{}"); - return ( -
-
- {adapter.label} - {adapter.kind} -
-