From c90d7a9dea52ac50820920a70e33900881475567 Mon Sep 17 00:00:00 2001 From: Kiyeon Jeon Date: Mon, 22 Jun 2026 22:14:09 +0900 Subject: [PATCH 1/3] feat(core): add theme() design-tokens layer + house brand Phase 1 of the design layer. `brand` is the reframe house style as code (the DESIGN.md values: accent #FF4D00, bg #0A0C14, the Inter type scale, Balanced motion tone, audio palette); `theme(overrides)` deep-merges a custom kit onto it so a consumer defines their brand once and reuses it. Pure data: a theme emits nothing into the IR, so referencing a token renders byte-identical to writing the literal (goldens unchanged). The Theme type is the foundation for a later IR-level, overlay-addressable theme. Exported from @reframe/core. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/index.ts | 1 + packages/core/src/theme.ts | 116 +++++++++++++++++++++++++++++++ packages/core/test/theme.test.ts | 50 +++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 packages/core/src/theme.ts create mode 100644 packages/core/test/theme.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 83cc6e4..4642350 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,6 @@ export * from "./ir.js"; export * from "./dsl.js"; +export { theme, brand, type Theme, type TypeStyle, type DeepPartial } from "./theme.js"; export { validateScene, validateComposition, SceneValidationError, PROPS_BY_TYPE, type ValidationIssue } from "./validate.js"; export { compileComposition, diff --git a/packages/core/src/theme.ts b/packages/core/src/theme.ts new file mode 100644 index 0000000..e78ce3a --- /dev/null +++ b/packages/core/src/theme.ts @@ -0,0 +1,116 @@ +/** + * Brand / design tokens as code. `brand` is the reframe house style (the values + * documented in DESIGN.md); `theme(overrides)` returns a copy with `overrides` + * deep-merged on top, so a consumer can define their own brand kit once and + * reference it across scenes. + * + * Pure data: a theme emits nothing into the IR, so referencing a token in a scene + * (`fill: brand.color.accent`) is byte-identical to writing the literal. This file + * is the source of truth; DESIGN.md documents the same values. + */ +import type { EaseName, Size } from "./ir.js"; + +/** A text style bundle, spreadable into a `text()` node: `text({ ...brand.type.headline, content })`. */ +export interface TypeStyle { + fontFamily: string; + fontSize: number; + fontWeight: number; + letterSpacing?: number; +} + +export interface Theme { + color: { + bg: string; + surface: string; + surface2: string; + fg: string; + muted: string; + mutedNeutral: string; + accent: string; + accent2: string; + dataViz: string[]; + }; + type: { + family: string; + display: TypeStyle; + headline: TypeStyle; + body: TypeStyle; + label: TypeStyle; + }; + motion: { + ease: { base: EaseName; enter: EaseName; exit: EaseName; playful: EaseName }; + energy: number; + speed: number; + dur: { micro: number; base: number; slow: number }; + }; + audio: { bgm: string[]; sfx: string[] }; + layout: { + size: Size; + fps: number; + margin: number; + radius: { bar: number; card: number; panel: number }; + }; +} + +/** The reframe house brand. Mirrors the DESIGN.md token front matter. */ +export const brand: Theme = { + color: { + bg: "#0A0C14", + surface: "#161922", + surface2: "#1E222D", + fg: "#FFFFFF", + muted: "#8B93A7", + mutedNeutral: "#8E8E93", + accent: "#FF4D00", + accent2: "#00C2A8", + dataViz: ["#54D6C0", "#7C5CFF", "#FF6FA5", "#FFC861"], + }, + type: { + family: "Inter", + display: { fontFamily: "Inter", fontSize: 92, fontWeight: 800 }, + headline: { fontFamily: "Inter", fontSize: 48, fontWeight: 700 }, + body: { fontFamily: "Inter", fontSize: 24, fontWeight: 400 }, + label: { fontFamily: "Inter", fontSize: 18, fontWeight: 600, letterSpacing: 2 }, + }, + motion: { + ease: { base: "easeOutCubic", enter: "easeOutBack", exit: "easeInOutQuad", playful: "springBouncy" }, + energy: 0.5, + speed: 1, + dur: { micro: 0.3, base: 0.5, slow: 1.0 }, + }, + audio: { + bgm: ["ambient-pad", "lofi", "pulse", "tension", "uplift"], + sfx: ["whoosh", "thud", "pop", "click", "confirm", "shimmer", "swoosh", "keypress", "footstep"], + }, + layout: { + size: { width: 1920, height: 1080 }, + fps: 30, + margin: 96, + radius: { bar: 6, card: 24, panel: 56 }, + }, +}; + +/** Recursive-partial of `Theme`: override any leaf or subtree; arrays are replaced whole. */ +export type DeepPartial = { + [K in keyof T]?: T[K] extends unknown[] ? T[K] : T[K] extends object ? DeepPartial : T[K]; +}; + +function isPlainObject(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +/** Deep-merge `over` onto `base`, returning a new object. Objects merge; scalars and arrays replace. Never mutates `base`. */ +function mergeInto(base: T, over: unknown): T { + if (!isPlainObject(base) || !isPlainObject(over)) return (over as T) ?? base; + const out: Record = { ...base }; + for (const [k, v] of Object.entries(over)) { + const bv = base[k]; + out[k] = isPlainObject(bv) && isPlainObject(v) ? mergeInto(bv, v) : v; + } + return out as T; +} + +/** A theme with `overrides` deep-merged onto the house `brand`. Pure; never mutates `brand`. */ +export function theme(overrides: DeepPartial = {}): Theme { + return mergeInto(brand, overrides); +} diff --git a/packages/core/test/theme.test.ts b/packages/core/test/theme.test.ts new file mode 100644 index 0000000..efddd4e --- /dev/null +++ b/packages/core/test/theme.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { brand, theme } from "../src/index.js"; + +describe("brand tokens", () => { + it("carries the canonical reframe house values", () => { + expect(brand.color.accent).toBe("#FF4D00"); + expect(brand.color.accent2).toBe("#00C2A8"); + expect(brand.color.bg).toBe("#0A0C14"); + expect(brand.color.fg).toBe("#FFFFFF"); + expect(brand.color.muted).toBe("#8B93A7"); + expect(brand.type.family).toBe("Inter"); + expect(brand.type.headline).toEqual({ fontFamily: "Inter", fontSize: 48, fontWeight: 700 }); + expect(brand.motion.ease.base).toBe("easeOutCubic"); + expect(brand.motion.energy).toBe(0.5); + expect(brand.layout.size).toEqual({ width: 1920, height: 1080 }); + }); +}); + +describe("theme()", () => { + it("returns the house brand unchanged with no overrides", () => { + expect(theme()).toEqual(brand); + expect(theme()).not.toBe(brand); // a copy, not the same reference + }); + + it("overrides only the given leaf and deep-merges the rest", () => { + const t = theme({ color: { accent: "#1E90FF" } }); + expect(t.color.accent).toBe("#1E90FF"); // overridden + expect(t.color.accent2).toBe(brand.color.accent2); // sibling kept + expect(t.color.bg).toBe(brand.color.bg); // sibling kept + expect(t.type).toEqual(brand.type); // untouched subtree kept + }); + + it("merges nested overrides (e.g. motion.energy) without dropping siblings", () => { + const t = theme({ motion: { energy: 0.8 } }); + expect(t.motion.energy).toBe(0.8); + expect(t.motion.ease).toEqual(brand.motion.ease); + expect(t.motion.dur).toEqual(brand.motion.dur); + }); + + it("replaces arrays wholesale rather than merging them", () => { + const t = theme({ color: { dataViz: ["#000000"] } }); + expect(t.color.dataViz).toEqual(["#000000"]); + }); + + it("never mutates the house brand", () => { + const before = JSON.parse(JSON.stringify(brand)) as typeof brand; + theme({ color: { accent: "#000000" }, motion: { energy: 0.1 } }); + expect(brand).toEqual(before); + }); +}); From 87b348b6d93fc21bac6119fcdb2d64f945ba8aa6 Mon Sep 17 00:00:00 2001 From: Kiyeon Jeon Date: Mon, 22 Jun 2026 22:14:20 +0900 Subject: [PATCH 2/3] refactor(examples): reference brand tokens, drop duplicated palettes Source examples/lib/deviceKit.ts (shared by every device scene) and 6 non-golden scenes from the brand tokens instead of re-declaring inline BG/FG/MUTED/accent. Canonicalizes their drifted values to the house palette (e.g. a random #FF5A1F orange becomes brand.color.accent #FF4D00, off-brand backgrounds become brand.color.bg) for a consistent look. The golden-tracked scenes are left untouched, so the determinism snapshots stay byte-identical. Co-Authored-By: Claude Opus 4.8 --- examples/lib/deviceKit.ts | 15 ++++++++------- examples/scenes/character-show.ts | 5 +++-- examples/scenes/cursor-fx.ts | 5 +++-- examples/scenes/data-explainer.ts | 3 ++- examples/scenes/device-gallery.ts | 9 +++++---- examples/scenes/device-presets.ts | 6 +++--- examples/scenes/figure-styles.ts | 3 ++- 7 files changed, 26 insertions(+), 20 deletions(-) diff --git a/examples/lib/deviceKit.ts b/examples/lib/deviceKit.ts index 201a827..d0bb2b8 100644 --- a/examples/lib/deviceKit.ts +++ b/examples/lib/deviceKit.ts @@ -7,16 +7,17 @@ import { group, rect, text, ellipse, path, seq, par, tween, wait, motionPath, - devicePreset, deviceScreen, deviceScreenCenter, deviceBounds, + devicePreset, deviceScreen, deviceScreenCenter, deviceBounds, brand, type NodeIR, type DevicePresetName, type TimelineIR, type GroupProps, } from "@reframe/core"; -export const BG = "#070809"; -export const FG = "#FFFFFF"; -export const MUTED = "#7C8496"; -export const SUB = ["#FF4D00", "#00C2A8", "#7C5CFF", "#F59E0B", "#3B82F6", "#EC4899", "#10B981"]; -export const CARD = "#161922"; -export const CARD2 = "#1E222D"; +// Sourced from the house brand (DESIGN.md tokens) so device scenes stay on-brand. +export const BG = brand.color.bg; +export const FG = brand.color.fg; +export const MUTED = brand.color.muted; +export const SUB = [brand.color.accent, brand.color.accent2, brand.color.dataViz[1]!, "#F59E0B", "#3B82F6", "#EC4899", "#10B981"]; +export const CARD = brand.color.surface; +export const CARD2 = brand.color.surface2; type Extra = Partial & Record; export const ri = (id: string, x: number, y: number, w: number, h: number, fill: string, radius = 0, extra: Extra = {}): NodeIR => diff --git a/examples/scenes/character-show.ts b/examples/scenes/character-show.ts index 43e9c47..ae0e35a 100644 --- a/examples/scenes/character-show.ts +++ b/examples/scenes/character-show.ts @@ -6,10 +6,11 @@ import { scene, group, ellipse, text, seq, tween, wait, oscillate, humanoid, characterPreset, + brand, } from "@reframe/core"; -const BG = "#0A0E1A"; -const ACC = "#FF5A1F"; +const BG = brand.color.bg; +const ACC = brand.color.accent; const ID = "hero"; const CX = 960, BASE_Y = 440, SCALE = 1.7; const AT = [CX, BASE_Y] as [number, number]; diff --git a/examples/scenes/cursor-fx.ts b/examples/scenes/cursor-fx.ts index de0f420..9d84173 100644 --- a/examples/scenes/cursor-fx.ts +++ b/examples/scenes/cursor-fx.ts @@ -5,10 +5,11 @@ import { scene, group, rect, text, seq, tween, wait, cursor, cursorTo, cursorClick, + brand, } from "@reframe/core"; -const BG = "#0E1320"; -const ACC = "#FF5A1F"; +const BG = brand.color.bg; +const ACC = brand.color.accent; interface Btn { id: string; x: number; y: number; label: string } const BTNS: Btn[] = [ diff --git a/examples/scenes/data-explainer.ts b/examples/scenes/data-explainer.ts index abdc1bb..01120ec 100644 --- a/examples/scenes/data-explainer.ts +++ b/examples/scenes/data-explainer.ts @@ -11,6 +11,7 @@ import { cameraTo, splitText, textIn, + brand, type NodeIR, } from "@reframe/core"; @@ -21,7 +22,7 @@ import { // depth — does the whole vocabulary compose cleanly in one timeline? const W = 1920, H = 1080; -const FG = "#EAF0FF", DIM = "#7E88A8", ACCENT = "#54D6C0"; +const FG = brand.color.fg, DIM = brand.color.muted, ACCENT = brand.color.dataViz[0]!; const BARS = [ { id: "q1", label: "Q1", val: 1.2, c: "#3A4D6B" }, diff --git a/examples/scenes/device-gallery.ts b/examples/scenes/device-gallery.ts index 3a2ed4d..20e23b0 100644 --- a/examples/scenes/device-gallery.ts +++ b/examples/scenes/device-gallery.ts @@ -1,6 +1,7 @@ import { scene, group, rect, text, seq, par, stagger, tween, wait, devicePreset, deviceScreen, deviceBounds, row, + brand, type NodeIR, type DevicePresetName, type DeviceStyle, } from "@reframe/core"; @@ -12,9 +13,9 @@ import { // · bottom row — one device per kind, glass vs neon, premium lighting. const W = 1920, H = 1080; -const BG = "#070811"; -const FG = "#FFFFFF"; -const MUTED = "#8A91A6"; +const BG = brand.color.bg; +const FG = brand.color.fg; +const MUTED = brand.color.muted; const tx = (id: string, x: number, y: number, s: string, size: number, weight: number, fill: string): NodeIR => text({ id, x, y, anchor: "center", content: s, fontFamily: "Inter", fontSize: size, fontWeight: weight, fill }); @@ -43,7 +44,7 @@ const miniApp = (id: string, name: DevicePresetName, accent: string): NodeIR[] = ]; }; -const ACC = ["#FF4D00", "#00C2A8", "#7C5CFF", "#3B82F6", "#F59E0B"]; +const ACC = [brand.color.accent, brand.color.accent2, brand.color.dataViz[1]!, "#3B82F6", "#F59E0B"]; // ── top row: four glass phones, identical opts but distinct ids → auto-varied ── const TOP_Y = 360; diff --git a/examples/scenes/device-presets.ts b/examples/scenes/device-presets.ts index 77618d0..affe6d1 100644 --- a/examples/scenes/device-presets.ts +++ b/examples/scenes/device-presets.ts @@ -1,12 +1,12 @@ -import { scene, group, rect, text, seq, par, beat, tween, wait, devicePreset, deviceScreen, type NodeIR } from "@reframe/core"; +import { scene, group, rect, text, seq, par, beat, tween, wait, devicePreset, deviceScreen, brand, type NodeIR } from "@reframe/core"; // Three devices from devicePreset(), side by side, each with content clipped to // its screen. devicePreset is a STATIC node generator — the motion (entrance + // in-screen scroll) is plain tweens on the device group ids and the content // handle the caller nests inside `content`. -const BG = "#06070A"; -const MUTED = "#7A8194"; +const BG = brand.color.bg; +const MUTED = brand.color.muted; // --- phone: a scrollable feed (authored in the phone's screen-local coords) --- const PS = deviceScreen("phone"); // { width: 352, height: 736 } diff --git a/examples/scenes/figure-styles.ts b/examples/scenes/figure-styles.ts index 45816be..a875cf9 100644 --- a/examples/scenes/figure-styles.ts +++ b/examples/scenes/figure-styles.ts @@ -6,9 +6,10 @@ import { scene, ellipse, text, seq, par, tween, wait, oscillate, figure, characterPreset, + brand, } from "@reframe/core"; -const BG = "#0E1424"; +const BG = brand.color.bg; const Y = 470, S = 1.35; const ce = (id: string, x: number, y: number, d: number, fill: string, opacity = 1) => From c7670071f3d7125ca3422458190243f211badc43 Mon Sep 17 00:00:00 2001 From: Kiyeon Jeon Date: Mon, 22 Jun 2026 22:14:20 +0900 Subject: [PATCH 3/3] docs(brand): document brand/theme() in DESIGN.md and the eDSL guide Show importing `brand` and referencing tokens (`fill: brand.color.accent`, `...brand.type.headline`) and building a custom kit with `theme({...})`. Note that theme.ts is the source of truth the doc mirrors. Co-Authored-By: Claude Opus 4.8 --- DESIGN.md | 21 ++++++++++++++++++--- docs/guides/edsl-guide.md | 17 +++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 3f92488..01227c0 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -59,9 +59,24 @@ When a scene's brief does not specify colors, fonts, or tone, use the tokens abo the default. When the brief does specify them, that brief wins; this file is the fallback and the reference, not a hard constraint. -These are documentation tokens. There is no engine field that reads this file yet (that -is a later phase). For now scenes restate the values; this doc is the single place that -says what the values should be. +These tokens also exist in code. `@reframe/core` (and the `reframe-video` package) export a +`brand` object with the same values, so a scene can reference a token instead of restating a +literal: + +```ts +import { scene, text, rect, brand, theme } from "reframe-video"; + +text({ id: "title", ...brand.type.headline, content: "Q4", fill: brand.color.fg }); +rect({ id: "bar", fill: brand.color.accent }); + +// a different brand kit, reusable across scenes: overrides deep-merge onto the house brand +const myBrand = theme({ color: { accent: "#1E90FF" } }); +``` + +`packages/core/src/theme.ts` is the source of truth for the values; this document mirrors and +explains them. (`brand` is pure data, so referencing a token renders byte-identical to writing +the literal. An engine-level `theme` field that the compiler resolves and an overlay can re-skin +is a later phase.) ## Brand diff --git a/docs/guides/edsl-guide.md b/docs/guides/edsl-guide.md index ab5a05f..5f1eda4 100644 --- a/docs/guides/edsl-guide.md +++ b/docs/guides/edsl-guide.md @@ -44,6 +44,23 @@ like one system. The full reference (with rationale) is `DESIGN.md` in the repo (entrances), `thud` (impact), `click`/`confirm` (UI). Anchor cues to labels. - Layout: 1920x1080 at 30fps, ~96px safe margin, radius 6 (bars) / 24 (cards) / 56 (panels). +These tokens are also in code. Import `brand` and reference a token instead of restating a +literal, so the palette lives in one place: + +```ts +import { scene, text, rect, brand, theme } from "@reframe/core"; + +text({ id: "title", ...brand.type.headline, content: "Q4", fill: brand.color.fg }); +rect({ id: "bar", fill: brand.color.accent }); + +// a reusable custom kit: overrides deep-merge onto the house brand +const myBrand = theme({ color: { accent: "#1E90FF" } }); +``` + +`brand.color.*`, `brand.type.{display,headline,body,label}` (spreadable into `text()`), +`brand.motion.*`, and `brand.layout.*` cover the tokens above. Referencing a token is +byte-identical to writing the literal. + ## Nodes Factories return plain data. Every node needs a unique `id`.