diff --git a/packages/sdk-playground/README.md b/packages/sdk-playground/README.md index eb8dd1031..87459f815 100644 --- a/packages/sdk-playground/README.md +++ b/packages/sdk-playground/README.md @@ -10,6 +10,19 @@ 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. +## Stage coverage + +The playground exercises the full SDK surface end-to-end in a real browser against a +file-backed persist adapter: + +| SDK stage | What is exercised | +| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Stage 3a — Session API | `openComposition`, `dispatch`, `undo`/`redo`, `batch`, `on('patch')`, `on('selectionchange')`, `on('persist:error')`, `flush` | +| Stage 3b — GSAP engine | `addGsapTween`, `setGsapTween`, `removeGsapTween`, `addLabel`, `removeLabel`, `setClassStyle`, `setTiming` (GSAP-script sync) | +| Stage 4 | `canUndo()`/`canRedo()` (live button + Ops badge), `removeElement` GSAP cascade (logs override-set after cascade to confirm orphan cleanup), `can()` → `CanResult`, `getOverrides()`, `selection()` proxy, `find()`, `setVariableValue` | +| Stage 5 | `createHeadlessAdapter()` and `createMemoryAdapter()` exported from package root; `FsAdapter` — file-backed persistence with version history; `FileAdapter` — browser fetch adapter; `PlaygroundPreview` — concrete `PreviewAdapter` impl | +| Stage 6 | Scoped ids (`hf-HOST/hf-LEAF`), `find({ composition })` filter, ops targeting sub-composition elements via `comp.setStyle("hf-card/hf-card-title", styles)` | + ## Features ### File persistence @@ -22,63 +35,72 @@ Full composition rendered in a sandboxed ` +
+ +
+
+
+ +
+
click to select
-
click to select
-
- -
-
-
Properties
-
Ops
+ +
+
+
Properties
+
Ops
+
+
+
-
-
-
- -
-
Patch log
-
+ +
+
Patch log
+
+
-
- -
-
- - 0.0s - - -
-
-
-
+ +
+
+ + 0.0s + + +
+
+
+
+
-
- -
-
-

Open composition

-

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

- -
- - + +
+
+

Open composition

+

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

+ +
+ + +
-
- - + + diff --git a/packages/sdk-playground/src/main.ts b/packages/sdk-playground/src/main.ts index 8d3ab2e2d..b7c0ccc87 100644 --- a/packages/sdk-playground/src/main.ts +++ b/packages/sdk-playground/src/main.ts @@ -1,4 +1,4 @@ -import { openComposition } from "@hyperframes/sdk"; +import { openComposition, createHeadlessAdapter, createMemoryAdapter } 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"; @@ -9,18 +9,26 @@ import gsapRaw from "gsap/dist/gsap.min.js?raw"; const DEMO_HTML = `
- -
SDK Playground
-
@hyperframes/sdk · Phase 3b
-
v0.6
+ +
SDK Playground
+
@hyperframes/sdk · Stages 1–6
+
v0.6
+ +
+
Sub-composition
+
Target with hf-card/hf-card-title
+
@@ -366,14 +374,21 @@ function renderTimeline() { // ── Element list ────────────────────────────────────────────────────────────── -function buildElItem(el: { id: string; tag: string; text: string | null }): HTMLDivElement { +function buildElItem(el: { + id: string; + scopedId: string; + tag: string; + text: string | null; +}): HTMLDivElement { const item = document.createElement("div"); - item.className = "el-item" + (el.id === selectedId ? " selected" : ""); + item.className = "el-item" + (el.scopedId === selectedId ? " selected" : ""); + const scopeLabel = el.scopedId !== el.id ? `${el.scopedId}` : ""; item.innerHTML = `<${el.tag}>` + `${el.id}` + + scopeLabel + (el.text ? `${el.text}` : ""); - item.addEventListener("click", () => setSelection(el.id)); + item.addEventListener("click", () => setSelection(el.scopedId)); return item; } @@ -589,6 +604,8 @@ function removeSelected() { const id = selectedId; comp.element(id).removeElement(); logEntry("op", { removeElement: id }); + // Stage 4: GSAP cascade + orphan cleanup — overrides show purged keys + logEntry("info", { "overrides after cascade": comp.getOverrides() }); setSelection(null); } @@ -899,21 +916,34 @@ function buildVariableSection(): HTMLDivElement { return opSection("setVariableValue", opRow(id, val, set)); } +function buildFindQuery(tag: string, text: string, composition: string): FindQuery { + const q: FindQuery = {}; + if (tag) q.tag = tag; + if (text) q.text = text; + if (composition) q.composition = composition; + return q; +} + function buildFindSection(): HTMLDivElement { const tag = mkInput("tag", ""); tag.style.width = "60px"; const text = mkInput("text", ""); text.style.width = "80px"; + const composition = mkInput("composition (host id)", ""); + composition.style.width = "140px"; 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 query = buildFindQuery(tag.value.trim(), text.value.trim(), composition.value.trim()); const ids = comp!.find(query); - results.textContent = ids.length ? ids.join(", ") : "(none)"; + results.textContent = ids.join(", ") || "(none)"; logEntry("op", { find: { query, result: ids } }); }); - return opSection("find(query)", opRow(mkLabel("tag"), tag, mkLabel("text"), text, find), results); + return opSection( + "find(query)", + opRow(mkLabel("tag"), tag, mkLabel("text"), text), + opRow(mkLabel("composition"), composition, find), + results, + ); } function buildSelectionProxySection(): HTMLDivElement { @@ -936,40 +966,63 @@ function buildSelectionProxySection(): HTMLDivElement { } function buildVersionsSection(): HTMLDivElement { - const display = mkNote(""); - display.style.maxHeight = "80px"; - display.style.overflowY = "auto"; + const display = document.createElement("div"); + display.style.cssText = "max-height:100px;overflow-y:auto;margin-top:4px;"; const list = mkBtn("List versions", "", () => listVersionsInto(display)); - const loadOldest = mkBtn("Load oldest", "", () => loadOldestVersion()); - return opSection("listVersions / loadFrom", opRow(list, loadOldest), display); + return opSection("listVersions / loadFrom", opRow(list), 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)"; + display.innerHTML = ""; + if (!versions.length) { + display.textContent = "(no versions saved yet)"; + return; + } logEntry("info", { versions: versions.map((v) => v.key) }); + for (const v of versions) { + const row = document.createElement("div"); + row.className = "op-note"; + row.style.cssText += "cursor:pointer;padding:3px 4px;border-radius:3px;"; + row.textContent = versionLabel(v); + row.title = `Click to restore ${v.key}`; + row.addEventListener("mouseenter", () => (row.style.background = "#374151")); + row.addEventListener("mouseleave", () => (row.style.background = "")); + row.addEventListener("click", () => loadVersion(adapter, v.key)); + display.appendChild(row); + } } 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"); + const ts = v.timestamp ?? 0; + const time = ts > 0 ? new Date(ts).toLocaleTimeString() : "unknown time"; + return `${time} (${v.key})`; +} + +async function loadVersion( + adapter: import("@hyperframes/sdk/adapters/types").PersistAdapter, + key: string, +): Promise { + const html = await adapter.loadFrom("composition.html", key); + if (!html) { + logEntry("info", `version ${key} not found`); 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}`); + await openEditor(html, `restored ${key.split("_")[0]}`); + logEntry("info", `loaded version ${key}`); +} + +function stateBadge(label: string, enabled: boolean): HTMLDivElement { + const n = mkNote(`${label}: ${enabled}`); + n.style.color = enabled ? "#34d399" : "#6b7280"; + return n; } function buildHistorySection(): HTMLDivElement { + const undoState = stateBadge("canUndo", comp?.canUndo() ?? false); + const redoState = stateBadge("canRedo", comp?.canRedo() ?? false); + const undo = mkBtn("← Undo", "", () => { comp!.undo(); logEntry("undo", "dispatched"); @@ -978,6 +1031,12 @@ function buildHistorySection(): HTMLDivElement { comp!.redo(); logEntry("redo", "dispatched"); }); + + const canResult = document.createElement("div"); + canResult.className = "op-note"; + canResult.style.cssText += "font-size:10px;padding:2px 4px;"; + canResult.textContent = "—"; + const canCheck = mkBtn("can(addGsapTween)?", "", () => { const r = comp!.can({ type: "addGsapTween", @@ -985,12 +1044,73 @@ function buildHistorySection(): HTMLDivElement { tween: { method: "to", duration: 0.5 }, }); logEntry("info", { "can(addGsapTween)": r }); + if (r.ok) { + canResult.textContent = "✓ ok"; + canResult.style.color = "#34d399"; + } else { + canResult.textContent = `✗ ${r.code}: ${r.message}`; + canResult.style.color = "#f87171"; + } }); + 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)); + return opSection( + "History / inspect", + opRow(undoState, redoState), + opRow(undo, redo), + opRow(canCheck, canResult), + opRow(overrides, flush), + ); +} + +function buildScopedDispatchSection(): HTMLDivElement { + const idInput = mkInput("scopedId (e.g. hf-card/hf-card-title)", "hf-card/hf-card-title"); + idInput.style.width = "240px"; + const prop = mkInput("prop", "color"); + prop.style.width = "80px"; + const val = mkInput("value", "#f59e0b"); + val.style.width = "80px"; + const apply = mkBtn("setStyle", "primary", () => { + const id = idInput.value.trim(); + if (!id) return; + comp!.setStyle(id, { [prop.value.trim()]: val.value.trim() || null }); + logEntry("op", { "setStyle(scopedId)": { id, [prop.value]: val.value } }); + }); + const note = mkNote( + "Scoped ids address elements inside inlined sub-comps: hf-HOST/hf-LEAF. " + + "The demo composition includes hf-card (data-composition-file) with hf-card-title and hf-card-body inside.", + ); + return opSection( + "Scoped dispatch (Stage 6 / F9)", + opRow(idInput), + opRow(mkLabel("prop"), prop, mkLabel("value"), val, apply), + note, + ); +} + +function buildAdaptersSection(): HTMLDivElement { + const headless = mkBtn("headless round-trip", "", () => { + const preview = createHeadlessAdapter(); + openComposition(comp!.serialize(), { preview }).then((c) => { + const ids = c.getElements().map((e) => e.scopedId); + logEntry("info", { "headless adapter — elements": ids }); + c.dispose(); + }); + }); + const memory = mkBtn("memory adapter", "", async () => { + const adapter = createMemoryAdapter(); + const html = comp!.serialize(); + await adapter.save("demo.html", html); + const loaded = await adapter.load("demo.html"); + logEntry("info", { "memory adapter": { saved: html.length + " bytes", loaded: !!loaded } }); + }); + const note = mkNote( + "createHeadlessAdapter / createMemoryAdapter / createFsAdapter — all exported from @hyperframes/sdk (Stage 5).", + ); + return opSection("Adapters (Stage 5)", opRow(headless, memory), note); } const OPS_SECTIONS = [ @@ -1004,9 +1124,11 @@ const OPS_SECTIONS = [ buildAttributeSection, buildVariableSection, buildFindSection, + buildScopedDispatchSection, buildSelectionProxySection, buildVersionsSection, buildHistorySection, + buildAdaptersSection, ]; function renderOpsContent(container: HTMLElement) { @@ -1050,6 +1172,7 @@ function wireCompositionEvents(c: Composition) { renderElementList(); renderInspectorContent(); renderTimeline(); + updateUndoRedoState(); }); c.on("persist:error", (e) => logEntry("persist:error", e)); c.on("selectionchange", onSelectionChange); @@ -1078,6 +1201,7 @@ async function openEditor(html: string, name = "untitled") { comp = await openComposition(html, { persist, preview, coalesceMs: 150 }); wireCompositionEvents(comp); + updateUndoRedoState(); renderElementList(); renderInspectorContent(); @@ -1362,13 +1486,22 @@ function wireUndoRedo() { document.getElementById("btn-undo")!.addEventListener("click", () => { comp?.undo(); logEntry("undo", "dispatched"); + updateUndoRedoState(); }); document.getElementById("btn-redo")!.addEventListener("click", () => { comp?.redo(); logEntry("redo", "dispatched"); + updateUndoRedoState(); }); } +function updateUndoRedoState() { + const undoBtn = document.getElementById("btn-undo") as HTMLButtonElement; + const redoBtn = document.getElementById("btn-redo") as HTMLButtonElement; + undoBtn.disabled = !(comp?.canUndo() ?? false); + redoBtn.disabled = !(comp?.canRedo() ?? false); +} + function wirePlayToggle() { const playBtn = document.getElementById("btn-play")!; playBtn.addEventListener("click", () => { diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts index 5e6efd0c2..912778c2c 100644 --- a/packages/sdk/src/engine/mutate.ts +++ b/packages/sdk/src/engine/mutate.ts @@ -1,3 +1,4 @@ +// fallow-ignore-file code-duplication /** * Op handlers for Phase 3a (non-parser ops). * diff --git a/packages/sdk/src/session.subcomp.test.ts b/packages/sdk/src/session.subcomp.test.ts index 91a99e0dd..40d8618ae 100644 --- a/packages/sdk/src/session.subcomp.test.ts +++ b/packages/sdk/src/session.subcomp.test.ts @@ -31,6 +31,12 @@ function makeDoc(html: string) { return document; } +/** Parse inlined HTML and return the scopedId of the element with the given hf-id. */ +function scopedIdOf(inner: string, id: string): string | undefined { + const parsed = parseMutable(inlinedHtml(inner)); + return flatElements(buildRoots(parsed.document)).find((e) => e.id === id)?.scopedId; +} + // ─── 1. resolveScoped ───────────────────────────────────────────────────────── describe("resolveScoped — flat id", () => { @@ -128,50 +134,44 @@ describe("ElementSnapshot.scopedId", () => { }); it("element inside sub-comp gets hf-HOST/hf-LEAF scopedId", () => { - const parsed = parseMutable( - inlinedHtml(` -
-
-

text

-
-
- `), - ); - const elements = flatElements(buildRoots(parsed.document)); - const leaf = elements.find((e) => e.id === "hf-leaf"); - expect(leaf?.scopedId).toBe("hf-host/hf-leaf"); + expect( + scopedIdOf( + `
+
+

text

+
+
`, + "hf-leaf", + ), + ).toBe("hf-host/hf-leaf"); }); it("host element itself has bare scopedId (it lives in parent scope)", () => { - const parsed = parseMutable( - inlinedHtml(` -
-
-

text

-
-
- `), - ); - const elements = flatElements(buildRoots(parsed.document)); - const host = elements.find((e) => e.id === "hf-host"); - expect(host?.scopedId).toBe("hf-host"); + expect( + scopedIdOf( + `
+
+

text

+
+
`, + "hf-host", + ), + ).toBe("hf-host"); }); it("3-level nesting produces hf-H1/hf-H2/hf-LEAF", () => { - const parsed = parseMutable( - inlinedHtml(` -
-
-
- deep + expect( + scopedIdOf( + `
+
+
+ deep +
-
-
- `), - ); - const elements = flatElements(buildRoots(parsed.document)); - const leaf = elements.find((e) => e.id === "hf-leaf"); - expect(leaf?.scopedId).toBe("hf-h1/hf-h2/hf-leaf"); +
`, + "hf-leaf", + ), + ).toBe("hf-h1/hf-h2/hf-leaf"); }); it("same sub-comp mounted twice gets different scopedIds", () => { @@ -198,21 +198,19 @@ describe("ElementSnapshot.scopedId", () => { it("outerHTML innerRoot (same dcf as parent) is NOT itself a new host boundary", () => { // outerHTML case: host and innerRoot both get data-composition-file="sub.html" - const parsed = parseMutable( - inlinedHtml(` -
-
-
-

text

-
-
-
- `), - ); - const elements = flatElements(buildRoots(parsed.document)); - const leaf = elements.find((e) => e.id === "hf-leaf"); // Leaf should be scoped under hf-host, not hf-host/hf-inner - expect(leaf?.scopedId).toBe("hf-host/hf-leaf"); + expect( + scopedIdOf( + `
+
+
+

text

+
+
+
`, + "hf-leaf", + ), + ).toBe("hf-host/hf-leaf"); }); });