Skip to content
Open
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
48 changes: 48 additions & 0 deletions docs/changelog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
26 changes: 23 additions & 3 deletions docs/concepts/variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 5 additions & 2 deletions docs/deploy/templates-on-lambda.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,18 @@ 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>
</body>
</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

Expand Down
9 changes: 8 additions & 1 deletion docs/prompting/capstone.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
>
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/prompting/design-systems.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
19 changes: 14 additions & 5 deletions docs/prompting/variables-and-templating.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
6 changes: 5 additions & 1 deletion docs/reference/html-schema.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions packages/core/package-subpaths.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
88 changes: 88 additions & 0 deletions packages/core/src/compiler/htmlBundler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
16 changes: 12 additions & 4 deletions packages/core/src/compiler/htmlBundler.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -1128,15 +1128,23 @@ 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,
): string | null {
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;
Expand Down Expand Up @@ -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);
}

/**
Expand Down
Loading
Loading