@waits/cadence 0.4.0 → 0.6.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
@@ -104,13 +104,19 @@ Layout: `src/schema` (the beats contract), `src/components` (scene + panels),
104
104
  `src/motion` (the lexicon), `src/theme` (themes + derivation), `src/templates`
105
105
  (the no-LLM repo→beats path), `prompts/art` (the optional painterly pack).
106
106
 
107
- ## Optional: painterly backgrounds
107
+ ## Optional: generated backgrounds
108
108
 
109
- The default backdrop is procedural and needs no key. For the optional
110
- "Hill Country Sublime" landscape pack (see **[ART-DIRECTION.md](ART-DIRECTION.md)**):
109
+ The default backdrop is procedural and needs no key. For custom painted
110
+ backdrops, `cadence art` generates them from a freeform prompt — landing in your
111
+ project's `.cadence/backgrounds/`, ready to reference as `image:<file>`:
111
112
 
112
113
  ```bash
113
- cadence art --landmark pennybacker --level heightened # needs OPENAI_API_KEY
114
+ cadence art --prompt "misty redwood coastline at dawn" --name redwood # needs OPENAI_API_KEY
115
+ cadence art --promote redwood # candidate → backgrounds/
114
116
  ```
115
117
 
116
- This is the only part of the toolchain that calls an external API.
118
+ Reusable subject sets go in a `--pack <file.json>`; the bundled "Hill Country
119
+ Sublime" landscape pack is the default (`cadence art --all`). This is the only
120
+ part of the toolchain that calls an external API. See
121
+ **[docs/guides/custom-backgrounds.md](docs/guides/custom-backgrounds.md)** and
122
+ **[ART-DIRECTION.md](ART-DIRECTION.md)**.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waits/cadence",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -3,15 +3,34 @@ import { COMPOSITION, NEGATIVES, STYLE } from "./style";
3
3
  import { LANDMARKS, type LandmarkKey } from "./landmarks";
4
4
  import { FANTASY, type FantasyLevel } from "./fantasy";
5
5
 
6
- /** Build the full image-gen prompt for a landmark × fantasy level × format. */
7
- export function composePrompt(landmark: LandmarkKey, level: FantasyLevel, format: Format): string {
6
+ /**
7
+ * Build the full image-gen prompt for a freeform subject × fantasy level × format.
8
+ * `style`/`negatives` default to the built-in luminist look; a pack or `--style-file`
9
+ * can override them. `COMPOSITION` (UI-safe framing) is always engine-supplied.
10
+ * `brand` (from `--brand`, a project's theme palette) appends a last, explicit
11
+ * palette nudge so it dominates any color the base style mentions.
12
+ */
13
+ export function composePrompt(opts: {
14
+ subject: string;
15
+ level: FantasyLevel;
16
+ style?: string;
17
+ negatives?: string;
18
+ format: Format;
19
+ brand?: { accent?: string; paper?: string };
20
+ }): string {
21
+ const brand = opts.brand?.accent
22
+ ? `Tune the palette toward the brand: a ${opts.brand.accent} accent${opts.brand.paper ? ` over ${opts.brand.paper}-toned light` : ""} — cohesive and harmonious, never garish.`
23
+ : "";
8
24
  return [
9
- STYLE,
10
- `Subject: ${LANDMARKS[landmark].subject}.`,
11
- FANTASY[level],
12
- COMPOSITION[format],
13
- NEGATIVES,
14
- ].join(" ");
25
+ opts.style ?? STYLE,
26
+ `Subject: ${opts.subject}.`,
27
+ FANTASY[opts.level],
28
+ COMPOSITION[opts.format],
29
+ brand,
30
+ opts.negatives ?? NEGATIVES,
31
+ ]
32
+ .filter(Boolean)
33
+ .join(" ");
15
34
  }
16
35
 
17
36
  export { LANDMARKS, FANTASY };
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Stage a merged public dir for a project render. Remotion's `--public-dir` is a
3
+ * single replacement dir (not an overlay), and the engine's own `public/` holds
4
+ * assets shipped beats reference (`mountains.jpg`, built-in `backgrounds/*.png`).
5
+ * So when a project supplies its own art in `<project>/.cadence/backgrounds/`, we
6
+ * merge the engine's `public/*` with the project's `backgrounds/` into a temp dir
7
+ * and point `--public-dir` there — both built-in and project images resolve.
8
+ *
9
+ * `_candidates/` is excluded (the unpromoted generation pile — not for rendering).
10
+ */
11
+ import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs";
12
+ import { dirname, join, resolve } from "node:path";
13
+ import { pkgFile } from "./_pkg";
14
+ import { findCadenceDir } from "./_theme";
15
+
16
+ const noCandidates = (src: string) => !src.includes(`${join("backgrounds", "_candidates")}`);
17
+
18
+ /** Returns an absolute staged public dir, or undefined when there's no project art. */
19
+ export function stagePublicDir(opts: { beatsFile?: string; outDir: string }): string | undefined {
20
+ const start = opts.beatsFile ? dirname(resolve(opts.beatsFile)) : process.cwd();
21
+ const cad = findCadenceDir(start);
22
+ const projBg = cad ? join(cad, "backgrounds") : undefined;
23
+ if (!projBg || !existsSync(projBg)) return undefined;
24
+
25
+ const staged = resolve(opts.outDir, ".pub");
26
+ rmSync(staged, { recursive: true, force: true });
27
+ mkdirSync(staged, { recursive: true });
28
+ cpSync(pkgFile("public"), staged, { recursive: true, filter: noCandidates });
29
+ cpSync(projBg, join(staged, "backgrounds"), { recursive: true, filter: noCandidates });
30
+ return staged;
31
+ }
@@ -1,21 +1,26 @@
1
1
  /**
2
- * "Hill Country Sublime" painting generator. Builds a prompt from the art system
3
- * (prompts/art/*) and calls OpenAI gpt-image-1 (base64 response → PNG). Writes to
4
- * public/backgrounds/_candidates/ and updates a manifest the ContactSheet reads.
2
+ * Background painting generator. Builds a prompt from the art system (prompts/art/*)
3
+ * and calls OpenAI gpt-image-1 (base64 → PNG), then updates a manifest the
4
+ * ContactSheet reads. Freeform `--prompt` generates anything; with no prompt it
5
+ * pulls subjects from the built-in pack (the "Hill Country Sublime" landmarks).
5
6
  *
6
- * bun run art --landmark pennybacker --level heightened [--format 16x9] [--quality high]
7
- * bun run art --all --level heightened --format 16x9 # every landmark
7
+ * Output is project-local: run against a repo with a `.cadence/` dir and art lands
8
+ * in `<project>/.cadence/backgrounds/` (candidates under `_candidates/`); otherwise
9
+ * the cadence engine's own `public/backgrounds/`.
8
10
  *
9
- * Requires OPENAI_API_KEY. Chosen winners get moved to public/backgrounds/ (committed).
11
+ * bun run art --prompt "misty redwood coastline at dawn" --name redwood
12
+ * bun run art --all --level heightened --format 16x9 # every built-in subject
13
+ * bun run art --promote redwood # candidate(s) → backgrounds/
14
+ *
15
+ * Requires OPENAI_API_KEY (except --promote, which only moves files).
10
16
  */
11
- import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
17
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
12
18
  import { join } from "node:path";
13
- import { composePrompt, LANDMARKS, type FantasyLevel, type LandmarkKey } from "../prompts/art/compose";
19
+ import { composePrompt, LANDMARKS, type FantasyLevel } from "../prompts/art/compose";
14
20
  import type { Format } from "../src/schema/beats";
21
+ import { discoverProjectTheme, findCadenceDir } from "./_theme";
15
22
 
16
23
  const SIZE: Record<Format, string> = { "16x9": "1536x1024", "1x1": "1024x1024", "9x16": "1024x1536" };
17
- const CANDIDATES = "public/backgrounds/_candidates";
18
- const MANIFEST = join(CANDIDATES, "manifest.json");
19
24
 
20
25
  const args = process.argv.slice(2);
21
26
  const flag = (name: string, def?: string) => {
@@ -25,26 +30,108 @@ const flag = (name: string, def?: string) => {
25
30
  return i >= 0 && !args[i + 1]?.startsWith("--") ? args[i + 1] : def;
26
31
  };
27
32
  const has = (name: string) => args.includes(name);
33
+ const slugify = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
34
+
35
+ // T4: project-local art base. A project's `.cadence/backgrounds/` if present
36
+ // (discovered from cwd), else the engine's committed `public/backgrounds/`.
37
+ const cad = findCadenceDir(process.cwd());
38
+ const ART_BASE = cad ? join(cad, "backgrounds") : "public/backgrounds";
39
+ const CANDIDATES = join(ART_BASE, "_candidates");
40
+ const MANIFEST = join(CANDIDATES, "manifest.json");
28
41
 
29
- const level = (flag("--level", "heightened") as FantasyLevel);
30
- const format = (flag("--format", "16x9") as Format);
31
- const quality = flag("--quality", "high")!;
32
- const landmarks: LandmarkKey[] = has("--all")
33
- ? (Object.keys(LANDMARKS) as LandmarkKey[])
34
- : [(flag("--landmark", "pennybacker") as LandmarkKey)];
42
+ const level = flag("--level", "heightened") as FantasyLevel;
43
+ const format = flag("--format", "16x9") as Format;
44
+ const quality = flag("--quality", "medium")!;
45
+
46
+ type Entry = { file: string; subject: string; level: string; format: string; name: string };
47
+ mkdirSync(CANDIDATES, { recursive: true });
48
+ const manifest: Entry[] = existsSync(MANIFEST) ? JSON.parse(readFileSync(MANIFEST, "utf8")) : [];
49
+
50
+ // T6: promote — move a chosen candidate (by slug or filename) to backgrounds/,
51
+ // and drop it from the candidates manifest (its path would otherwise go stale).
52
+ const promote = flag("--promote");
53
+ if (promote) {
54
+ const key = slugify(promote);
55
+ const matches = manifest.filter((m) => m.name === promote || m.name.startsWith(`${key}-`));
56
+ if (!matches.length) {
57
+ console.error(`✗ no candidate matching "${promote}" in ${CANDIDATES}`);
58
+ process.exit(1);
59
+ }
60
+ mkdirSync(ART_BASE, { recursive: true });
61
+ for (const m of matches) {
62
+ const from = join(CANDIDATES, m.name);
63
+ if (existsSync(from)) {
64
+ renameSync(from, join(ART_BASE, m.name));
65
+ console.log(`✓ promoted ${m.name} → ${ART_BASE}/ (reference as image:${m.name})`);
66
+ }
67
+ }
68
+ const promoted = new Set(matches.map((m) => m.name));
69
+ writeFileSync(MANIFEST, JSON.stringify(manifest.filter((m) => !promoted.has(m.name)), null, 2));
70
+ process.exit(0);
71
+ }
72
+
73
+ // Subject source: a user-supplied pack (`--pack file.json`) or the built-in
74
+ // landmark pack. A pack may also override the style/negatives (else engine defaults).
75
+ type Pack = { style?: string; negatives?: string; subjects: Record<string, { name: string; subject: string }> };
76
+ const packFile = flag("--pack");
77
+ const pack: Pack = packFile ? JSON.parse(readFileSync(packFile, "utf8")) : { subjects: LANDMARKS };
78
+ const styleFile = flag("--style-file");
79
+ const style = styleFile ? readFileSync(styleFile, "utf8") : pack.style;
80
+ const negatives = pack.negatives;
81
+
82
+ // --brand: tint generated art toward the project's theme palette (accent + paper).
83
+ let brand: { accent?: string; paper?: string } | undefined;
84
+ if (has("--brand")) {
85
+ const tf = discoverProjectTheme(process.cwd());
86
+ if (tf) {
87
+ const colors = (JSON.parse(readFileSync(tf, "utf8")).colors ?? {}) as Record<string, string>;
88
+ brand = { accent: colors.signalBlue, paper: colors.paper };
89
+ console.error(`· --brand: ${tf} (accent ${brand.accent ?? "—"})`);
90
+ } else {
91
+ console.error("· --brand: no .cadence/theme.json found — generating unbranded");
92
+ }
93
+ }
94
+
95
+ // A job = one image to generate. Freeform `--prompt` (needs `--name`) wins;
96
+ // otherwise pull subjects from the pack via --landmark/--all.
97
+ type Job = { subject: string; slug: string; label: string };
98
+ const promptText = flag("--prompt");
99
+ let jobs: Job[];
100
+ if (promptText) {
101
+ const name = flag("--name");
102
+ if (!name) {
103
+ console.error("✗ --prompt requires --name <slug> (used for the filename + manifest label)");
104
+ process.exit(1);
105
+ }
106
+ jobs = [{ subject: promptText, slug: slugify(name), label: name }];
107
+ } else {
108
+ const keys = has("--all") ? Object.keys(pack.subjects) : [flag("--landmark", Object.keys(pack.subjects)[0])!];
109
+ jobs = keys.map((k) => {
110
+ const s = pack.subjects[k];
111
+ if (!s) {
112
+ console.error(`✗ "${k}" not in pack (have: ${Object.keys(pack.subjects).join(", ")})`);
113
+ process.exit(1);
114
+ }
115
+ return { subject: s.subject, slug: k, label: s.name };
116
+ });
117
+ }
118
+
119
+ // Cost guard: batch runs cost real money — require explicit --yes after an estimate.
120
+ const COST: Record<string, number> = { low: 0.02, medium: 0.06, high: 0.19 }; // ~USD/image, approx
121
+ if (jobs.length > 1 && !has("--yes")) {
122
+ const est = (COST[quality] ?? 0.06) * jobs.length;
123
+ console.error(`⚠ ${jobs.length} images at quality "${quality}" ≈ $${est.toFixed(2)} (approx). Re-run with --yes to proceed.`);
124
+ process.exit(1);
125
+ }
35
126
 
36
127
  if (!process.env.OPENAI_API_KEY) {
37
128
  console.error("✗ OPENAI_API_KEY not set");
38
129
  process.exit(1);
39
130
  }
40
131
 
41
- mkdirSync(CANDIDATES, { recursive: true });
42
- type Entry = { file: string; landmark: string; level: string; format: string; name: string };
43
- const manifest: Entry[] = existsSync(MANIFEST) ? JSON.parse(readFileSync(MANIFEST, "utf8")) : [];
44
-
45
- async function generate(landmark: LandmarkKey) {
46
- const prompt = composePrompt(landmark, level, format);
47
- const fname = `${landmark}-${level}-${format}.png`;
132
+ async function generate(job: Job) {
133
+ const prompt = composePrompt({ subject: job.subject, level, format, style, negatives, brand });
134
+ const fname = `${job.slug}-${level}-${format}.png`;
48
135
  process.stdout.write(`· ${fname} … `);
49
136
  const res = await fetch("https://api.openai.com/v1/images/generations", {
50
137
  method: "POST",
@@ -59,14 +146,15 @@ async function generate(landmark: LandmarkKey) {
59
146
  const json = await res.json();
60
147
  const b64 = json.data[0].b64_json as string;
61
148
  writeFileSync(join(CANDIDATES, fname), Buffer.from(b64, "base64"));
62
- const entry: Entry = { file: `backgrounds/_candidates/${fname}`, landmark: LANDMARKS[landmark].name, level, format, name: fname };
149
+ const entry: Entry = { file: `backgrounds/_candidates/${fname}`, subject: job.label, level, format, name: fname };
63
150
  const idx = manifest.findIndex((m) => m.name === fname);
64
- if (idx >= 0) manifest[idx] = entry; else manifest.push(entry);
151
+ if (idx >= 0) manifest[idx] = entry;
152
+ else manifest.push(entry);
65
153
  console.log("ok");
66
154
  }
67
155
 
68
- for (const lm of landmarks) {
69
- await generate(lm);
156
+ for (const job of jobs) {
157
+ await generate(job);
70
158
  }
71
159
  writeFileSync(MANIFEST, JSON.stringify(manifest, null, 2));
72
- console.log(`\n✓ ${landmarks.length} image(s) → ${CANDIDATES}/ (manifest: ${manifest.length} total)`);
160
+ console.log(`\n✓ ${jobs.length} image(s) → ${CANDIDATES}/ (manifest: ${manifest.length} total)`);
package/scripts/render.ts CHANGED
@@ -7,9 +7,10 @@
7
7
  * bun run render src/content/streams.beats.ts [--format 16x9|1x1|9x16] [--frame N]
8
8
  */
9
9
  import { spawnSync } from "node:child_process";
10
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
11
11
  import { basename, join, resolve } from "node:path";
12
12
  import { type Format } from "../src/schema/beats";
13
+ import { stagePublicDir } from "./_assets";
13
14
  import { loadBeats } from "./_beats";
14
15
  import { binPath, pkgFile } from "./_pkg";
15
16
  import { resolveOutDir, resolveTheme } from "./_theme";
@@ -48,8 +49,12 @@ writeFileSync(propsPath, JSON.stringify(parsed));
48
49
  const bin = binPath("remotion");
49
50
  const common = [pkgFile("src/index.ts"), "Changelog"];
50
51
  const suffix = theme && theme !== "default" ? `-${theme}` : "";
52
+ // Stage a merged public dir when the project supplies its own backgrounds.
53
+ const staged = stagePublicDir({ beatsFile: file, outDir });
54
+ const pub = staged ? [`--public-dir=${staged}`] : [];
51
55
  const res = frame
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 });
56
+ ? spawnSync(bin, ["still", ...common, join(outDir, `${name}-${parsed.format}${suffix}-f${frame}.png`), `--props=${propsPath}`, `--frame=${frame}`, ...pub], { stdio: "inherit", env })
57
+ : spawnSync(bin, ["render", ...common, join(outDir, `${name}-${parsed.format}${suffix}.mp4`), `--props=${propsPath}`, "--image-format=jpeg", ...pub], { stdio: "inherit", env });
58
+ if (staged) rmSync(staged, { recursive: true, force: true });
54
59
 
55
60
  process.exit(res.status ?? 0);
@@ -15,6 +15,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync }
15
15
  import { basename, join } from "node:path";
16
16
  import { type Beat, type Format } from "../src/schema/beats";
17
17
  import type { StoryboardCell, StoryboardProps } from "../src/components/Storyboard";
18
+ import { stagePublicDir } from "./_assets";
18
19
  import { auditBeats, ICON, rankFindings } from "./_audit";
19
20
  import { beatTimings, FPS, loadBeats } from "./_beats";
20
21
  import { binPath, pkgFile } from "./_pkg";
@@ -87,6 +88,9 @@ writeFileSync(propsPath, JSON.stringify(parsed));
87
88
 
88
89
  const bin = binPath("remotion");
89
90
  const entry = pkgFile("src/index.ts");
91
+ // Stage a merged public dir so project backgrounds resolve in the per-beat stills.
92
+ const staged = stagePublicDir({ beatsFile: file, outDir });
93
+ const pub = staged ? [`--public-dir=${staged}`] : [];
90
94
  const TRANSPARENT_PX =
91
95
  "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+P+/HgAFhAJ/wlseKgAAAABJRU5ErkJggg==";
92
96
 
@@ -95,7 +99,7 @@ const cells: StoryboardCell[] = parsed.beats.map((b, i) => {
95
99
  const out = join(tmp, `beat-${i}.png`);
96
100
  const res = spawnSync(
97
101
  bin,
98
- ["still", entry, "Changelog", out, `--props=${propsPath}`, `--frame=${timings[i].mid}`, "--scale=0.33"],
102
+ ["still", entry, "Changelog", out, `--props=${propsPath}`, `--frame=${timings[i].mid}`, "--scale=0.33", ...pub],
99
103
  { stdio: ["ignore", "ignore", "inherit"], env },
100
104
  );
101
105
  let img = TRANSPARENT_PX;
@@ -117,6 +121,7 @@ const sheet = spawnSync(bin, ["still", entry, "Storyboard", outPng, `--props=${s
117
121
  env,
118
122
  });
119
123
  rmSync(tmp, { recursive: true, force: true });
124
+ if (staged) rmSync(staged, { recursive: true, force: true });
120
125
 
121
126
  if (sheet.status !== 0) {
122
127
  console.error("\n✗ storyboard sheet render failed");
@@ -3,13 +3,13 @@ import { AbsoluteFill, Img, continueRender, delayRender, staticFile } from "remo
3
3
  import { COLORS, RADIUS } from "../brand/tokens";
4
4
  import { FONTS } from "../brand/fonts";
5
5
 
6
- type Entry = { file: string; landmark: string; level: string; format: string; name: string };
6
+ type Entry = { file: string; subject: string; level: string; format: string; name: string };
7
7
 
8
8
  /**
9
- * Review board for generated paintings. Reads the candidates manifest written by
10
- * `bun run art` and lays them out in a labeled grid. Render with
11
- * `remotion still ContactSheet out/contact.png` to pick winners, then move the
12
- * chosen files from _candidates/ to committed public/backgrounds/.
9
+ * Review board for generated backgrounds. Reads the candidates manifest written
10
+ * by `cadence art` and lays them out in a labeled grid. Render with
11
+ * `remotion still ContactSheet out/contact.png` to pick winners, then promote the
12
+ * chosen files from _candidates/ to backgrounds/ (`cadence art --promote`).
13
13
  */
14
14
  export const ContactSheet: React.FC = () => {
15
15
  const [handle] = useState(() => delayRender("manifest"));
@@ -24,16 +24,16 @@ export const ContactSheet: React.FC = () => {
24
24
 
25
25
  return (
26
26
  <AbsoluteFill style={{ background: COLORS.paper, padding: 64, fontFamily: FONTS.body }}>
27
- <div style={{ fontFamily: FONTS.display, fontSize: 44, fontWeight: 600, letterSpacing: "-0.02em", color: COLORS.ink }}>Hill Country Sublime</div>
27
+ <div style={{ fontFamily: FONTS.display, fontSize: 44, fontWeight: 600, letterSpacing: "-0.02em", color: COLORS.ink }}>Background candidates</div>
28
28
  <div style={{ fontFamily: FONTS.note, fontSize: 26, color: COLORS.signalBlue, marginBottom: 32 }}>
29
- {items.length ? `${items.length} candidates — pick winners → public/backgrounds/` : "no candidates yet — run `bun run art --all`"}
29
+ {items.length ? `${items.length} candidates — pick winners → backgrounds/` : "no candidates yet — run `cadence art --prompt … --name …`"}
30
30
  </div>
31
31
  <div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 24 }}>
32
32
  {items.map((it) => (
33
33
  <div key={it.name} style={{ borderRadius: RADIUS.lg, overflow: "hidden", border: `1px solid ${COLORS.hairline}`, background: COLORS.paperElevated }}>
34
34
  <Img src={staticFile(it.file)} style={{ width: "100%", aspectRatio: "16 / 9", objectFit: "cover", display: "block" }} />
35
35
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", padding: "12px 16px" }}>
36
- <span style={{ fontSize: 18, fontWeight: 600, color: COLORS.ink }}>{it.landmark}</span>
36
+ <span style={{ fontSize: 18, fontWeight: 600, color: COLORS.ink }}>{it.subject}</span>
37
37
  <span style={{ fontFamily: FONTS.mono, fontSize: 12, textTransform: "uppercase", letterSpacing: "0.06em", color: COLORS.textMuted }}>{it.level} · {it.format}</span>
38
38
  </div>
39
39
  </div>