@waits/cadence 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,16 +21,22 @@ no generic AI-video look, no hosted service. Free and open source.
21
21
  npx skills add ryanwaits/cadence # adds the skill to Claude Code / Cursor / Codex
22
22
  ```
23
23
 
24
- Then just ask your agent. Or drive the CLI directly:
24
+ Then just ask your agent. Or drive the CLI directly — install the `cadence`
25
+ binary globally, or run it with `npx` (no install):
25
26
 
26
27
  ```bash
27
- npx cadence create --release owner/name --install "npm i your-pkg" # a repo a video
28
- npx cadence create my.beats.json --format 9x16 # a beats file → a video
29
- npx cadence themes # list built-in themes
28
+ npm i -g @waits/cadence # a persistent `cadence` binary
29
+ cadence create --release owner/name --install "npm i your-pkg" # a repo → a video
30
+ cadence create my.beats.json --format 9x16 # a beats file → a video
31
+ cadence themes # list built-in themes
32
+
33
+ npx @waits/cadence create --release owner/name --install "npm i …" # …or run without installing
30
34
  ```
31
35
 
32
36
  A render runs entirely on your machine (or your own GitHub Actions) — free, no API key.
33
37
 
38
+ → **New here? Start with the [guides](docs/guides/)** — install, changelog, announcement, milestone, branding, and CI walkthroughs.
39
+
34
40
  ## Why it doesn't look AI-generated
35
41
 
36
42
  Most AI video either fabricates a fake product or reaches for the same neon-on-black
@@ -60,6 +66,7 @@ milestones. The agent (via the skill) writes the beats; you tweak and render.
60
66
  Panels visualize what a change *produces* — pick by result:
61
67
  `feed · data-table · status · stat · proof · stream-resume · fork · upload-progress · diagram`.
62
68
 
69
+ - **[docs/guides/](docs/guides/)** — task-oriented walkthroughs (install, changelog, announcement, milestone, branding, iterate, CI).
63
70
  - **[docs/recipes.md](docs/recipes.md)** — worked examples (changelog, announcement, showcase, brand-from-URL, CI).
64
71
  - **[docs/gallery.md](docs/gallery.md)** — the same engine, visibly different videos.
65
72
  - **[WALKTHROUGH.md](WALKTHROUGH.md)** — architecture + how to adjust each part.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waits/cadence",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -53,6 +53,7 @@
53
53
  "devDependencies": {
54
54
  "@changesets/changelog-git": "^0.2.1",
55
55
  "@changesets/cli": "^2.31.0",
56
+ "@types/node": "22",
56
57
  "@types/react": "19.2.16",
57
58
  "typescript": "5.7.3"
58
59
  }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Heuristic beats checks, as a pure function so both `cadence audit` and
3
+ * `cadence storyboard` can surface them. Advisory only — no auto-fix. Mirrors
4
+ * the authoring guidance in the cadence skill (3-6 beats, ~2-10s each, short
5
+ * declarative headlines, an install/CTA closer).
6
+ */
7
+ import type { Beat } from "../src/schema/beats";
8
+ import { FPS } from "./_beats";
9
+
10
+ export type Level = "error" | "warn" | "info";
11
+ export type Finding = { level: Level; msg: string };
12
+
13
+ /** Ranked findings for a parsed beats array. */
14
+ export function auditBeats(beats: Beat[]): Finding[] {
15
+ const findings: Finding[] = [];
16
+ const add = (level: Level, msg: string) => findings.push({ level, msg });
17
+
18
+ // 1. Beat count — skill guidance is to keep it tight (3-6).
19
+ if (beats.length < 2) add("warn", `only ${beats.length} beat — most videos want 3-6`);
20
+ if (beats.length > 7) add("warn", `${beats.length} beats — tighten toward 3-6; long videos lose attention`);
21
+
22
+ // 2. Per-beat duration (≈2-10s at 30fps).
23
+ beats.forEach((b, i) => {
24
+ const s = (b.durationInFrames / FPS).toFixed(1);
25
+ if (b.durationInFrames < 45) add("warn", `beat ${i + 1} "${b.headline}" is ${s}s — too short to read`);
26
+ else if (b.durationInFrames > 360) add("warn", `beat ${i + 1} "${b.headline}" is ${s}s — likely too long`);
27
+ });
28
+
29
+ // 3. Headline length — short + declarative.
30
+ beats.forEach((b, i) => {
31
+ if (b.headline.length > 48)
32
+ add("warn", `beat ${i + 1} headline is ${b.headline.length} chars — shorten ("${b.headline.slice(0, 40)}…")`);
33
+ });
34
+
35
+ // 4. Motion monotony — every beat with an explicit enter uses the same one.
36
+ const enters = beats.map((b) => b.headlineMotion?.enter).filter(Boolean);
37
+ if (enters.length >= 3 && new Set(enters).size === 1)
38
+ add("info", `every headline uses the "${enters[0]}" enter — vary it for rhythm`);
39
+
40
+ // 5. Install/CTA closer — heuristic: last beat centered with a bash command or a badge/caption.
41
+ const last = beats[beats.length - 1];
42
+ const isCloser = last.layout === "center" && (last.code?.lang === "bash" || !!last.badge || !!last.caption);
43
+ if (!isCloser) add("info", "last beat doesn't look like an install/CTA closer (centered + install command)");
44
+
45
+ return findings;
46
+ }
47
+
48
+ const order: Record<Level, number> = { error: 0, warn: 1, info: 2 };
49
+ /** Ranked error → warn → info (stable copy). */
50
+ export const rankFindings = (findings: Finding[]): Finding[] =>
51
+ [...findings].sort((a, b) => order[a.level] - order[b.level]);
52
+
53
+ export const ICON: Record<Level, string> = { error: "✗", warn: "▲", info: "·" };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Shared beats helpers: load + validate a beats module, and compute per-beat
3
+ * timings. Used by render / audit / storyboard so the "how do I read a beats
4
+ * file" logic lives in one place. Beats are laid end-to-end by the Changelog
5
+ * composition (`<Series.Sequence durationInFrames>`), so a beat's start frame is
6
+ * the running sum of prior durations — see `src/components/Changelog.tsx`.
7
+ */
8
+ import { readFileSync } from "node:fs";
9
+ import { resolve } from "node:path";
10
+ import { pathToFileURL } from "node:url";
11
+ import { changelogSchema, type Beat, type ChangelogVideo } from "../src/schema/beats";
12
+
13
+ export const FPS = 30;
14
+
15
+ /** Load a `.ts`/`.js` module (default export) or a `.json` file, then validate. */
16
+ export async function loadBeats(file: string): Promise<ChangelogVideo> {
17
+ const raw = file.endsWith(".json")
18
+ ? JSON.parse(readFileSync(resolve(file), "utf8"))
19
+ : (await import(pathToFileURL(resolve(file)).href)).default;
20
+ return changelogSchema.parse(raw);
21
+ }
22
+
23
+ export type BeatTiming = { start: number; mid: number; dur: number };
24
+
25
+ /**
26
+ * Per-beat `{start, mid, dur}` frames + total. `mid` (start + ⌊dur/2⌋) is the
27
+ * representative frame for a still — entrance motion has settled by then.
28
+ */
29
+ export function beatTimings(beats: Beat[]): { timings: BeatTiming[]; totalFrames: number } {
30
+ let start = 0;
31
+ const timings = beats.map((b) => {
32
+ const dur = b.durationInFrames;
33
+ const t = { start, mid: start + Math.floor(dur / 2), dur };
34
+ start += dur;
35
+ return t;
36
+ });
37
+ return { timings, totalFrames: start };
38
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Project-local `.cadence/` resolution shared by render / storyboard / make.
3
+ *
4
+ * A repo captures its own cadence setup under `<project>/.cadence/`:
5
+ * - `theme.json` — brand (colors, mono font, code syntax theme, code chrome)
6
+ * cadence discovers it automatically when run against the project, and writes
7
+ * **outputs alongside it** (`<project>/.cadence/out/`) so renders are anchored to
8
+ * the project, not the current directory. No project-specific files live in the
9
+ * cadence engine itself.
10
+ *
11
+ * Theme precedence: --theme-file > --theme <name> > .cadence/theme.json > default.
12
+ * Output dir: --out <dir> > <project>/.cadence/out > ./out.
13
+ */
14
+ import { existsSync } from "node:fs";
15
+ import { dirname, join, resolve } from "node:path";
16
+ import { THEMES } from "../src/theme";
17
+
18
+ /** Walk up from `start` (max 6 levels) for a `.cadence` directory. */
19
+ export function findCadenceDir(start: string): string | undefined {
20
+ let dir = resolve(start);
21
+ for (let i = 0; i < 6; i++) {
22
+ const p = join(dir, ".cadence");
23
+ if (existsSync(p)) return p;
24
+ const parent = dirname(dir);
25
+ if (parent === dir) break;
26
+ dir = parent;
27
+ }
28
+ return undefined;
29
+ }
30
+
31
+ /** A project's `.cadence/theme.json`, if one exists near `start`. */
32
+ export function discoverProjectTheme(start: string): string | undefined {
33
+ const cad = findCadenceDir(start);
34
+ if (!cad) return undefined;
35
+ const p = join(cad, "theme.json");
36
+ return existsSync(p) ? p : undefined;
37
+ }
38
+
39
+ /**
40
+ * Where renders are written. Precedence: an explicit `--out <dir>` > a project's
41
+ * `<project>/.cadence/out` (so outputs are anchored to the project regardless of
42
+ * cwd) > `./out`. `beatsFile` (when present) anchors discovery near the beats
43
+ * file; otherwise discovery starts from the current directory.
44
+ */
45
+ export function resolveOutDir(opts: { outFlag?: string; beatsFile?: string }): string {
46
+ if (opts.outFlag) return resolve(opts.outFlag);
47
+ const start = opts.beatsFile ? dirname(resolve(opts.beatsFile)) : process.cwd();
48
+ const cad = findCadenceDir(start);
49
+ return cad ? join(cad, "out") : "out";
50
+ }
51
+
52
+ /**
53
+ * Resolve which theme to use. Returns the (possibly auto-discovered) `themeFile`
54
+ * and/or named `theme`. Exits on an unknown named theme. Logs when a project
55
+ * theme is auto-discovered so it's never a silent surprise.
56
+ */
57
+ export function resolveTheme(opts: { theme?: string; themeFile?: string; beatsFile?: string }): {
58
+ theme?: string;
59
+ themeFile?: string;
60
+ } {
61
+ let { theme, themeFile } = opts;
62
+ if (!themeFile && !theme && opts.beatsFile) {
63
+ const found = discoverProjectTheme(dirname(resolve(opts.beatsFile)));
64
+ if (found) {
65
+ themeFile = found;
66
+ console.error(`· using project theme ${found}`);
67
+ }
68
+ }
69
+ if (theme && !themeFile && !THEMES[theme]) {
70
+ console.error(`unknown --theme "${theme}". have: ${Object.keys(THEMES).join(", ")}`);
71
+ process.exit(1);
72
+ }
73
+ return { theme, themeFile };
74
+ }
package/scripts/audit.ts CHANGED
@@ -1,14 +1,12 @@
1
1
  /**
2
2
  * `cadence audit <beats>` — static, heuristic checks on a beats file. Advisory
3
3
  * only: ranked findings, no auto-fix (edit content by hand; use `redesign` to
4
- * re-skin). Mirrors the authoring guidance in the cadence skill.
4
+ * re-skin). The checks live in `_audit.ts` so `storyboard` can reuse them.
5
5
  *
6
6
  * cadence audit src/content/streams-launch.beats.ts
7
7
  */
8
- import { readFileSync } from "node:fs";
9
- import { resolve } from "node:path";
10
- import { pathToFileURL } from "node:url";
11
- import { changelogSchema } from "../src/schema/beats";
8
+ import { loadBeats } from "./_beats";
9
+ import { auditBeats, ICON, rankFindings } from "./_audit";
12
10
 
13
11
  const file = process.argv.slice(2).find((a) => !a.startsWith("-"));
14
12
  if (!file) {
@@ -16,48 +14,9 @@ if (!file) {
16
14
  process.exit(1);
17
15
  }
18
16
 
19
- const raw = file.endsWith(".json")
20
- ? JSON.parse(readFileSync(resolve(file), "utf8"))
21
- : (await import(pathToFileURL(resolve(file)).href)).default;
22
- const parsed = changelogSchema.parse(raw);
17
+ const parsed = await loadBeats(file);
23
18
  const beats = parsed.beats;
24
- const FPS = 30;
25
-
26
- type Level = "error" | "warn" | "info";
27
- const findings: { level: Level; msg: string }[] = [];
28
- const add = (level: Level, msg: string) => findings.push({ level, msg });
29
-
30
- // 1. Beat count — skill guidance is to keep it tight (3-6).
31
- if (beats.length < 2) add("warn", `only ${beats.length} beat — most videos want 3-6`);
32
- if (beats.length > 7) add("warn", `${beats.length} beats — tighten toward 3-6; long videos lose attention`);
33
-
34
- // 2. Per-beat duration (≈2-10s at 30fps).
35
- beats.forEach((b, i) => {
36
- const s = (b.durationInFrames / FPS).toFixed(1);
37
- if (b.durationInFrames < 45) add("warn", `beat ${i + 1} "${b.headline}" is ${s}s — too short to read`);
38
- else if (b.durationInFrames > 360) add("warn", `beat ${i + 1} "${b.headline}" is ${s}s — likely too long`);
39
- });
40
-
41
- // 3. Headline length — short + declarative.
42
- beats.forEach((b, i) => {
43
- if (b.headline.length > 48)
44
- add("warn", `beat ${i + 1} headline is ${b.headline.length} chars — shorten ("${b.headline.slice(0, 40)}…")`);
45
- });
46
-
47
- // 4. Motion monotony — every beat with an explicit enter uses the same one.
48
- const enters = beats.map((b) => b.headlineMotion?.enter).filter(Boolean);
49
- if (enters.length >= 3 && new Set(enters).size === 1)
50
- add("info", `every headline uses the "${enters[0]}" enter — vary it for rhythm`);
51
-
52
- // 5. Install/CTA closer — heuristic: last beat centered with a bash command or a badge/caption.
53
- const last = beats[beats.length - 1];
54
- const isCloser = last.layout === "center" && (last.code?.lang === "bash" || !!last.badge || !!last.caption);
55
- if (!isCloser) add("info", "last beat doesn't look like an install/CTA closer (centered + install command)");
56
-
57
- // Report, ranked: error → warn → info. "Issues" = error+warn; info are notes.
58
- const order: Record<Level, number> = { error: 0, warn: 1, info: 2 };
59
- findings.sort((a, b) => order[a.level] - order[b.level]);
60
- const icon: Record<Level, string> = { error: "✗", warn: "▲", info: "·" };
19
+ const findings = rankFindings(auditBeats(beats));
61
20
  const issues = findings.filter((f) => f.level !== "info").length;
62
21
 
63
22
  if (issues === 0) {
@@ -66,4 +25,4 @@ if (issues === 0) {
66
25
  } else {
67
26
  console.log(`${file}: ${issues} issue${issues > 1 ? "s" : ""} across ${beats.length} beats`);
68
27
  }
69
- for (const f of findings) console.log(` ${icon[f.level]} ${f.msg}`);
28
+ for (const f of findings) console.log(` ${ICON[f.level]} ${f.msg}`);
package/scripts/cli.ts CHANGED
@@ -22,6 +22,7 @@ const sub = rawSub ? (ALIAS[rawSub] ?? rawSub) : rawSub;
22
22
  const SCRIPTS: Record<string, string> = {
23
23
  guide: "scripts/guide.ts", // interactive walkthrough
24
24
  render: "scripts/render.ts", // a beats file → a video (create delegates here)
25
+ storyboard: "scripts/storyboard.ts", // a beats file → a preview sheet (no MP4)
25
26
  study: "scripts/theme.ts", // brand color / URL / screenshot → a theme
26
27
  audit: "scripts/audit.ts", // check a beats file for issues
27
28
  redesign: "scripts/redesign.ts", // re-skin a beats file
@@ -33,6 +34,7 @@ const HELP = `cadence — turn a repo / release into a changelog or announcement
33
34
 
34
35
  commands:
35
36
  create a repo OR a beats file → a video cadence create --release owner/name --install "npm i pkg"
37
+ storyboard a beats file → a preview sheet cadence storyboard x.beats.ts (or: cadence create … --dry-run)
36
38
  study a brand color / URL → a theme cadence study --from-url https://acme.dev --name acme
37
39
  audit check a beats file for issues cadence audit src/content/x.beats.ts
38
40
  redesign re-skin a beats file (new look) cadence redesign x.beats.ts --theme slate
@@ -45,7 +47,9 @@ utilities:
45
47
  templates list available templates
46
48
  themes list available themes
47
49
 
48
- flags shared by create/render/redesign: --format 16x9|1x1|9x16, --theme <name>, --theme-file <path>, --frame <n>`;
50
+ flags shared by create/render/redesign: --format 16x9|1x1|9x16, --theme <name>, --theme-file <path>, --frame <n>, --out <dir>
51
+ outputs: written to <project>/.cadence/out when run against a repo with a .cadence/ dir, else ./out (override with --out)
52
+ preview before rendering: cadence storyboard <beats> · cadence create … --dry-run (plan + one still per beat, no MP4)`;
49
53
 
50
54
  if (!sub || sub === "help" || sub === "--help") {
51
55
  console.log(HELP);
@@ -65,7 +69,11 @@ if (sub === "themes") {
65
69
  function resolveScript(verb: string, args: string[]): string | undefined {
66
70
  if (verb === "create") {
67
71
  const beatsFile = args.find((a) => !a.startsWith("-") && /\.(beats\.)?(ts|js|json)$/.test(a));
68
- return beatsFile ? "scripts/render.ts" : "scripts/make.ts";
72
+ const dryRun = args.includes("--dry-run");
73
+ // beats-file flow: --dry-run → storyboard (preview sheet), else render (MP4).
74
+ if (beatsFile) return dryRun ? "scripts/storyboard.ts" : "scripts/render.ts";
75
+ // repo flow: make.ts handles --dry-run itself (generates beats → storyboard).
76
+ return "scripts/make.ts";
69
77
  }
70
78
  return SCRIPTS[verb];
71
79
  }
package/scripts/make.ts CHANGED
@@ -13,6 +13,7 @@ import { manifestFromChangelogText, manifestFromReleaseBody } from "../src/adapt
13
13
  import type { BackgroundSpec } from "../src/templates";
14
14
  import { TEMPLATES } from "../src/templates";
15
15
  import { binPath, pkgFile } from "./_pkg";
16
+ import { resolveOutDir } from "./_theme";
16
17
 
17
18
  const args = process.argv.slice(2);
18
19
  const flag = (n: string) => {
@@ -67,10 +68,22 @@ const beats = template(manifest, {
67
68
 
68
69
  console.error(`· ${manifest.product} ${manifest.version}: ${manifest.features.length} features${manifest.dropped ? ` (+${manifest.dropped} dropped)` : ""} → ${templateName}`);
69
70
 
70
- // 3. Write beats JSON + render
71
- mkdirSync("out", { recursive: true });
72
- const jsonPath = join("out", `make-${manifest.product}.beats.json`);
71
+ // 3. Write beats JSON, then render — or, with --dry-run, storyboard it (preview
72
+ // sheet + plan, no MP4). Outputs go to the project's .cadence/out (or --out).
73
+ const outDir = resolveOutDir({ outFlag: flag("--out") });
74
+ mkdirSync(outDir, { recursive: true });
75
+ const jsonPath = join(outDir, `make-${manifest.product}.beats.json`);
73
76
  writeFileSync(jsonPath, JSON.stringify(beats));
77
+ // Pin the child to the same out dir so beats + renders colocate.
78
+ const outArgs = ["--out", outDir];
79
+
80
+ if (args.includes("--dry-run")) {
81
+ // repo → storyboard. --frame is meaningless here; --theme-file is supported.
82
+ const passthru = ["--format", "--theme", "--theme-file"].flatMap((f) => (flag(f) ? [f, flag(f)!] : []));
83
+ const res = spawnSync(binPath("tsx"), [pkgFile("scripts/storyboard.ts"), jsonPath, ...passthru, ...outArgs], { stdio: "inherit" });
84
+ process.exit(res.status ?? 0);
85
+ }
86
+
74
87
  const passthru = ["--format", "--theme", "--frame"].flatMap((f) => (flag(f) ? [f, flag(f)!] : []));
75
- const res = spawnSync(binPath("tsx"), [pkgFile("scripts/render.ts"), jsonPath, ...passthru], { stdio: "inherit" });
88
+ const res = spawnSync(binPath("tsx"), [pkgFile("scripts/render.ts"), jsonPath, ...passthru, ...outArgs], { stdio: "inherit" });
76
89
  process.exit(res.status ?? 0);
package/scripts/render.ts CHANGED
@@ -9,10 +9,10 @@
9
9
  import { spawnSync } from "node:child_process";
10
10
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
11
  import { basename, join, resolve } from "node:path";
12
- import { pathToFileURL } from "node:url";
13
- import { changelogSchema, type Format } from "../src/schema/beats";
14
- import { THEMES } from "../src/theme";
12
+ import { type Format } from "../src/schema/beats";
13
+ import { loadBeats } from "./_beats";
15
14
  import { binPath, pkgFile } from "./_pkg";
15
+ import { resolveOutDir, resolveTheme } from "./_theme";
16
16
 
17
17
  const args = process.argv.slice(2);
18
18
  const getFlag = (name: string) => {
@@ -29,34 +29,27 @@ if (!file) {
29
29
  }
30
30
 
31
31
  // Accept either a `.beats.ts` module or a plain `.json` beats file.
32
- const raw = file.endsWith(".json")
33
- ? JSON.parse(readFileSync(resolve(file), "utf8"))
34
- : (await import(pathToFileURL(resolve(file)).href)).default;
35
- const parsed = changelogSchema.parse(raw);
32
+ const parsed = await loadBeats(file);
36
33
 
37
34
  const fmt = getFlag("--format") as Format | undefined;
38
35
  if (fmt) parsed.format = fmt;
39
36
  const frame = getFlag("--frame");
40
- const theme = getFlag("--theme");
41
- const themeFile = getFlag("--theme-file");
42
- if (theme && !themeFile && !THEMES[theme]) {
43
- console.error(`unknown --theme "${theme}". have: ${Object.keys(THEMES).join(", ")}`);
44
- process.exit(1);
45
- }
37
+ const { theme, themeFile } = resolveTheme({ theme: getFlag("--theme"), themeFile: getFlag("--theme-file"), beatsFile: file });
46
38
  const env: NodeJS.ProcessEnv = { ...process.env };
47
39
  if (theme) env.REMOTION_VIDEO_THEME = theme;
48
40
  if (themeFile) env.REMOTION_VIDEO_THEME_JSON = readFileSync(resolve(themeFile), "utf8");
49
41
 
50
- mkdirSync("out", { recursive: true });
42
+ const outDir = resolveOutDir({ outFlag: getFlag("--out"), beatsFile: file });
43
+ mkdirSync(outDir, { recursive: true });
51
44
  const name = basename(file).replace(/\.beats\.(ts|js|json)$/, "").replace(/\.(ts|js|json)$/, "");
52
- const propsPath = join("out", `.props-${name}-${parsed.format}.json`);
45
+ const propsPath = join(outDir, `.props-${name}-${parsed.format}.json`);
53
46
  writeFileSync(propsPath, JSON.stringify(parsed));
54
47
 
55
48
  const bin = binPath("remotion");
56
49
  const common = [pkgFile("src/index.ts"), "Changelog"];
57
50
  const suffix = theme && theme !== "default" ? `-${theme}` : "";
58
51
  const res = frame
59
- ? spawnSync(bin, ["still", ...common, join("out", `${name}-${parsed.format}${suffix}-f${frame}.png`), `--props=${propsPath}`, `--frame=${frame}`], { stdio: "inherit", env })
60
- : spawnSync(bin, ["render", ...common, join("out", `${name}-${parsed.format}${suffix}.mp4`), `--props=${propsPath}`, "--image-format=jpeg"], { stdio: "inherit", env });
52
+ ? spawnSync(bin, ["still", ...common, join(outDir, `${name}-${parsed.format}${suffix}-f${frame}.png`), `--props=${propsPath}`, `--frame=${frame}`], { stdio: "inherit", env })
53
+ : spawnSync(bin, ["render", ...common, join(outDir, `${name}-${parsed.format}${suffix}.mp4`), `--props=${propsPath}`, "--image-format=jpeg"], { stdio: "inherit", env });
61
54
 
62
55
  process.exit(res.status ?? 0);
package/scripts/smoke.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * bun run check:render
7
7
  */
8
8
  import { spawnSync } from "node:child_process";
9
+ import { existsSync, rmSync } from "node:fs";
9
10
  import { resolve } from "node:path";
10
11
 
11
12
  type Case = { file: string; frame: number; theme?: string; format?: string; note: string };
@@ -36,8 +37,25 @@ for (const c of CASES) {
36
37
  }
37
38
  }
38
39
 
40
+ let total = CASES.length;
41
+
42
+ // Storyboard: the dry-run preview path (plan + one still per beat → a sheet).
43
+ // The only automated guard for the data-URI contact-sheet render.
44
+ total++;
45
+ process.stdout.write("· storyboard sheet (clarinet-3.18) … ");
46
+ const sbOut = resolve("out/clarinet-3.18.storyboard.png");
47
+ rmSync(sbOut, { force: true });
48
+ const sb = spawnSync(bin, ["scripts/storyboard.ts", "src/content/clarinet-3.18.beats.ts"], { stdio: ["ignore", "ignore", "pipe"], encoding: "utf8" });
49
+ if (sb.status === 0 && existsSync(sbOut)) {
50
+ console.log("ok");
51
+ } else {
52
+ failed++;
53
+ console.log("FAIL");
54
+ console.error((sb.stderr || "").split("\n").slice(-6).join("\n"));
55
+ }
56
+
39
57
  if (failed) {
40
- console.error(`\n✗ ${failed}/${CASES.length} render cases failed`);
58
+ console.error(`\n✗ ${failed}/${total} cases failed`);
41
59
  process.exit(1);
42
60
  }
43
- console.log(`\n✓ ${CASES.length} render cases ok`);
61
+ console.log(`\n✓ ${total} cases ok`);
@@ -0,0 +1,125 @@
1
+ /**
2
+ * `cadence storyboard <beats>` — a dry-run / mock: print the whole video plan
3
+ * (beat-by-beat outline + pacing) AND render a single contact-sheet PNG with one
4
+ * representative still per beat — no MP4. The cheap artifact a user + the skill
5
+ * iterate on (features, theme, background) before committing to a full render.
6
+ *
7
+ * Per-beat stills are rendered at the beat's mid frame (entrance motion settled),
8
+ * downscaled, encoded as data URIs, and laid into the `Storyboard` composition —
9
+ * data URIs (not staticFile/public) so it works from a global install too.
10
+ *
11
+ * cadence storyboard src/content/streams-launch.beats.ts [--theme slate] [--format 9x16]
12
+ */
13
+ import { spawnSync } from "node:child_process";
14
+ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
15
+ import { basename, join } from "node:path";
16
+ import { type Beat, type Format } from "../src/schema/beats";
17
+ import type { StoryboardCell, StoryboardProps } from "../src/components/Storyboard";
18
+ import { auditBeats, ICON, rankFindings } from "./_audit";
19
+ import { beatTimings, FPS, loadBeats } from "./_beats";
20
+ import { binPath, pkgFile } from "./_pkg";
21
+ import { resolveOutDir, resolveTheme } from "./_theme";
22
+
23
+ const args = process.argv.slice(2);
24
+ const getFlag = (name: string) => {
25
+ const eq = args.find((a) => a.startsWith(`${name}=`));
26
+ if (eq) return eq.split("=")[1];
27
+ const i = args.indexOf(name);
28
+ return i >= 0 ? args[i + 1] : undefined;
29
+ };
30
+
31
+ const file = args.find((a) => !a.startsWith("-"));
32
+ if (!file) {
33
+ console.error("usage: cadence storyboard <beats.ts|.json> [--theme <name>] [--theme-file <path>] [--format 16x9|1x1|9x16]");
34
+ process.exit(1);
35
+ }
36
+
37
+ const { theme, themeFile } = resolveTheme({ theme: getFlag("--theme"), themeFile: getFlag("--theme-file"), beatsFile: file });
38
+
39
+ /** Short label for a beat's backdrop (omitted background = the procedural default). */
40
+ const bgTag = (b: Beat): string => {
41
+ const bg = b.background;
42
+ if (!bg) return "shapes";
43
+ if (bg.src) return "image";
44
+ if (bg.gradient) return "gradient";
45
+ if (bg.solid) return "solid";
46
+ return "shapes";
47
+ };
48
+
49
+ const parsed = await loadBeats(file);
50
+ const fmt = (getFlag("--format") as Format | undefined) ?? parsed.format;
51
+ parsed.format = fmt;
52
+ const themeLabel = themeFile ? `custom (${basename(themeFile)})` : (theme ?? "default");
53
+ const { timings, totalFrames } = beatTimings(parsed.beats);
54
+ const name = basename(file).replace(/\.beats\.(ts|js|json)$/, "").replace(/\.(ts|js|json)$/, "");
55
+
56
+ // --- 1. The plan (text) ---
57
+ console.log(`\nstoryboard: ${file}`);
58
+ console.log(` ${parsed.beats.length} beats · ${(totalFrames / FPS).toFixed(1)}s · ${fmt} · theme ${themeLabel}\n`);
59
+
60
+ parsed.beats.forEach((b, i) => {
61
+ const t = timings[i];
62
+ const start = `${(t.start / FPS).toFixed(1)}s`.padStart(6);
63
+ const dur = `${(t.dur / FPS).toFixed(1)}s`.padStart(5);
64
+ const layout = b.layout.padEnd(6);
65
+ const panel = (b.panel?.kind ?? "—").padEnd(14);
66
+ const bg = bgTag(b).padEnd(8);
67
+ console.log(` ${String(i + 1).padStart(2)} @${start} ${dur} ${layout} ${panel} ${bg} ${b.headline}`);
68
+ });
69
+
70
+ const findings = rankFindings(auditBeats(parsed.beats));
71
+ if (findings.length) {
72
+ console.log("");
73
+ for (const f of findings) console.log(` ${ICON[f.level]} ${f.msg}`);
74
+ }
75
+
76
+ // --- 2. The sheet (one still per beat → a single PNG) ---
77
+ const env: NodeJS.ProcessEnv = { ...process.env };
78
+ if (theme) env.REMOTION_VIDEO_THEME = theme;
79
+ if (themeFile) env.REMOTION_VIDEO_THEME_JSON = readFileSync(themeFile, "utf8");
80
+
81
+ const outDir = resolveOutDir({ outFlag: getFlag("--out"), beatsFile: file });
82
+ mkdirSync(outDir, { recursive: true });
83
+ const tmp = join(outDir, `.sb-${name}`);
84
+ mkdirSync(tmp, { recursive: true });
85
+ const propsPath = join(tmp, "props.json");
86
+ writeFileSync(propsPath, JSON.stringify(parsed));
87
+
88
+ const bin = binPath("remotion");
89
+ const entry = pkgFile("src/index.ts");
90
+ const TRANSPARENT_PX =
91
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+P+/HgAFhAJ/wlseKgAAAABJRU5ErkJggg==";
92
+
93
+ console.log(`\nrendering ${parsed.beats.length} stills…`);
94
+ const cells: StoryboardCell[] = parsed.beats.map((b, i) => {
95
+ const out = join(tmp, `beat-${i}.png`);
96
+ const res = spawnSync(
97
+ bin,
98
+ ["still", entry, "Changelog", out, `--props=${propsPath}`, `--frame=${timings[i].mid}`, "--scale=0.33"],
99
+ { stdio: ["ignore", "ignore", "inherit"], env },
100
+ );
101
+ let img = TRANSPARENT_PX;
102
+ if (res.status === 0 && existsSync(out) && statSync(out).size > 0) {
103
+ img = `data:image/png;base64,${readFileSync(out).toString("base64")}`;
104
+ } else {
105
+ console.warn(` ! beat ${i + 1} still failed — placeholder used`);
106
+ }
107
+ return { img, headline: b.headline, panel: b.panel?.kind ?? "—", seconds: `${(b.durationInFrames / FPS).toFixed(1)}s` };
108
+ });
109
+
110
+ const sheetProps: StoryboardProps = { format: fmt, name, cells };
111
+ const sheetPropsPath = join(tmp, "sheet.json");
112
+ writeFileSync(sheetPropsPath, JSON.stringify(sheetProps));
113
+
114
+ const outPng = join(outDir, `${name}.storyboard.png`);
115
+ const sheet = spawnSync(bin, ["still", entry, "Storyboard", outPng, `--props=${sheetPropsPath}`], {
116
+ stdio: ["ignore", "ignore", "inherit"],
117
+ env,
118
+ });
119
+ rmSync(tmp, { recursive: true, force: true });
120
+
121
+ if (sheet.status !== 0) {
122
+ console.error("\n✗ storyboard sheet render failed");
123
+ process.exit(sheet.status ?? 1);
124
+ }
125
+ console.log(`\n→ ${outPng}\n`);
package/src/Root.tsx CHANGED
@@ -2,6 +2,7 @@ import { Composition } from "remotion";
2
2
  import { Changelog } from "./components/Changelog";
3
3
  import { MotionReel } from "./components/MotionReel";
4
4
  import { ContactSheet } from "./components/ContactSheet";
5
+ import { Storyboard, sheetLayout, type StoryboardProps } from "./components/Storyboard";
5
6
  import { changelogSchema } from "./schema/beats";
6
7
  import { prepareChangelog } from "./prepare";
7
8
  import { waitForFonts } from "./brand/fonts";
@@ -9,6 +10,18 @@ import streams from "./content/streams.beats";
9
10
 
10
11
  const defaultProps = changelogSchema.parse(streams);
11
12
 
13
+ // A 1×1 transparent PNG — keeps the Storyboard sample props tiny + self-contained.
14
+ const TRANSPARENT_PX =
15
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+P+/HgAFhAJ/wlseKgAAAABJRU5ErkJggg==";
16
+ const storyboardSample: StoryboardProps = {
17
+ format: "16x9",
18
+ name: "storyboard preview",
19
+ cells: [
20
+ { img: TRANSPARENT_PX, headline: "First beat.", panel: "—", seconds: "5.0s" },
21
+ { img: TRANSPARENT_PX, headline: "Second beat.", panel: "data-table", seconds: "6.0s" },
22
+ ],
23
+ };
24
+
12
25
  export const RemotionRoot: React.FC = () => {
13
26
  return (
14
27
  <>
@@ -46,6 +59,20 @@ export const RemotionRoot: React.FC = () => {
46
59
  return c;
47
60
  }}
48
61
  />
62
+ <Composition
63
+ id="Storyboard"
64
+ component={Storyboard}
65
+ defaultProps={storyboardSample}
66
+ durationInFrames={1}
67
+ fps={30}
68
+ width={1600}
69
+ height={900}
70
+ calculateMetadata={async (c) => {
71
+ await waitForFonts();
72
+ const { width, height } = sheetLayout(c.props.format, c.props.cells.length);
73
+ return { ...c, width, height };
74
+ }}
75
+ />
49
76
  </>
50
77
  );
51
78
  };
@@ -9,6 +9,9 @@ export const COLORS = activeTheme.colors;
9
9
 
10
10
  export const CARET_BG = activeTheme.caretBg;
11
11
 
12
+ /** Code window chrome — `"window"` (floating editor) or `"minimal"` (docs-style). */
13
+ export const CODE_CHROME = activeTheme.codeChrome ?? "window";
14
+
12
15
  /** Light [from, to] gradient for the procedural default backdrop ("shapes"). */
13
16
  export const BACKDROP: [string, string] = activeTheme.backdrop ?? ["#e6edff", "#f8faff"];
14
17
 
@@ -1,17 +1,66 @@
1
- import { Series } from "remotion";
2
- import type { ChangelogVideo } from "../schema/beats";
1
+ import { AbsoluteFill, Sequence, Series } from "remotion";
2
+ import { COLORS } from "../brand/tokens";
3
+ import type { Beat, ChangelogVideo } from "../schema/beats";
4
+ import { Background } from "./Background";
3
5
  import { ChangelogScene } from "./ChangelogScene";
4
6
 
5
- /** Top-level composition: a Series of beats. Duration/dimensions/tokens are set
6
- * by `calculateMetadata` in Root before this renders. */
7
+ /** Frames of crossfade when the backdrop actually changes between beats. */
8
+ const XFADE = 18;
9
+
10
+ /** Identity key for a backdrop — beats that resolve to the same key share one
11
+ * continuous Background (no transition between them). Omitted background and an
12
+ * explicit `shapes` both resolve to the default procedural backdrop. */
13
+ function bgKey(bg: Beat["background"]): string {
14
+ if (!bg || bg.shapes) return "shapes";
15
+ if (bg.src) return `img:${bg.src}:${bg.treatment ?? "kenburns"}`;
16
+ if (bg.gradient) return `grad:${bg.gradient[0]},${bg.gradient[1]}:${bg.angle ?? 160}`;
17
+ if (bg.solid) return `solid:${bg.solid}`;
18
+ return "shapes";
19
+ }
20
+
21
+ type Segment = { key: string; bg: Beat["background"]; from: number; duration: number };
22
+
23
+ /** Collapse consecutive same-backdrop beats into segments laid end-to-end. */
24
+ function backgroundSegments(beats: Beat[]): Segment[] {
25
+ const segs: Segment[] = [];
26
+ let frame = 0;
27
+ for (const b of beats) {
28
+ const key = bgKey(b.background);
29
+ const last = segs[segs.length - 1];
30
+ if (last && last.key === key) last.duration += b.durationInFrames;
31
+ else segs.push({ key, bg: b.background, from: frame, duration: b.durationInFrames });
32
+ frame += b.durationInFrames;
33
+ }
34
+ return segs;
35
+ }
36
+
37
+ /**
38
+ * Top-level composition. The backdrop is a **continuous layer** underneath the
39
+ * content: consecutive beats with the same backdrop share one Background (so it
40
+ * doesn't fade out/in or reset its Ken Burns between them) — only the content
41
+ * (headline, code, panel) transitions per beat. A real backdrop change
42
+ * crossfades (the next segment starts XFADE frames early and fades in on top).
43
+ * Duration/dimensions/tokens are set by `calculateMetadata` in Root.
44
+ */
7
45
  export const Changelog: React.FC<ChangelogVideo> = ({ format, beats }) => {
46
+ const segments = backgroundSegments(beats);
8
47
  return (
9
- <Series>
10
- {beats.map((beat) => (
11
- <Series.Sequence key={beat.id} durationInFrames={beat.durationInFrames}>
12
- <ChangelogScene beat={beat} format={format} />
13
- </Series.Sequence>
14
- ))}
15
- </Series>
48
+ <AbsoluteFill style={{ backgroundColor: COLORS.ink }}>
49
+ {segments.map((seg, i) => {
50
+ const from = i === 0 ? 0 : Math.max(0, seg.from - XFADE);
51
+ return (
52
+ <Sequence key={`${seg.key}-${seg.from}`} from={from} durationInFrames={seg.from + seg.duration - from} layout="none">
53
+ <Background bg={seg.bg} />
54
+ </Sequence>
55
+ );
56
+ })}
57
+ <Series>
58
+ {beats.map((beat) => (
59
+ <Series.Sequence key={beat.id} durationInFrames={beat.durationInFrames}>
60
+ <ChangelogScene beat={beat} format={format} />
61
+ </Series.Sequence>
62
+ ))}
63
+ </Series>
64
+ </AbsoluteFill>
16
65
  );
17
66
  };
@@ -3,7 +3,6 @@ import { COLORS, EASE } from "../brand/tokens";
3
3
  import { FONTS } from "../brand/fonts";
4
4
  import { isLightBackdrop } from "../brand/tone";
5
5
  import type { Beat, Format } from "../schema/beats";
6
- import { Background } from "./Background";
7
6
  import { Headline } from "./Headline";
8
7
  import { CodeWindow, codeTypingDoneFrame } from "./CodeWindow";
9
8
  import { Panel } from "./panels";
@@ -32,9 +31,10 @@ export const ChangelogScene: React.FC<{ beat: Beat; format: Format }> = ({ beat,
32
31
  // Sequential: the output panel waits for the code to finish "running".
33
32
  const panelStart = beat.code ? codeTypingDoneFrame(beat.code.tokens ?? [], beat.code.motion) + OUTPUT_GAP : 0;
34
33
 
34
+ // Background is a continuous layer in Changelog (so same-bg beats don't
35
+ // re-fade); this scene renders only the content that transitions per beat.
35
36
  return (
36
- <AbsoluteFill style={{ backgroundColor: COLORS.ink }}>
37
- <Background bg={beat.background} />
37
+ <AbsoluteFill>
38
38
  <Headline eyebrow={beat.eyebrow} headline={beat.headline} motion={beat.headlineMotion} format={format} light={light} />
39
39
 
40
40
  <div
@@ -1,5 +1,5 @@
1
1
  import { useCurrentFrame } from "remotion";
2
- import { CARET_BG, COLORS, FLOAT_SHADOW } from "../brand/tokens";
2
+ import { CARET_BG, CODE_CHROME, COLORS, FLOAT_SHADOW } from "../brand/tokens";
3
3
  import { FONTS } from "../brand/fonts";
4
4
  import { useMotion, type MotionSpec } from "../motion/useMotion";
5
5
  import { CODE_BG, type CodeLine } from "../code/highlight";
@@ -24,6 +24,7 @@ export const codeTypingDoneFrame = (tokens: CodeLine[], motion?: MotionSpec) =>
24
24
  export const CodeWindow: React.FC<Props> = ({ filename, tokens, motion = { enter: "settle", delay: 12 }, fontSize = 22 }) => {
25
25
  const frame = useCurrentFrame();
26
26
  const style = useMotion(motion);
27
+ const minimal = CODE_CHROME === "minimal";
27
28
 
28
29
  const typeStart = typeStartFor(motion);
29
30
  const total = totalChars(tokens);
@@ -81,7 +82,7 @@ export const CodeWindow: React.FC<Props> = ({ filename, tokens, motion = { enter
81
82
  <div
82
83
  style={{
83
84
  width: "100%",
84
- borderRadius: 20,
85
+ borderRadius: minimal ? 12 : 20,
85
86
  background: `${CODE_BG}f7`,
86
87
  boxShadow: FLOAT_SHADOW,
87
88
  backdropFilter: "blur(6px)",
@@ -90,11 +91,13 @@ export const CodeWindow: React.FC<Props> = ({ filename, tokens, motion = { enter
90
91
  ...style,
91
92
  }}
92
93
  >
93
- <div style={{ height: 54, display: "flex", alignItems: "center", padding: "0 22px", gap: 9 }}>
94
- <Dot color="#f6645f" /><Dot color="#f7bd45" /><Dot color="#2fc94e" />
95
- <span style={{ flex: 1, textAlign: "center", color: "rgba(0,0,0,0.34)", fontSize: 15, fontFamily: FONTS.mono, marginRight: 60 }}>{filename}</span>
96
- </div>
97
- <div style={{ padding: "26px 40px 42px", fontFamily: FONTS.mono, fontSize, lineHeight: `${lineHeight}px`, color: COLORS.ink }}>
94
+ {!minimal && (
95
+ <div style={{ height: 54, display: "flex", alignItems: "center", padding: "0 22px", gap: 9 }}>
96
+ <Dot color="#f6645f" /><Dot color="#f7bd45" /><Dot color="#2fc94e" />
97
+ <span style={{ flex: 1, textAlign: "center", color: "rgba(0,0,0,0.34)", fontSize: 15, fontFamily: FONTS.mono, marginRight: 60 }}>{filename}</span>
98
+ </div>
99
+ )}
100
+ <div style={{ padding: minimal ? "30px 34px" : "26px 40px 42px", fontFamily: FONTS.mono, fontSize, lineHeight: `${lineHeight}px`, color: COLORS.ink }}>
98
101
  {lines}
99
102
  </div>
100
103
  </div>
@@ -0,0 +1,68 @@
1
+ import { AbsoluteFill, Img } from "remotion";
2
+ import { type Format } from "../schema/beats";
3
+ import { COLORS, RADIUS } from "../brand/tokens";
4
+ import { FONTS } from "../brand/fonts";
5
+
6
+ /**
7
+ * A contact sheet for a beats file: one representative still per beat in a
8
+ * labeled grid. Pure and props-driven — images arrive as base64 data URIs in
9
+ * props (NOT staticFile/public, so it works from a global install), unlike
10
+ * ContactSheet which fetches a manifest. `scripts/storyboard.ts` renders the
11
+ * per-beat stills, encodes them, and renders this composition to a single PNG.
12
+ */
13
+ export type StoryboardCell = { img: string; headline: string; panel: string; seconds: string };
14
+ export type StoryboardProps = { format: Format; name: string; cells: StoryboardCell[] };
15
+
16
+ const PAD = 56;
17
+ const GAP = 24;
18
+ const HEADER = 110;
19
+ const CAPTION = 56;
20
+ const WIDTH = 1600;
21
+
22
+ /**
23
+ * Grid geometry + total canvas height for a given format and beat count. Used
24
+ * both by the component (columns + cell aspect) and by `calculateMetadata` in
25
+ * Root.tsx (canvas height) so the sheet fits its contents with no clipping.
26
+ */
27
+ export function sheetLayout(format: Format, count: number) {
28
+ const cols = format === "9x16" ? 4 : 3;
29
+ const rows = Math.max(1, Math.ceil(count / cols));
30
+ const cellW = (WIDTH - PAD * 2 - GAP * (cols - 1)) / cols;
31
+ const ar = format === "16x9" ? 9 / 16 : format === "9x16" ? 16 / 9 : 1; // height / width
32
+ const cellH = cellW * ar + CAPTION;
33
+ const height = Math.round(HEADER + PAD * 2 + rows * cellH + (rows - 1) * GAP);
34
+ const cellAspect = format === "16x9" ? "16 / 9" : format === "9x16" ? "9 / 16" : "1 / 1";
35
+ return { cols, width: WIDTH, height, cellAspect };
36
+ }
37
+
38
+ export const Storyboard: React.FC<StoryboardProps> = ({ format, name, cells }) => {
39
+ const { cols, cellAspect } = sheetLayout(format, cells.length);
40
+ return (
41
+ <AbsoluteFill style={{ background: COLORS.paper, padding: PAD, fontFamily: FONTS.body }}>
42
+ <div style={{ fontFamily: FONTS.display, fontSize: 40, fontWeight: 600, letterSpacing: "-0.02em", color: COLORS.ink }}>{name}</div>
43
+ <div style={{ fontFamily: FONTS.note, fontSize: 22, color: COLORS.signalBlue, marginBottom: 28 }}>
44
+ storyboard · {cells.length} beats · {format}
45
+ </div>
46
+ <div style={{ display: "grid", gridTemplateColumns: `repeat(${cols}, 1fr)`, gap: GAP }}>
47
+ {cells.map((c, i) => (
48
+ <div key={i} style={{ borderRadius: RADIUS.lg, overflow: "hidden", border: `1px solid ${COLORS.hairline}`, background: COLORS.paperElevated }}>
49
+ <Img
50
+ src={c.img}
51
+ onError={() => {}}
52
+ delayRenderTimeoutInMilliseconds={8000}
53
+ style={{ width: "100%", aspectRatio: cellAspect, objectFit: "cover", display: "block", background: COLORS.paper }}
54
+ />
55
+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 12, padding: "12px 16px" }}>
56
+ <span style={{ fontSize: 17, fontWeight: 600, color: COLORS.ink, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
57
+ {i + 1}. {c.headline}
58
+ </span>
59
+ <span style={{ fontFamily: FONTS.mono, fontSize: 12, textTransform: "uppercase", letterSpacing: "0.06em", color: COLORS.textMuted, whiteSpace: "nowrap" }}>
60
+ {c.panel} · {c.seconds}
61
+ </span>
62
+ </div>
63
+ </div>
64
+ ))}
65
+ </div>
66
+ </AbsoluteFill>
67
+ );
68
+ };
@@ -62,6 +62,12 @@ export type ThemeConfig = {
62
62
  caretBg: string;
63
63
  /** Syntax colors. */
64
64
  codeTheme: CodeTheme;
65
+ /**
66
+ * Code window chrome. `"window"` (default) is a floating editor with traffic-
67
+ * light dots + a filename tab; `"minimal"` is chromeless (just the code surface)
68
+ * to match a docs-style snippet component.
69
+ */
70
+ codeChrome?: "window" | "minimal";
65
71
  /** Light [from, to] gradient for the procedural default backdrop (Background "shapes"). */
66
72
  backdrop?: [string, string];
67
73
  };