From 900d779ebcd9019ee9217a20dd47f0c7343b8b5a Mon Sep 17 00:00:00 2001 From: Kiyeon Jeon Date: Mon, 22 Jun 2026 22:39:12 +0900 Subject: [PATCH 1/3] feat(core): resolve design-token refs at compile time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2a. A `token("color.accent")` ref is the deferred string "$color.accent" on a color prop; unlike Phase 1's `brand.color.accent` (baked at author time), the compiler resolves it against the scene's new `SceneIR.design` field (merged onto the house brand) so the scene can be re-skinned later. Scoped to color props (fill/stroke/shadowColor), so a text `content: "$5M"` is never touched; evaluate/interpolate are unchanged (they only see resolved hex). Golden-safe: no design + no token ref → `hasDesign` false → no resolution → byte-identical IR. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/compile.ts | 30 ++++++++++++++++++++++-- packages/core/src/dsl.ts | 3 +++ packages/core/src/index.ts | 13 ++++++++++- packages/core/src/ir.ts | 8 +++++++ packages/core/src/theme.ts | 44 ++++++++++++++++++++++++++++++++++++ 5 files changed, 95 insertions(+), 3 deletions(-) diff --git a/packages/core/src/compile.ts b/packages/core/src/compile.ts index 6f408a7..2b543bf 100644 --- a/packages/core/src/compile.ts +++ b/packages/core/src/compile.ts @@ -17,6 +17,7 @@ import { DEFAULT_TWEEN_DURATION, } from "./ir.js"; import { pathPoint, pathTangentAngle } from "./path.js"; +import { brand, theme, getDeepPath, type Theme } from "./theme.js"; export interface PropertySegment { target: string; @@ -67,10 +68,15 @@ export interface CompiledScene { hasPerspective: boolean; /** True iff `camera.zSort` is on (gates depth-ordered paint; needs perspective). */ zSort: boolean; + /** True iff the scene sets `design` or any color prop resolves a `token()` ref. */ + hasDesign: boolean; } const key = (target: string, prop: string) => `${target}.${prop}`; +/** Props on which a `$token` ref resolves to a design-token value (color props only). */ +const COLOR_PROPS = new Set(["fill", "stroke", "shadowColor"]); + /** Deep time-stretch: multiply every duration/interval/offset by k. Used to * realise a beat's `scale`/`duration` without threading scale through the walk. */ function scaleTimeline(tl: TimelineIR, k: number): TimelineIR { @@ -121,11 +127,28 @@ export function compileScene(ir: SceneIR): CompiledScene { }; collect(ir.nodes); + // Design tokens: a `token("color.accent")` ref is the string "$color.accent" on a + // color prop. Resolve it against the scene's `design` (merged onto the house brand) + // at compile time, so evaluate/interpolate only ever see resolved hex. Scoped to + // color props so a text `content: "$5M"` is never touched. A no-op (byte-identical) + // 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; + const resolved = getDeepPath(effectiveTheme, value.slice(1)); + if (typeof resolved === "string") { + usedDesign = true; + return resolved; + } + return value; // unknown token: render the literal, never crash + }; + const initialValues = new Map(); for (const [id, node] of nodeById) { for (const [prop, value] of Object.entries(node.props)) { if (typeof value === "number" || typeof value === "string") { - initialValues.set(key(id, prop), value); + initialValues.set(key(id, prop), resolveToken(value, prop)); } } } @@ -133,7 +156,7 @@ export function compileScene(ir: SceneIR): CompiledScene { const override = ir.states?.[ir.initial] ?? {}; for (const [id, props] of Object.entries(override)) { for (const [prop, value] of Object.entries(props)) { - initialValues.set(key(id, prop), value); + initialValues.set(key(id, prop), resolveToken(value, prop)); } } } @@ -164,6 +187,8 @@ export function compileScene(ir: SceneIR): CompiledScene { const current = new Map(initialValues); const pushSegment = (seg: PropertySegment) => { + seg.from = resolveToken(seg.from, seg.prop); + seg.to = resolveToken(seg.to, seg.prop); const k = key(seg.target, seg.prop); let list = segments.get(k); if (!list) segments.set(k, (list = [])); @@ -421,5 +446,6 @@ export function compileScene(ir: SceneIR): CompiledScene { hasCamera, hasPerspective, zSort, + hasDesign: ir.design !== undefined || usedDesign, }; } diff --git a/packages/core/src/dsl.ts b/packages/core/src/dsl.ts index a28c30a..4993bff 100644 --- a/packages/core/src/dsl.ts +++ b/packages/core/src/dsl.ts @@ -27,6 +27,7 @@ import type { } from "./ir.js"; import { compileScene } from "./compile.js"; import { validateComposition, validateScene } from "./validate.js"; +import type { DeepPartial, Theme } from "./theme.js"; export interface SceneInput { id: string; @@ -41,6 +42,8 @@ export interface SceneInput { timeline?: TimelineIR; behaviors?: BehaviorIR[]; audio?: AudioIR; + /** Design tokens (brand overrides); `token()` color refs resolve against these. */ + design?: DeepPartial; meta?: Record; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4642350..a1f63c2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,16 @@ export * from "./ir.js"; export * from "./dsl.js"; -export { theme, brand, type Theme, type TypeStyle, type DeepPartial } from "./theme.js"; +export { + theme, + brand, + token, + getDeepPath, + setDeepPath, + type Theme, + type TypeStyle, + type DeepPartial, + type ThemeTokenPath, +} from "./theme.js"; export { validateScene, validateComposition, SceneValidationError, PROPS_BY_TYPE, type ValidationIssue } from "./validate.js"; export { compileComposition, @@ -23,6 +33,7 @@ export { type TimelineAddress, type BeatAddress, type BehaviorAddress, + type DesignTokenAddress, type ManifestSummary, type LintFinding, } from "./manifest.js"; diff --git a/packages/core/src/ir.ts b/packages/core/src/ir.ts index 1eb0ae1..c1487b2 100644 --- a/packages/core/src/ir.ts +++ b/packages/core/src/ir.ts @@ -8,6 +8,7 @@ * Semantics: a scene is evaluated as a pure function of continuous time * `evaluate(scene, tSeconds) -> DisplayList`. `fps` is a render hint only. */ +import type { Theme, DeepPartial } from "./theme.js"; export type EaseName = | "linear" @@ -622,6 +623,13 @@ export interface SceneIR { behaviors?: BehaviorIR[]; /** Label-anchored sound design — cues survive retiming and regeneration. */ audio?: AudioIR; + /** + * Design tokens (brand overrides) for this scene. `token()` color refs resolve + * against these at compile time, falling back to the house `brand` for any token + * not set here. Overlay-addressable as `design.` (e.g. `design.color.accent`), + * so a re-skin survives an AI regeneration of the base. + */ + design?: DeepPartial; /** Reserved for v2 (Madeus-style temporal constraints). */ constraints?: unknown[]; /** Editor-only data (Theatre.js state.json pattern). */ diff --git a/packages/core/src/theme.ts b/packages/core/src/theme.ts index e78ce3a..e8c3048 100644 --- a/packages/core/src/theme.ts +++ b/packages/core/src/theme.ts @@ -114,3 +114,47 @@ function mergeInto(base: T, over: unknown): T { export function theme(overrides: DeepPartial = {}): Theme { return mergeInto(brand, overrides); } + +/** The token paths `token()` can defer (scalar color leaves; resolved at compile time). */ +export type ThemeTokenPath = + | "color.bg" + | "color.surface" + | "color.surface2" + | "color.fg" + | "color.muted" + | "color.mutedNeutral" + | "color.accent" + | "color.accent2"; + +/** + * A DEFERRED reference to a design token, for use on a color prop: + * `fill: token("color.accent")`. Unlike `brand.color.accent` (which bakes the + * literal at author time), this stays unresolved in the IR and the compiler + * resolves it against the scene's `design` (falling back to the house `brand`), + * so the scene can be re-skinned later via a `design.*` overlay. + */ +export function token(path: ThemeTokenPath): string { + return `$${path}`; +} + +/** Read a dotted path (e.g. "color.accent") from a nested object; `undefined` if any segment is absent. */ +export function getDeepPath(obj: unknown, path: string): unknown { + let cur: unknown = obj; + for (const k of path.split(".")) { + if (typeof cur !== "object" || cur === null) return undefined; + cur = (cur as Record)[k]; + } + return cur; +} + +/** Set a dotted path on a nested object, creating intermediate objects. Mutates `obj`. */ +export function setDeepPath(obj: Record, path: string, value: unknown): void { + const keys = path.split("."); + let cur = obj; + for (let i = 0; i < keys.length - 1; i++) { + const k = keys[i]!; + if (typeof cur[k] !== "object" || cur[k] === null) cur[k] = {}; + cur = cur[k] as Record; + } + cur[keys[keys.length - 1]!] = value; +} From 0d4c8f548acaa078157407bc93bfe92c60c67a2a Mon Sep 17 00:00:00 2001 From: Kiyeon Jeon Date: Mon, 22 Jun 2026 22:39:12 +0900 Subject: [PATCH 2/3] feat(core): overlay-addressable design tokens (design.* address) Phase 2b. An overlay can patch `design.color.accent` and every node that references that token re-skins on recompile. Addressed by token NAME (validated against the brand shape), so the re-skin survives an AI regen of the base and an unknown token path is reported as a loud orphan, never a crash. `sceneManifest` now surfaces the patchable `design.*` tokens. Tests cover resolution, the "$5M" content scoping, re-skin, orphan, regen-survival, and manifest. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/compose.ts | 20 ++++++ packages/core/src/manifest.ts | 28 ++++++++ packages/core/test/design.test.ts | 111 ++++++++++++++++++++++++++++++ 3 files changed, 159 insertions(+) create mode 100644 packages/core/test/design.test.ts diff --git a/packages/core/src/compose.ts b/packages/core/src/compose.ts index ec08554..0f17f07 100644 --- a/packages/core/src/compose.ts +++ b/packages/core/src/compose.ts @@ -12,6 +12,7 @@ import { compileScene } from "./compile.js"; import type { BehaviorIR, Ease, NodeIR, PropValue, SceneIR, TimelineIR } from "./ir.js"; import { PROPS_BY_TYPE, validateScene } from "./validate.js"; +import { brand, getDeepPath, setDeepPath } from "./theme.js"; export interface OverlayDoc { reframeOverlay: 1; @@ -20,6 +21,13 @@ export interface OverlayDoc { /** Scene id this overlay was authored against — mismatch is a warning. */ target?: string; scene?: { background?: string; duration?: number; fps?: number }; + /** + * Design-token patch: dotted token path -> value (e.g. `{ "color.accent": "#1E90FF" }`). + * Re-skins the scene's `design` so every `token()` ref resolves to the new value on + * recompile. Addressed by token NAME, so it survives an AI regen of the base; an + * unknown token path is reported as an orphan. + */ + design?: Record; /** nodeId -> prop -> value; null deletes the prop key (USD "block"). */ nodes?: Record>; /** stateName -> nodeId -> prop -> value/null. */ @@ -228,6 +236,18 @@ function applyOverlay( } } + // --- design tokens (validated against the brand shape) --- + if (overlay.design) { + for (const [path, value] of Object.entries(overlay.design)) { + if (getDeepPath(brand, path) === undefined) { + orphan(`design.${path}`, `"${path}" is not a known design token`); + continue; + } + setDeepPath((ir.design ??= {}) as Record, path, value); + applied(`design.${path}`, "set"); + } + } + // --- node base props --- for (const [id, patch] of Object.entries(overlay.nodes ?? {})) { const node = nodeById.get(id); diff --git a/packages/core/src/manifest.ts b/packages/core/src/manifest.ts index 0c76359..642c3e0 100644 --- a/packages/core/src/manifest.ts +++ b/packages/core/src/manifest.ts @@ -13,6 +13,7 @@ import type { CompiledScene } from "./compile.js"; import type { NodeIR, Size, TimelineIR } from "./ir.js"; import { TIMELINE_PATCHABLE } from "./compose.js"; import { PROPS_BY_TYPE } from "./validate.js"; +import { brand, theme, type Theme } from "./theme.js"; export interface NodeAddress { id: string; @@ -71,6 +72,13 @@ export interface ManifestSummary { motionAddressableRatio: number; } +/** A patchable design token: its dotted path, current effective value, and overlay address. */ +export interface DesignTokenAddress { + path: string; // e.g. "color.accent" + value: string | number; + address: string; // `design.` +} + export interface SceneManifest { scene: { id: string; duration: number; fps: number; size: Size; background?: string }; nodes: NodeAddress[]; @@ -78,9 +86,28 @@ export interface SceneManifest { timeline: TimelineAddress[]; beats: BeatAddress[]; behaviors: BehaviorAddress[]; + /** Patchable design tokens (the scene's `design` merged onto the house brand). */ + design: DesignTokenAddress[]; summary: ManifestSummary; } +/** Dotted paths to every scalar (string/number) leaf in a theme — the patchable token set. */ +function designTokenAddresses(effective: Theme): DesignTokenAddress[] { + const out: DesignTokenAddress[] = []; + const walk = (obj: Record, prefix: string) => { + for (const [k, v] of Object.entries(obj)) { + const path = prefix ? `${prefix}.${k}` : k; + if (typeof v === "string" || typeof v === "number") { + out.push({ path, value: v, address: `design.${path}` }); + } else if (v && typeof v === "object" && !Array.isArray(v)) { + walk(v as Record, path); + } + } + }; + walk(effective as unknown as Record, ""); + return out; +} + export interface LintFinding { rule: string; severity: "warn" | "error"; @@ -209,6 +236,7 @@ export function sceneManifest(compiled: CompiledScene): SceneManifest { timeline, beats, behaviors, + design: designTokenAddresses(ir.design ? theme(ir.design) : brand), summary: { nodeCount: nodes.length, labeledSteps: timeline.length + beats.length, diff --git a/packages/core/test/design.test.ts b/packages/core/test/design.test.ts new file mode 100644 index 0000000..db2eb64 --- /dev/null +++ b/packages/core/test/design.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { + scene, + rect, + ellipse, + text, + token, + brand, + compileScene, + composeScene, + sceneManifest, + type SceneInput, +} from "../src/index.js"; + +const base = (nodes: SceneInput["nodes"], design?: SceneInput["design"]): SceneInput => ({ + id: "t", + size: { width: 100, height: 100 }, + fps: 30, + duration: 1, + nodes, + ...(design ? { design } : {}), +}); + +const box = (fill: string) => rect({ id: "box", x: 0, y: 0, width: 10, height: 10, anchor: "center", fill }); + +describe("token()", () => { + it("produces a deferred sentinel string", () => { + expect(token("color.accent")).toBe("$color.accent"); + }); +}); + +describe("compile-time token resolution", () => { + it("resolves a color token against the house brand by default", () => { + const c = compileScene(scene(base([box(token("color.accent"))]))); + expect(c.initialValues.get("box.fill")).toBe(brand.color.accent); + expect(c.hasDesign).toBe(true); + }); + + it("resolves against the scene's design (deferred, not baked at author time)", () => { + const c = compileScene(scene(base([box(token("color.accent"))], { color: { accent: "#1E90FF" } }))); + expect(c.initialValues.get("box.fill")).toBe("#1E90FF"); + }); + + it("falls back to brand for tokens the partial design does not set", () => { + const c = compileScene( + scene( + base( + [rect({ id: "box", x: 0, y: 0, width: 10, height: 10, anchor: "center", fill: token("color.bg") })], + { color: { accent: "#1E90FF" } }, // only accent overridden + ), + ), + ); + expect(c.initialValues.get("box.fill")).toBe(brand.color.bg); + }); + + it("only resolves on color props — a text content of '$5M' is never touched", () => { + const c = compileScene( + scene( + base([ + text({ id: "tx", x: 0, y: 0, anchor: "center", content: "$5M", fontFamily: "Inter", fontSize: 40, fill: token("color.fg") }), + ]), + ), + ); + expect(c.initialValues.get("tx.content")).toBe("$5M"); // not a color prop -> untouched + expect(c.initialValues.get("tx.fill")).toBe(brand.color.fg); // color prop -> resolved + }); + + it("leaves an unknown token literal rather than crashing", () => { + const c = compileScene(scene(base([box("$color.nope")]))); + expect(c.initialValues.get("box.fill")).toBe("$color.nope"); + }); + + it("is golden-safe: no design and no token ref -> hasDesign false, literal untouched", () => { + const c = compileScene(scene(base([box("#123456")]))); + expect(c.hasDesign).toBe(false); + expect(c.initialValues.get("box.fill")).toBe("#123456"); + }); +}); + +describe("overlay re-skin (design.* address)", () => { + it("patches a design token and re-skins every ref on recompile", () => { + const s = scene(base([box(token("color.accent"))])); + const { ir, report } = composeScene(s, { reframeOverlay: 1, design: { "color.accent": "#22C55E" } }); + expect(report.orphans).toHaveLength(0); + expect(report.applied.some((a) => a.address === "design.color.accent")).toBe(true); + expect(compileScene(ir).initialValues.get("box.fill")).toBe("#22C55E"); + }); + + it("reports an unknown token path as an orphan, never throws", () => { + const s = scene(base([box(token("color.accent"))])); + const { report } = composeScene(s, { reframeOverlay: 1, design: { "color.nope": "#000000" } }); + expect(report.orphans.some((o) => o.address === "design.color.nope")).toBe(true); + }); + + it("survives a regen: the same patch re-applies to a redesigned base that keeps the token", () => { + // v2 has completely different node ids/types but still references color.accent + const v2 = scene(base([ellipse({ id: "newshape", x: 0, y: 0, width: 20, height: 20, anchor: "center", fill: token("color.accent") })])); + const { ir, report } = composeScene(v2, { reframeOverlay: 1, design: { "color.accent": "#22C55E" } }); + expect(report.orphans).toHaveLength(0); + expect(compileScene(ir).initialValues.get("newshape.fill")).toBe("#22C55E"); + }); +}); + +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" } })))); + const accent = m.design.find((d) => d.path === "color.accent"); + expect(accent?.address).toBe("design.color.accent"); + expect(accent?.value).toBe("#1E90FF"); // reflects the scene's design override + }); +}); From af67fabc039e4b650a0c96134b89dff4e3704320 Mon Sep 17 00:00:00 2001 From: Kiyeon Jeon Date: Mon, 22 Jun 2026 22:39:13 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs(examples):=20themed-card=20demo=20?= =?UTF-8?q?=E2=80=94=20re-skin=20via=20a=20design=20overlay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A card whose every color is a token() ref; themed-card-reskin.json patches design.color.{accent,bg,surface} to re-skin it with no edit to the scene. Co-Authored-By: Claude Opus 4.8 --- examples/overlays/themed-card-reskin.json | 10 +++++ examples/scenes/themed-card.ts | 47 +++++++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 examples/overlays/themed-card-reskin.json create mode 100644 examples/scenes/themed-card.ts diff --git a/examples/overlays/themed-card-reskin.json b/examples/overlays/themed-card-reskin.json new file mode 100644 index 0000000..b930846 --- /dev/null +++ b/examples/overlays/themed-card-reskin.json @@ -0,0 +1,10 @@ +{ + "reframeOverlay": 1, + "name": "themed-card-reskin", + "target": "themed-card", + "design": { + "color.accent": "#1E90FF", + "color.bg": "#0B1020", + "color.surface": "#141B2E" + } +} diff --git a/examples/scenes/themed-card.ts b/examples/scenes/themed-card.ts new file mode 100644 index 0000000..d84b25c --- /dev/null +++ b/examples/scenes/themed-card.ts @@ -0,0 +1,47 @@ +// Design tokens, deferred and re-skinnable. Every color on this card is a +// `token("color.…")` ref, not a literal — the compiler resolves it against the +// scene's design (falling back to the house brand). Re-skin the whole card by +// patching design tokens in an overlay, no edit to the scene itself: +// reframe frame themed-card.ts --t 1 +// 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"; + +const W = 1920, H = 1080, CX = 960, CY = 540; + +export default scene({ + id: "themed-card", + size: { width: W, height: H }, + fps: 30, + background: "#000000", + 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") }), + 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, + content: "every color is token(...), re-skinnable via overlay", fontFamily: "Inter", fontSize: 24, fontWeight: 500, fill: token("color.muted") }), + rect({ id: "chip", x: CX - 300, y: CY + 56, width: 150, height: 44, radius: 22, anchor: "top-left", opacity: 0, fill: token("color.accent") }), + text({ id: "chiptext", x: CX - 225, y: CY + 78, anchor: "center", opacity: 0, + content: "Accent", fontFamily: "Inter", fontSize: 20, fontWeight: 700, fill: token("color.bg") }), + ], + + timeline: seq( + wait(0.2), + par( + tween("card", { opacity: 1, scale: 1 }, { duration: 0.6, ease: "easeOutBack", label: "card-in" }), + seq(wait(0.15), par( + tween("bar", { opacity: 1 }, { duration: 0.4, ease: "easeOutCubic", label: "bar-in" }), + tween("title", { opacity: 1 }, { duration: 0.5, ease: "easeOutCubic", label: "title-in" }), + tween("sub", { opacity: 1 }, { duration: 0.5, ease: "easeOutCubic", label: "sub-in" }), + tween("chip", { opacity: 1 }, { duration: 0.5, ease: "easeOutCubic", label: "chip-in" }), + tween("chiptext", { opacity: 1 }, { duration: 0.5, ease: "easeOutCubic", label: "chiptext-in" }), + )), + ), + wait(1.4), + ), +});