From 484ab54442b2294ab9590356533e3d6d18951dd2 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 3 May 2026 00:33:16 +0000 Subject: [PATCH 1/2] feat(core): scope getVariables() per sub-comp instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Building on PR 1's getVariables() helper, this PR routes per-instance values into the correct sub-composition. Same composition source can now be embedded N times with different content via data-variable-values on each host element. How it works: - compositionLoader, before injecting wrapped scripts, layers the host element's data-variable-values JSON over the sub-comp's declared defaults (its own data-composition-variables) and writes the merged object to window.__hfVariablesByComp[compositionId]. Skipped when both sides are empty so the table only grows for instances that actually carry values. - compositionScoping's wrapper IIFE now takes a fourth parameter __hyperframes alongside the existing scoped document/gsap/window. The scoped __hyperframes shadows getVariables() to read from __hfVariablesByComp[__hfCompId], returning a fresh object each call so script mutations don't leak into the shared table. - Top-level scripts (not wrapped by compositionScoping) keep using the unscoped window.__hyperframes.getVariables(), which reads data-composition-variables defaults plus the CLI override (window.__hfVariables) — same path as PR 1. - readDeclaredDefaults is exported from getVariables.ts so the loader reuses the exact same defaults-extraction logic the helper uses for the top-level path. Inline templates (no separate document root) get host overrides only — no declared defaults — since there's no separate to read data-composition-variables from. External sub-comps fetched via data-composition-src get the full declared defaults + host overrides merge. Tests: 3 new compositionScoping tests covering scoped getVariables invocation, missing-entry fallback, and mutation isolation. 5 new compositionLoader tests covering merge order, declared-only path, empty-skip, invalid-host-JSON resilience, and per-instance scoping across two hosts sharing a source. 3 new getVariables tests covering the newly-public readDeclaredDefaults. All 622 core tests green. Docs: docs/concepts/compositions.mdx switched its sub-comp example from hand-rolled JSON.parse(host.dataset.variableValues) to the new __hyperframes.getVariables() pattern. data-attributes.mdx clarifies per-instance scoping behavior. This is PR 2 of a 4-PR stack. PR 3 adds schema validation + lint; PR 4 ships skill / scaffold updates. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/concepts/compositions.mdx | 76 +++++----- docs/concepts/data-attributes.mdx | 4 +- .../src/compiler/compositionScoping.test.ts | 70 +++++++++ .../core/src/compiler/compositionScoping.ts | 14 +- .../src/runtime/compositionLoader.test.ts | 138 ++++++++++++++++++ .../core/src/runtime/compositionLoader.ts | 42 ++++++ .../core/src/runtime/getVariables.test.ts | 29 +++- packages/core/src/runtime/getVariables.ts | 17 ++- packages/core/src/runtime/window.d.ts | 9 ++ 9 files changed, 359 insertions(+), 40 deletions(-) diff --git a/docs/concepts/compositions.mdx b/docs/concepts/compositions.mdx index 5c9dbfa26..b9e698a40 100644 --- a/docs/concepts/compositions.mdx +++ b/docs/concepts/compositions.mdx @@ -129,52 +129,62 @@ Every composition has two layers: HyperFrames does not automatically bind `data-var-*` attributes into your composition DOM or CSS. -Today, the supported pattern is: +The supported pattern is: -1. Pass per-instance values on the composition host with `data-variable-values` -2. Read those values inside the composition and apply them in your own script +1. Declare the variables once on the sub-comp's `` root with `data-composition-variables` (id + type + default). +2. Pass per-instance values on each composition host with `data-variable-values`. +3. Read the resolved values inside the composition with `window.__hyperframes.getVariables()`. The runtime layers the host's `data-variable-values` over the declared defaults on a per-instance basis, so the same source can be embedded multiple times with different values. ```html index.html
+
``` ```html compositions/card.html - + + +
+

+ + + + +
+ + ``` -If you are building tooling on top of `@hyperframes/core`, you can also declare variable metadata separately with `data-composition-variables` and read it via `extractCompositionMetadata()`. That metadata is descriptive only; you still apply the actual values manually inside the composition. +If you are building tooling on top of `@hyperframes/core`, the same `data-composition-variables` array is readable via `extractCompositionMetadata()` for Studio editing UI and analysis pipelines. ## Listing Compositions diff --git a/docs/concepts/data-attributes.mdx b/docs/concepts/data-attributes.mdx index 064b55aed..ed1caaff1 100644 --- a/docs/concepts/data-attributes.mdx +++ b/docs/concepts/data-attributes.mdx @@ -29,8 +29,8 @@ Hyperframes uses HTML data attributes to control timing, media playback, and [co | `data-width` | `"1920"` | Composition width in pixels | | `data-height` | `"1080"` | Composition height in pixels | | `data-composition-src` | `"./intro.html"` | Path to external [composition](/concepts/compositions) HTML file | -| `data-variable-values` | `'{"title":"Hello"}'` | JSON object of values passed to a nested composition. HyperFrames carries these values through, but your composition script must read and apply them manually. | -| `data-composition-variables` | `'[{"id":"title","type":"string","label":"Title","default":"Hello"}]'` | JSON array of declared variables (`id`, `type`, `label`, `default`). Drives Studio editing UI and provides defaults read by `window.__hyperframes.getVariables()`. The CLI flag `hyperframes render --variables ''` overrides these defaults at render time. | +| `data-variable-values` | `'{"title":"Hello"}'` | JSON object of values passed to a nested composition. Inside the sub-composition, read them via `window.__hyperframes.getVariables()` — the runtime layers these over the sub-comp's own `data-composition-variables` defaults and exposes the merged result on a per-instance basis (the same source can be embedded multiple times with different values). | +| `data-composition-variables` | `'[{"id":"title","type":"string","label":"Title","default":"Hello"}]'` | JSON array of declared variables (`id`, `type`, `label`, `default`). Drives Studio editing UI and provides defaults read by `window.__hyperframes.getVariables()`. The CLI flag `hyperframes render --variables ''` overrides these defaults at top-level render time; host elements override them per-instance via `data-variable-values`. | ## Element Visibility diff --git a/packages/core/src/compiler/compositionScoping.test.ts b/packages/core/src/compiler/compositionScoping.test.ts index 95110af8c..1c01100c0 100644 --- a/packages/core/src/compiler/compositionScoping.test.ts +++ b/packages/core/src/compiler/compositionScoping.test.ts @@ -51,6 +51,76 @@ body { margin: 0; } expect(scoped).not.toContain('[data-start="0"]'); }); + it("exposes a scoped __hyperframes.getVariables that reads __hfVariablesByComp[compId]", () => { + const { document } = parseHTML(`
`); + const fakeWindow: Record = { + document, + __timelines: {}, + __hfVariablesByComp: { + "card-1": { title: "Pro", price: "$29" }, + "card-2": { title: "Enterprise", price: "Custom" }, + }, + __hyperframes: { + getVariables: () => ({ title: "TOP-LEVEL-LEAK" }), + fitTextFontSize: () => undefined, + }, + __captured: undefined as unknown, + }; + const wrapped = wrapScopedCompositionScript( + `window.__captured = __hyperframes.getVariables();`, + "card-1", + ); + + new Function("window", wrapped)(fakeWindow); + + expect(fakeWindow.__captured).toEqual({ title: "Pro", price: "$29" }); + }); + + it("scoped getVariables returns {} when __hfVariablesByComp has no entry for the comp", () => { + const { document } = parseHTML(`
`); + const fakeWindow: Record = { + document, + __timelines: {}, + __hyperframes: { + getVariables: () => ({ title: "TOP-LEVEL-LEAK" }), + fitTextFontSize: () => undefined, + }, + __captured: undefined as unknown, + }; + const wrapped = wrapScopedCompositionScript( + `window.__captured = __hyperframes.getVariables();`, + "missing", + ); + + new Function("window", wrapped)(fakeWindow); + + expect(fakeWindow.__captured).toEqual({}); + }); + + it("scoped getVariables returns a fresh object — mutations don't leak into the shared table", () => { + const { document } = parseHTML(`
`); + const variablesByComp: Record> = { + "card-1": { title: "Pro" }, + }; + const fakeWindow: Record = { + document, + __timelines: {}, + __hfVariablesByComp: variablesByComp, + __hyperframes: { + getVariables: () => ({}), + fitTextFontSize: () => undefined, + }, + }; + const wrapped = wrapScopedCompositionScript( + `var v = __hyperframes.getVariables(); v.title = "MUTATED"; v.added = "extra";`, + "card-1", + ); + + new Function("window", wrapped)(fakeWindow); + + expect(variablesByComp["card-1"]).toEqual({ title: "Pro" }); + }); + it("executes document and GSAP selectors inside the composition root", () => { const { document } = parseHTML(`

Scene

diff --git a/packages/core/src/compiler/compositionScoping.ts b/packages/core/src/compiler/compositionScoping.ts index 34af7ba26..ea3c55953 100644 --- a/packages/core/src/compiler/compositionScoping.ts +++ b/packages/core/src/compiler/compositionScoping.ts @@ -261,11 +261,21 @@ export function wrapScopedCompositionScript( return typeof value === "function" ? value.bind(target) : value; }, }); + var __hfBaseHyperframes = window.__hyperframes; + var __hfScopedHyperframes = !__hfBaseHyperframes + ? __hfBaseHyperframes + : Object.assign({}, __hfBaseHyperframes, { + getVariables: function() { + var byComp = window.__hfVariablesByComp; + var scoped = byComp && __hfCompId ? byComp[__hfCompId] : null; + return scoped ? Object.assign({}, scoped) : {}; + }, + }); var __hfRun = function() { try { - (function(document, gsap, window) { + (function(document, gsap, window, __hyperframes) { ${source} - }).call(window, __hfScopedDocument, __hfScopedGsap, __hfScopedWindow); + }).call(window, __hfScopedDocument, __hfScopedGsap, __hfScopedWindow, __hfScopedHyperframes); } catch (_err) { console.error(__hfErrorLabel, __hfCompId, _err); } diff --git a/packages/core/src/runtime/compositionLoader.test.ts b/packages/core/src/runtime/compositionLoader.test.ts index 6bcc2bcb0..f5ce2312c 100644 --- a/packages/core/src/runtime/compositionLoader.test.ts +++ b/packages/core/src/runtime/compositionLoader.test.ts @@ -264,6 +264,144 @@ describe("loadExternalCompositions", () => { expect(host1.querySelector("p")?.textContent).toBe("A"); expect(host2.querySelector("p")?.textContent).toBe("B"); }); + + describe("variable scoping (window.__hfVariablesByComp)", () => { + type WindowWithScopedVars = Window & { + __hfVariablesByComp?: Record>; + }; + + afterEach(() => { + delete (window as WindowWithScopedVars).__hfVariablesByComp; + }); + + it("merges sub-comp declared defaults with host data-variable-values", async () => { + const host = document.createElement("div"); + host.setAttribute("data-composition-src", "https://example.com/card.html"); + host.setAttribute("data-composition-id", "card-1"); + host.setAttribute("data-variable-values", '{"title":"Pro","price":"$29"}'); + document.body.appendChild(host); + + const compositionHtml = ` + + +

card

+ + + `; + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(compositionHtml, { status: 200 }), + ); + + await loadExternalCompositions({ ...defaultParams }); + + const byComp = (window as WindowWithScopedVars).__hfVariablesByComp ?? {}; + expect(byComp["card-1"]).toEqual({ + title: "Pro", // host wins over declared default + price: "$29", // host wins + theme: "light", // host omits → declared default falls through + }); + }); + + it("uses declared defaults when host has no data-variable-values", async () => { + const host = document.createElement("div"); + host.setAttribute("data-composition-src", "https://example.com/card.html"); + host.setAttribute("data-composition-id", "card-2"); + document.body.appendChild(host); + + const compositionHtml = ` + +

x

+ + `; + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(compositionHtml, { status: 200 }), + ); + + await loadExternalCompositions({ ...defaultParams }); + + const byComp = (window as WindowWithScopedVars).__hfVariablesByComp ?? {}; + expect(byComp["card-2"]).toEqual({ title: "Default Title" }); + }); + + it("skips registration when neither declared defaults nor host overrides exist", async () => { + const host = document.createElement("div"); + host.setAttribute("data-composition-src", "https://example.com/card.html"); + host.setAttribute("data-composition-id", "card-empty"); + document.body.appendChild(host); + + const compositionHtml = ` +

x

+ `; + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(compositionHtml, { status: 200 }), + ); + + await loadExternalCompositions({ ...defaultParams }); + + const byComp = (window as WindowWithScopedVars).__hfVariablesByComp; + expect(byComp?.["card-empty"]).toBeUndefined(); + }); + + it("ignores invalid JSON in host data-variable-values", async () => { + const host = document.createElement("div"); + host.setAttribute("data-composition-src", "https://example.com/card.html"); + host.setAttribute("data-composition-id", "card-bad"); + host.setAttribute("data-variable-values", "{not json"); + document.body.appendChild(host); + + const compositionHtml = ` + +

x

+ + `; + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(compositionHtml, { status: 200 }), + ); + + await loadExternalCompositions({ ...defaultParams }); + + const byComp = (window as WindowWithScopedVars).__hfVariablesByComp ?? {}; + expect(byComp["card-bad"]).toEqual({ title: "OK" }); + }); + + it("registers per-instance entries for multiple sub-comps with the same source", async () => { + const host1 = document.createElement("div"); + host1.setAttribute("data-composition-src", "https://example.com/card.html"); + host1.setAttribute("data-composition-id", "card-A"); + host1.setAttribute("data-variable-values", '{"title":"Pro","price":"$29"}'); + document.body.appendChild(host1); + + const host2 = document.createElement("div"); + host2.setAttribute("data-composition-src", "https://example.com/card.html"); + host2.setAttribute("data-composition-id", "card-B"); + host2.setAttribute("data-variable-values", '{"title":"Enterprise","price":"Custom"}'); + document.body.appendChild(host2); + + const compositionHtml = ` + +

x

+ + `; + vi.spyOn(globalThis, "fetch").mockImplementation( + async () => new Response(compositionHtml, { status: 200 }), + ); + + await loadExternalCompositions({ ...defaultParams }); + + const byComp = (window as WindowWithScopedVars).__hfVariablesByComp ?? {}; + expect(byComp["card-A"]).toEqual({ title: "Pro", price: "$29" }); + expect(byComp["card-B"]).toEqual({ title: "Enterprise", price: "Custom" }); + }); + }); }); describe("loadInlineTemplateCompositions", () => { diff --git a/packages/core/src/runtime/compositionLoader.ts b/packages/core/src/runtime/compositionLoader.ts index df484f108..6c87119a7 100644 --- a/packages/core/src/runtime/compositionLoader.ts +++ b/packages/core/src/runtime/compositionLoader.ts @@ -1,4 +1,5 @@ import { scopeCssToComposition, wrapScopedCompositionScript } from "../compiler/compositionScoping"; +import { readDeclaredDefaults } from "./getVariables"; type LoadExternalCompositionsParams = { injectedStyles: HTMLStyleElement[]; @@ -73,6 +74,19 @@ function resolveScriptSourceUrl(scriptSrc: string, compositionUrl: URL | null): } } +function parseHostVariableValues(host: Element): Record { + const raw = host.getAttribute("data-variable-values"); + if (!raw) return {}; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return {}; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; + return parsed as Record; +} + async function mountCompositionContent(params: { host: Element; hostCompositionId: string | null; @@ -88,6 +102,15 @@ async function mountCompositionContent(params: { headStyles?: HTMLStyleElement[]; /** Extra