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
21 changes: 18 additions & 3 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 17 additions & 0 deletions docs/guides/edsl-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
15 changes: 8 additions & 7 deletions examples/lib/deviceKit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GroupProps> & Record<string, unknown>;
export const ri = (id: string, x: number, y: number, w: number, h: number, fill: string, radius = 0, extra: Extra = {}): NodeIR =>
Expand Down
5 changes: 3 additions & 2 deletions examples/scenes/character-show.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
5 changes: 3 additions & 2 deletions examples/scenes/cursor-fx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand Down
3 changes: 2 additions & 1 deletion examples/scenes/data-explainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
cameraTo,
splitText,
textIn,
brand,
type NodeIR,
} from "@reframe/core";

Expand All @@ -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" },
Expand Down
9 changes: 5 additions & 4 deletions examples/scenes/device-gallery.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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 });
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 3 additions & 3 deletions examples/scenes/device-presets.ts
Original file line number Diff line number Diff line change
@@ -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 }
Expand Down
3 changes: 2 additions & 1 deletion examples/scenes/figure-styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
116 changes: 116 additions & 0 deletions packages/core/src/theme.ts
Original file line number Diff line number Diff line change
@@ -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<T> = {
[K in keyof T]?: T[K] extends unknown[] ? T[K] : T[K] extends object ? DeepPartial<T[K]> : T[K];
};

function isPlainObject(v: unknown): v is Record<string, unknown> {
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<T>(base: T, over: unknown): T {
if (!isPlainObject(base) || !isPlainObject(over)) return (over as T) ?? base;
const out: Record<string, unknown> = { ...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> = {}): Theme {
return mergeInto(brand, overrides);
}
50 changes: 50 additions & 0 deletions packages/core/test/theme.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading