From b4c814ece948a6685186220862bce529fde92cab Mon Sep 17 00:00:00 2001 From: Kiyeon Jeon Date: Mon, 22 Jun 2026 23:24:13 +0900 Subject: [PATCH 1/4] feat(core): resolve token() on scene background + gradient stops (2d) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends design-token resolution past color props to the two other slots where a token would otherwise misrender silently. `background: token("color.bg")` resolves at compile (exposed as CompiledScene.background; renderer/preview read it). A `$ref` in a gradient stop resolves in evaluate against CompiledScene.effectiveTheme — gradients are static objects that bypass the value path, so resolveGradient returns the SAME object when no stop is a token, keeping non-token scenes byte-identical. No type-widening needed (both slots are already string-typed). Numeric/type tokens (IR widening) remain a later phase. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/compile.ts | 19 +++++++++++--- packages/core/src/evaluate.ts | 26 +++++++++++++++---- packages/core/test/design.test.ts | 37 +++++++++++++++++++++++++++ packages/preview/src/main.ts | 2 +- packages/renderer-canvas/src/index.ts | 3 ++- 5 files changed, 77 insertions(+), 10 deletions(-) diff --git a/packages/core/src/compile.ts b/packages/core/src/compile.ts index 2b543bf..06c5c1a 100644 --- a/packages/core/src/compile.ts +++ b/packages/core/src/compile.ts @@ -70,6 +70,10 @@ export interface CompiledScene { zSort: boolean; /** True iff the scene sets `design` or any color prop resolves a `token()` ref. */ hasDesign: boolean; + /** The scene's design tokens merged onto the house brand (`brand` when no `design`). Used to resolve gradient-stop tokens in evaluate. */ + effectiveTheme: Theme; + /** The scene background with any `token()` ref resolved (undefined when no background). Prefer over `ir.background`. */ + background?: string; } const key = (target: string, prop: string) => `${target}.${prop}`; @@ -134,15 +138,22 @@ export function compileScene(ir: SceneIR): CompiledScene { // when no token refs exist; `usedDesign` records whether any actually resolved. const effectiveTheme: Theme = ir.design ? theme(ir.design) : brand; let usedDesign = false; - const resolveToken = (value: PropValue, prop: string): PropValue => { - if (typeof value !== "string" || !value.startsWith("$") || !COLOR_PROPS.has(prop)) return value; + // Resolve a "$color.bg" string ref to its theme value (no prop gate). Unknown token: + // return the literal (never crash). Used for color props, the scene background, and + // gradient stop colors (the last resolved in evaluate, which shares effectiveTheme). + const resolveColorString = (value: string): string => { + if (!value.startsWith("$")) return value; const resolved = getDeepPath(effectiveTheme, value.slice(1)); if (typeof resolved === "string") { usedDesign = true; return resolved; } - return value; // unknown token: render the literal, never crash + return value; }; + const resolveToken = (value: PropValue, prop: string): PropValue => + typeof value === "string" && COLOR_PROPS.has(prop) ? resolveColorString(value) : value; + // the scene background is a color slot too (`background: token("color.bg")`) + const background = typeof ir.background === "string" ? resolveColorString(ir.background) : undefined; const initialValues = new Map(); for (const [id, node] of nodeById) { @@ -447,5 +458,7 @@ export function compileScene(ir: SceneIR): CompiledScene { hasPerspective, zSort, hasDesign: ir.design !== undefined || usedDesign, + effectiveTheme, + ...(background !== undefined && { background }), }; } diff --git a/packages/core/src/evaluate.ts b/packages/core/src/evaluate.ts index fed2ace..d297839 100644 --- a/packages/core/src/evaluate.ts +++ b/packages/core/src/evaluate.ts @@ -8,7 +8,8 @@ import { sampleBehavior } from "./behaviors.js"; import { cameraMatrix } from "./camera.js"; import type { CompiledScene, MotionDriver, PropertySegment } from "./compile.js"; import { isGradient } from "./gradient.js"; -import type { Anchor, BackdropIR, BlendMode, ClipShape, ImageFit, MatteMode, NodeIR, Paint, PropValue } from "./ir.js"; +import type { Anchor, BackdropIR, BlendMode, ClipShape, Gradient, ImageFit, MatteMode, NodeIR, Paint, PropValue } from "./ir.js"; +import { getDeepPath } from "./theme.js"; import { lerpValue, resolveEase } from "./interpolate.js"; import { pathBBox, pathPoint, pathTangentAngle } from "./path.js"; @@ -381,6 +382,21 @@ export function evaluate(compiled: CompiledScene, t: number): DisplayList { return v === "" && base === undefined ? undefined : String(v); }; + // Resolve any `token()` ref in a gradient's stop colors against the scene's theme. + // Gradients are static objects that bypass the value system, so they're resolved here. + // Returns the SAME object when no stop is a `$ref`, so non-token gradients stay byte-identical. + const resolveGradient = (g: Gradient): Gradient => { + if (!g.stops.some((s) => s.color.startsWith("$"))) return g; + return { + ...g, + stops: g.stops.map((s) => { + if (!s.color.startsWith("$")) return s; + const r = getDeepPath(compiled.effectiveTheme, s.color.slice(1)); + return typeof r === "string" ? { ...s, color: r } : s; + }), + }; + }; + // Sample blur + drop-shadow/glow into a partial spread onto the op. Only the // authored effects are included → absent ⇒ no op fields ⇒ byte-identical. type Fx = { blur?: number; shadowColor?: string; shadowBlur?: number; shadowX?: number; shadowY?: number; blend?: BlendMode }; @@ -539,8 +555,8 @@ export function evaluate(compiled: CompiledScene, t: number): DisplayList { // a gradient paint passes through as-is; a color string samples (animatable) via opt() const fillP = node.props.fill; const strokeP = node.props.stroke; - const fill = isGradient(fillP) ? fillP : opt(id, "fill", fillP); - const stroke = isGradient(strokeP) ? strokeP : opt(id, "stroke", strokeP); + const fill = isGradient(fillP) ? resolveGradient(fillP) : opt(id, "fill", fillP); + const stroke = isGradient(strokeP) ? resolveGradient(strokeP) : opt(id, "stroke", strokeP); ops.push({ type: node.type, id, @@ -616,8 +632,8 @@ export function evaluate(compiled: CompiledScene, t: number): DisplayList { const oy = num(id, "originY", node.props.originY ?? 0); const fillP = node.props.fill; const strokeP = node.props.stroke; - const fill = isGradient(fillP) ? fillP : opt(id, "fill", fillP); - const stroke = isGradient(strokeP) ? strokeP : opt(id, "stroke", strokeP); + const fill = isGradient(fillP) ? resolveGradient(fillP) : opt(id, "fill", fillP); + const stroke = isGradient(strokeP) ? resolveGradient(strokeP) : opt(id, "stroke", strokeP); const dStr = str(id, "d", node.props.d); const needsBox = isGradient(fill) || isGradient(stroke); // gradient maps to the path's bbox ops.push({ diff --git a/packages/core/test/design.test.ts b/packages/core/test/design.test.ts index db2eb64..59b4687 100644 --- a/packages/core/test/design.test.ts +++ b/packages/core/test/design.test.ts @@ -9,6 +9,9 @@ import { compileScene, composeScene, sceneManifest, + evaluate, + linearGradient, + isGradient, type SceneInput, } from "../src/index.js"; @@ -101,6 +104,40 @@ describe("overlay re-skin (design.* address)", () => { }); }); +describe("scene background tokens", () => { + it("resolves a background token onto CompiledScene.background", () => { + const c = compileScene(scene({ ...base([box("#fff")]), background: token("color.bg") })); + expect(c.background).toBe(brand.color.bg); + }); + + it("leaves a literal background untouched (golden-safe)", () => { + const c = compileScene(scene({ ...base([box("#fff")]), background: "#123456" })); + expect(c.background).toBe("#123456"); + expect(c.hasDesign).toBe(false); + }); +}); + +describe("gradient-stop tokens", () => { + const gbox = (fill: ReturnType) => + rect({ id: "g", x: 0, y: 0, width: 10, height: 10, anchor: "center", fill }); + + it("resolves a token in a gradient stop at evaluate time", () => { + const c = compileScene( + scene(base([gbox(linearGradient([token("color.accent"), "#ffffff"]))], { color: { accent: "#1E90FF" } })), + ); + const op = evaluate(c, 0).find((o) => o.id === "g"); + const fill = op?.fill; + expect(isGradient(fill) ? fill.stops[0]?.color : undefined).toBe("#1E90FF"); + }); + + it("returns the same gradient object when no stop is a token (byte-identical)", () => { + const grad = linearGradient(["#aaaaaa", "#bbbbbb"]); + const c = compileScene(scene(base([gbox(grad)]))); + const op = evaluate(c, 0).find((o) => o.id === "g"); + expect(op?.fill).toBe(c.nodeById.get("g")?.props.fill); // referential equality preserved + }); +}); + describe("manifest surfaces design tokens", () => { it("lists design. addresses with the current effective value", () => { const m = sceneManifest(compileScene(scene(base([box(token("color.accent"))], { color: { accent: "#1E90FF" } })))); diff --git a/packages/preview/src/main.ts b/packages/preview/src/main.ts index cd5f592..3bc6485 100644 --- a/packages/preview/src/main.ts +++ b/packages/preview/src/main.ts @@ -1166,7 +1166,7 @@ function drawSceneAt(compiled: import("@reframe/core").CompiledScene, localT: nu ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.globalAlpha = alpha; - ctx.fillStyle = compiled.ir.background ?? "#000"; + ctx.fillStyle = compiled.background ?? compiled.ir.background ?? "#000"; ctx.fillRect(0, 0, canvas.width, canvas.height); drawDisplayList(ctx, evaluate(compiled, localT), images); ctx.restore(); diff --git a/packages/renderer-canvas/src/index.ts b/packages/renderer-canvas/src/index.ts index fb44b8e..2715914 100644 --- a/packages/renderer-canvas/src/index.ts +++ b/packages/renderer-canvas/src/index.ts @@ -66,7 +66,8 @@ export function renderFrame( images?: ImageRegistry, videos?: VideoRegistry, ): void { - const { size, background } = compiled.ir; + const { size } = compiled.ir; + const background = compiled.background ?? compiled.ir.background; // resolved token() bg, else literal ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, size.width, size.height); if (background) { From 14eedfc934a26978e24592c95706d5ab58c25780 Mon Sep 17 00:00:00 2001 From: Kiyeon Jeon Date: Mon, 22 Jun 2026 23:24:13 +0900 Subject: [PATCH 2/4] docs(examples): themed-card uses a background token + gradient-stop tokens The scene background is token("color.bg") and the accent bar is a gradient whose stops are tokens, so --theme / a design.* batch column re-skins them too. Co-Authored-By: Claude Opus 4.8 --- examples/scenes/themed-card.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/scenes/themed-card.ts b/examples/scenes/themed-card.ts index d84b25c..af60b5f 100644 --- a/examples/scenes/themed-card.ts +++ b/examples/scenes/themed-card.ts @@ -6,7 +6,7 @@ // reframe frame themed-card.ts --t 1 --overlay examples/overlays/themed-card-reskin.json // The overlay address `design.color.accent` survives an AI regen of the base. -import { scene, rect, text, token, seq, par, tween, wait } from "@reframe/core"; +import { scene, rect, text, token, linearGradient, seq, par, tween, wait } from "@reframe/core"; const W = 1920, H = 1080, CX = 960, CY = 540; @@ -14,13 +14,13 @@ export default scene({ id: "themed-card", size: { width: W, height: H }, fps: 30, - background: "#000000", + background: token("color.bg"), // the scene background re-skins too nodes: [ - // full-bleed themed background (scene `background` tokens are a later phase) - rect({ id: "stage", x: 0, y: 0, width: W, height: H, fill: token("color.bg") }), rect({ id: "card", x: CX, y: CY, width: 760, height: 380, radius: 24, anchor: "center", opacity: 0, scale: 0.94, fill: token("color.surface"), shadowColor: "#04060C", shadowBlur: 60, shadowY: 30 }), - rect({ id: "bar", x: CX - 340, y: CY - 150, width: 8, height: 120, anchor: "top-left", opacity: 0, fill: token("color.accent") }), + // accent bar: a gradient whose stops are tokens (gradient-stop re-skin) + rect({ id: "bar", x: CX - 340, y: CY - 150, width: 8, height: 120, anchor: "top-left", opacity: 0, + fill: linearGradient([token("color.accent"), token("color.accent2")], { angle: 90 }) }), text({ id: "title", x: CX - 300, y: CY - 110, anchor: "center-left", opacity: 0, content: "On brand, by token", fontFamily: "Inter", fontSize: 56, fontWeight: 800, fill: token("color.fg") }), text({ id: "sub", x: CX - 300, y: CY - 36, anchor: "center-left", opacity: 0, From 8487cc9d9e60e03dca7ae4449f8e3886b7d09cae Mon Sep 17 00:00:00 2001 From: Kiyeon Jeon Date: Mon, 22 Jun 2026 23:24:13 +0900 Subject: [PATCH 3/4] docs(brand): note token() now resolves on background + gradient stops Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 9 +++++---- DESIGN.md | 4 ++-- docs/guides/edsl-guide.md | 3 ++- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9e890e8..9edae5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -161,15 +161,16 @@ no `Math.random()`/`Date` (use `wiggle` with a seed, or pass a `seed` knob). - Design tokens / brand (`packages/core/src/theme.ts`, `DESIGN.md`) — the house brand as code: `brand` (the DESIGN.md values) + `theme(overrides)` (deep-merge a reusable brand kit). `brand.color.accent` bakes the literal at author time; `token("color.accent")` is a DEFERRED ref the compiler resolves - against the scene's `design?: DeepPartial` field (falling back to `brand`), scoped to color - props (`fill`/`stroke`/`shadowColor`). Golden-safe via `CompiledScene.hasDesign` (no `design`/`$ref` + against the scene's `design?: DeepPartial` field (falling back to `brand`). Resolves on color + props (`fill`/`stroke`/`shadowColor`), the scene `background`, and gradient stops (the last in + `evaluate` via `CompiledScene.effectiveTheme`/`.background`). Golden-safe via `CompiledScene.hasDesign` (no `design`/`$ref` → byte-identical; resolution only touches `$`-strings on color props, so a text `content:"$5M"` is untouched). Overlay-addressable as `design.` (`compose.ts`, validated against the brand shape, regen-stable by name, orphan-clean); `sceneManifest().design` surfaces them. Re-skin via `render/frame/player --theme brand.json` (a nested partial theme, flattened in `render-cli/overlay.ts` `loadThemeDoc`) or a `batch` `design.` column (one mp4 per brand). Demo - `examples/scenes/themed-card.ts` + `examples/data/themed-card-brands.json`. Numeric/type/gradient-stop - tokens + scene-`background` tokens are a later phase. + `examples/scenes/themed-card.ts` + `examples/data/themed-card-brands.json`. Numeric/type tokens + (IR type-widening) are a later phase. - Photo/video montage (`packages/core/src/montage.ts`) — `photoMontage(shots, opts)` / `videoMontage` (same generator) turn a list of shots — images AND video clips, mixed (video detected by src extension, plays as a clip for its `hold`, audio muted by default diff --git a/DESIGN.md b/DESIGN.md index 54555ac..259dd5b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -89,8 +89,8 @@ rect({ id: "bar", fill: token("color.accent") }); Then re-skin with no edit to the scene: `reframe frame scene.ts --theme brand.json` (a brand kit is a nested partial theme), or a `batch` data file with a `design.` column (one mp4 per brand). An overlay can patch `design.color.accent` directly, and the re-skin survives an AI -regen of the base (the address is the token name). Color props resolve tokens today; numeric and -type tokens are a later phase. +regen of the base (the address is the token name). Tokens resolve on color props, the scene +background, and gradient stops; numeric and type tokens are a later phase. ## Brand diff --git a/docs/guides/edsl-guide.md b/docs/guides/edsl-guide.md index fa563ce..9a5a62f 100644 --- a/docs/guides/edsl-guide.md +++ b/docs/guides/edsl-guide.md @@ -83,7 +83,8 @@ Re-skin without touching the scene: `reframe batch scene.ts brands.json`. An overlay can also patch `design.color.accent` directly, and that re-skin survives an AI regen -of the base (the address is the token name). Only color props resolve tokens today. +of the base (the address is the token name). Tokens resolve on color props (`fill`/`stroke`/ +`shadowColor`), the scene `background`, and gradient stops; numeric and type tokens are a later phase. ## Nodes From 5e5c6aa316c59fd4899db8e60f6c4b73f9d8d16b Mon Sep 17 00:00:00 2001 From: Kiyeon Jeon Date: Mon, 22 Jun 2026 23:27:13 +0900 Subject: [PATCH 4/4] test(design): narrow DisplayOp/props union for gradient-fill assertions Fixes the CI typecheck (op.fill on the DisplayOp union); runtime behavior unchanged. Use `"fill" in op` narrowing instead of a bare property access. Co-Authored-By: Claude Opus 4.8 --- packages/core/test/design.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/core/test/design.test.ts b/packages/core/test/design.test.ts index 59b4687..1ca1516 100644 --- a/packages/core/test/design.test.ts +++ b/packages/core/test/design.test.ts @@ -126,7 +126,7 @@ describe("gradient-stop tokens", () => { scene(base([gbox(linearGradient([token("color.accent"), "#ffffff"]))], { color: { accent: "#1E90FF" } })), ); const op = evaluate(c, 0).find((o) => o.id === "g"); - const fill = op?.fill; + const fill = op && "fill" in op ? op.fill : undefined; expect(isGradient(fill) ? fill.stops[0]?.color : undefined).toBe("#1E90FF"); }); @@ -134,7 +134,10 @@ describe("gradient-stop tokens", () => { const grad = linearGradient(["#aaaaaa", "#bbbbbb"]); const c = compileScene(scene(base([gbox(grad)]))); const op = evaluate(c, 0).find((o) => o.id === "g"); - expect(op?.fill).toBe(c.nodeById.get("g")?.props.fill); // referential equality preserved + const evFill = op && "fill" in op ? op.fill : undefined; + const node = c.nodeById.get("g"); + const srcFill = node && "fill" in node.props ? node.props.fill : undefined; + expect(evFill).toBe(srcFill); // referential equality preserved }); });