Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 43 additions & 33 deletions docs/concepts/compositions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<html>` 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
<div
data-composition-id="card"
data-composition-id="card-pro"
data-composition-src="compositions/card.html"
data-start="0"
data-track-index="1"
data-variable-values='{"title":"Hello","color":"#ff4d4f"}'
data-variable-values='{"title":"Pro","color":"#ff4d4f"}'
></div>
<div
data-composition-id="card-enterprise"
data-composition-src="compositions/card.html"
data-start="card-pro"
data-track-index="1"
data-variable-values='{"title":"Enterprise","color":"#22c55e"}'
></div>
```

```html compositions/card.html
<template id="card-template">
<div data-composition-id="card" data-width="1920" data-height="1080">
<h1 class="title">Fallback</h1>

<style>
[data-composition-id="card"] {
--card-color: #111827;
}

[data-composition-id="card"] .title {
color: var(--card-color);
}
</style>

<script>
const root = document.querySelector('[data-composition-id="card"]');
const vars = JSON.parse(root?.getAttribute("data-variable-values") ?? "{}");
const titleEl = root?.querySelector(".title");

if (titleEl) {
titleEl.textContent = vars.title ?? "Fallback";
}

root?.style.setProperty("--card-color", String(vars.color ?? "#111827"));
</script>
</div>
</template>
<html data-composition-variables='[
{"id":"title","type":"string","label":"Title","default":"Fallback"},
{"id":"color","type":"color","label":"Color","default":"#111827"}
]'>
<body>
<div data-composition-id="card" data-width="1920" data-height="1080">
<h1 class="title"></h1>

<style>
[data-composition-id="card"] {
--card-color: #111827;
}

[data-composition-id="card"] .title {
color: var(--card-color);
}
</style>

<script>
// Inside a sub-comp script, getVariables() returns the per-instance
// values: declared defaults < host data-variable-values overrides.
const { title, color } = __hyperframes.getVariables();
const root = document.querySelector('[data-composition-id="card"]');
root.querySelector(".title").textContent = title;
root.style.setProperty("--card-color", color);
</script>
</div>
</body>
</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

Expand Down
4 changes: 2 additions & 2 deletions docs/concepts/data-attributes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<json>'` 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 '<json>'` overrides these defaults at top-level render time; host elements override them per-instance via `data-variable-values`. |

## Element Visibility

Expand Down
68 changes: 68 additions & 0 deletions packages/core/src/compiler/compositionScoping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,74 @@ body { margin: 0; }
expect(scoped).not.toContain('[data-start="0"]');
});

it("exposes a scoped __hyperframes.getVariables that reads __hfVariablesByComp[compId]", () => {
const { document } = parseHTML(`<div data-composition-id="card-1"></div>`);
const fakeWindow: Record<string, unknown> = {
document,
__timelines: {},
__hfVariablesByComp: {
"card-1": { title: "Pro", price: "$29" },
"card-2": { title: "Enterprise", price: "Custom" },
},
__hyperframes: {
getVariables: () => ({ title: "TOP-LEVEL-LEAK" }),
fitTextFontSize: () => undefined,
},
};
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(`<div data-composition-id="missing"></div>`);
const fakeWindow: Record<string, unknown> = {
document,
__timelines: {},
__hyperframes: {
getVariables: () => ({ title: "TOP-LEVEL-LEAK" }),
fitTextFontSize: () => undefined,
},
};
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(`<div data-composition-id="card-1"></div>`);
const variablesByComp: Record<string, Record<string, unknown>> = {
"card-1": { title: "Pro" },
};
const fakeWindow: Record<string, unknown> = {
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(`
<div data-composition-id="scene" data-start="intro"><h1 class="title">Scene</h1></div>
Expand Down
14 changes: 12 additions & 2 deletions packages/core/src/compiler/compositionScoping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
138 changes: 138 additions & 0 deletions packages/core/src/runtime/compositionLoader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Record<string, unknown>>;
};

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 = `
<html data-composition-variables='[
{"id":"title","type":"string","label":"Title","default":"Default"},
{"id":"price","type":"string","label":"Price","default":"$0"},
{"id":"theme","type":"string","label":"Theme","default":"light"}
]'>
<body>
<div data-composition-id="card-1"><p>card</p></div>
</body>
</html>
`;
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 = `
<html data-composition-variables='[
{"id":"title","type":"string","label":"Title","default":"Default Title"}
]'>
<body><div data-composition-id="card-2"><p>x</p></div></body>
</html>
`;
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 = `
<html><body><div data-composition-id="card-empty"><p>x</p></div></body></html>
`;
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 = `
<html data-composition-variables='[{"id":"title","type":"string","label":"Title","default":"OK"}]'>
<body><div data-composition-id="card-bad"><p>x</p></div></body>
</html>
`;
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 = `
<html data-composition-variables='[
{"id":"title","type":"string","label":"Title","default":"Default"},
{"id":"price","type":"string","label":"Price","default":"$0"}
]'>
<body><div data-composition-id="card-A"><p>x</p></div></body>
</html>
`;
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", () => {
Expand Down
Loading
Loading