fix(core,sdk): namespace composition variables so they stop shadowing theme tokens

A declared composition variable was written to the composition root as a CSS
custom property named after its own id, with no namespace. A variable called
accent therefore set --accent inline and shadowed the host theme for that
whole subtree.

That is worse than a naming clash. The accent enum values are green, blue and
violet, which are not colours, they are selectors a composition maps onto
theme slots. A representative composition maps blue to var(--accent, #18181b).
The runtime then set --accent to blue, so the lookup resolved to the CSS
keyword and no host theme could win. green and violet escaped only because
they route to --brand and --accent-2, which nothing shadowed, which is why
this survived: it was invisible for two of three values.

Variables are now written to --hf-var-<slug>. The bare name is still written
as a deprecated alias, but only for ids that are not reserved theme tokens,
which is what actually fixes the collision.

Four writers had the bug, not one: the runtime bindings, the scoped
getVariables path, the compiler stylesheet, and the SDK mutate and
apply-patches path. Fixing only the runtime left the compiler emitting the
bare name into compiled output, where a host theme supplied as an inline
style attribute still rendered the keyword. All four now route through one
helper, and the helper takes the raw id so callers cannot derive the name
themselves.

That last point closed a real defect rather than a tidy-up. Two sites derived
the property name differently, one verbatim and one slugged, so an id like
Accent produced two disjoint property sets: one path reserved it, the other
aliased it, and an SDK edit silently never landed. A test pins that every
injection path derives one name per id.

Docs that taught binding the bare name are corrected, including the capstone,
whose ink variable is reserved and would have re-skinned the ground while
quietly ignoring the ink. Rendered output is unchanged there, so the published
videos stay accurate.
This commit is contained in:
Miguel Ángel
2026-08-09 17:45:02 +00:00
parent adb13ce125
commit e6b8e396f4
24 changed files with 777 additions and 79 deletions
+48
View File
@@ -8,6 +8,54 @@ Recent HyperFrames releases, including user-facing features, fixes, and migratio
{/* New release entries are prepended by `bun run changelog:draft <version> --write`. */}
<Update
label="Unreleased"
description="Migration note"
tags={["Migration", "Core"]}
>
## Composition variables no longer take over theme token names
A declared composition variable is now written to the CSS custom property
`--hf-var-<slug>` instead of the bare `--<slug>`.
**Reserved theme-token ids change immediately, with no deprecation period.**
Fifteen names belong to the page hosting a composition: `accent`, `accent-2`,
`accent-3`, `accent2`, `bg`, `border`, `brand`, `fg`, `ink`, `muted`,
`primary`, `secondary`, `surface`, `tertiary`, `text`. A variable using one of
those ids is now written only to `--hf-var-<slug>`, never to the bare name. The
old behaviour was already broken rather than useful: a variable declared
`accent` overwrote the very `--accent` token the composition was trying to
read, so a composition mapping the value `blue` onto `var(--accent, #18181b)`
rendered the CSS keyword `blue`, and no host theme could win it back.
**Every other id keeps the bare alias for one deprecation cycle.** A variable
called `speed` still writes both `--hf-var-speed` and `--speed`. The bare
alias is deprecated and will be removed in a future release.
### Are you affected
You are affected if a composition binds `var(--<id>)` for an id it declares in
`data-composition-variables`. List the custom properties your compositions
read, then compare that list against your declared ids:
```bash
grep -rho 'var(--[A-Za-z0-9_-]*' . --include='*.html' | sort -u
```
Any match on a reserved name in the list above stops resolving to the declared
value once you upgrade. Any other match still resolves, through the deprecated
alias.
### What to change
Rewrite the binding to the namespaced name: `var(--accent)` becomes
`var(--hf-var-accent)`. If the value should follow a host theme when one is
present and fall back to the declared variable otherwise, write
`var(--accent, var(--hf-var-accent))` instead. Declarations, `--variables`
overrides, `data-variable-values`, `data-var-text`, `data-var-src`, and
`getVariables()` are unchanged; only the CSS custom property name moves.
</Update>
<Update
label="HyperFrames v0.7.103"
description="Released - 2026-08-09"
+23 -3
View File
@@ -86,15 +86,35 @@ Use direct bindings for the normal cases:
<style>
.card-title {
color: var(--accent);
color: var(--hf-var-accent);
}
</style>
```
- `data-var-text` replaces the element’s own text.
- `data-var-src` replaces an image, video, audio, or source URL.
- Scalar variables are available as CSS custom properties such as
`var(--accent)`.
- Scalar variables are available as CSS custom properties named
`--hf-var-<slug>`, so the `accent` variable above is read as
`var(--hf-var-accent)`.
The slug is the id lowercased, with anything that is not a letter, digit or
hyphen replaced by a hyphen. So `accentColor` is readable as
`var(--hf-var-accentcolor)`, and `swap_at` as `var(--hf-var-swap-at)`.
<Note>
**How a variable is named in CSS.** Every scalar variable is written to
`--hf-var-<slug>`. It is also written to the bare `--<slug>`, but only when the id
is not one of the fifteen reserved theme-token names: `accent`, `accent-2`,
`accent-3`, `accent2`, `bg`, `border`, `brand`, `fg`, `ink`, `muted`,
`primary`, `secondary`, `surface`, `tertiary`, `text`. Those names belong to
the page hosting the composition, so a variable never writes them. A variable
called `accent` cannot shadow the host theme's `--accent`.
The bare `--<slug>` alias is deprecated and will be removed in a future release.
Bind `var(--hf-var-<slug>)` in new work. When a value should follow the host
theme if there is one and fall back to the declared variable otherwise, write
`var(--accent, var(--hf-var-accent))`.
</Note>
Use `window.__hyperframes.getVariables()` only when the result needs conditions,
loops, or derived values:
+5 -2
View File
@@ -30,7 +30,7 @@ example exposes a headline and accent color:
data-height="1080"
data-duration="5"
data-no-timeline
style="color:var(--accent)"
style="color:var(--hf-var-accent)"
>
<h1 data-var-text="title">Welcome</h1>
</div>
@@ -38,7 +38,10 @@ example exposes a headline and accent color:
</html>
```
See [Variables](/concepts/variables) for every type and binding method.
A scalar variable is exposed as the CSS custom property `--hf-var-<slug>`, which
is why the stage binds `var(--hf-var-accent)` rather than `var(--accent)`. See
[Variables](/concepts/variables) for every type and binding method, and for the
reserved theme-token names a variable never writes.
## Test locally
+8 -1
View File
@@ -97,7 +97,7 @@ That is the same catch-and-tighten loop [Iterating](/prompting/iterating) teache
>
> **Technique, pinned:** continuous world-camera via GSAP transforms (the dolly is the film); parallax layers in every region; `data-chart`, `world-map`, `sdf-iris`, and a caption component adapted from the registry; per-character typing at the open; matched-motion handoffs at every region boundary (the wire IS the match); full footage pipeline (generated clip → HEVC → auto-proxy → background removal → lower-third → word-synced captions); real beat grid from `hyperframes beats`; real Three.js via the frame adapter; seeded two-frame-hold confetti; SFX on hits; VO-paced reveals throughout.
>
> **Variables:** expose `ground` (default `#0a0a0a`) and `ink` (default `#3CE6AC`) as composition variables on the single root file, bound via CSS custom properties everywhere (including the duotoned mural), so one `--variables` call re-skins the entire journey. It will be rendered twice: the default brand palette, and a second full render with `{"ground":"#0d1420","ink":"#c8ff3d"}`.
> **Variables:** expose `ground` (default `#0a0a0a`) and `ink` (default `#3CE6AC`) as composition variables on the single root file, bound everywhere via their namespaced CSS custom properties `var(--hf-var-ground)` and `var(--hf-var-ink)` (including the duotoned mural), so one `--variables` call re-skins the entire journey. It will be rendered twice: the default brand palette, and a second full render with `{"ground":"#0d1420","ink":"#c8ff3d"}`.
>
> **Architecture constraint (technical):** single composition file — one `index.html`, one variable scope. The world is one wide scene space traversed by a camera transform; regions may still be timed `class="clip"` sections synchronized to the camera's arrival (so capture stays efficient), but their positions form one continuous world and the wire + ruler + protagonist chip layers persist across 100% of the film. No `data-composition-src` sub-files.
>
@@ -142,6 +142,13 @@ hyperframes render --variables '{"ground":"#0d1420","ink":"#c8ff3d"}' --strict-v
<video controls muted loop playsinline preload="metadata" src="https://static.heygen.ai/hyperframes-oss/docs/images/prompting/capstone-timeline-reskin-v2.mp4#t=0.1" style={{ borderRadius: "0.5rem", marginTop: "0.75rem" }}></video>
*The same composition, navy and acid green — the wire, ruler, chart, map, glass, and even the generated mural all follow the override.*
Both colors are bound as `var(--hf-var-ground)` and `var(--hf-var-ink)`, not as
`var(--ground)` and `var(--ink)`. Every declared variable is written to
`--hf-var-<slug>`, and `ink` is one of the reserved theme-token names (see
[Variables](/concepts/variables)), so it is written to the namespaced property
only. A `var(--ink)` binding would have re-skinned the ground and quietly
ignored the ink.
The mural trick from the previous capstone carries over. The artwork is generated in grayscale, then duotoned live by CSS layers driven by the `ground` and `ink` variables. Even the raster re-skins. Keep color in CSS and shapes in the raster whenever a template frame needs to survive a re-skin.
## What the prompt actually adds
+3 -1
View File
@@ -78,7 +78,9 @@ The constant parts come from that one authoring spec, or from one set of importe
When `frame.md` changes, rerun the workflow steps that generate or assemble the affected compositions. That's how the authored HTML picks up the new brand value.
Some compositions already exist and have to re-skin at render time. Declare the shared brand tokens as composition variables instead. Every scalar variable is applied as a `--{id}` CSS custom property on the composition root, so `var(--id)` in your CSS follows the override.
Some compositions already exist and have to re-skin at render time. Declare the shared brand tokens as composition variables instead. Every scalar variable is applied to the composition root as the CSS custom property `--hf-var-{slug}`, so `var(--hf-var-{slug})` in your CSS follows the override.
Brand work is where the exact name matters. Fifteen role names belong to the page hosting the composition (`accent`, `bg`, `ink`, `primary`, `surface` and the rest of the reserved list in [Variables](/concepts/variables)), and a variable never writes them. So a variable called `accent` is readable as `var(--hf-var-accent)` only, and a stylesheet still saying `var(--accent)` will not follow the override. Bind the namespaced name, or write `var(--accent, var(--hf-var-accent))` when a host theme should win wherever one is defined.
This is where design systems and templating meet. The brand is shared. The content is parameterized.
+14 -5
View File
@@ -179,10 +179,19 @@ full list and the compile-time-vs-live-DOM rule behind it.
<Note>
An authored CSS custom property always wins over a same-named variable. Say
your composition already defines its own `:root { --accent: ... }` as a
hand-written theme token. A variable called `accent` never overwrites it — the
authored value stands. A render-time `--variables` override still wins over
both. So when you need to override an authored value per render, use
`--variables`, not a same-named declared variable.
hand-written theme token. A variable called `accent` never overwrites it, and
it cannot: `accent` is one of the reserved theme-token names, so the variable
is only ever written to `--hf-var-accent`. A render-time `--variables`
override lands on `--hf-var-accent` too, so a `var(--accent)` binding keeps
showing the authored value. To drive that property per render, bind
`var(--hf-var-accent)`, or `var(--accent, var(--hf-var-accent))` if the
authored theme should still win whenever it is present.
For an id that is *not* a reserved theme-token name, the bare `--{slug}` alias
is still written: an authored `--{slug}` wins over the declared default, and a
render-time `--variables` override wins over both. That alias is deprecated
and will be removed in a future release, so prefer `var(--hf-var-{slug})`. The
reserved names are listed in [Variables](/concepts/variables).
</Note>
## Related
@@ -213,7 +222,7 @@ capstone page.
This is the clause in the [full capstone prompt](/prompting/capstone#the-prompt-word-for-word)
that buys the piece — prompt language you can lift for your own video:
> **Variables:** expose `ground` (default `#0a0a0a`) and `ink` (default `#3CE6AC`) as composition variables on the single root file, bound via CSS custom properties everywhere (including the duotoned mural), so one `--variables` call re-skins the entire journey. It will be rendered twice: the default brand palette, and a second full render with `{"ground":"#0d1420","ink":"#c8ff3d"}`.
> **Variables:** expose `ground` (default `#0a0a0a`) and `ink` (default `#3CE6AC`) as composition variables on the single root file, bound everywhere via their namespaced CSS custom properties `var(--hf-var-ground)` and `var(--hf-var-ink)` (including the duotoned mural), so one `--variables` call re-skins the entire journey. It will be rendered twice: the default brand palette, and a second full render with `{"ground":"#0d1420","ink":"#c8ff3d"}`.
>
> **Architecture constraint (technical):** single composition file — one `index.html`, one variable scope. […] No `data-composition-src` sub-files.
+5 -1
View File
@@ -281,9 +281,13 @@ render or a nested host:
></div>
```
Use `data-var-text`, `data-var-src`, or CSS `var(--variableId)` for direct
Use `data-var-text`, `data-var-src`, or CSS `var(--hf-var-<slug>)` for direct
bindings. Use `getVariables()` when the value affects logic.
A scalar variable is written to `--hf-var-<slug>`. The bare `--<slug>` is a
deprecated alias, and it is not written at all when `<id>` is one of the fifteen
reserved theme-token names listed in [Variables](/concepts/variables).
## Animation contract
A composition using GSAP must:
+6
View File
@@ -158,6 +158,12 @@
"types": "./dist/compiler/htmlDocument.d.ts",
"environments": ["browser", "bun", "node"]
},
"./runtime/theme-tokens": {
"source": "./src/runtime/themeTokens.ts",
"runtime": "./dist/runtime/themeTokens.js",
"types": "./dist/runtime/themeTokens.d.ts",
"environments": ["browser", "bun", "node"]
},
"./runtime/position-edits": {
"source": "./src/runtime/positionEdits.ts",
"runtime": "./dist/runtime/positionEdits.js",
+10
View File
@@ -167,6 +167,12 @@
"import": "./src/compiler/htmlDocument.ts",
"types": "./src/compiler/htmlDocument.ts"
},
"./runtime/theme-tokens": {
"bun": "./src/runtime/themeTokens.ts",
"node": "./dist/runtime/themeTokens.js",
"import": "./src/runtime/themeTokens.ts",
"types": "./src/runtime/themeTokens.ts"
},
"./runtime/position-edits": {
"bun": "./src/runtime/positionEdits.ts",
"node": "./dist/runtime/positionEdits.js",
@@ -403,6 +409,10 @@
"import": "./dist/compiler/htmlDocument.js",
"types": "./dist/compiler/htmlDocument.d.ts"
},
"./runtime/theme-tokens": {
"import": "./dist/runtime/themeTokens.js",
"types": "./dist/runtime/themeTokens.d.ts"
},
"./runtime/position-edits": {
"import": "./dist/runtime/positionEdits.js",
"types": "./dist/runtime/positionEdits.d.ts"
@@ -1388,3 +1388,91 @@ describe("bundleToSingleHtml", () => {
}
});
});
/** The `<style data-hf-composition-variables>` block the compiler emits, if any. */
function emittedVariableSheet(bundled: string): string {
const { document } = parseHTML(bundled);
return document.querySelector("style[data-hf-composition-variables]")?.textContent ?? "";
}
describe("compiled composition variable properties", () => {
/**
* The case the authored-<style> guard cannot see: the host theme arrives as
* an inline `style` attribute, so nothing in any <style> block mentions
* `--accent`. The composition declares `accent: "blue"` — an enum-ish
* selector, not a colour — and CSS-binds `color: var(--accent)`.
*/
const HOST_INLINE_THEME = `<!doctype html>
<html><body>
<div
id="stage"
style="--accent: #7c3aed"
data-composition-id="main"
data-width="320"
data-height="180"
data-start="0"
data-duration="5">
<div
id="card"
data-composition-variables='[{"id":"accent","type":"string","label":"Accent","default":"blue"},{"id":"cardTitle","type":"string","label":"Title","default":"Hi"}]'>
<h1 id="headline" style="color: var(--accent)">Hi</h1>
</div>
</div>
<script>window.__timelines={};</script>
</body></html>`;
it("never writes a theme token's bare name, whatever defines the theme", async () => {
const bundled = await bundleToSingleHtml(makeTempProject({ "index.html": HOST_INLINE_THEME }));
const sheet = emittedVariableSheet(bundled);
expect(sheet).toContain("--hf-var-accent: blue");
// The whole point: `var(--accent)` must keep resolving to the host's
// #7c3aed, so the compiled sheet may not define --accent anywhere.
expect(sheet).not.toContain("--accent:");
expect(bundled).not.toContain("--accent: blue");
});
it("keeps the deprecated bare alias for a non-reserved id", async () => {
const bundled = await bundleToSingleHtml(makeTempProject({ "index.html": HOST_INLINE_THEME }));
const sheet = emittedVariableSheet(bundled);
expect(sheet).toContain("--hf-var-cardtitle: Hi");
expect(sheet).toContain("--cardtitle: Hi");
});
/**
* Compiler leg of the cross-site agreement gate: the runtime legs are in
* runtime/themeTokens.test.ts and the SDK's in
* packages/sdk/src/session.variabledecls.test.ts, against these same ids.
* A mixed-case id is what exposes drift — the name is slugged once, in
* variableCssPropertiesForId, so an author's var(--hf-var-accentcolor)
* binding resolves no matter which site defined the property.
*/
const MIXED_CASE_IDS = `<!doctype html>
<html><body>
<div
id="stage"
data-composition-id="main"
data-width="320"
data-height="180"
data-start="0"
data-duration="5"
data-composition-variables='[{"id":"accentColor","type":"color","label":"Accent color","default":"#00c3ff"},{"id":"Accent","type":"string","label":"Accent","default":"blue"}]'>
<h1 id="headline" style="color: var(--hf-var-accentcolor)">Hi</h1>
</div>
<script>window.__timelines={};</script>
</body></html>`;
it("derives one slugified property per id, whatever the id's casing", async () => {
const bundled = await bundleToSingleHtml(makeTempProject({ "index.html": MIXED_CASE_IDS }));
const sheet = emittedVariableSheet(bundled);
expect(sheet).toContain("--hf-var-accentcolor: #00c3ff");
expect(sheet).toContain("--accentcolor: #00c3ff");
expect(sheet).not.toContain("accentColor");
// `Accent` slugs onto the reserved `accent`, so it is reserved here too —
// the same id cannot be aliasable on one path and reserved on another.
expect(sheet).toContain("--hf-var-accent: blue");
expect(sheet).not.toContain("--accent:");
expect(sheet).not.toContain("--Accent");
});
});
+12 -4
View File
@@ -1,7 +1,7 @@
import { markFlattenedInnerRoot } from "../runtime/flattenedRoot";
export { FLATTENED_INNER_ROOT_STRIP_ATTRS } from "../runtime/flattenedRoot";
import { parseHostVariableValues } from "../runtime/getVariables";
import { cssVariableName } from "../tokenSlug";
import { collidingCssName, variableCssPropertiesForId } from "../runtime/themeTokens";
import { readFileSync, existsSync } from "fs";
import { resolve, relative, dirname, isAbsolute, sep } from "path";
import { CSS_URL_RE, isNonRelativeUrl } from "./assetPaths.js";
@@ -1128,7 +1128,12 @@ export async function bundleToSingleHtml(
return document.toString();
}
/** One stylesheet rule defining primitive composition variables under `selector`. */
/**
* One stylesheet rule defining primitive composition variables under
* `selector`. Same namespacing the runtime applies (`variableCssPropertiesForId`)
* — the namespaced `--hf-var-{slug}` always, plus the deprecated bare alias
* unless the id names a theme token, where writing it would shadow the host's.
*/
function compositionVariablesCssBlock(
variables: Record<string, unknown>,
selector: string,
@@ -1136,7 +1141,10 @@ function compositionVariablesCssBlock(
const lines: string[] = [];
for (const [id, value] of Object.entries(variables)) {
if ((typeof value === "string" && value !== "") || typeof value === "number") {
lines.push(` ${cssVariableName(id)}: ${String(value)};`);
const { namespaced, legacy } = variableCssPropertiesForId(id);
for (const name of legacy === null ? [namespaced] : [namespaced, legacy]) {
lines.push(` ${name}: ${String(value)};`);
}
}
}
if (lines.length === 0) return null;
@@ -1185,7 +1193,7 @@ function authoredDefinesPredicate(document: Document): (id: string) => boolean {
const authoredCss = [...document.querySelectorAll("style:not([data-hf-composition-variables])")]
.map((s) => s.textContent || "")
.join("\n");
return (id) => new RegExp(`${cssVariableName(id)}\\s*:`).test(authoredCss);
return (id) => new RegExp(`${collidingCssName(id)}\\s*:`).test(authoredCss);
}
/**
@@ -63,7 +63,7 @@ describe("applyVariableBindings", () => {
expect(document.querySelector("h1")?.textContent).toBe("Overridden");
});
it("applies scalar variables as --{id} custom props on the root", () => {
it("applies scalar variables as --hf-var-{id} custom props on the root", () => {
setDeclared([
{ id: "accent", type: "color", label: "Accent", default: "#00C3FF" },
{ id: "count", type: "number", label: "Count", default: 3 },
@@ -72,7 +72,11 @@ describe("applyVariableBindings", () => {
document.body.innerHTML = `<div id="root" data-hf-root></div>`;
applyVariableBindings(document);
const root = document.getElementById("root");
expect(root?.style.getPropertyValue("--accent")).toBe("#ff0000");
// `accent` is a theme token, so it gets the namespaced name ONLY — the
// bare --accent belongs to the host theme.
expect(root?.style.getPropertyValue("--hf-var-accent")).toBe("#ff0000");
expect(root?.style.getPropertyValue("--accent")).toBe("");
expect(root?.style.getPropertyValue("--hf-var-count")).toBe("3");
expect(root?.style.getPropertyValue("--count")).toBe("3");
});
@@ -84,8 +88,11 @@ describe("applyVariableBindings", () => {
document.body.innerHTML = `<div id="root" data-hf-root></div>`;
applyVariableBindings(document);
const root = document.getElementById("root");
expect(root?.style.getPropertyValue("--brandFont")).toBe("Inter");
expect(root?.style.getPropertyValue("--img")).toBe("");
// The id is slugified (case-folded) exactly once, in
// variableCssPropertiesForId, so every injection site agrees on the name.
expect(root?.style.getPropertyValue("--hf-var-brandfont")).toBe("Inter");
expect(root?.style.getPropertyValue("--brandfont")).toBe("Inter");
expect(root?.style.getPropertyValue("--hf-var-img")).toBe("");
});
it("preserves element children when binding text on a container", () => {
@@ -169,10 +176,67 @@ describe("applyVariableBindings", () => {
win.__hfVariables = { accent: "red; background: url(//evil?data=secret)" };
document.body.innerHTML = `<div id="root" data-hf-root></div>`;
applyVariableBindings(document);
const css = document.getElementById("root")?.style.getPropertyValue("--accent") ?? "";
const css = document.getElementById("root")?.style.getPropertyValue("--hf-var-accent") ?? "";
expect(css).not.toContain(";");
expect(css).not.toContain("{");
expect(css).not.toContain("<");
});
});
});
/** Every custom property currently set inline on `el`, sorted. */
function customProps(el: HTMLElement): string[] {
const names: string[] = [];
for (let i = 0; i < el.style.length; i++) {
const name = el.style.item(i);
if (name.startsWith("--")) names.push(name);
}
return names.sort();
}
describe("injected CSS custom properties", () => {
/** A composition root under a host that defines the `--accent` theme token. */
function representativeRoot(): HTMLElement {
setDeclared([
{ id: "accent", type: "enum", label: "Accent", default: "blue" },
{ id: "accentColor", type: "color", label: "Accent color", default: "#00c3ff" },
{ id: "count", type: "number", label: "Count", default: 3 },
]);
document.body.innerHTML = `<div id="host" style="--accent: #7c3aed"><div id="root" data-hf-root></div></div>`;
applyVariableBindings(document);
return document.getElementById("root") as HTMLElement;
}
it("writes exactly this property set for a representative composition", () => {
// Names are slugged (case-folded) once, in variableCssPropertiesForId,
// so `accentColor` cannot land here as `--hf-var-accentColor` while the
// compiler bakes `--hf-var-accentcolor` into the shipped stylesheet.
expect(customProps(representativeRoot())).toEqual([
"--accentcolor",
"--count",
"--hf-var-accent",
"--hf-var-accentcolor",
"--hf-var-count",
]);
});
it("does not shadow the host theme's --accent", () => {
const root = representativeRoot();
expect(root.style.getPropertyValue("--accent")).toBe("");
expect(root.style.getPropertyValue("--hf-var-accent")).toBe("blue");
// The cascade, not the property map: var(--accent) still finds the host's
// value rather than the enum keyword the composition maps onto it.
expect(window.getComputedStyle(root).getPropertyValue("--accent")).toBe("#7c3aed");
});
it("removes both names when a value resolves to the empty string", () => {
setDeclared([{ id: "tint", type: "color", label: "Tint", default: "#123456" }]);
document.body.innerHTML = `<div id="root" data-hf-root></div>`;
applyVariableBindings(document);
const root = document.getElementById("root") as HTMLElement;
expect(customProps(root)).toEqual(["--hf-var-tint", "--tint"]);
win.__hfVariables = { tint: "" };
applyVariableBindings(document);
expect(customProps(root)).toEqual([]);
});
});
@@ -13,9 +13,13 @@
* node is replaced, mirroring the SDK's setOwnText semantics — a text
* binding must never delete nested clips or animation targets.
* - Every scalar variable (and a font value's family name) is applied as a
* `--{id}` CSS custom property on its composition root, so CSS bindings
* like `color: var(--accent)` respond to render/preview overrides instead
* of only the persisted default.
* `--hf-var-{slug}` CSS custom property on its composition root, so CSS
* bindings like `color: var(--hf-var-accent)` respond to render/preview
* overrides instead of only the persisted default. The bare `--{slug}` is
* written too as a deprecated alias, but never for a theme-token name —
* a variable called `accent` must not shadow the host theme's `--accent`.
* The slug is variableCssPropertiesForId's, shared with the declared-default
* injector, the compiler and the SDK, so one id has one property name.
*
* Values resolve against the element's owning composition — the same scope
* chain the color-grading runtime uses: `__hfVariablesByComp[compId]` for
@@ -28,6 +32,7 @@
*/
import { readVariablesForElement } from "./variableScope";
import { variableCssPropertiesForId } from "./themeTokens";
import { isScalarVariableValue as isScalar } from "@hyperframes/parsers/composition";
// data-var-src only rebinds media `src` on media elements. A user-controlled
@@ -158,7 +163,12 @@ function applyCssCustomProperties(doc: Document, cache: ScopeValuesCache): void
for (const [id, value] of Object.entries(values)) {
const css = cssValueFor(value);
if (css !== null && root instanceof HTMLElement) {
root.style.setProperty(`--${id}`, sanitizeCssValue(css));
const clean = sanitizeCssValue(css);
// An empty value REMOVES the property (CSSOM setProperty semantics).
// Both names go through the same call so an alias can't go stale.
const { namespaced, legacy } = variableCssPropertiesForId(id);
root.style.setProperty(namespaced, clean);
if (legacy) root.style.setProperty(legacy, clean);
}
}
}
+57 -1
View File
@@ -276,7 +276,7 @@ describe("css variable injection (figma brand-token chain)", () => {
applyCssVariables(el, { blank: "", real: "#123456" });
expect(el.style.getPropertyValue("--blank")).toBe("");
expect(el.style.getPropertyValue("--real")).toBe("#123456");
expect(el.getAttribute("data-hf-css-vars")).toBe("--real");
expect(el.getAttribute("data-hf-css-vars")).toBe("--hf-var-real --real");
});
it("clearAppliedCssVariables removes exactly what was applied", async () => {
@@ -296,3 +296,59 @@ describe("css variable injection (figma brand-token chain)", () => {
expect(getVariables()["figma:brand"]).toBe("#445566");
});
});
/** Every custom property currently set inline on `el`, sorted. */
function customProps(el: HTMLElement): string[] {
const names: string[] = [];
for (let i = 0; i < el.style.length; i++) {
const name = el.style.item(i);
if (name.startsWith("--")) names.push(name);
}
return names.sort();
}
describe("injected CSS custom property names", () => {
afterEach(() => {
document.body.innerHTML = "";
setOverrides(undefined);
});
it("applyCssVariables writes and records exactly these names", async () => {
const { applyCssVariables } = await import("./getVariables");
const el = document.createElement("div");
applyCssVariables(el, { accent: "blue", accentColor: "#00c3ff", count: 3, blank: "" });
expect(customProps(el)).toEqual([
"--accentcolor",
"--count",
"--hf-var-accent",
"--hf-var-accentcolor",
"--hf-var-count",
]);
expect(el.getAttribute("data-hf-css-vars")).toBe(
"--hf-var-accent --hf-var-accentcolor --accentcolor --hf-var-count --count",
);
});
it("clearAppliedCssVariables clears the whole pair, leaving no stale alias", async () => {
const { applyCssVariables, clearAppliedCssVariables } = await import("./getVariables");
const el = document.createElement("div");
applyCssVariables(el, { accent: "blue", tint: "#111111" });
expect(customProps(el)).toEqual(["--hf-var-accent", "--hf-var-tint", "--tint"]);
clearAppliedCssVariables(el);
expect(customProps(el)).toEqual([]);
expect(el.hasAttribute("data-hf-css-vars")).toBe(false);
});
it("a declared `accent` leaves the host's --accent alone", async () => {
const { injectCompositionCssVariables } = await import("./getVariables");
document.body.innerHTML =
`<div id="host" style="--accent: #7c3aed">` +
`<div id="root" ${VARIABLES_ATTR}='[{"id":"accent","type":"enum","label":"a","default":"blue"}]'></div>` +
`</div>`;
injectCompositionCssVariables(document);
const root = document.getElementById("root") as HTMLElement;
expect(root.style.getPropertyValue("--accent")).toBe("");
expect(root.style.getPropertyValue("--hf-var-accent")).toBe("blue");
expect(window.getComputedStyle(root).getPropertyValue("--accent")).toBe("#7c3aed");
});
});
+12 -8
View File
@@ -20,6 +20,7 @@
* const { title = "Untitled", theme = "light" } = getVariables<MyVars>();
*/
import { cssVariableName, detectSlugCollisions } from "../tokenSlug";
import { collidingCssName, variableCssPropertiesForId } from "./themeTokens";
export function getVariables<
T extends Record<string, unknown> = Record<string, unknown>,
@@ -82,18 +83,21 @@ function hasInlineStyle(target: Element): target is Element & ElementCSSInlineSt
/**
* Define primitive-valued variables as CSS custom properties on `target`,
* recording the applied names so re-init/unmount can clear them. Empty
* strings are skipped — CSSOM setProperty("", …) REMOVES the property, and a
* blank default has no CSS meaning anyway.
* recording every applied name (the namespaced one and, for a non-reserved
* id, its deprecated bare alias) so re-init/unmount can clear the pair.
* Empty strings are skipped — CSSOM setProperty("", …) REMOVES the property,
* and a blank default has no CSS meaning anyway.
*/
export function applyCssVariables(target: Element, variables: Record<string, unknown>): void {
if (!hasInlineStyle(target)) return;
const applied: string[] = [];
for (const [id, value] of Object.entries(variables)) {
if ((typeof value === "string" && value !== "") || typeof value === "number") {
const name = cssVariableName(id);
target.style.setProperty(name, String(value));
applied.push(name);
const { namespaced, legacy } = variableCssPropertiesForId(id);
for (const name of legacy === null ? [namespaced] : [namespaced, legacy]) {
target.style.setProperty(name, String(value));
applied.push(name);
}
}
}
if (applied.length > 0) target.setAttribute(APPLIED_VARS_ATTR, applied.join(" "));
@@ -107,7 +111,7 @@ export function filterVariablesIfAbsent(
): Record<string, unknown> {
const filtered: Record<string, unknown> = {};
for (const [id, value] of Object.entries(variables)) {
const name = cssVariableName(id);
const name = collidingCssName(id);
const existing =
(hasInlineStyle(target) ? target.style.getPropertyValue(name) : "") ||
(view ? view.getComputedStyle(target).getPropertyValue(name) : "");
@@ -173,7 +177,7 @@ function applyDeclaredForElement(
const toApply: Record<string, unknown> = {};
for (const [id, value] of Object.entries(declared)) {
if (id in overrides) continue; // overrides applied below, always win
const name = cssVariableName(id);
const name = collidingCssName(id);
// define-if-absent: respect authored/compiled definitions
const existing =
el.style.getPropertyValue(name) ||
@@ -0,0 +1,126 @@
/**
* @vitest-environment jsdom
*/
import { describe, it, expect, afterEach } from "vitest";
import { THEME_TOKENS, variableCssPropertiesForId, collidingCssName } from "./themeTokens";
import { applyVariableBindings } from "./applyVariableBindings";
import { getVariables, injectCompositionCssVariables } from "./getVariables";
describe("THEME_TOKENS", () => {
it("is non-empty", () => {
expect(THEME_TOKENS.size).toBeGreaterThan(0);
});
it("reserves the tokens compositions read from their host", () => {
for (const token of ["accent", "brand", "accent-2", "bg", "fg", "surface", "muted"]) {
expect(THEME_TOKENS.has(token)).toBe(true);
}
});
it("does not reserve ordinary variable ids", () => {
for (const id of ["swap_at", "title", "count", "flowSpeed"]) {
expect(THEME_TOKENS.has(id)).toBe(false);
}
});
it("stores bare names, so callers can name the token they would shadow", () => {
for (const token of THEME_TOKENS) expect(token.startsWith("--")).toBe(false);
});
});
describe("variableCssPropertiesForId", () => {
it("gives a non-reserved id both the namespaced name and the legacy alias", () => {
expect(variableCssPropertiesForId("swap_at")).toEqual({
namespaced: "--hf-var-swap-at",
legacy: "--swap-at",
});
});
it("gives a reserved id the namespaced name only", () => {
expect(variableCssPropertiesForId("accent")).toEqual({
namespaced: "--hf-var-accent",
legacy: null,
});
});
it("slugifies the id, so a mixed-case id has ONE name", () => {
// Case-folded, not camel-split: `accentColor` is `accentcolor`, never
// `accent-color`. The slug shape is tokenSlug's contract, not this one.
expect(variableCssPropertiesForId("accentColor")).toEqual({
namespaced: "--hf-var-accentcolor",
legacy: "--accentcolor",
});
});
it("reserves a mixed-case theme-token id exactly as it reserves the bare one", () => {
// The consequence of deriving the bare name verbatim: `Accent` would test
// as un-reserved and be aliased to `--Accent` on the paths that skipped
// the slug, while the compiler reserved it. Same id, two answers.
expect(variableCssPropertiesForId("Accent")).toEqual(variableCssPropertiesForId("accent"));
expect(variableCssPropertiesForId("Accent").legacy).toBeNull();
});
it("collidingCssName names the property define-if-absent must test", () => {
expect(collidingCssName("accentColor")).toBe("--accentcolor");
expect(collidingCssName("Accent")).toBe("--hf-var-accent");
});
});
/**
* The agreement gate. A composition author binds `var(--hf-var-<slug>)`; that
* only works if every site that injects the variable derives the SAME
* property name from the id. These drove different derivations once (the
* runtime bindings path used the id verbatim, everything else slugified), so
* a mixed-case id silently produced two properties and the binding could only
* match one.
*
* The compiler leg is pinned in packages/core/src/compiler/htmlBundler.test.ts
* and the SDK's two writers in packages/sdk/src/session.variabledecls.test.ts,
* against these same ids. (They cannot run here: this file is jsdom, and
* importing the bundler drags in esbuild, which refuses to load under it.)
*/
describe("every injection path derives one property name per id", () => {
const MIXED = "accentColor"; // non-reserved, mixed case
const RESERVED = "Accent"; // slugs onto a theme token, mixed case
const EXPECTED = "--hf-var-accentcolor";
const EXPECTED_RESERVED = "--hf-var-accent";
const DECLS = JSON.stringify([
{ id: MIXED, type: "color", label: "Accent colour", default: "#00c3ff" },
{ id: RESERVED, type: "color", label: "Accent", default: "#7c3aed" },
]);
afterEach(() => {
delete (window as Window & { __hfVariables?: unknown; __hyperframes?: unknown }).__hfVariables;
delete (window as Window & { __hyperframes?: unknown }).__hyperframes;
document.documentElement.removeAttribute("data-composition-variables");
document.head.innerHTML = "";
document.body.innerHTML = "";
});
it("applyVariableBindings (runtime, resolved values)", () => {
(window as Window & { __hyperframes?: unknown }).__hyperframes = { getVariables };
document.documentElement.setAttribute("data-composition-variables", DECLS);
document.body.innerHTML = `<div id="root" data-hf-root></div>`;
applyVariableBindings(document);
const style = document.getElementById("root")?.style;
expect(style?.getPropertyValue(EXPECTED)).toBe("#00c3ff");
expect(style?.getPropertyValue("--hf-var-accentColor")).toBe("");
expect(style?.getPropertyValue(EXPECTED_RESERVED)).toBe("#7c3aed");
// Reserved on this path too: the host's --accent stays the host's.
expect(style?.getPropertyValue("--accent")).toBe("");
expect(style?.getPropertyValue("--Accent")).toBe("");
});
it("injectCompositionCssVariables (runtime, declared defaults)", () => {
document.body.innerHTML = `<div id="decl" data-composition-variables='${DECLS}'></div>`;
injectCompositionCssVariables(document);
const style = (document.getElementById("decl") as HTMLElement).style;
expect(style.getPropertyValue(EXPECTED)).toBe("#00c3ff");
expect(style.getPropertyValue("--hf-var-accentColor")).toBe("");
expect(style.getPropertyValue(EXPECTED_RESERVED)).toBe("#7c3aed");
expect(style.getPropertyValue("--accent")).toBe("");
});
});
+87
View File
@@ -0,0 +1,87 @@
/**
* The CSS custom properties a composition expects its HOST to define — the
* theme namespace. Declared composition variables are injected as custom
* properties too, so an id that happens to match one of these names would
* overwrite the theme token by inline-style specificity and no host theme
* could win it back. A variable declared `accent: "blue"` is not a colour, it
* is a selector the composition maps onto `var(--accent)`; writing `--accent:
* blue` makes that lookup resolve to the CSS keyword instead of the theme.
*
* Reserved ids therefore get the namespaced property ONLY (see
* `variableCssPropertiesForId`), which is what stops the collision.
*
* Derivation, not memory — the names shipped theme blocks in this repo define
* alongside `--accent`:
*
* rg -U --pcre2 '\{[^{}]*--accent\s*:[^{}]*\}' -o skills registry docs
*
* ...filtered to theme ROLES (a slot a host fills: ground, ink, accent,
* surface) and away from literal values (`--green`, `--cream`), typography
* (`--font-*`, `--sans`) and component-local names (`--tab-bg`), which are not
* a host contract and are better left aliasable.
*/
import { cssVariableName } from "../tokenSlug";
/** Reserved theme-token names, without the leading `--`. */
export const THEME_TOKENS: ReadonlySet<string> = new Set([
"accent",
"accent-2",
"accent-3",
"accent2",
"bg",
"border",
"brand",
"fg",
"ink",
"muted",
"primary",
"secondary",
"surface",
"tertiary",
"text",
]);
/** The custom properties one injected variable writes. */
export interface VariableCssNames {
/** Always written. Namespaced, so nothing else can be shadowed by it. */
namespaced: string;
/**
* The bare name, kept as a deprecated alias for compositions that read
* `var(--<id>)` today. `null` for a reserved id — writing it is the bug.
*/
legacy: string | null;
}
/**
* THE property names one injected variable writes. Every injection site takes
* the raw variable id here — runtime bindings, runtime declared defaults, the
* compiler and the SDK writer — so one id yields exactly one namespaced
* property everywhere, and an authored `var(--hf-var-<slug>)` binding matches
* whichever site happened to define it.
*
* Taking the id (not a pre-derived name) is the point: the slug is applied
* here, once. `cssVariableName` is already the form the figma importer emits
* `var(--<slug>)` against and the form the compiler bakes into shipped
* output, and it is the only form guaranteed to be a legal custom-property
* name — a raw id may carry spaces or dots. Slugging folds case, which the
* reserved test below depends on: derived verbatim, the same declared id
* `Accent` would be aliasable on one path and reserved on another.
*/
export function variableCssPropertiesForId(id: string): VariableCssNames {
const bare = cssVariableName(id).slice(2);
return {
namespaced: `--hf-var-${bare}`,
legacy: THEME_TOKENS.has(bare) ? null : `--${bare}`,
};
}
/**
* The one name define-if-absent tests. An authored definition can only be
* clobbered by a name we would actually write, and a reserved id never writes
* the bare one — so an authored `--accent` must not suppress `--hf-var-accent`.
*/
export function collidingCssName(id: string): string {
const { namespaced, legacy } = variableCssPropertiesForId(id);
return legacy ?? namespaced;
}
@@ -197,7 +197,7 @@ window.__timelines = { t: tl };</script>
expect(liveTitle.style.getPropertyValue("color")).not.toBe("#f00");
});
it("mirrors setVariableValue onto the live root's CSS custom property", async () => {
it("mirrors setVariableValue onto the live root's namespaced custom property", async () => {
const html = `<!DOCTYPE html>
<html data-composition-variables='[{"id":"accent","default":"#fff"}]'>
<body>
@@ -216,7 +216,10 @@ window.__timelines = { t: tl };</script>
const liveRoot = iframe.contentDocument!.querySelector(
'[data-hf-id="hf-stage"]',
) as HTMLElement;
expect(liveRoot.style.getPropertyValue("--accent")).toBe("#0f0");
// `accent` is a theme token: the edit lands on --hf-var-accent, and the
// authored --accent stays whatever the document (or its host) set.
expect(liveRoot.style.getPropertyValue("--hf-var-accent")).toBe("#0f0");
expect(liveRoot.style.getPropertyValue("--accent")).toBe("#fff");
});
it("mirrors declareVariable/removeVariable onto the live document's schema attribute", async () => {
+28 -11
View File
@@ -20,6 +20,7 @@ import {
setStyleSheet,
} from "./model.js";
import { keyToPath, stylePath } from "./patches.js";
import { variableCssPropertiesForId } from "@hyperframes/core/runtime/theme-tokens";
import {
writeVariableDefault,
clearVariableDefault,
@@ -116,6 +117,32 @@ function applyVariableDefault(declEl: Element | null, id: string, newDefault: un
// ─── Patch application ───────────────────────────────────────────────────────
/**
* The CSS custom-property patches a scalar `var.{id}` override implies.
*
* Current sessions persist a paired style override, but sets written before
* the model/CSS split carry only `var.{id}`, so the CSS is derived here and
* `var()` bindings rehydrate. Names come from core so this stays in step with
* the SDK writer: a theme-token id gets `--hf-var-{slug}` only.
*
* Object values (font, image) are never CSS and yield nothing. `typeof null`
* is `"object"`, so null is admitted explicitly rather than by omission.
*/
function cssPatchesForVariableOverride(rootId: string, key: string, value: unknown): JsonPatchOp[] {
if (!key.startsWith("var.")) return [];
if (value !== null && typeof value === "object") return [];
const { namespaced, legacy } = variableCssPropertiesForId(key.slice("var.".length));
const names = legacy === null ? [namespaced] : [namespaced, legacy];
return names.map((cssVar) => {
const path = stylePath(rootId, cssVar);
return value === null
? ({ op: "remove", path } as JsonPatchOp)
: ({ op: "replace", path, value: String(value) } as JsonPatchOp);
});
}
/**
* Replay a stored override-set onto a freshly-parsed base document (T3 init).
* A null value means the property was explicitly deleted — emit a remove patch
@@ -146,17 +173,7 @@ export function applyOverrideSet(parsed: ParsedDocument, overrides: OverrideSet)
} else {
patches.push({ op: "replace", path, value });
}
// A scalar `var.{id}` override must also restore the `--{id}` CSS custom
// prop on the root. Current sessions persist a paired style override, but
// sets written before the model/CSS split only carry `var.{id}`; derive the
// CSS here so `var(--{id})` bindings rehydrate. Object (font/image) values
// are never CSS, so they are skipped.
if (rootId && key.startsWith("var.") && value !== null && typeof value !== "object") {
const cssPath = stylePath(rootId, `--${key.slice("var.".length)}`);
patches.push({ op: "replace", path: cssPath, value: String(value) });
} else if (rootId && key.startsWith("var.") && value === null) {
patches.push({ op: "remove", path: stylePath(rootId, `--${key.slice("var.".length)}`) });
}
if (rootId) patches.push(...cssPatchesForVariableOverride(rootId, key, value));
}
applyPatchesToDocument(parsed, patches);
}
+41 -27
View File
@@ -54,6 +54,7 @@ import {
import { upsertCssRule } from "./cssWriter.js";
import { mintHfId, EXCLUDED_TAGS } from "@hyperframes/core/hf-ids";
import { EDIT_BASE_X_ATTR, EDIT_BASE_Y_ATTR } from "@hyperframes/core/runtime/position-edits";
import { variableCssPropertiesForId } from "@hyperframes/core/runtime/theme-tokens";
import { readClipTiming, writeClipTiming } from "@hyperframes/core/composition-contract";
import { parseGsapScriptAcornForWrite } from "@hyperframes/core/gsap-parser-acorn";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
@@ -873,9 +874,9 @@ function handleSetVariableValue(
const oldVarDefault = readVariableDefault(declEl, id);
// Update the JSON model (B1 — drives the runtime) and keep the CSS custom
// prop as secondary / compat for compositions that CSS-bind directly to
// --{id}. Object values (font / image) are not valid CSS custom property
// values (LOCKED §7) — cssCompatChange clears any stale scalar prop instead.
// props as secondary / compat for compositions that CSS-bind directly to
// the variable. Object values (font / image) are not valid CSS custom
// property values (LOCKED §7) — cssCompatChanges clears stale scalar props.
// Emitting separate model + style patches keeps apply-patches.ts pure per
// path type, so inverse patches restore the exact pre-call state.
writeVariableDefault(declEl, id, value);
@@ -883,8 +884,11 @@ function handleSetVariableValue(
const forward: JsonPatchOp[] = [modelP.forward];
const inverse: JsonPatchOp[] = [modelP.inverse];
const css = cssCompatChange(parsed, id, isObjectVariableValue(value) ? null : String(value));
if (css) {
for (const css of cssCompatChanges(
parsed,
id,
isObjectVariableValue(value) ? null : String(value),
)) {
forward.push(css.forward);
inverse.push(css.inverse);
}
@@ -893,29 +897,38 @@ function handleSetVariableValue(
}
/**
* Keep the `--{id}` CSS compat custom property on the root in sync with a
* scalar default (same secondary channel handleSetVariableValue maintains).
* Pass null to clear. Returns the patch pair, or null when there is no root
* or nothing to change.
* Keep the CSS compat custom properties on the root in sync with a scalar
* default (same secondary channel handleSetVariableValue maintains). Pass
* null to clear. Returns one patch pair per property, empty when there is no
* root or nothing to change.
*
* Core's `variableCssPropertiesForId` owns which names those are, so the SDK
* writer and the runtime/compiler injectors cannot drift: the namespaced
* `--hf-var-{slug}` always, plus the deprecated bare `--{slug}` unless the id
* names a theme token — writing `--accent` there would shadow the host's.
*/
function cssCompatChange(
function cssCompatChanges(
parsed: ParsedDocument,
id: string,
newVal: string | null,
): { forward: JsonPatchOp; inverse: JsonPatchOp } | null {
): { forward: JsonPatchOp; inverse: JsonPatchOp }[] {
const root = findRoot(parsed.document);
const rootId = root?.getAttribute("data-hf-id");
if (!root || !rootId) return null;
const cssVar = `--${id}`;
const oldCssValue = getElementStyles(root)[cssVar] ?? null;
if (newVal !== null) {
if (oldCssValue === newVal) return null;
setElementStyles(root, { [cssVar]: newVal });
return scalarChange(stylePath(rootId, cssVar), oldCssValue, newVal);
if (!root || !rootId) return [];
const { namespaced, legacy } = variableCssPropertiesForId(id);
const changes: { forward: JsonPatchOp; inverse: JsonPatchOp }[] = [];
for (const cssVar of legacy === null ? [namespaced] : [namespaced, legacy]) {
const oldCssValue = getElementStyles(root)[cssVar] ?? null;
if (newVal !== null) {
if (oldCssValue === newVal) continue;
setElementStyles(root, { [cssVar]: newVal });
changes.push(scalarChange(stylePath(rootId, cssVar), oldCssValue, newVal));
} else if (oldCssValue !== null) {
setElementStyles(root, { [cssVar]: null });
changes.push(scalarDelete(stylePath(rootId, cssVar), oldCssValue));
}
}
if (oldCssValue === null) return null;
setElementStyles(root, { [cssVar]: null });
return scalarDelete(stylePath(rootId, cssVar), oldCssValue);
return changes;
}
/**
@@ -1000,8 +1013,7 @@ function handleDeclareVariable(
// Same CSS compat channel every other variable op maintains — a composition
// CSS-bound to var(--id) must resolve regardless of which op set the value.
if (isScalar(declaration.default)) {
const css = cssCompatChange(parsed, declaration.id, String(declaration.default));
if (css) {
for (const css of cssCompatChanges(parsed, declaration.id, String(declaration.default))) {
result.forward.push(css.forward);
result.inverse.push(css.inverse);
}
@@ -1033,8 +1045,11 @@ function handleUpdateVariableDeclaration(
const valueP = valueChange(variablePath(id), oldDefault ?? null, newDefault);
result.forward.push(valueP.forward);
result.inverse.push(valueP.inverse);
const css = cssCompatChange(parsed, id, isScalar(newDefault) ? String(newDefault) : null);
if (css) {
for (const css of cssCompatChanges(
parsed,
id,
isScalar(newDefault) ? String(newDefault) : null,
)) {
result.forward.push(css.forward);
result.inverse.push(css.inverse);
}
@@ -1053,8 +1068,7 @@ function handleRemoveVariableDeclaration(parsed: ParsedDocument, id: string): Mu
forward: [patchRemove(path)],
inverse: [patchAdd(path, old)],
};
const css = cssCompatChange(parsed, id, null);
if (css) {
for (const css of cssCompatChanges(parsed, id, null)) {
result.forward.push(css.forward);
result.inverse.push(css.inverse);
}
+111 -1
View File
@@ -41,6 +41,10 @@ const UNDECLARED_HTML = `<!DOCTYPE html>
<body>${BARE_HTML}</body>
</html>`;
function rootStyles(comp: Awaited<ReturnType<typeof openComposition>>) {
return comp.getElements().find((e) => e.id === "hf-stage")?.inlineStyles ?? {};
}
describe("declareVariable", () => {
it("creates the attribute on a composition with no declarations", async () => {
const comp = await openComposition(UNDECLARED_HTML);
@@ -234,12 +238,118 @@ describe("patch grammar", () => {
});
comp.declareVariable(TITLE_DECL);
comp.removeVariableDeclaration("title");
// Declaration ops also maintain the --{id} CSS compat prop (scalar defaults).
// Declaration ops also maintain the CSS compat props (scalar defaults):
// the namespaced name plus, for a non-reserved id like `title`, the
// deprecated bare alias.
expect(events).toEqual([
"add /variableDeclarations/title",
"add /elements/hf-stage/inlineStyles/--hf-var-title",
"add /elements/hf-stage/inlineStyles/--title",
"remove /variableDeclarations/title",
"remove /elements/hf-stage/inlineStyles/--hf-var-title",
"remove /elements/hf-stage/inlineStyles/--title",
]);
});
});
describe("theme-token variable ids", () => {
const ACCENT_DECL: CompositionVariable = {
id: "accent",
type: "string",
label: "Accent",
default: "blue",
};
/** A composition whose root sits under a host that themes `--accent`. */
const THEMED_HTML = `<!DOCTYPE html>
<html data-composition-variables='${JSON.stringify([ACCENT_DECL, TITLE_DECL])}'>
<body><div id="host" style="--accent: #7c3aed">${BARE_HTML}</div></body>
</html>`;
it("setVariableValue namespaces a reserved id, leaving the theme's --accent free", async () => {
const comp = await openComposition(THEMED_HTML);
comp.setVariableValue("accent", "red");
expect(rootStyles(comp)["--hf-var-accent"]).toBe("red");
expect(rootStyles(comp)["--accent"]).toBeUndefined();
expect(comp.serialize()).not.toContain("--accent: red");
});
it("still writes the deprecated bare alias for a non-reserved id", async () => {
const comp = await openComposition(THEMED_HTML);
comp.setVariableValue("title", "Hi");
expect(rootStyles(comp)["--hf-var-title"]).toBe("Hi");
expect(rootStyles(comp)["--title"]).toBe("Hi");
});
it("declare/remove of a reserved id never touches the bare property", async () => {
const comp = await openComposition(UNDECLARED_HTML);
comp.declareVariable(ACCENT_DECL);
expect(rootStyles(comp)["--hf-var-accent"]).toBe("blue");
expect(rootStyles(comp)["--accent"]).toBeUndefined();
comp.removeVariableDeclaration("accent");
expect(rootStyles(comp)["--hf-var-accent"]).toBeUndefined();
});
it("rehydrating a legacy var.{id} override set does not resurrect --accent", async () => {
// Pre-model/CSS-split shape: only the model key, CSS derived on replay.
const comp = await openComposition(THEMED_HTML, {
overrides: { "var.accent": "red", "var.title": "Hi" },
});
expect(rootStyles(comp)["--hf-var-accent"]).toBe("red");
expect(rootStyles(comp)["--accent"]).toBeUndefined();
expect(rootStyles(comp)["--title"]).toBe("Hi");
});
});
/**
* The SDK half of the cross-site agreement gate (core's half lives in
* packages/core/src/runtime/themeTokens.test.ts). The property name is
* derived from the id by core's `variableCssPropertiesForId` in exactly one
* place, so a mixed-case id cannot land on `--hf-var-accentColor` here and
* `--hf-var-accentcolor` in the runtime/compiler injectors.
*/
describe("mixed-case variable ids", () => {
const MIXED_DECL: CompositionVariable = {
id: "accentColor",
type: "string",
label: "Accent colour",
default: "#00c3ff",
};
/** Slugs onto the reserved `accent`, so the reserved test must agree too. */
const CASED_TOKEN_DECL: CompositionVariable = {
id: "Accent",
type: "string",
label: "Accent",
default: "blue",
};
const MIXED_HTML = `<!DOCTYPE html>
<html data-composition-variables='${JSON.stringify([MIXED_DECL, CASED_TOKEN_DECL])}'>
<body>${BARE_HTML}</body>
</html>`;
it("setVariableValue writes the slugified property, not the id verbatim", async () => {
const comp = await openComposition(MIXED_HTML);
comp.setVariableValue("accentColor", "red");
expect(rootStyles(comp)["--hf-var-accentcolor"]).toBe("red");
expect(rootStyles(comp)["--hf-var-accentColor"]).toBeUndefined();
expect(rootStyles(comp)["--accentcolor"]).toBe("red");
});
it("reserves a mixed-case theme-token id, as core's injectors do", async () => {
const comp = await openComposition(MIXED_HTML);
comp.setVariableValue("Accent", "red");
expect(rootStyles(comp)["--hf-var-accent"]).toBe("red");
expect(rootStyles(comp)["--Accent"]).toBeUndefined();
expect(rootStyles(comp)["--accent"]).toBeUndefined();
});
it("rehydrating a legacy var.{id} override set derives the same slug", async () => {
const comp = await openComposition(MIXED_HTML, {
overrides: { "var.accentColor": "red", "var.Accent": "green" },
});
expect(rootStyles(comp)["--hf-var-accentcolor"]).toBe("red");
expect(rootStyles(comp)["--hf-var-accentColor"]).toBeUndefined();
expect(rootStyles(comp)["--hf-var-accent"]).toBe("green");
expect(rootStyles(comp)["--Accent"]).toBeUndefined();
});
});
+1 -1
View File
@@ -30,7 +30,7 @@
"files": 11
},
"hyperframes-core": {
"hash": "e9daaccaed05b8b4",
"hash": "45686c48c3c7f39b",
"files": 19
},
"hyperframes-creative": {
+1
View File
@@ -69,6 +69,7 @@ Surfaced here; full rationale in the linked reference. Do not violate:
- No `<br>` in body text; transformed elements must be block-level + sized; pulsing absolute decoratives need peak clearance. → `determinism-rules.md`
- `<video>`/`<audio>` work at **any nesting depth** (including inside a sub-comp `<template>` or wrapper); the framework owns playback and seeks/decodes media wherever it lives. The one caveat is timelines, not placement: a sub-comp timeline can't animate host-root elements. → `variables-and-media.md`
- Every `id` must be unique across the **assembled** page; inside a sub-comp, prefix ids with the composition id (`#<id>-hero`). Duplicate `<video>`/`<img>` ids render **blank** — the producer injects frames by `getElementById`, and cross-file dupes slip past `lint`. → `composition-patterns.md`
- A declared variable is read from CSS as `var(--hf-var-<slug>)`. The bare `var(--<id>)` is a deprecated alias that is **not** written at all when `<id>` is a reserved theme-token name (`accent`, `accent-2`, `accent-3`, `accent2`, `bg`, `border`, `brand`, `fg`, `ink`, `muted`, `primary`, `secondary`, `surface`, `tertiary`, `text`); those belong to the host page's theme. The alias for every other id will be removed in a future release. → `variables-and-media.md`
- A full-screen scene fill goes on a full-bleed **child** (`position:absolute; inset:0`), never on the composition root itself — the producer's frame compositing can drop the root element's own `background` (the frame renders **black**) even though preview/`snapshot` show it correctly. → `composition-patterns.md`
## Editing existing compositions
@@ -22,14 +22,15 @@ Declare variables on the `<html>` element with `data-composition-variables`. Eac
<h1 class="clip" data-start="0" data-duration="5" data-var-text="title">Fallback</h1>
<style>
.card {
color: var(--accent);
color: var(--hf-var-accent);
}
</style>
```
- `data-var-src="id"` substitutes the element's `src` (URL string or image `{url}`); the authored `src` is the fallback.
- `data-var-text="id"` substitutes the element's own text; element children (nested clips, animated spans) are preserved.
- Every scalar variable is applied automatically as a `--{id}` CSS custom property on the composition root, so `var(--id)` CSS responds to overrides — no `setProperty` boilerplate.
- Every scalar variable is applied automatically as the `--hf-var-{slug}` CSS custom property on the composition root, so `var(--hf-var-{slug})` CSS responds to overrides (no `setProperty` boilerplate).
- The bare `--{slug}` is written too, but **only when the id is not a reserved theme-token name**: `accent`, `accent-2`, `accent-3`, `accent2`, `bg`, `border`, `brand`, `fg`, `ink`, `muted`, `primary`, `secondary`, `surface`, `tertiary`, `text`. Those belong to the host page's theme, so a variable called `accent` never shadows the theme's `--accent`. The bare alias is deprecated and will be removed in a future release, so bind `var(--hf-var-{slug})`; use `var(--accent, var(--hf-var-accent))` when the host theme should win and the declared value is only the fallback.
- Bindings resolve identically in preview and render, and per-instance for sub-compositions.
- Caveat: media with audio should keep a real fallback `src` — render audio extraction reads the authored attribute (lint: `media_variable_src_no_fallback`).