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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions examples/overlays/themed-card-reskin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"reframeOverlay": 1,
"name": "themed-card-reskin",
"target": "themed-card",
"design": {
"color.accent": "#1E90FF",
"color.bg": "#0B1020",
"color.surface": "#141B2E"
}
}
47 changes: 47 additions & 0 deletions examples/scenes/themed-card.ts
Original file line number Diff line number Diff line change
@@ -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),
),
});
30 changes: 28 additions & 2 deletions packages/core/src/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -121,19 +127,36 @@ 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<string, PropValue>();
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));
}
}
}
if (ir.initial !== undefined) {
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));
}
}
}
Expand Down Expand Up @@ -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 = []));
Expand Down Expand Up @@ -421,5 +446,6 @@ export function compileScene(ir: SceneIR): CompiledScene {
hasCamera,
hasPerspective,
zSort,
hasDesign: ir.design !== undefined || usedDesign,
};
}
20 changes: 20 additions & 0 deletions packages/core/src/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string, PropValue>;
/** nodeId -> prop -> value; null deletes the prop key (USD "block"). */
nodes?: Record<string, Record<string, PropValue | null>>;
/** stateName -> nodeId -> prop -> value/null. */
Expand Down Expand Up @@ -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<string, unknown>, path, value);
applied(`design.${path}`, "set");
}
}

// --- node base props ---
for (const [id, patch] of Object.entries(overlay.nodes ?? {})) {
const node = nodeById.get(id);
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/dsl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<Theme>;
meta?: Record<string, unknown>;
}

Expand Down
13 changes: 12 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -23,6 +33,7 @@ export {
type TimelineAddress,
type BeatAddress,
type BehaviorAddress,
type DesignTokenAddress,
type ManifestSummary,
type LintFinding,
} from "./manifest.js";
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/ir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.<path>` (e.g. `design.color.accent`),
* so a re-skin survives an AI regeneration of the base.
*/
design?: DeepPartial<Theme>;
/** Reserved for v2 (Madeus-style temporal constraints). */
constraints?: unknown[];
/** Editor-only data (Theatre.js state.json pattern). */
Expand Down
28 changes: 28 additions & 0 deletions packages/core/src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -71,16 +72,42 @@ 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.<path>`
}

export interface SceneManifest {
scene: { id: string; duration: number; fps: number; size: Size; background?: string };
nodes: NodeAddress[];
states: StateAddress[];
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<string, unknown>, 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<string, unknown>, path);
}
}
};
walk(effective as unknown as Record<string, unknown>, "");
return out;
}

export interface LintFinding {
rule: string;
severity: "warn" | "error";
Expand Down Expand Up @@ -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,
Expand Down
44 changes: 44 additions & 0 deletions packages/core/src/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,47 @@ function mergeInto<T>(base: T, over: unknown): T {
export function theme(overrides: DeepPartial<Theme> = {}): 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<string, unknown>)[k];
}
return cur;
}

/** Set a dotted path on a nested object, creating intermediate objects. Mutates `obj`. */
export function setDeepPath(obj: Record<string, unknown>, 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<string, unknown>;
}
cur[keys[keys.length - 1]!] = value;
}
Loading
Loading