From 1604228a27ea51eb6de8f222f455d2b3320894a3 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 13 Jun 2026 18:50:00 -0700 Subject: [PATCH] feat(sdk): file-backed fs adapter + setTiming GSAP-script sync; add sdk-playground --- bun.lock | 33 +- packages/sdk-playground/.gitignore | 4 + packages/sdk-playground/README.md | 84 ++ packages/sdk-playground/index.html | 454 ++++++ packages/sdk-playground/package.json | 17 + packages/sdk-playground/src/fileAdapter.ts | 59 + packages/sdk-playground/src/main.ts | 1461 ++++++++++++++++++++ packages/sdk-playground/vite.config.ts | 85 ++ packages/sdk/src/adapters/fs.ts | 94 +- packages/sdk/src/engine/mutate.ts | 31 + 10 files changed, 2298 insertions(+), 24 deletions(-) create mode 100644 packages/sdk-playground/.gitignore create mode 100644 packages/sdk-playground/README.md create mode 100644 packages/sdk-playground/index.html create mode 100644 packages/sdk-playground/package.json create mode 100644 packages/sdk-playground/src/fileAdapter.ts create mode 100644 packages/sdk-playground/src/main.ts create mode 100644 packages/sdk-playground/vite.config.ts diff --git a/bun.lock b/bun.lock index 2ee4d14bf..6a5067de1 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ }, "packages/aws-lambda": { "name": "@hyperframes/aws-lambda", - "version": "0.6.93", + "version": "0.6.95", "dependencies": { "@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/client-sfn": "^3.700.0", @@ -54,7 +54,7 @@ }, "packages/cli": { "name": "@hyperframes/cli", - "version": "0.6.93", + "version": "0.6.95", "bin": { "hyperframes": "./dist/cli.js", }, @@ -101,7 +101,7 @@ }, "packages/core": { "name": "@hyperframes/core", - "version": "0.6.93", + "version": "0.6.95", "dependencies": { "@babel/parser": "^7.27.0", "@chenglou/pretext": "^0.0.5", @@ -134,7 +134,7 @@ }, "packages/engine": { "name": "@hyperframes/engine", - "version": "0.6.93", + "version": "0.6.95", "dependencies": { "@hono/node-server": "^1.13.0", "@hyperframes/core": "workspace:^", @@ -152,7 +152,7 @@ }, "packages/gcp-cloud-run": { "name": "@hyperframes/gcp-cloud-run", - "version": "0.6.93", + "version": "0.6.95", "dependencies": { "@google-cloud/storage": "^7.14.0", "@google-cloud/workflows": "^4.2.0", @@ -172,7 +172,7 @@ }, "packages/player": { "name": "@hyperframes/player", - "version": "0.6.93", + "version": "0.6.95", "devDependencies": { "@types/bun": "^1.1.0", "gsap": "^3.12.5", @@ -184,7 +184,7 @@ }, "packages/producer": { "name": "@hyperframes/producer", - "version": "0.6.93", + "version": "0.6.95", "dependencies": { "@fontsource/archivo-black": "^5.2.8", "@fontsource/eb-garamond": "^5.2.7", @@ -225,7 +225,7 @@ }, "packages/sdk": { "name": "@hyperframes/sdk", - "version": "0.6.86", + "version": "0.6.91", "dependencies": { "@hyperframes/core": "workspace:*", "linkedom": "^0.18.12", @@ -236,9 +236,20 @@ "vitest": "^3.2.4", }, }, + "packages/sdk-playground": { + "name": "@hyperframes/sdk-playground", + "dependencies": { + "@hyperframes/core": "workspace:*", + "@hyperframes/sdk": "workspace:*", + "gsap": "^3.15.0", + }, + "devDependencies": { + "vite": "^6.4.2", + }, + }, "packages/shader-transitions": { "name": "@hyperframes/shader-transitions", - "version": "0.6.93", + "version": "0.6.95", "dependencies": { "html2canvas": "^1.4.1", }, @@ -250,7 +261,7 @@ }, "packages/studio": { "name": "@hyperframes/studio", - "version": "0.6.93", + "version": "0.6.95", "dependencies": { "@codemirror/autocomplete": "^6.20.1", "@codemirror/commands": "^6.10.3", @@ -649,6 +660,8 @@ "@hyperframes/sdk": ["@hyperframes/sdk@workspace:packages/sdk"], + "@hyperframes/sdk-playground": ["@hyperframes/sdk-playground@workspace:packages/sdk-playground"], + "@hyperframes/shader-transitions": ["@hyperframes/shader-transitions@workspace:packages/shader-transitions"], "@hyperframes/studio": ["@hyperframes/studio@workspace:packages/studio"], diff --git a/packages/sdk-playground/.gitignore b/packages/sdk-playground/.gitignore new file mode 100644 index 000000000..85dde486b --- /dev/null +++ b/packages/sdk-playground/.gitignore @@ -0,0 +1,4 @@ +.hf-versions/ +composition.html +dist/ +node_modules/ diff --git a/packages/sdk-playground/README.md b/packages/sdk-playground/README.md new file mode 100644 index 000000000..eb8dd1031 --- /dev/null +++ b/packages/sdk-playground/README.md @@ -0,0 +1,84 @@ +# @hyperframes/sdk-playground + +Interactive browser playground for the `@hyperframes/sdk` API. Open a composition, edit it through the full SDK op surface, watch the preview update live. + +## Running + +```bash +bun run --cwd packages/sdk-playground dev +``` + +Serves at `http://localhost:5173`. On first load it reads `packages/sdk-playground/composition.html` from disk (if present) or falls back to a built-in demo composition. + +## Features + +### File persistence + +Composition state is persisted to `packages/sdk-playground/composition.html` via a Vite dev-server plugin backed by `@hyperframes/sdk/adapters/fs`. Every save writes a timestamped snapshot to `.hf-versions/composition.html/` (capped at 20). Reload the page and your last state is restored. + +### Preview iframe + +Full composition rendered in a sandboxed ` + + +
click to select
+ + + +
+
+
Properties
+
Ops
+
+
+
+
+ + +
+
Patch log
+
+
+ + + +
+
+ + 0.0s + + +
+
+
+
+
+
+ + +
+
+

Open composition

+

Paste any HyperFrames composition HTML — the outer data-hf-root element and its contents.

+ +
+ + +
+
+
+ + + + diff --git a/packages/sdk-playground/package.json b/packages/sdk-playground/package.json new file mode 100644 index 000000000..e15f423e8 --- /dev/null +++ b/packages/sdk-playground/package.json @@ -0,0 +1,17 @@ +{ + "name": "@hyperframes/sdk-playground", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "dependencies": { + "@hyperframes/core": "workspace:*", + "@hyperframes/sdk": "workspace:*", + "gsap": "^3.15.0" + }, + "devDependencies": { + "vite": "^6.4.2" + } +} diff --git a/packages/sdk-playground/src/fileAdapter.ts b/packages/sdk-playground/src/fileAdapter.ts new file mode 100644 index 000000000..d7b7d8047 --- /dev/null +++ b/packages/sdk-playground/src/fileAdapter.ts @@ -0,0 +1,59 @@ +import type { PersistAdapter, PersistVersionEntry } from "@hyperframes/sdk/adapters/types"; +import type { PersistErrorEvent } from "@hyperframes/sdk"; + +const API = "/api/composition"; + +class FileAdapter implements PersistAdapter { + private errorHandlers = new Set<(e: PersistErrorEvent) => void>(); + + async read(_path: string): Promise { + const res = await fetch(API); + if (res.status === 404) return undefined; + if (!res.ok) throw new Error(`read failed: ${res.status}`); + return res.text(); + } + + async write(_path: string, content: string): Promise { + try { + const res = await fetch(API, { + method: "PUT", + headers: { "Content-Type": "text/html; charset=utf-8" }, + body: content, + }); + if (!res.ok) throw new Error(`write failed: ${res.status}`); + } catch (err) { + for (const h of this.errorHandlers) h({ error: { message: String(err), cause: err } }); + } + } + + async flush(): Promise {} + + async listVersions(_path: string): Promise { + const res = await fetch("/api/composition/versions"); + if (!res.ok) return []; + const rows = (await res.json()) as Array<{ key: string; timestamp?: number }>; + return rows.map((r) => ({ key: r.key, timestamp: r.timestamp })); + } + + async loadFrom(_path: string, versionKey: string): Promise { + const res = await fetch(`/api/composition?version=${encodeURIComponent(versionKey)}`); + if (res.status === 404) return undefined; + if (!res.ok) throw new Error(`loadFrom failed: ${res.status}`); + return res.text(); + } + + on(event: "persist:error", handler: (e: PersistErrorEvent) => void): () => void { + if (event !== "persist:error") return () => {}; + this.errorHandlers.add(handler); + return () => this.errorHandlers.delete(handler); + } +} + +export async function createFileAdapter(): Promise<{ + adapter: PersistAdapter; + initialHtml: string | undefined; +}> { + const adapter = new FileAdapter(); + const initialHtml = await adapter.read("composition.html"); + return { adapter, initialHtml }; +} diff --git a/packages/sdk-playground/src/main.ts b/packages/sdk-playground/src/main.ts new file mode 100644 index 000000000..8d3ab2e2d --- /dev/null +++ b/packages/sdk-playground/src/main.ts @@ -0,0 +1,1461 @@ +import { openComposition } from "@hyperframes/sdk"; +import { createFileAdapter } from "./fileAdapter.js"; +import type { Composition, GsapTweenSpec, PreviewAdapter, FindQuery } from "@hyperframes/sdk"; +import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn"; +import type { GsapAnimation } from "@hyperframes/core"; +import gsapRaw from "gsap/dist/gsap.min.js?raw"; + +// ── Demo composition ────────────────────────────────────────────────────────── + +const DEMO_HTML = ` +
+ +
SDK Playground
+
@hyperframes/sdk · Phase 3b
+
v0.6
+ +
`.trim(); + +// ── App state ───────────────────────────────────────────────────────────────── + +let comp: Composition | null = null; +let playgroundPreview: PlaygroundPreview | null = null; +let selectedId: string | null = null; +let playMode = false; +let patchCount = 0; +let activeTab: "properties" | "ops" = "properties"; +let lastTweenId = ""; +let updateTimer: ReturnType | undefined; +let timelineDuration = 0; +let prevPlayPct = 0; + +// ── Small value helpers ───────────────────────────────────────────────────── + +/** parseFloat with a numeric fallback — isolates the `?? / ||` so callers stay simple. */ +function numOr(value: string | number | undefined, fallback: number): number { + const n = typeof value === "number" ? value : parseFloat(value ?? ""); + return isNaN(n) ? fallback : n; +} + +/** Coerce a raw input string to a number when it parses, else keep the string. */ +function coerceNum(raw: string): string | number { + const n = Number(raw); + return isNaN(n) ? raw : n; +} + +function getFrame(): HTMLIFrameElement { + return document.getElementById("preview-frame") as HTMLIFrameElement; +} + +function postToFrame(message: unknown): void { + getFrame().contentWindow?.postMessage(message, "*"); +} + +// ── Preview adapter ─────────────────────────────────────────────────────────── + +class PlaygroundPreview implements PreviewAdapter { + private handlers: Array<(ids: string[]) => void> = []; + + elementAtPoint(_x: number, _y: number) { + return null; + } + applyDraft(_id: string, _props: { dx?: number; dy?: number; width?: number; height?: number }) {} + commitPreview() {} + cancelPreview() {} + + select(ids: string[], _opts?: { additive?: boolean }) { + for (const h of this.handlers) h([...ids]); + } + + on(event: "selection", handler: (ids: string[]) => void): () => void { + if (event !== "selection") return () => {}; + this.handlers.push(handler); + return () => { + this.handlers = this.handlers.filter((h) => h !== handler); + }; + } +} + +// ── Preview iframe ──────────────────────────────────────────────────────────── + +// Bridge script injected into the preview iframe. Pure string — runs in the +// sandboxed iframe, talks to the parent over postMessage. +const BRIDGE_SCRIPT = ``; + +function buildSrcdoc(html: string, selId: string | null): string { + // Baked-in highlight covers the initial render; postMessage updates it live without reload + const highlight = selId + ? `[data-hf-id="${selId}"]{outline:2px solid #3b82f6!important;outline-offset:1px;}` + : ""; + return ` + + + + +${html} +${BRIDGE_SCRIPT} +`; +} + +function schedulePreviewUpdate() { + clearTimeout(updateTimer); + updateTimer = setTimeout(updatePreviewNow, 350); +} + +function updatePreviewNow() { + if (!comp) return; + getFrame().srcdoc = buildSrcdoc(comp.serialize(), selectedId); +} + +function sendSelectionToIframe(id: string | null) { + postToFrame({ type: "hf:select", id }); +} + +function updatePreviewScale() { + const outer = document.getElementById("preview-scaler-outer")!; + const inner = document.getElementById("preview-scaler-inner")!; + const scale = outer.offsetWidth / 1280; + inner.style.transform = `scale(${scale})`; + outer.style.height = `${720 * scale}px`; +} + +// ── Log ─────────────────────────────────────────────────────────────────────── + +const LOG_COLORS: Record = { + patch: "#60a5fa", + undo: "#fbbf24", + redo: "#fbbf24", + selectionchange: "#a78bfa", + "persist:error": "#f87171", + op: "#34d399", + info: "#6b7280", +}; + +function logBody(data: unknown): HTMLElement { + if (typeof data === "string") { + const span = document.createElement("span"); + span.style.color = "#9ca3af"; + span.textContent = data; + return span; + } + const pre = document.createElement("pre"); + pre.textContent = JSON.stringify(data, null, 2); + return pre; +} + +function logEntry(type: string, data: unknown) { + const logEl = document.getElementById("log-entries")!; + const color = LOG_COLORS[type] ?? "#9ca3af"; + const d = document.createElement("div"); + d.className = "log-entry"; + d.style.borderLeftColor = color; + const typeSpan = document.createElement("span"); + typeSpan.className = "log-type"; + typeSpan.style.color = color; + typeSpan.textContent = `[${type}]`; + d.appendChild(typeSpan); + d.appendChild(logBody(data)); + logEl.prepend(d); + while (logEl.children.length > 300) logEl.lastElementChild?.remove(); +} + +// ── Timeline ────────────────────────────────────────────────────────────────── + +const TRACK_COLORS = ["#3b82f6", "#8b5cf6", "#f59e0b", "#10b981", "#f87171", "#06b6d4"]; + +function selectorToHfId(selector: string): string | null { + const m = /\[data-hf-id=['"]([^'"]+)['"]\]/.exec(selector); + if (m) return m[1]; + if (/^#/.test(selector.trim())) return selector.trim().slice(1); + return null; +} + +function gsapScriptOf(html: string): string | null { + const m = /]*>([\s\S]*?)<\/script>/i.exec(html); + return m && m[1] ? m[1] : null; +} + +function bucketFor(map: Map, key: string): GsapAnimation[] { + let b = map.get(key); + if (!b) { + b = []; + map.set(key, b); + } + return b; +} + +function groupAnimationsById( + animations: GsapAnimation[], +): { id: string; label: string; tweens: GsapAnimation[] }[] { + const byId = new Map(); + for (const anim of animations) { + const id = selectorToHfId(anim.targetSelector) ?? anim.targetSelector; + bucketFor(byId, id).push(anim); + } + return Array.from(byId.entries()).map(([id, tweens]) => ({ id, label: id, tweens })); +} + +function parseTimelineData(): { id: string; label: string; tweens: GsapAnimation[] }[] { + if (!comp) return []; + const script = gsapScriptOf(comp.serialize()); + if (!script) return []; + return groupAnimationsById(parseGsapScriptAcorn(script).animations); +} + +/** Prefer element-level timing attrs (written by setTiming) over the GSAP parse. */ +function computeTiming( + ds: string | undefined, + de: string | undefined, + fbStart: number, + fbDur: number, +): { start: number; d: number } { + const start = ds === undefined ? fbStart : parseFloat(ds); + const d = ds === undefined || de === undefined ? fbDur : parseFloat(de) - parseFloat(ds); + return { start, d }; +} + +function resolveTweenTiming(anim: GsapAnimation, trackId: string): { start: number; d: number } { + const fbStart = anim.resolvedStart ?? 0; + const fbDur = numOr(anim.duration as number | undefined, 0.4); + const el = comp ? comp.getElement(trackId) : null; + if (!el) return { start: fbStart, d: fbDur }; + return computeTiming(el.attributes["data-start"], el.attributes["data-end"], fbStart, fbDur); +} + +function buildTweenBlock( + anim: GsapAnimation, + track: { id: string; label: string }, + color: string, + dur: number, +): HTMLDivElement { + const { start, d } = resolveTweenTiming(anim, track.id); + const block = document.createElement("div"); + block.className = "tl-block"; + block.style.left = `${(start / dur) * 100}%`; + block.style.width = `${Math.max((d / dur) * 100, 1.5)}%`; + block.style.background = color; + block.title = `${anim.method}(${track.label}) @${start.toFixed(2)}s dur:${d.toFixed(2)}s`; + block.dataset.tweenId = anim.id; + block.dataset.start = String(start); + block.dataset.duration = String(d); + block.dataset.trackId = track.id; + block.appendChild(makeHandle("tl-handle-l", "trim-start")); + block.appendChild(makeHandle("tl-handle-r", "trim-end")); + return block; +} + +function makeHandle(side: string, drag: string): HTMLDivElement { + const h = document.createElement("div"); + h.className = `tl-handle ${side}`; + h.dataset.drag = drag; + return h; +} + +function buildTrackRow( + track: { id: string; label: string; tweens: GsapAnimation[] }, + index: number, + dur: number, +): HTMLDivElement { + const color = TRACK_COLORS[index % TRACK_COLORS.length]; + const row = document.createElement("div"); + row.className = "tl-row"; + const labelEl = document.createElement("div"); + labelEl.className = "tl-label"; + labelEl.textContent = track.label; + row.appendChild(labelEl); + const trackArea = document.createElement("div"); + trackArea.className = "tl-track"; + for (const anim of track.tweens) trackArea.appendChild(buildTweenBlock(anim, track, color, dur)); + row.appendChild(trackArea); + return row; +} + +function renderTimeline() { + const tracksEl = document.getElementById("tl-tracks"); + if (!tracksEl) return; + const dur = timelineDuration > 0 ? timelineDuration : 1; + tracksEl.innerHTML = ""; + parseTimelineData().forEach((track, i) => tracksEl.appendChild(buildTrackRow(track, i, dur))); +} + +// ── Element list ────────────────────────────────────────────────────────────── + +function buildElItem(el: { id: string; tag: string; text: string | null }): HTMLDivElement { + const item = document.createElement("div"); + item.className = "el-item" + (el.id === selectedId ? " selected" : ""); + item.innerHTML = + `<${el.tag}>` + + `${el.id}` + + (el.text ? `${el.text}` : ""); + item.addEventListener("click", () => setSelection(el.id)); + return item; +} + +function renderElementList() { + if (!comp) return; + const list = document.getElementById("element-list")!; + list.innerHTML = ""; + const elements = comp.getElements().filter((e) => !e.attributes["data-hf-root"]); + for (const el of elements) list.appendChild(buildElItem(el)); +} + +// ── Selection ───────────────────────────────────────────────────────────────── + +function setSelection(id: string | null) { + selectedId = id; + document.getElementById("sel-display")!.textContent = id ?? "(none)"; + renderElementList(); + renderInspectorContent(); + // instant highlight via postMessage — no srcdoc reload + sendSelectionToIframe(id); +} + +// ── Property form (Properties tab) ─────────────────────────────────────────── + +function propLabel(text: string): HTMLSpanElement { + const lbl = document.createElement("span"); + lbl.className = "prop-label"; + lbl.textContent = text; + return lbl; +} + +function buildColorControl(row: HTMLElement, prop: string, currentVal: string) { + const picker = document.createElement("input"); + picker.type = "color"; + picker.className = "prop-color prop-input"; + const text = document.createElement("input"); + text.type = "text"; + text.className = "prop-input"; + text.style.flex = "1"; + const normalized = currentVal.trim(); + trySetValue(picker, normalized); + text.value = normalized; + picker.addEventListener("input", () => { + text.value = picker.value; + commitStyle(prop, picker.value); + }); + text.addEventListener("blur", () => { + commitStyle(prop, text.value.trim() || null); + trySetValue(picker, text.value); + }); + row.appendChild(picker); + row.appendChild(text); +} + +// Only valid hex colors assign to a ; ignore anything else. +function trySetValue(picker: HTMLInputElement, value: string) { + try { + picker.value = value; + } catch { + /* non-hex color */ + } +} + +function buildSelectControl(row: HTMLElement, prop: string, currentVal: string, options: string[]) { + const sel = document.createElement("select"); + sel.className = "prop-input"; + for (const opt of options) { + const o = document.createElement("option"); + o.value = opt; + o.textContent = opt; + if (opt === currentVal) o.selected = true; + sel.appendChild(o); + } + sel.addEventListener("change", () => commitStyle(prop, sel.value || null)); + row.appendChild(sel); +} + +function buildTextControl( + row: HTMLElement, + prop: string, + currentVal: string, + type: "text" | "number", +) { + const input = document.createElement("input"); + input.type = type; + input.className = "prop-input"; + input.style.flex = "1"; + input.value = currentVal; + input.addEventListener("blur", () => commitStyle(prop, input.value.trim() || null)); + input.addEventListener("keydown", (e) => { + if (e.key === "Enter") input.blur(); + }); + row.appendChild(input); +} + +function makeStyleRow( + label: string, + prop: string, + currentVal: string, + type: "text" | "color" | "number" | "select", + options?: string[], +): HTMLDivElement { + const row = document.createElement("div"); + row.className = "prop-row"; + row.appendChild(propLabel(label)); + if (type === "color") buildColorControl(row, prop, currentVal); + else if (type === "select") buildSelectControl(row, prop, currentVal, options ?? []); + else buildTextControl(row, prop, currentVal, type); + return row; +} + +function commitStyle(prop: string, value: string | null) { + if (!comp || !selectedId) return; + comp.element(selectedId).setStyle({ [prop]: value }); + logEntry("op", { "element().setStyle": { id: selectedId, [prop]: value } }); +} + +type PropEl = NonNullable>; + +function appendContentSection(container: HTMLElement, el: PropEl) { + if (el.text === null) return; + const sec = propSection("Content"); + const row = document.createElement("div"); + row.className = "prop-row"; + const ta = document.createElement("textarea"); + ta.className = "prop-input wide"; + ta.value = el.text; + ta.addEventListener("blur", () => { + if (!comp || !selectedId) return; + comp.setText(selectedId, ta.value); + logEntry("op", { setText: { id: selectedId, value: ta.value } }); + }); + row.appendChild(ta); + sec.appendChild(row); + container.appendChild(sec); +} + +const WEIGHTS = ["", "300", "400", "500", "600", "700", "800", "900"]; + +function styleVal(el: PropEl, key: string): string { + return el.inlineStyles[key] ?? ""; +} + +function appendTypographySection(container: HTMLElement, el: PropEl) { + const sec = propSection("Typography"); + sec.appendChild(makeStyleRow("Color", "color", styleVal(el, "color"), "color")); + sec.appendChild(makeStyleRow("Size", "fontSize", styleVal(el, "fontSize"), "text")); + sec.appendChild( + makeStyleRow("Weight", "fontWeight", styleVal(el, "fontWeight"), "select", WEIGHTS), + ); + container.appendChild(sec); +} + +function appendBoxSection(container: HTMLElement, el: PropEl) { + const sec = propSection("Box"); + sec.appendChild(makeStyleRow("Background", "background", styleVal(el, "background"), "color")); + sec.appendChild(makeStyleRow("Opacity", "opacity", styleVal(el, "opacity"), "text")); + sec.appendChild(makeStyleRow("Left", "left", styleVal(el, "left"), "text")); + sec.appendChild(makeStyleRow("Top", "top", styleVal(el, "top"), "text")); + container.appendChild(sec); +} + +function attrRow(name: string, val: string): HTMLDivElement { + const row = document.createElement("div"); + row.className = "prop-row"; + const lbl = propLabel(name); + lbl.style.maxWidth = "80px"; + lbl.style.overflow = "hidden"; + lbl.style.textOverflow = "ellipsis"; + const inp = document.createElement("input"); + inp.type = "text"; + inp.className = "prop-input"; + inp.style.flex = "1"; + inp.value = val; + inp.addEventListener("blur", () => commitAttr(name, inp.value)); + inp.addEventListener("keydown", (e) => { + if (e.key === "Enter") inp.blur(); + }); + row.appendChild(lbl); + row.appendChild(inp); + return row; +} + +function commitAttr(name: string, raw: string) { + if (!comp || !selectedId) return; + comp.element(selectedId).setAttribute(name, raw.trim() || null); + logEntry("op", { setAttribute: { id: selectedId, name, value: raw } }); +} + +function appendAttributesSection(container: HTMLElement, el: PropEl) { + const sec = propSection("Attributes"); + const attrs = Object.entries(el.attributes).filter( + ([k]) => !k.startsWith("data-hf-") && k !== "class" && k !== "style", + ); + for (const [name, val] of attrs) sec.appendChild(attrRow(name, val)); + if (attrs.length === 0) sec.appendChild(mkNote("no attributes")); + container.appendChild(sec); +} + +function appendDangerSection(container: HTMLElement) { + const sec = propSection("Danger"); + const delBtn = document.createElement("button"); + delBtn.textContent = "Remove element"; + delBtn.className = "danger"; + delBtn.style.fontSize = "11px"; + delBtn.addEventListener("click", removeSelected); + sec.appendChild(delBtn); + container.appendChild(sec); +} + +function removeSelected() { + if (!comp || !selectedId) return; + const id = selectedId; + comp.element(id).removeElement(); + logEntry("op", { removeElement: id }); + setSelection(null); +} + +function appendAnimationsSection(container: HTMLElement, el: PropEl) { + const sec = propSection("Animations"); + if (el.animationIds.length > 0) + for (const aid of el.animationIds) sec.appendChild(tweenChip(aid)); + else sec.appendChild(mkNote("no tweens")); + sec.appendChild(addTweenToggle()); + container.appendChild(sec); +} + +function tweenChip(aid: string): HTMLDivElement { + const chip = document.createElement("div"); + chip.className = "tween-id"; + chip.title = aid; + chip.textContent = aid; + return chip; +} + +function addTweenToggle(): DocumentFragment { + const frag = document.createDocumentFragment(); + const addBtn = document.createElement("button"); + addBtn.textContent = "+ Add tween"; + addBtn.style.marginTop = "6px"; + addBtn.style.fontSize = "11px"; + const form = document.createElement("div"); + form.id = "add-tween-form"; + addBtn.addEventListener("click", () => toggleAddTweenForm(form)); + frag.appendChild(addBtn); + frag.appendChild(form); + return frag; +} + +function toggleAddTweenForm(form: HTMLElement) { + if (form.classList.contains("open")) { + form.classList.remove("open"); + return; + } + form.classList.add("open"); + buildAddTweenForm(form); +} + +function renderPropertiesContent(container: HTMLElement) { + if (!comp || !selectedId) { + container.innerHTML = '
Select an element
'; + return; + } + const el = comp.getElement(selectedId); + if (!el) { + container.innerHTML = '
Element not found
'; + return; + } + container.innerHTML = ""; + appendContentSection(container, el); + appendTypographySection(container, el); + appendBoxSection(container, el); + appendAttributesSection(container, el); + appendDangerSection(container); + appendAnimationsSection(container, el); +} + +function propSection(title: string): HTMLDivElement { + const sec = document.createElement("div"); + sec.className = "prop-section"; + const h = document.createElement("div"); + h.className = "prop-section-title"; + h.textContent = title; + sec.appendChild(h); + return sec; +} + +interface TweenFormInputs { + method: HTMLSelectElement; + prop: HTMLInputElement; + val: HTMLInputElement; + dur: HTMLInputElement; + ease: HTMLInputElement; + pos: HTMLInputElement; +} + +function applyTweenPosition(tween: GsapTweenSpec, pos: string) { + if (pos !== "") tween.position = parseFloat(pos); +} + +function readTweenSpecFromForm(f: TweenFormInputs): GsapTweenSpec { + const propKey = f.prop.value.trim() || "x"; + const tween: GsapTweenSpec = { + method: (f.method.value || "to") as "to" | "from" | "fromTo", + duration: numOr(f.dur.value, 0.5), + ease: f.ease.value || "power2.out", + properties: { [propKey]: coerceNum(f.val.value.trim()) }, + }; + applyTweenPosition(tween, f.pos.value.trim()); + return tween; +} + +function buildAddTweenForm(form: HTMLElement) { + form.innerHTML = ""; + const inputs: TweenFormInputs = { + method: mkSelect(["to", "from", "fromTo"], "to"), + prop: mkInput("property", "x"), + val: mkInput("value", "200"), + dur: mkNumInput("duration", "0.5"), + ease: mkInput("ease", "power2.out"), + pos: mkNumInput("position", ""), + }; + appendRow(form, mkLabel("Method"), inputs.method); + appendRow(form, mkLabel("Prop"), inputs.prop, mkLabel("Val"), inputs.val); + appendRow(form, mkLabel("Duration"), inputs.dur, mkLabel("Ease"), inputs.ease); + appendRow(form, mkLabel("Position"), inputs.pos); + + const addBtn = document.createElement("button"); + addBtn.textContent = "Add"; + addBtn.className = "primary"; + addBtn.style.marginTop = "6px"; + addBtn.addEventListener("click", () => submitAddTween(form, inputs)); + form.appendChild(addBtn); +} + +function submitAddTween(form: HTMLElement, inputs: TweenFormInputs) { + if (!comp || !selectedId) return; + const tween = readTweenSpecFromForm(inputs); + lastTweenId = comp.addGsapTween(selectedId, tween); + logEntry("op", { addGsapTween: { target: selectedId, tween, id: lastTweenId } }); + form.classList.remove("open"); + renderInspectorContent(); +} + +// ── Ops tab (raw SDK test panel) ────────────────────────────────────────────── + +// Shared display node for the addGsapTween / setGsapTween / removeGsapTween group. +let tweenIdDisplay: HTMLDivElement; + +function needSelection(): boolean { + if (selectedId) return true; + logEntry("info", "select an element first"); + return false; +} + +function buildPreviewSelectSection(): HTMLDivElement { + const preview = playgroundPreview!; + const ids = comp! + .getElements() + .filter((e) => !e.attributes["data-hf-root"]) + .map((e) => e.id); + const buttons = ids.map((id) => + mkBtn(id, "", () => { + preview.select([id]); + logEntry("op", { "preview.select": [id] }); + }), + ); + const clear = mkBtn("clear", "danger", () => { + preview.select([]); + logEntry("op", { "preview.select": [] }); + }); + return opSection("PreviewAdapter.select()", opRow(...buttons, clear)); +} + +function buildSetStyleSection(): HTMLDivElement { + const colorInput = mkInput("#f43f5e", "#f43f5e"); + colorInput.style.width = "100px"; + const setColor = mkBtn("Headline color", "primary", () => { + if (!needSelection()) return; + comp!.setStyle(selectedId!, { color: colorInput.value }); + logEntry("op", { setStyle: { id: selectedId, color: colorInput.value } }); + }); + const bold = mkBtn("Bold", "", () => + comp!.setStyle(selectedId ?? "hf-headline", { fontWeight: "700" }), + ); + const reset = mkBtn("Reset weight", "", () => + comp!.setStyle(selectedId ?? "hf-headline", { fontWeight: null }), + ); + return opSection("setStyle", opRow(colorInput, setColor), opRow(bold, reset)); +} + +function buildSetTextSection(): HTMLDivElement { + const textInput = mkInput("new text", ""); + const set = mkBtn("Set", "primary", () => { + if (!needSelection()) return; + comp!.setText(selectedId!, textInput.value); + logEntry("op", { setText: { id: selectedId, value: textInput.value } }); + }); + return opSection("setText", opRow(textInput, set)); +} + +function buildAddGsapTweenSection(): HTMLDivElement { + const target = mkInput("target id", selectedId ?? "hf-badge"); + target.style.width = "110px"; + const dur = mkNumInput("dur", "0.8"); + const ease = mkInput("ease", "power2.out"); + ease.style.width = "110px"; + const propKey = mkInput("prop", "x"); + propKey.style.width = "60px"; + const propVal = mkInput("val", "200"); + propVal.style.width = "60px"; + tweenIdDisplay = document.createElement("div"); + tweenIdDisplay.id = "anim-id-display"; + tweenIdDisplay.textContent = lastTweenId || "no tween added yet"; + const add = mkBtn("Add →", "primary", () => { + const tween: GsapTweenSpec = { + method: "to", + duration: numOr(dur.value, 0.5), + ease: ease.value || "power2.out", + properties: { [propKey.value.trim()]: coerceNum(propVal.value) }, + }; + lastTweenId = comp!.addGsapTween(target.value.trim(), tween); + tweenIdDisplay.textContent = lastTweenId; + logEntry("op", { addGsapTween: { target: target.value, tween, id: lastTweenId } }); + }); + return opSection( + "addGsapTween", + opRow(target, dur, ease), + opRow(propKey, propVal, add), + tweenIdDisplay, + ); +} + +function needTween(): boolean { + if (lastTweenId) return true; + logEntry("info", "add a tween first"); + return false; +} + +function buildSetGsapTweenSection(): HTMLDivElement { + const newDur = mkNumInput("new dur", "1.5"); + const update = mkBtn("Update dur", "", () => { + if (!needTween()) return; + comp!.setGsapTween(lastTweenId, { duration: numOr(newDur.value, 1) }); + logEntry("op", { setGsapTween: { id: lastTweenId, duration: newDur.value } }); + }); + const remove = mkBtn("Remove", "danger", () => { + if (!needTween()) return; + comp!.removeGsapTween(lastTweenId); + logEntry("op", { removeGsapTween: lastTweenId }); + lastTweenId = ""; + tweenIdDisplay.textContent = "no tween added yet"; + }); + return opSection( + "setGsapTween / removeGsapTween", + opRow(mkNote("operates on last added tween")), + opRow(newDur, update, remove), + ); +} + +function buildLabelSection(): HTMLDivElement { + const name = mkInput("label", "midpoint"); + name.style.width = "100px"; + const pos = mkNumInput("pos", "1.5"); + const add = mkBtn("Add", "primary", () => { + const labelName = name.value.trim(); + comp!.dispatch({ type: "addLabel", name: labelName, position: parseFloat(pos.value) }); + logEntry("op", { addLabel: { name: labelName, position: pos.value } }); + }); + const remove = mkBtn("Remove", "danger", () => { + comp!.dispatch({ type: "removeLabel", name: name.value.trim() }); + logEntry("op", { removeLabel: name.value.trim() }); + }); + return opSection("addLabel / removeLabel", opRow(name, pos, add, remove)); +} + +function buildClassStyleSection(): HTMLDivElement { + const sel = mkInput("selector", ".badge"); + sel.style.width = "80px"; + const prop = mkInput("prop", "background"); + prop.style.width = "90px"; + const val = mkInput("value", "#8b5cf6"); + val.style.width = "90px"; + const apply = mkBtn("Apply", "primary", () => { + const selector = sel.value.trim(); + const styles = { [prop.value.trim()]: val.value.trim() || null }; + comp!.dispatch({ type: "setClassStyle", selector, styles }); + logEntry("op", { setClassStyle: { selector, ...styles } }); + }); + return opSection("setClassStyle", opRow(sel, prop, val, apply)); +} + +function buildAttributeSection(): HTMLDivElement { + const name = mkInput("name", "data-custom"); + name.style.width = "110px"; + const val = mkInput("value", "hello"); + val.style.width = "100px"; + const set = mkBtn("Set attr", "primary", () => { + if (!needSelection()) return; + comp!.element(selectedId!).setAttribute(name.value.trim(), val.value.trim() || null); + logEntry("op", { setAttribute: { id: selectedId, name: name.value, value: val.value } }); + }); + const remove = mkBtn("Remove el", "danger", () => { + if (!needSelection()) return; + removeSelected(); + }); + return opSection( + "setAttribute / removeElement", + opRow(mkNote("operates on selected element")), + opRow(name, val, set, remove), + ); +} + +function buildVariableSection(): HTMLDivElement { + const id = mkInput("variable id", "brand-color"); + id.style.width = "110px"; + const val = mkInput("value", "#f43f5e"); + val.style.width = "100px"; + const set = mkBtn("Set", "primary", () => { + comp!.setVariableValue(id.value.trim(), val.value.trim()); + logEntry("op", { setVariableValue: { id: id.value, value: val.value } }); + }); + return opSection("setVariableValue", opRow(id, val, set)); +} + +function buildFindSection(): HTMLDivElement { + const tag = mkInput("tag", ""); + tag.style.width = "60px"; + const text = mkInput("text", ""); + text.style.width = "80px"; + const results = mkNote(""); + const find = mkBtn("Find", "primary", () => { + const query: FindQuery = {}; + if (tag.value.trim()) query.tag = tag.value.trim(); + if (text.value.trim()) query.text = text.value.trim(); + const ids = comp!.find(query); + results.textContent = ids.length ? ids.join(", ") : "(none)"; + logEntry("op", { find: { query, result: ids } }); + }); + return opSection("find(query)", opRow(mkLabel("tag"), tag, mkLabel("text"), text, find), results); +} + +function buildSelectionProxySection(): HTMLDivElement { + const prop = mkInput("prop", "opacity"); + prop.style.width = "80px"; + const val = mkInput("value", "0.5"); + val.style.width = "80px"; + const setStyle = mkBtn("setStyle", "primary", () => { + comp!.selection().setStyle({ [prop.value.trim()]: val.value.trim() || null }); + logEntry("op", { "selection().setStyle": { [prop.value]: val.value } }); + }); + const remove = mkBtn("remove", "danger", () => { + const ids = comp!.getSelection(); + comp!.selection().removeElement(); + logEntry("op", { "selection().removeElement": ids }); + setSelection(null); + }); + const current = mkNote(`current: ${comp!.getSelection().join(", ") || "(none)"}`); + return opSection("selection() proxy", opRow(current), opRow(prop, val, setStyle, remove)); +} + +function buildVersionsSection(): HTMLDivElement { + const display = mkNote(""); + display.style.maxHeight = "80px"; + display.style.overflowY = "auto"; + const list = mkBtn("List versions", "", () => listVersionsInto(display)); + const loadOldest = mkBtn("Load oldest", "", () => loadOldestVersion()); + return opSection("listVersions / loadFrom", opRow(list, loadOldest), display); +} + +async function listVersionsInto(display: HTMLElement) { + const { adapter } = await createFileAdapter(); + const versions = await adapter.listVersions("composition.html"); + display.textContent = versions.length ? versions.map(versionLabel).join("\n") : "(no versions)"; + logEntry("info", { versions: versions.map((v) => v.key) }); +} + +function versionLabel(v: { key: string; timestamp?: number }): string { + return `${v.key} (${new Date(v.timestamp ?? 0).toLocaleTimeString()})`; +} + +async function loadOldestVersion() { + const { adapter } = await createFileAdapter(); + const versions = await adapter.listVersions("composition.html"); + const oldest = versions[versions.length - 1]; + if (!oldest) { + logEntry("info", "no versions"); + return; + } + const html = await adapter.loadFrom("composition.html", oldest.key); + if (!html) return; + await openEditor(html, `v${oldest.key}`); + logEntry("info", `loaded version ${oldest.key}`); +} + +function buildHistorySection(): HTMLDivElement { + const undo = mkBtn("← Undo", "", () => { + comp!.undo(); + logEntry("undo", "dispatched"); + }); + const redo = mkBtn("Redo →", "", () => { + comp!.redo(); + logEntry("redo", "dispatched"); + }); + const canCheck = mkBtn("can(addGsapTween)?", "", () => { + const r = comp!.can({ + type: "addGsapTween", + target: "hf-badge", + tween: { method: "to", duration: 0.5 }, + }); + logEntry("info", { "can(addGsapTween)": r }); + }); + const overrides = mkBtn("getOverrides()", "", () => logEntry("info", comp!.getOverrides())); + const flush = mkBtn("flush", "", () => { + comp!.flush().then(() => logEntry("info", "flush complete")); + }); + return opSection("History / inspect", opRow(undo, redo), opRow(canCheck, overrides, flush)); +} + +const OPS_SECTIONS = [ + buildPreviewSelectSection, + buildSetStyleSection, + buildSetTextSection, + buildAddGsapTweenSection, + buildSetGsapTweenSection, + buildLabelSection, + buildClassStyleSection, + buildAttributeSection, + buildVariableSection, + buildFindSection, + buildSelectionProxySection, + buildVersionsSection, + buildHistorySection, +]; + +function renderOpsContent(container: HTMLElement) { + if (!comp) { + container.innerHTML = '
No composition open
'; + return; + } + container.innerHTML = ""; + for (const build of OPS_SECTIONS) container.appendChild(build()); +} + +// ── Inspector content router ────────────────────────────────────────────────── + +function renderInspectorContent() { + const container = document.getElementById("inspector-content")!; + if (activeTab === "properties") renderPropertiesContent(container); + else renderOpsContent(container); +} + +// ── Open editor ─────────────────────────────────────────────────────────────── + +function resetUiForOpen(name: string) { + patchCount = 0; + selectedId = null; + lastTweenId = ""; + prevPlayPct = 0; + timelineDuration = 0; + document.getElementById("log-entries")!.innerHTML = ""; + document.getElementById("sel-display")!.textContent = "(none)"; + document.getElementById("comp-name")!.textContent = name; + (document.getElementById("tl-scrubber") as HTMLInputElement).value = "0"; + document.getElementById("tl-time")!.textContent = "0.0s"; + document.getElementById("tl-dur")!.textContent = "–"; +} + +function wireCompositionEvents(c: Composition) { + c.on("patch", (e) => { + patchCount++; + logEntry(`patch #${patchCount}`, e.patches); + schedulePreviewUpdate(); + renderElementList(); + renderInspectorContent(); + renderTimeline(); + }); + c.on("persist:error", (e) => logEntry("persist:error", e)); + c.on("selectionchange", onSelectionChange); +} + +function onSelectionChange(ids: string[]) { + const id = ids[0] ?? null; + selectedId = id; + document.getElementById("sel-display")!.textContent = id ?? "(none)"; + renderElementList(); + renderInspectorContent(); + sendSelectionToIframe(id); + logEntry("selectionchange", ids); +} + +async function openEditor(html: string, name = "untitled") { + if (comp) { + comp.dispose(); + comp = null; + } + resetUiForOpen(name); + + const { adapter: persist } = await createFileAdapter(); + const preview = new PlaygroundPreview(); + playgroundPreview = preview; + + comp = await openComposition(html, { persist, preview, coalesceMs: 150 }); + wireCompositionEvents(comp); + + renderElementList(); + renderInspectorContent(); + updatePreviewNow(); + renderTimeline(); + logEntry("info", `opened — ${comp.getElements().length} elements`); +} + +// ── DOM helpers ─────────────────────────────────────────────────────────────── + +function mkBtn(label: string, cls: string, onClick: () => void): HTMLButtonElement { + const b = document.createElement("button"); + b.textContent = label; + if (cls) b.className = cls; + b.addEventListener("click", onClick); + return b; +} + +function mkInput(placeholder: string, defaultValue = ""): HTMLInputElement { + const i = document.createElement("input"); + i.type = "text"; + i.placeholder = placeholder; + i.value = defaultValue; + return i; +} + +function mkNumInput(placeholder: string, defaultValue = ""): HTMLInputElement { + const i = document.createElement("input"); + i.type = "number"; + i.placeholder = placeholder; + i.value = defaultValue; + return i; +} + +function mkSelect(opts: string[], defaultVal: string): HTMLSelectElement { + const s = document.createElement("select"); + s.className = "prop-input"; + for (const o of opts) { + const op = document.createElement("option"); + op.value = o; + op.textContent = o; + if (o === defaultVal) op.selected = true; + s.appendChild(op); + } + return s; +} + +function mkLabel(text: string): HTMLSpanElement { + const s = document.createElement("span"); + s.className = "prop-label"; + s.textContent = text; + s.style.width = "auto"; + return s; +} + +function mkNote(text: string): HTMLDivElement { + const d = document.createElement("div"); + d.className = "op-note"; + d.textContent = text; + return d; +} + +function opRow(...children: HTMLElement[]): HTMLDivElement { + const d = document.createElement("div"); + d.className = "op-row"; + for (const c of children) d.appendChild(c); + return d; +} + +function appendRow(parent: HTMLElement, ...children: HTMLElement[]) { + const row = opRow(...children); + row.style.marginBottom = "4px"; + parent.appendChild(row); +} + +function opSection(title: string, ...rows: HTMLElement[]): HTMLDivElement { + const d = document.createElement("div"); + d.className = "op-section"; + const h = document.createElement("div"); + h.className = "op-title"; + h.textContent = title; + d.appendChild(h); + for (const row of rows) d.appendChild(row); + return d; +} + +// ── Timeline drag ───────────────────────────────────────────────────────────── + +type DragType = "move" | "trim-start" | "trim-end"; + +interface DragState { + tweenId: string; + trackId: string; + type: DragType; + startX: number; + origStart: number; + origDuration: number; + blockEl: HTMLElement; + snapDur: number; + trackW: number; + dragged: boolean; +} + +let dragState: DragState | null = null; + +function dragTypeOf(target: HTMLElement): DragType { + return (target.dataset.drag as DragType | undefined) ?? "move"; +} + +function buildDragState(target: HTMLElement, block: HTMLElement, clientX: number): DragState { + const body = document.getElementById("tl-body")!; + return { + tweenId: block.dataset.tweenId ?? "", + trackId: block.dataset.trackId ?? "", + type: dragTypeOf(target), + startX: clientX, + origStart: numOr(block.dataset.start, 0), + origDuration: numOr(block.dataset.duration, 0.4), + blockEl: block, + snapDur: timelineDuration > 0 ? timelineDuration : 1, + trackW: body.offsetWidth - 120, + dragged: false, + }; +} + +function onDragStart(e: MouseEvent) { + const target = e.target as HTMLElement; + const block = target.closest(".tl-block") as HTMLElement | null; + if (!block || !block.dataset.tweenId) return; + e.preventDefault(); + dragState = buildDragState(target, block, e.clientX); + block.classList.add("dragging"); +} + +function moveBlock(ds: DragState, dt: number) { + const newStart = Math.max(0, ds.origStart + dt); + ds.blockEl.style.left = `${(newStart / ds.snapDur) * 100}%`; + ds.blockEl.dataset.start = String(newStart); +} + +function trimEnd(ds: DragState, dt: number) { + const newDur = Math.max(0.05, ds.origDuration + dt); + ds.blockEl.style.width = `${Math.max((newDur / ds.snapDur) * 100, 1.5)}%`; + ds.blockEl.dataset.duration = String(newDur); +} + +// trim-start: right edge fixed, left edge moves — both start and duration change +function trimStart(ds: DragState, dt: number) { + const newStart = Math.max(0, Math.min(ds.origStart + dt, ds.origStart + ds.origDuration - 0.05)); + const newDur = ds.origStart + ds.origDuration - newStart; + ds.blockEl.style.left = `${(newStart / ds.snapDur) * 100}%`; + ds.blockEl.style.width = `${Math.max((newDur / ds.snapDur) * 100, 1.5)}%`; + ds.blockEl.dataset.start = String(newStart); + ds.blockEl.dataset.duration = String(newDur); +} + +const DRAG_MOVERS: Record void> = { + move: moveBlock, + "trim-end": trimEnd, + "trim-start": trimStart, +}; + +function onDragMove(e: MouseEvent) { + if (!dragState) return; + if (Math.abs(e.clientX - dragState.startX) > 2) dragState.dragged = true; + const dt = ((e.clientX - dragState.startX) / dragState.trackW) * dragState.snapDur; + DRAG_MOVERS[dragState.type](dragState, dt); +} + +function commitDragTiming(type: DragType, trackId: string, start: number, dur: number) { + if (type === "move") { + comp!.setTiming(trackId, { start }); + logEntry("op", { setTiming: { id: trackId, start: start.toFixed(3) } }); + return; + } + comp!.setTiming(trackId, { start, duration: dur }); + logEntry("op", { setTiming: { id: trackId, start: start.toFixed(3), duration: dur.toFixed(3) } }); +} + +function finishDrag(ds: DragState) { + if (!ds.dragged) { + setSelection(ds.trackId); + return; + } + if (!comp) return; + const newStart = numOr(ds.blockEl.dataset.start, 0); + const newDur = numOr(ds.blockEl.dataset.duration, ds.origDuration); + commitDragTiming(ds.type, ds.trackId, newStart, newDur); +} + +function onDragEnd() { + if (!dragState) return; + const ds = dragState; + ds.blockEl.classList.remove("dragging"); + dragState = null; + finishDrag(ds); +} + +function wireDragListeners() { + const tracksEl = document.getElementById("tl-tracks")!; + tracksEl.addEventListener("mousedown", onDragStart); + document.addEventListener("mousemove", onDragMove); + document.addEventListener("mouseup", onDragEnd); +} + +// ── Playhead + iframe message bridge ────────────────────────────────────────── + +function updatePlayhead(pct: number) { + const body = document.getElementById("tl-body"); + const ph = document.getElementById("tl-playhead"); + if (!body || !ph) return; + const labelW = 120; + ph.style.left = `${labelW + (body.offsetWidth - labelW) * pct}px`; +} + +function setScrubberTime(pct: number, t: number) { + const scrubber = document.getElementById("tl-scrubber") as HTMLInputElement; + scrubber.value = String(Math.round(pct * 1000)); + document.getElementById("tl-time")!.textContent = `${t.toFixed(1)}s`; + updatePlayhead(pct); +} + +function onIframeClick(data: { id?: string }) { + if (data.id) playgroundPreview?.select([data.id]); +} + +function onIframeDeselect() { + playgroundPreview?.select([]); +} + +function onIframeDragend(data: { id: string; dx: number; dy: number }) { + if (!comp) return; + const el = comp.getElement(data.id); + if (!el) return; + const left = numOr(el.inlineStyles["left"], 0) + data.dx; + const top = numOr(el.inlineStyles["top"], 0) + data.dy; + comp.setStyle(data.id, { left: `${Math.round(left)}px`, top: `${Math.round(top)}px` }); +} + +function onIframeDuration(data: { duration: number }) { + if (!(data.duration > 0)) return; + timelineDuration = data.duration; + document.getElementById("tl-dur")!.textContent = `${timelineDuration.toFixed(1)}s`; + setScrubberTime(0, 0); + renderTimeline(); + // If play mode was active before the srcdoc rebuilt, resume + if (playMode) postToFrame({ type: "hf:play" }); +} + +function onIframeTime(data: { time: number }) { + if (timelineDuration <= 0) return; + const t = data.time; + const pct = Math.min(1, t / timelineDuration); + setScrubberTime(pct, t); + maybeLoop(pct); + prevPlayPct = pct; +} + +// loop: edge-trigger restart when crossing the end +function maybeLoop(pct: number) { + if (pct < 0.99 || prevPlayPct >= 0.99 || !playMode) return; + postToFrame({ type: "hf:seek", time: 0 }); + postToFrame({ type: "hf:play" }); +} + +const MSG_HANDLERS: Record void> = { + "hf:click": onIframeClick, + "hf:deselect": onIframeDeselect, + "hf:dragend": onIframeDragend, + "hf:duration": onIframeDuration, + "hf:time": onIframeTime, +}; + +function onWindowMessage(e: MessageEvent) { + const handler = e.data && MSG_HANDLERS[e.data.type]; + if (handler) handler(e.data); +} + +// ── Wire up static DOM ──────────────────────────────────────────────────────── + +function wireUndoRedo() { + document.getElementById("btn-undo")!.addEventListener("click", () => { + comp?.undo(); + logEntry("undo", "dispatched"); + }); + document.getElementById("btn-redo")!.addEventListener("click", () => { + comp?.redo(); + logEntry("redo", "dispatched"); + }); +} + +function wirePlayToggle() { + const playBtn = document.getElementById("btn-play")!; + playBtn.addEventListener("click", () => { + playMode = !playMode; + playBtn.textContent = playMode ? "⏸" : "▶"; + playBtn.classList.toggle("primary", playMode); + if (!playMode) { + postToFrame({ type: "hf:pause" }); + return; + } + prevPlayPct = 0; + postToFrame({ type: "hf:seek", time: 0 }); + postToFrame({ type: "hf:play" }); + }); +} + +function wireTabs() { + document.querySelectorAll(".ins-tab").forEach((tab) => { + tab.addEventListener("click", () => { + document.querySelectorAll(".ins-tab").forEach((t) => t.classList.remove("active")); + tab.classList.add("active"); + activeTab = (tab.dataset.tab as "properties" | "ops") ?? "properties"; + renderInspectorContent(); + }); + }); +} + +function hideOpenOverlay() { + document.getElementById("open-overlay")!.classList.remove("visible"); +} + +function wireOpenDialog() { + document.getElementById("btn-open")!.addEventListener("click", () => { + const overlay = document.getElementById("open-overlay")!; + const ta = document.getElementById("open-textarea") as HTMLTextAreaElement; + ta.value = comp ? comp.serialize() : DEMO_HTML; + overlay.classList.add("visible"); + ta.focus(); + }); + document.getElementById("btn-open-cancel")!.addEventListener("click", hideOpenOverlay); + document.getElementById("btn-open-confirm")!.addEventListener("click", confirmOpen); + document.getElementById("open-overlay")!.addEventListener("click", (e) => { + if (e.target === e.currentTarget) hideOpenOverlay(); + }); +} + +function confirmOpen() { + const ta = document.getElementById("open-textarea") as HTMLTextAreaElement; + const html = ta.value.trim(); + if (!html) return; + hideOpenOverlay(); + openEditor(html, "custom").catch((err) => logEntry("persist:error", String(err))); +} + +function wireScrubber() { + const scrubber = document.getElementById("tl-scrubber") as HTMLInputElement; + scrubber.addEventListener("input", () => { + if (!timelineDuration) return; + const pct = parseInt(scrubber.value) / 1000; + const t = pct * timelineDuration; + document.getElementById("tl-time")!.textContent = `${t.toFixed(1)}s`; + postToFrame({ type: "hf:seek", time: t }); + updatePlayhead(pct); + }); +} + +function wireStaticControls() { + wireDragListeners(); + wireUndoRedo(); + wirePlayToggle(); + wireTabs(); + wireOpenDialog(); + wireScrubber(); + window.addEventListener("message", onWindowMessage); + const ro = new ResizeObserver(updatePreviewScale); + ro.observe(document.getElementById("preview-scaler-outer")!); +} + +// ── Init ────────────────────────────────────────────────────────────────────── + +async function init() { + wireStaticControls(); + updatePreviewScale(); + const { initialHtml } = await createFileAdapter(); + await openEditor(initialHtml ?? DEMO_HTML, initialHtml ? "composition.html" : "demo"); +} + +init().catch((err) => { + document.body.innerHTML = `
${String(err)}
`; +}); diff --git a/packages/sdk-playground/vite.config.ts b/packages/sdk-playground/vite.config.ts new file mode 100644 index 000000000..300c6fc61 --- /dev/null +++ b/packages/sdk-playground/vite.config.ts @@ -0,0 +1,85 @@ +import { defineConfig } from "vite"; +import path from "node:path"; +import type { Plugin } from "vite"; +import type { Connect } from "vite"; +import type { ServerResponse } from "node:http"; +import { createFsAdapter } from "@hyperframes/sdk/adapters/fs"; +import type { PersistAdapter } from "@hyperframes/sdk/adapters/types"; + +const COMP_ROOT = path.resolve(import.meta.dirname); +const COMP_PATH = "composition.html"; + +function sendHtml(res: ServerResponse, html: string | undefined) { + if (html === undefined) { + res.statusCode = 404; + res.end(""); + return; + } + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(html); +} + +function readBody(req: Connect.IncomingMessage): Promise { + return new Promise((resolve) => { + const chunks: Buffer[] = []; + req.on("data", (c: Buffer) => chunks.push(c)); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + }); +} + +function versionKeyOf(req: Connect.IncomingMessage): string | null { + return new URL(req.url ?? "/", "http://localhost").searchParams.get("version"); +} + +async function handleCompositionGet( + adapter: PersistAdapter, + req: Connect.IncomingMessage, + res: ServerResponse, +) { + const versionKey = versionKeyOf(req); + const html = versionKey + ? await adapter.loadFrom(COMP_PATH, versionKey) + : await adapter.read(COMP_PATH); + sendHtml(res, html); +} + +async function handleCompositionPut( + adapter: PersistAdapter, + req: Connect.IncomingMessage, + res: ServerResponse, +) { + await adapter.write(COMP_PATH, await readBody(req)); + res.statusCode = 204; + res.end(); +} + +function methodNotAllowed(res: ServerResponse) { + res.statusCode = 405; + res.end(); +} + +function compositionPlugin(): Plugin { + const adapter = createFsAdapter({ root: COMP_ROOT }); + + return { + name: "hf-composition", + configureServer(server) { + server.middlewares.use("/api/composition/versions", async (req, res) => { + if (req.method !== "GET") return methodNotAllowed(res); + const versions = await adapter.listVersions(COMP_PATH); + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(versions.map((v) => ({ key: v.key, timestamp: v.timestamp })))); + }); + + server.middlewares.use("/api/composition", async (req, res) => { + if (req.method === "GET") return handleCompositionGet(adapter, req, res); + if (req.method === "PUT") return handleCompositionPut(adapter, req, res); + methodNotAllowed(res); + }); + }, + }; +} + +export default defineConfig({ + plugins: [compositionPlugin()], +}); diff --git a/packages/sdk/src/adapters/fs.ts b/packages/sdk/src/adapters/fs.ts index 1a92149c2..dd1947c0e 100644 --- a/packages/sdk/src/adapters/fs.ts +++ b/packages/sdk/src/adapters/fs.ts @@ -1,44 +1,110 @@ import type { PersistAdapter, PersistVersionEntry } from "./types.js"; import type { PersistErrorEvent } from "../types.js"; +import { readFile, writeFile, mkdir, readdir, unlink } from "node:fs/promises"; +import { join, dirname } from "node:path"; export interface FsAdapterOptions { /** Root directory for composition files */ root: string; + /** Max versions to keep per file. Default: 20 */ + maxVersions?: number; } -// Phase 4 — fs adapter stub. Full implementation in SDK Phase 4 (adapters stage). -// Uses Node.js fs/promises; not browser-safe (must be conditionally imported by consumers). +const DEFAULT_MAX_VERSIONS = 20; class FsAdapter implements PersistAdapter { private readonly root: string; + private readonly maxVersions: number; + private errorHandlers: Array<(e: PersistErrorEvent) => void> = []; constructor(opts: FsAdapterOptions) { this.root = opts.root; + this.maxVersions = opts.maxVersions ?? DEFAULT_MAX_VERSIONS; } - async read(_path: string): Promise { - throw new Error("FsAdapter: Phase 4 — not yet implemented"); + async read(path: string): Promise { + try { + return await readFile(this.abs(path), "utf8"); + } catch (err: unknown) { + if (isNotFound(err)) return undefined; + throw err; + } } - async write(_path: string, _content: string): Promise { - throw new Error("FsAdapter: Phase 4 — not yet implemented"); + async write(path: string, content: string): Promise { + try { + const abs = this.abs(path); + await mkdir(dirname(abs), { recursive: true }); + await writeFile(abs, content, "utf8"); + await this.appendVersion(path, content); + } catch (err) { + for (const h of this.errorHandlers) h({ error: { message: String(err), cause: err } }); + } } - async flush(): Promise { - throw new Error("FsAdapter: Phase 4 — not yet implemented"); + async flush(): Promise {} + + async listVersions(path: string): Promise { + const dir = this.versionsDir(path); + try { + const entries = await readdir(dir); + const sorted = entries + .filter((f) => f.endsWith(".html")) + .sort() + .reverse(); + return Promise.all( + sorted.map(async (f) => ({ + key: f.replace(/\.html$/, ""), + content: await readFile(join(dir, f), "utf8"), + timestamp: Number(f.replace(/\.html$/, "")), + })), + ); + } catch { + return []; + } + } + + async loadFrom(path: string, versionKey: string): Promise { + try { + return await readFile(join(this.versionsDir(path), `${versionKey}.html`), "utf8"); + } catch { + return undefined; + } } - async listVersions(_path: string): Promise { - throw new Error("FsAdapter: Phase 4 — not yet implemented"); + on(event: "persist:error", handler: (e: PersistErrorEvent) => void): () => void { + if (event !== "persist:error") return () => {}; + this.errorHandlers.push(handler); + return () => { + const i = this.errorHandlers.indexOf(handler); + if (i !== -1) this.errorHandlers.splice(i, 1); + }; } - async loadFrom(_path: string, _versionKey: string): Promise { - throw new Error("FsAdapter: Phase 4 — not yet implemented"); + private abs(path: string): string { + return join(this.root, path); } - on(_event: "persist:error", _handler: (e: PersistErrorEvent) => void): () => void { - return () => {}; + private versionsDir(path: string): string { + return join(this.root, ".hf-versions", path); } + + private async appendVersion(path: string, content: string): Promise { + const dir = this.versionsDir(path); + await mkdir(dir, { recursive: true }); + const key = String(Date.now()); + await writeFile(join(dir, `${key}.html`), content, "utf8"); + // prune oldest beyond maxVersions + const all = (await readdir(dir)).filter((f) => f.endsWith(".html")).sort(); + const excess = all.length - this.maxVersions; + if (excess > 0) { + await Promise.all(all.slice(0, excess).map((f) => unlink(join(dir, f)).catch(() => {}))); + } + } +} + +function isNotFound(err: unknown): boolean { + return (err as NodeJS.ErrnoException)?.code === "ENOENT"; } export function createFsAdapter(opts: FsAdapterOptions): PersistAdapter { diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts index 370f86831..e749f3a82 100644 --- a/packages/sdk/src/engine/mutate.ts +++ b/packages/sdk/src/engine/mutate.ts @@ -285,6 +285,12 @@ function handleSetTiming( timing: { start?: number; duration?: number; trackIndex?: number }, ): MutationResult { const result: MutationResult = { forward: [], inverse: [] }; + + // Pre-parse GSAP script once; sync tween positions for GSAP-scripted compositions. + const origScript = getGsapScript(parsed.document); + const parsedGsap = origScript ? parseGsapScriptAcornForWrite(origScript) : null; + let currentScript = origScript; + for (const id of ids) { const el = findById(parsed.document, id); if (!el) continue; @@ -332,7 +338,32 @@ function handleSetTiming( result.inverse.push(p.inverse); el.setAttribute("data-track-index", String(newTrack)); } + + // Sync GSAP tween positions so GSAP-scripted and clip-model compositions stay + // consistent. The runtime stamps data-start/data-duration FROM the GSAP script, + // so without this the runtime would overwrite our attribute edits on next init. + if (parsedGsap && currentScript) { + for (const { id: animId, animation } of parsedGsap.located) { + const sel = animation.targetSelector; + if (sel !== `[data-hf-id="${id}"]` && sel !== `[data-hf-id='${id}']` && sel !== `#${id}`) + continue; + const updates: Partial = {}; + if (timing.start !== undefined && newStart !== null) updates.position = newStart; + if (timing.duration !== undefined && newDuration !== null) updates.duration = newDuration; + if (Object.keys(updates).length === 0) continue; + currentScript = updateAnimationInScript(currentScript, animId, updates); + } + } + } + + // Flush accumulated GSAP script changes as a single patch pair. + if (origScript && currentScript && currentScript !== origScript) { + setGsapScript(parsed.document, currentScript); + const gsapResult = gsapScriptChange(origScript, currentScript); + result.forward.push(...gsapResult.forward); + result.inverse.push(...gsapResult.inverse); } + return result; }