@devinilabs/reelstack 1.2.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.
Files changed (145) hide show
  1. package/LICENSE +128 -0
  2. package/README.md +125 -0
  3. package/cli/beats.js +124 -0
  4. package/cli/bootstrap.js +124 -0
  5. package/cli/capture.js +34 -0
  6. package/cli/direction.js +114 -0
  7. package/cli/icons.js +49 -0
  8. package/cli/index.js +101 -0
  9. package/cli/init.js +253 -0
  10. package/cli/license.js +168 -0
  11. package/cli/lint.js +865 -0
  12. package/cli/preview.js +59 -0
  13. package/cli/render.js +404 -0
  14. package/cli/scaffold.js +239 -0
  15. package/cli/smoke.js +76 -0
  16. package/cli/update.js +26 -0
  17. package/cli/utils.js +184 -0
  18. package/docs/buyers-guide.md +220 -0
  19. package/docs/design-discipline.md +130 -0
  20. package/docs/family-galleries/dark.md +95 -0
  21. package/docs/family-galleries/forbidden.md +78 -0
  22. package/docs/family-galleries/glass.md +98 -0
  23. package/docs/family-galleries/paper.md +82 -0
  24. package/docs/family-galleries/warm.md +86 -0
  25. package/docs/superpowers/plans/2026-05-09-reelstack-init-readiness-gate.md +1166 -0
  26. package/docs/superpowers/specs/2026-05-09-reelstack-init-readiness-gate-design.md +233 -0
  27. package/families/dark/components/DriftingSpotlights.tsx +59 -0
  28. package/families/dark/components/FilmGrain.tsx +44 -0
  29. package/families/dark/components/ForestCard.tsx +43 -0
  30. package/families/dark/components/GridBackground.tsx +29 -0
  31. package/families/dark/components/RadialVignette.tsx +21 -0
  32. package/families/dark/components/Scanlines.tsx +35 -0
  33. package/families/dark/components/SegmentOpacity.ts +37 -0
  34. package/families/dark/components/index.ts +13 -0
  35. package/families/dark/index.ts +31 -0
  36. package/families/dark/palette.ts +98 -0
  37. package/families/dark/presets/claudedispatch.ts +46 -0
  38. package/families/dark/presets/codedrop.ts +37 -0
  39. package/families/dark/presets/gpt55.ts +54 -0
  40. package/families/dark/presets/notebooklm.ts +50 -0
  41. package/families/dark/presets/resourcescta.ts +35 -0
  42. package/families/dark/presets/skills.ts +40 -0
  43. package/families/dark/presets/stitch.ts +46 -0
  44. package/families/dark/presets/stitch2.ts +43 -0
  45. package/families/dark/typography.ts +16 -0
  46. package/families/forbidden/components/ForbiddenCausticBlobs.tsx +52 -0
  47. package/families/forbidden/components/NewsprintTexture.tsx +28 -0
  48. package/families/forbidden/components/TintedShadow.tsx +36 -0
  49. package/families/forbidden/components/index.ts +38 -0
  50. package/families/forbidden/index.ts +17 -0
  51. package/families/forbidden/palette.ts +88 -0
  52. package/families/forbidden/presets/heretic.ts +44 -0
  53. package/families/forbidden/typography.ts +18 -0
  54. package/families/glass/components/BreakdownCard.tsx +158 -0
  55. package/families/glass/components/CausticBlobs.tsx +49 -0
  56. package/families/glass/components/Counter.tsx +72 -0
  57. package/families/glass/components/EyebrowPill.tsx +59 -0
  58. package/families/glass/components/FilmStrip.tsx +202 -0
  59. package/families/glass/components/FloatingGlyphs.tsx +78 -0
  60. package/families/glass/components/GlassCard.tsx +58 -0
  61. package/families/glass/components/GlassCardBezel.tsx +45 -0
  62. package/families/glass/components/HairlineGrid.tsx +30 -0
  63. package/families/glass/components/IridescentRing.tsx +114 -0
  64. package/families/glass/components/IridescentText.tsx +98 -0
  65. package/families/glass/components/LightBeam.tsx +46 -0
  66. package/families/glass/components/ParticleBurst.tsx +62 -0
  67. package/families/glass/components/SonarRings.tsx +81 -0
  68. package/families/glass/components/StaggeredWords.tsx +74 -0
  69. package/families/glass/components/index.ts +20 -0
  70. package/families/glass/index.ts +31 -0
  71. package/families/glass/palette.ts +93 -0
  72. package/families/glass/presets/claudewatch.ts +64 -0
  73. package/families/glass/presets/claudewatchcta.ts +43 -0
  74. package/families/glass/presets/graphify.ts +45 -0
  75. package/families/glass/presets/gstack.ts +48 -0
  76. package/families/glass/presets/jcode.ts +50 -0
  77. package/families/glass/presets/lilagents.ts +52 -0
  78. package/families/glass/presets/paperclip.ts +43 -0
  79. package/families/glass/typography.ts +15 -0
  80. package/families/index.ts +49 -0
  81. package/families/paper/components/CardSpring.tsx +42 -0
  82. package/families/paper/components/CreamGrid.tsx +26 -0
  83. package/families/paper/components/EditorialSerifText.tsx +51 -0
  84. package/families/paper/components/GreenAccentCard.tsx +10 -0
  85. package/families/paper/components/PaperShadow.tsx +30 -0
  86. package/families/paper/components/ScaleBlurText.tsx +40 -0
  87. package/families/paper/components/index.ts +11 -0
  88. package/families/paper/index.ts +23 -0
  89. package/families/paper/palette.ts +102 -0
  90. package/families/paper/presets/designreel.ts +32 -0
  91. package/families/paper/presets/devini3d.ts +45 -0
  92. package/families/paper/presets/justdrop.ts +39 -0
  93. package/families/paper/presets/opus.ts +48 -0
  94. package/families/paper/typography.ts +17 -0
  95. package/families/warm/components/AccentGlow.tsx +60 -0
  96. package/families/warm/components/BentoCell.tsx +56 -0
  97. package/families/warm/components/BentoGrid.tsx +30 -0
  98. package/families/warm/components/FilmGrain.tsx +36 -0
  99. package/families/warm/components/ScaleBlurCounter.tsx +71 -0
  100. package/families/warm/components/WarmSurface.tsx +35 -0
  101. package/families/warm/components/index.ts +11 -0
  102. package/families/warm/index.ts +19 -0
  103. package/families/warm/palette.ts +81 -0
  104. package/families/warm/presets/huashu.ts +49 -0
  105. package/families/warm/presets/mempalace.ts +51 -0
  106. package/families/warm/typography.ts +17 -0
  107. package/package.json +85 -0
  108. package/reference/dark/claudedispatch.tsx +2441 -0
  109. package/reference/dark/notebooklm.tsx +2316 -0
  110. package/reference/dark/stitch.tsx +3040 -0
  111. package/reference/forbidden/heretic.tsx +2636 -0
  112. package/reference/glass/claudewatch.tsx +3827 -0
  113. package/reference/glass/graphify.tsx +2418 -0
  114. package/reference/glass/paperclip.tsx +2218 -0
  115. package/reference/paper/designreel.tsx +883 -0
  116. package/reference/paper/justdrop.tsx +1898 -0
  117. package/reference/paper/opus.tsx +1770 -0
  118. package/reference/warm/huashu.tsx +3413 -0
  119. package/reference/warm/mempalace.tsx +2909 -0
  120. package/skill/SKILL.md +229 -0
  121. package/skill/commands/reelstack-beats.md +20 -0
  122. package/skill/commands/reelstack-capture.md +24 -0
  123. package/skill/commands/reelstack-critique.md +15 -0
  124. package/skill/commands/reelstack-dark.md +40 -0
  125. package/skill/commands/reelstack-direction.md +17 -0
  126. package/skill/commands/reelstack-forbidden.md +25 -0
  127. package/skill/commands/reelstack-glass.md +39 -0
  128. package/skill/commands/reelstack-icons.md +22 -0
  129. package/skill/commands/reelstack-init.md +17 -0
  130. package/skill/commands/reelstack-lint.md +22 -0
  131. package/skill/commands/reelstack-paper.md +36 -0
  132. package/skill/commands/reelstack-render.md +20 -0
  133. package/skill/commands/reelstack-warm.md +36 -0
  134. package/templates/dark/template.tsx +115 -0
  135. package/templates/forbidden/template.tsx +111 -0
  136. package/templates/glass/template.tsx +201 -0
  137. package/templates/paper/template.tsx +133 -0
  138. package/templates/warm/template.tsx +210 -0
  139. package/utils/ai-purple-blocklist.ts +13 -0
  140. package/utils/banned-fonts.ts +11 -0
  141. package/utils/cubic-bezier.ts +36 -0
  142. package/utils/easing.ts +84 -0
  143. package/utils/grid.ts +13 -0
  144. package/utils/render-presets.json +56 -0
  145. package/utils/safe-zones.tsx +57 -0
package/cli/lint.js ADDED
@@ -0,0 +1,865 @@
1
+ /**
2
+ * `reelstack lint <file> [--critique]` — static-analysis pass over a reel composition.
3
+ *
4
+ * Reports (default mode — list of violations with line numbers):
5
+ * - SAFE_ZONE_BREACH: element y-coords overlap reserved bands.
6
+ * - MOTION_FLOOR_VIOLATION: scenes with too few simultaneous animation layers.
7
+ * - HERO_TEXT_OVERFLOW: counter or hero text wider than canvas - 64px gutter.
8
+ * - AUDIO_NOT_LOCKED: file imports <Audio> but defines no BEAT const block.
9
+ * - HAND_DRAWN_BRAND: inline <svg> longer than 800 chars in a logo-sized element.
10
+ * - BANNED_FONT: fontFamily uses one of the generic LLM-default fonts.
11
+ * - PURE_BLACK_TEXT: color: "#000" / "#000000" — kills warmth on cream/glass families.
12
+ * - AI_PURPLE_ACCENT: hex color in the AI-purple blocklist (#7c3aed, etc.).
13
+ * - EMOJI_GLYPH: literal emoji in JSX text — hand-drawn brand violation.
14
+ * - ACCENT_ALLOWLIST_VIOLATION: hex color outside the resolved family's ALLOWED_ACCENTS list.
15
+ * - SPACING_NON_GRID: padding/margin/gap not on the 4px grid.
16
+ * - MISSING_REDUCE_MOTION: component imports useCurrentFrame but lacks a `reduceMotion` prop.
17
+ * - GENERIC_PLACEHOLDER: "Lorem ipsum", "John Doe", "Acme Corp" — leftover stub copy.
18
+ * - PILL_TAG_OVERSTRETCHED: pill/tag/chip/badge component forced wider than its content via style overrides.
19
+ * - RIPPLE_TOO_LOUD: SonarRings/ParticleBurst/IridescentRing exceeds house-style intensity ceiling.
20
+ * - RIPPLE_STACK: more than 2 ripple emitters anchored to the same (cx, cy) in one scene.
21
+ *
22
+ * With `--critique`, the same set of static checks drives a 5-dimension radar
23
+ * (Palette · Motion · Timing · Hierarchy · Brand fit) plus a Keep / Fix / Quick
24
+ * punch list. Inspired by alchaincyf/huashu-design's 5-dim expert critique pattern.
25
+ *
26
+ * The lint pass is intentionally conservative — false positives are
27
+ * acceptable; false negatives ship bad reels.
28
+ */
29
+ const fs = require("node:fs");
30
+ const path = require("node:path");
31
+ const { c, info, success, fail, warn } = require("./utils");
32
+
33
+ const TOP_SAFE = 290;
34
+ const BOTTOM_SAFE = 422;
35
+ const CANVAS_H = 1920;
36
+ const CANVAS_W = 1080;
37
+ const HERO_TEXT_MAX = CANVAS_W - 64; // 1016
38
+
39
+ // ─── Inline taste-skill blocklists ──────────────────────────────────────
40
+ // Maintenance note: these mirror the canonical lists in utils/banned-fonts.ts
41
+ // and utils/ai-purple-blocklist.ts. We inline here because cli/* is plain
42
+ // CommonJS and importing TS source would require a build step. Refactor in
43
+ // v1.2 if/when we add tsc emit to the publish pipeline.
44
+ const BANNED_FONTS = [
45
+ "Inter",
46
+ "Roboto",
47
+ "Helvetica",
48
+ "Arial",
49
+ "Open Sans",
50
+ "Lato",
51
+ "Montserrat",
52
+ "Poppins",
53
+ "Raleway",
54
+ ];
55
+
56
+ const AI_PURPLE_HEXES = [
57
+ "#7c3aed",
58
+ "#8b5cf6",
59
+ "#6366f1",
60
+ "#a78bfa",
61
+ "#c084fc",
62
+ "#9333ea",
63
+ "#7e22ce",
64
+ "#a855f7",
65
+ ];
66
+
67
+ const FAMILY_ALLOWED_ACCENTS = {
68
+ glass: [
69
+ "#7FE8D4", "#8B7FE8", "#E89BC4", "#F2D88F",
70
+ "#9D8BF2", "#85DDC9", "#0E0E12",
71
+ ],
72
+ paper: [
73
+ "#1a1a1a", "#2d2d2d", "#6b6b6b", "#1e3a27",
74
+ "#2d4433", "#D4663A", "#1e7a45", "#c0392b", "#5be8a0",
75
+ ],
76
+ forbidden: [
77
+ "#0E0B12", "#D97757", "#C4506B", "#6B5BD9", "#A87FE8",
78
+ ],
79
+ // dark + warm: skip allowlist enforcement here.
80
+ // - "warm" is enforced via Warm's ALLOWED_ACCENTS in palette.ts (single-accent rule).
81
+ // - "dark" allows multi-brand accents by design (cinematic partnership reels).
82
+ };
83
+
84
+ const GENERIC_PLACEHOLDERS = [
85
+ /lorem ipsum/i,
86
+ /\bjohn doe\b/i,
87
+ /\backme corp\b/i,
88
+ /\bfoo bar\b/i,
89
+ /placeholder text/i,
90
+ ];
91
+
92
+ // Emoji & symbol ranges (covers most emoji + dingbats).
93
+ const EMOJI_REGEX = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{27BF}]/u;
94
+
95
+ function lint(file) {
96
+ if (!fs.existsSync(file)) {
97
+ fail(`File not found: ${file}`);
98
+ // Sentinel: `null` means "couldn't lint" (file missing/unreadable),
99
+ // which is distinct from `[]` which means "linted, no violations".
100
+ // run() must check for null and exit non-zero WITHOUT printing
101
+ // "Lint clean" — see v1.1 audit blocker.
102
+ return null;
103
+ }
104
+ const src = fs.readFileSync(file, "utf8");
105
+ const lines = src.split(/\r?\n/);
106
+ const violations = [];
107
+ const isTemplate = /[\\/]templates[\\/]/.test(file);
108
+ const lineOfIdx = (idx) => src.slice(0, idx).split("\n").length;
109
+
110
+ // ─── Safe-zone breaches ──────────────────────────────────────────────
111
+ // Naive: find `top:` / `bottom:` literal pixel values and flag if inside
112
+ // the reserved bands.
113
+ lines.forEach((line, i) => {
114
+ const topMatch = line.match(/top:\s*(-?\d+)\s*,?/);
115
+ if (topMatch) {
116
+ const y = Number(topMatch[1]);
117
+ if (y >= 0 && y < TOP_SAFE && !line.includes("data-reelstack-safe-override")) {
118
+ violations.push({ line: i + 1, code: "SAFE_ZONE_BREACH", msg: `top: ${y} overlaps top reserved band (0..${TOP_SAFE}).` });
119
+ }
120
+ }
121
+ const bottomMatch = line.match(/bottom:\s*(-?\d+)\s*,?/);
122
+ if (bottomMatch) {
123
+ const b = Number(bottomMatch[1]);
124
+ if (b >= 0 && b < BOTTOM_SAFE && !line.includes("data-reelstack-safe-override")) {
125
+ violations.push({ line: i + 1, code: "SAFE_ZONE_BREACH", msg: `bottom: ${b} overlaps bottom reserved band (0..${BOTTOM_SAFE}).` });
126
+ }
127
+ }
128
+ });
129
+
130
+ // ─── Audio lock ──────────────────────────────────────────────────────
131
+ const importsAudio = /from\s+["']remotion["'][^;]*\bAudio\b/.test(src) || /<Audio\b/.test(src);
132
+ const definesBeats = /\bBEATS\s*=\s*\{/.test(src);
133
+ if (importsAudio && !definesBeats) {
134
+ violations.push({ line: 1, code: "AUDIO_NOT_LOCKED", msg: "Reel imports <Audio> but defines no BEATS const block. Run /reelstack-beats to lock motion to whisper SRT." });
135
+ }
136
+
137
+ // ─── Hand-drawn brand logos ──────────────────────────────────────────
138
+ const inlineSvgs = src.match(/<svg[\s\S]*?<\/svg>/g) || [];
139
+ for (const svg of inlineSvgs) {
140
+ if (svg.length > 800) {
141
+ const idx = src.indexOf(svg);
142
+ const lineNum = src.slice(0, idx).split("\n").length;
143
+ violations.push({
144
+ line: lineNum,
145
+ code: "HAND_DRAWN_BRAND",
146
+ msg: `Inline <svg> is ${svg.length} chars — likely hand-drawn. Use /reelstack-icons to pull a real brand SVG.`,
147
+ });
148
+ }
149
+ }
150
+
151
+ // ─── Motion-floor heuristic ──────────────────────────────────────────
152
+ // Walk Sequences explicitly (depth-tracked) and count motion-related
153
+ // calls inside each balanced <Sequence>...</Sequence> block.
154
+ // Opener (from={0}): require ≥4. Anchor/body/cta: require ≥3.
155
+ const motionCallsRegex = /interpolate\s*\(|\bspring\s*\(|useCurrentFrame\s*\(/g;
156
+ const lineOf = (idx) => src.slice(0, idx).split("\n").length;
157
+
158
+ let cursor = 0;
159
+ while (true) {
160
+ const openIdx = src.indexOf("<Sequence", cursor);
161
+ if (openIdx === -1) break;
162
+
163
+ // Find end of opening tag — track > but skip those inside JSX expressions.
164
+ // Simple scan: walk forward, balance { } depth; close on > at depth 0.
165
+ let braceDepth = 0;
166
+ let tagEnd = -1;
167
+ for (let i = openIdx; i < src.length; i++) {
168
+ const ch = src[i];
169
+ if (ch === "{") braceDepth++;
170
+ else if (ch === "}") braceDepth--;
171
+ else if (ch === ">" && braceDepth === 0) {
172
+ tagEnd = i;
173
+ break;
174
+ }
175
+ }
176
+ if (tagEnd === -1) break;
177
+
178
+ const openTag = src.slice(openIdx, tagEnd + 1);
179
+
180
+ // Self-closing? Skip — cannot contain motion calls.
181
+ if (openTag.endsWith("/>")) {
182
+ cursor = tagEnd + 1;
183
+ continue;
184
+ }
185
+
186
+ // Extract from={N} (only flag explicitly-numeric froms).
187
+ const fromMatch = openTag.match(/\bfrom=\{(-?\d+)\}/);
188
+ if (!fromMatch) {
189
+ // No literal-number `from`; can't classify as opener vs anchor reliably.
190
+ // Skip this Sequence rather than misreport.
191
+ cursor = tagEnd + 1;
192
+ continue;
193
+ }
194
+ const fromValue = Number(fromMatch[1]);
195
+
196
+ // Walk forward to find the matching </Sequence>, balancing depth.
197
+ let depth = 1;
198
+ let scan = tagEnd + 1;
199
+ let bodyEnd = -1;
200
+ while (scan < src.length) {
201
+ const nextOpen = src.indexOf("<Sequence", scan);
202
+ const nextClose = src.indexOf("</Sequence>", scan);
203
+ if (nextClose === -1) break;
204
+ if (nextOpen !== -1 && nextOpen < nextClose) {
205
+ depth++;
206
+ scan = nextOpen + "<Sequence".length;
207
+ } else {
208
+ depth--;
209
+ if (depth === 0) {
210
+ bodyEnd = nextClose;
211
+ break;
212
+ }
213
+ scan = nextClose + "</Sequence>".length;
214
+ }
215
+ }
216
+ if (bodyEnd === -1) {
217
+ // Unbalanced; don't crash — just stop walking Sequences.
218
+ break;
219
+ }
220
+
221
+ const body = src.slice(tagEnd + 1, bodyEnd);
222
+ const motionCalls = (body.match(motionCallsRegex) || []).length;
223
+
224
+ const required = fromValue === 0 ? 4 : 3;
225
+ if (motionCalls < required) {
226
+ violations.push({
227
+ line: lineOf(openIdx),
228
+ code: "MOTION_FLOOR_VIOLATION",
229
+ msg: `Sequence at frame ${fromValue} has ${motionCalls} motion calls, family rule is ≥3 (≥4 in opener).`,
230
+ });
231
+ }
232
+
233
+ // Advance past this opener tag so nested Sequences are re-discovered
234
+ // on the next iteration. We don't skip past the whole block.
235
+ cursor = tagEnd + 1;
236
+ }
237
+
238
+ // ─── Hero-text overflow ──────────────────────────────────────────────
239
+ // Heuristic: fontSize > 256 paired with hero-text signals (tabularNums,
240
+ // letterSpacing, Counter, HeroText) within a 5-line window.
241
+ lines.forEach((line, i) => {
242
+ const fsMatch = line.match(/fontSize:\s*(\d+)/);
243
+ if (!fsMatch) return;
244
+ const px = Number(fsMatch[1]);
245
+ if (px <= 256) return;
246
+ const window = lines.slice(Math.max(0, i - 2), Math.min(lines.length, i + 3)).join(" ");
247
+ if (/(tabular-?nums|tabularNums|Counter|HeroText|letterSpacing)/i.test(window)) {
248
+ violations.push({
249
+ line: i + 1,
250
+ code: "HERO_TEXT_OVERFLOW",
251
+ msg: `fontSize ${px}px on apparent hero-counter; cap at 256px or wrap with maxWidth=${HERO_TEXT_MAX}.`,
252
+ });
253
+ }
254
+ });
255
+
256
+ // ─── Banned fonts (taste-skill rule) ─────────────────────────────────
257
+ try {
258
+ const fontFamilyRegex = /fontFamily:\s*["'`]([^"'`]+)["'`]/g;
259
+ let mFF;
260
+ while ((mFF = fontFamilyRegex.exec(src)) !== null) {
261
+ const decl = mFF[1].toLowerCase();
262
+ const hit = BANNED_FONTS.find((f) => decl.includes(f.toLowerCase()));
263
+ if (hit) {
264
+ violations.push({
265
+ line: lineOfIdx(mFF.index),
266
+ code: "BANNED_FONT",
267
+ msg: `fontFamily "${mFF[1]}" includes generic LLM-default font "${hit}". Use the family's typography preset instead.`,
268
+ });
269
+ }
270
+ }
271
+ } catch {}
272
+
273
+ // ─── Pure-black text (taste-skill rule) ──────────────────────────────
274
+ // Detects pure black across multiple CSS color forms on color/background/
275
+ // fill/stroke properties:
276
+ // - #000 / #000000 (case insensitive)
277
+ // - rgb(0,0,0) / rgb( 0 , 0 , 0 ) (whitespace tolerant)
278
+ // - rgba(0,0,0, 1) (alpha 0..1)
279
+ // - "black" CSS keyword
280
+ try {
281
+ const blackHexRe = /^#0{3}$|^#0{6}$/i;
282
+ const blackRgbRe = /^rgba?\(\s*0\s*,\s*0\s*,\s*0\s*(?:,\s*(?:0|0?\.\d+|1(?:\.0+)?)\s*)?\)$/i;
283
+ const propRe = /(color|background|fill|stroke):\s*["'`]([^"'`]+)["'`]/g;
284
+ let mB;
285
+ while ((mB = propRe.exec(src)) !== null) {
286
+ const prop = mB[1];
287
+ const val = mB[2].trim();
288
+ const isBlack =
289
+ blackHexRe.test(val) ||
290
+ blackRgbRe.test(val) ||
291
+ val.toLowerCase() === "black";
292
+ if (isBlack) {
293
+ violations.push({
294
+ line: lineOfIdx(mB.index),
295
+ code: "PURE_BLACK_TEXT",
296
+ msg: `${prop}: "${val}" is pure black — kills warmth. Use the family's text-primary token (e.g. #0E0E12 / #1a1a1a / #0E0B12).`,
297
+ });
298
+ }
299
+ }
300
+ } catch {}
301
+
302
+ // ─── AI-purple accent (taste-skill rule) ─────────────────────────────
303
+ // Catches purple references across:
304
+ // - 6-char hex (#7c3aed)
305
+ // - 3-char hex shorthand normalized to 6-char (#73e → #7733ee)
306
+ // - rgb(R, G, B) / rgba(R, G, B, A) numeric forms (converted to hex)
307
+ // Forbidden-family files (importing @devinilabs/reelstack/families/forbidden)
308
+ // get an exemption for #6B5BD9 (ultraviolet) and #A87FE8 (plasma) since
309
+ // those are family-native accents that happen to land in the purple gamut.
310
+ try {
311
+ const blocklist = new Set(AI_PURPLE_HEXES.map((h) => h.toLowerCase()));
312
+ const isForbiddenFamily =
313
+ /from\s+["']@devinilabs\/reelstack\/families\/forbidden/.test(src);
314
+ const forbiddenExempt = new Set(["#6b5bd9", "#a87fe8"]);
315
+
316
+ const toHex = (n) => {
317
+ const v = Math.max(0, Math.min(255, Number(n)));
318
+ return v.toString(16).padStart(2, "0");
319
+ };
320
+ const expandShortHex = (hex) =>
321
+ "#" + hex.slice(1).split("").map((ch) => ch + ch).join("");
322
+
323
+ const flaggedAt = new Set(); // dedupe by hex+line
324
+ const pushIfBlocked = (canonHex, displayValue, idx) => {
325
+ const lc = canonHex.toLowerCase();
326
+ if (!blocklist.has(lc)) return;
327
+ if (isForbiddenFamily && forbiddenExempt.has(lc)) return;
328
+ const line = lineOfIdx(idx);
329
+ const key = lc + ":" + line;
330
+ if (flaggedAt.has(key)) return;
331
+ flaggedAt.add(key);
332
+ violations.push({
333
+ line,
334
+ code: "AI_PURPLE_ACCENT",
335
+ msg: `${displayValue} is on the AI-purple blocklist — every "AI" demo uses these. Pick a family-native accent instead.`,
336
+ });
337
+ };
338
+
339
+ // 6-char hex
340
+ const hex6Re = /#[0-9a-fA-F]{6}\b/g;
341
+ let mH6;
342
+ while ((mH6 = hex6Re.exec(src)) !== null) {
343
+ pushIfBlocked(mH6[0], `Hex ${mH6[0]}`, mH6.index);
344
+ }
345
+
346
+ // 3-char hex (NOT followed by another hex pair so we don't double-match #abcdef).
347
+ // Use lookahead to ensure exactly 3 hex digits, terminated by non-hex.
348
+ const hex3Re = /#[0-9a-fA-F]{3}(?![0-9a-fA-F])/g;
349
+ let mH3;
350
+ while ((mH3 = hex3Re.exec(src)) !== null) {
351
+ const expanded = expandShortHex(mH3[0]);
352
+ pushIfBlocked(expanded, `Hex ${mH3[0]} (≡ ${expanded})`, mH3.index);
353
+ }
354
+
355
+ // rgb()/rgba() numeric color
356
+ const rgbRe = /rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*(?:0|0?\.\d+|1(?:\.0+)?)\s*)?\)/gi;
357
+ let mRgb;
358
+ while ((mRgb = rgbRe.exec(src)) !== null) {
359
+ const canon = "#" + toHex(mRgb[1]) + toHex(mRgb[2]) + toHex(mRgb[3]);
360
+ pushIfBlocked(canon, `${mRgb[0]} (≡ ${canon})`, mRgb.index);
361
+ }
362
+ } catch {}
363
+
364
+ // ─── Emoji glyphs in JSX text ────────────────────────────────────────
365
+ try {
366
+ const textRegex = />([^<>]+)</g;
367
+ let mE;
368
+ while ((mE = textRegex.exec(src)) !== null) {
369
+ if (EMOJI_REGEX.test(mE[1])) {
370
+ violations.push({
371
+ line: lineOfIdx(mE.index),
372
+ code: "EMOJI_GLYPH",
373
+ msg: `Literal emoji in JSX text — hand-drawn brand violation. Use /reelstack-icons or a custom motion glyph.`,
374
+ });
375
+ }
376
+ }
377
+ } catch {}
378
+
379
+ // ─── Accent allowlist (per-family) ───────────────────────────────────
380
+ try {
381
+ const familyMatch = src.match(/from\s+["']@devinilabs\/reelstack\/families\/(\w+)/);
382
+ const family = familyMatch ? familyMatch[1].toLowerCase() : null;
383
+ const allowed = family && FAMILY_ALLOWED_ACCENTS[family];
384
+ if (allowed) {
385
+ const allowLc = allowed.map((h) => h.toLowerCase());
386
+ const hexAccentRegex = /#[0-9a-fA-F]{6}\b/g;
387
+ const flagged = new Set();
388
+ let mA;
389
+ while ((mA = hexAccentRegex.exec(src)) !== null) {
390
+ const lc = mA[0].toLowerCase();
391
+ if (/^#0{6}$/.test(lc)) continue;
392
+ if (AI_PURPLE_HEXES.includes(lc)) continue;
393
+ if (allowLc.includes(lc)) continue;
394
+ const key = lc + ":" + lineOfIdx(mA.index);
395
+ if (flagged.has(key)) continue;
396
+ flagged.add(key);
397
+ violations.push({
398
+ line: lineOfIdx(mA.index),
399
+ code: "ACCENT_ALLOWLIST_VIOLATION",
400
+ msg: `Hex ${mA[0]} is not in the ${family} family's ALLOWED_ACCENTS list. Use the palette tokens instead.`,
401
+ });
402
+ }
403
+ }
404
+ } catch {}
405
+
406
+ // ─── Spacing non-grid (taste-skill rule) ─────────────────────────────
407
+ // Catches off-grid spacing in three forms:
408
+ // - numeric: padding: 23
409
+ // - string: padding: "23" / padding: "23px"
410
+ // - shorthand string: padding: "16 30 16 8" → check each component
411
+ try {
412
+ const checkVal = (rawProp, val, idx) => {
413
+ if (!Number.isFinite(val)) return;
414
+ if (val === 0) return;
415
+ if (val === 1 || val === 2) return; // hairlines allowed.
416
+ if (val % 4 !== 0) {
417
+ violations.push({
418
+ line: lineOfIdx(idx),
419
+ code: "SPACING_NON_GRID",
420
+ msg: `${rawProp}: ${val} is off the 4px grid. Round to ${Math.round(val / 4) * 4}.`,
421
+ });
422
+ }
423
+ };
424
+ const propPattern =
425
+ "(padding|margin|gap)(?:Top|Bottom|Left|Right|Inline|Block)?";
426
+
427
+ // Numeric form: padding: 23
428
+ const numRe = new RegExp(propPattern + ":\\s*(\\d+)\\b(?!\\s*[\"'`])", "g");
429
+ let mN;
430
+ while ((mN = numRe.exec(src)) !== null) {
431
+ checkVal(mN[1], Number(mN[2]), mN.index);
432
+ }
433
+
434
+ // String form: padding: "23" / "23px" / "16 30 16 8" / "16px 8px"
435
+ const strRe = new RegExp(propPattern + ":\\s*[\"'`]([^\"'`]+)[\"'`]", "g");
436
+ let mStr;
437
+ while ((mStr = strRe.exec(src)) !== null) {
438
+ const prop = mStr[1];
439
+ const raw = mStr[2].trim();
440
+ // Split shorthand on whitespace; check each component.
441
+ const parts = raw.split(/\s+/);
442
+ for (const part of parts) {
443
+ const m = /^(\d+)(?:px)?$/.exec(part);
444
+ if (!m) continue; // skip non-numeric tokens (auto, %, var(...), etc.)
445
+ checkVal(prop, Number(m[1]), mStr.index);
446
+ }
447
+ }
448
+ } catch {}
449
+
450
+ // ─── Missing reduceMotion prop ───────────────────────────────────────
451
+ try {
452
+ if (!isTemplate) {
453
+ const usesFrame = /\buseCurrentFrame\s*\(/.test(src);
454
+ if (usesFrame) {
455
+ const fcRegex = /React\.FC<\{([^}]*)\}>/g;
456
+ let mFC;
457
+ let foundFC = false;
458
+ while ((mFC = fcRegex.exec(src)) !== null) {
459
+ foundFC = true;
460
+ if (!/reduceMotion/.test(mFC[1])) {
461
+ violations.push({
462
+ line: lineOfIdx(mFC.index),
463
+ code: "MISSING_REDUCE_MOTION",
464
+ msg: `React.FC component uses useCurrentFrame but prop type lacks "reduceMotion". Add reduceMotion?: boolean for accessibility.`,
465
+ });
466
+ }
467
+ }
468
+ if (!foundFC && /export\s+const\s+\w+\s*[:=]\s*React\.FC/.test(src) && !/reduceMotion/.test(src)) {
469
+ violations.push({
470
+ line: 1,
471
+ code: "MISSING_REDUCE_MOTION",
472
+ msg: `Component uses useCurrentFrame but no reduceMotion prop found anywhere. Add reduceMotion?: boolean.`,
473
+ });
474
+ }
475
+ }
476
+ }
477
+ } catch {}
478
+
479
+ // ─── Generic placeholder copy ────────────────────────────────────────
480
+ try {
481
+ if (!isTemplate) {
482
+ lines.forEach((line, i) => {
483
+ for (const re of GENERIC_PLACEHOLDERS) {
484
+ if (re.test(line)) {
485
+ violations.push({
486
+ line: i + 1,
487
+ code: "GENERIC_PLACEHOLDER",
488
+ msg: `Stub copy detected ("${line.match(re)[0]}"). Replace with real product copy before shipping.`,
489
+ });
490
+ break;
491
+ }
492
+ }
493
+ });
494
+ }
495
+ } catch {}
496
+
497
+ // ─── Pill / tag width discipline ─────────────────────────────────────
498
+ // Pill, Tag, Chip, and Badge components must hug their content. When
499
+ // they sit inside a flex column the default `align-items: stretch`
500
+ // bleeds across the canvas and breaks the eyebrow-callout pattern.
501
+ // The components themselves now ship with `alignSelf: flex-start` +
502
+ // `width: max-content`, so this rule catches the only remaining gap:
503
+ // call-site `style` overrides that re-stretch the pill.
504
+ try {
505
+ const HUG_OK = /^(max-content|fit-content|min-content|auto)$/i;
506
+ for (const mTag of src.matchAll(/<(\w+(?:Pill|Tag|Chip|Badge))\b([^>]*?)(?:\/?>|>)/g)) {
507
+ const componentName = mTag[1];
508
+ const opener = mTag[0];
509
+ const idx = mTag.index;
510
+ const styleMatch = opener.match(/style=\{\{([\s\S]*?)\}\}/);
511
+ if (!styleMatch) continue;
512
+ const styleBlock = styleMatch[1];
513
+
514
+ const widthMatch = styleBlock.match(/\bwidth:\s*["'`]?([^,"\s'`}]+)["'`]?/);
515
+ if (widthMatch && !HUG_OK.test(widthMatch[1])) {
516
+ violations.push({
517
+ line: lineOfIdx(idx),
518
+ code: "PILL_TAG_OVERSTRETCHED",
519
+ msg: `<${componentName}> has style.width="${widthMatch[1]}" — tag/pill components must hug content. Remove width or set "max-content".`,
520
+ });
521
+ }
522
+
523
+ if (/\balignSelf:\s*["'`]stretch["'`]/.test(styleBlock)) {
524
+ violations.push({
525
+ line: lineOfIdx(idx),
526
+ code: "PILL_TAG_OVERSTRETCHED",
527
+ msg: `<${componentName}> has alignSelf: "stretch" — tag/pill components must hug content. Remove or set "flex-start".`,
528
+ });
529
+ }
530
+
531
+ if (/\bflex:\s*["'`]?[1-9]/.test(styleBlock)) {
532
+ violations.push({
533
+ line: lineOfIdx(idx),
534
+ code: "PILL_TAG_OVERSTRETCHED",
535
+ msg: `<${componentName}> has flex grow > 0 — tag/pill components must hug content.`,
536
+ });
537
+ }
538
+
539
+ if (/\bflexGrow:\s*[1-9]/.test(styleBlock)) {
540
+ violations.push({
541
+ line: lineOfIdx(idx),
542
+ code: "PILL_TAG_OVERSTRETCHED",
543
+ msg: `<${componentName}> has flexGrow > 0 — tag/pill components must hug content.`,
544
+ });
545
+ }
546
+ }
547
+ } catch {}
548
+
549
+ // ─── Ripple-effect intensity ceiling ─────────────────────────────────
550
+ // Ripple primitives (SonarRings, ParticleBurst, IridescentRing) read
551
+ // as ambient texture, not protagonist. When their counts climb or when
552
+ // multiple ripple emitters stack at the same anchor in the same scene,
553
+ // the eye loses the hero copy. Ceilings here are the values used in
554
+ // the canonical reels (ClaudeWatchReel, GraphifyReel) — anything above
555
+ // is louder than the house style sanctions.
556
+ try {
557
+ const RIPPLE_CEILINGS = {
558
+ SonarRings: { prop: "count", max: 4, msg: "SonarRings count > 4 reads as visual noise. House ceiling is 4." },
559
+ ParticleBurst: { prop: "count", max: 20, msg: "ParticleBurst count > 20 overwhelms the hero. House ceiling is 20." },
560
+ IridescentRing: { prop: "radius", max: 260, msg: "IridescentRing radius > 260 dominates the frame. House ceiling is 260." },
561
+ };
562
+ for (const [name, rule] of Object.entries(RIPPLE_CEILINGS)) {
563
+ const tagRe = new RegExp(`<${name}\\b([^>]*?)(?:/?>|>)`, "g");
564
+ for (const mTag of src.matchAll(tagRe)) {
565
+ const opener = mTag[0];
566
+ const idx = mTag.index;
567
+ const propRe = new RegExp(`\\b${rule.prop}=\\{(-?\\d+)\\}`);
568
+ const propMatch = opener.match(propRe);
569
+ if (!propMatch) continue;
570
+ const value = Number(propMatch[1]);
571
+ if (value > rule.max) {
572
+ violations.push({
573
+ line: lineOfIdx(idx),
574
+ code: "RIPPLE_TOO_LOUD",
575
+ msg: `<${name} ${rule.prop}={${value}}> — ${rule.msg}`,
576
+ });
577
+ }
578
+ }
579
+ }
580
+
581
+ // Ripple-stack penalty: more than 2 ripple emitters anchored to the
582
+ // same (cx, cy) in the same Sequence implies overlapping shockwaves.
583
+ // We approximate "same anchor" by exact-match cx + cy props in adjacent
584
+ // JSX nodes within ~12 lines.
585
+ const ripplePoints = []; // { cx, cy, line, name }
586
+ const rippleRe = /<(SonarRings|ParticleBurst|IridescentRing)\b([^>]*?)(?:\/?>|>)/g;
587
+ for (const mR of src.matchAll(rippleRe)) {
588
+ const opener = mR[0];
589
+ const cx = (opener.match(/\bcx=\{(-?\d+)\}/) || [])[1];
590
+ const cy = (opener.match(/\bcy=\{(-?\d+)\}/) || [])[1];
591
+ if (cx == null || cy == null) continue;
592
+ ripplePoints.push({
593
+ key: `${cx},${cy}`,
594
+ line: lineOfIdx(mR.index),
595
+ name: mR[1],
596
+ });
597
+ }
598
+ const stackCounts = {};
599
+ for (const p of ripplePoints) {
600
+ stackCounts[p.key] = (stackCounts[p.key] || []);
601
+ stackCounts[p.key].push(p);
602
+ }
603
+ for (const [anchor, group] of Object.entries(stackCounts)) {
604
+ if (group.length > 2) {
605
+ violations.push({
606
+ line: group[0].line,
607
+ code: "RIPPLE_STACK",
608
+ msg: `${group.length} ripple primitives anchored at (${anchor}) — house rule: max 2 ripple emitters per anchor. Spread them or remove one.`,
609
+ });
610
+ }
611
+ }
612
+ } catch {}
613
+
614
+ return violations;
615
+ }
616
+
617
+ // ─── Critique mode (5-dim radar + Keep/Fix/Quick) ────────────────────────
618
+
619
+ function bar(score, max = 10) {
620
+ const filled = Math.max(0, Math.min(max, Math.round(score)));
621
+ return "█".repeat(filled) + "░".repeat(max - filled);
622
+ }
623
+
624
+ function countCode(violations, code) {
625
+ return violations.filter((v) => v.code === code).length;
626
+ }
627
+
628
+ /**
629
+ * computeCritique(violations, src?) — pure scoring helper extracted for tests.
630
+ *
631
+ * Takes a violations array (from lint()) and an optional source string (used
632
+ * only for the "un-eased interpolate" timing-penalty heuristic and the
633
+ * BEATS-present keep-line). Returns the radar scores plus keep/fix/quick.
634
+ *
635
+ * Returned shape exposes both `brand` (legacy renderCritique consumer) and
636
+ * `brandFit` (camelCase form preferred by external callers / tests).
637
+ */
638
+ function computeCritique(violations, src = "") {
639
+ const v = violations || [];
640
+
641
+ const accentViols = countCode(v, "ACCENT_ALLOWLIST_VIOLATION");
642
+ const motionViols = countCode(v, "MOTION_FLOOR_VIOLATION");
643
+ const audioViols = countCode(v, "AUDIO_NOT_LOCKED");
644
+ const spacingViols = countCode(v, "SPACING_NON_GRID");
645
+ const heroOverflowViols = countCode(v, "HERO_TEXT_OVERFLOW");
646
+ const bannedFontViols = countCode(v, "BANNED_FONT");
647
+ const blackTextViols = countCode(v, "PURE_BLACK_TEXT");
648
+ const aiPurpleViols = countCode(v, "AI_PURPLE_ACCENT");
649
+ const emojiViols = countCode(v, "EMOJI_GLYPH");
650
+ const placeholderViols = countCode(v, "GENERIC_PLACEHOLDER");
651
+
652
+ // interpolate(frame,…) without explicit `easing:` option in same call.
653
+ let unEasedInterp = 0;
654
+ const interpRe = /interpolate\s*\(\s*frame\b[\s\S]*?\)/g;
655
+ let mi;
656
+ while ((mi = interpRe.exec(src)) !== null) {
657
+ if (!/easing\s*:/.test(mi[0])) unEasedInterp++;
658
+ }
659
+
660
+ // ─── Placeholder-BEATS detection ───────────────────────────────────────
661
+ // A freshly-scaffolded reel ships with a stub BEATS block whose values are
662
+ // round-number-frame defaults (hook:0, reveal:90, body:540, anchor:1200,
663
+ // cta:1620). Real whisper-SRT-derived BEATs come from `seconds * 30` of
664
+ // arbitrary VO timestamps and are almost never multiples of 30.
665
+ //
666
+ // We use BOTH heuristics together for robustness:
667
+ // 1) Strict scaffold-default match: exact hook/reveal/body/anchor/cta
668
+ // values from templates/{family}/template.tsx.
669
+ // 2) Loose "all values multiples of 30" check on any BEATS block
670
+ // with ≥3 entries — covers hand-edited scaffolds where someone
671
+ // bumped one value but left the rest stub-round.
672
+ // Either one trips the placeholder flag.
673
+ let placeholderBeats = false;
674
+ try {
675
+ const beatsBlockRe = /\bBEATS\s*=\s*\{([\s\S]*?)\}/;
676
+ const m = beatsBlockRe.exec(src);
677
+ if (m) {
678
+ const body = m[1];
679
+ const entryRe = /(\w+)\s*:\s*(\d+)/g;
680
+ const entries = {};
681
+ let me;
682
+ while ((me = entryRe.exec(body)) !== null) {
683
+ entries[me[1]] = Number(me[2]);
684
+ }
685
+ const values = Object.values(entries);
686
+
687
+ // Heuristic 1: exact scaffold defaults.
688
+ const isScaffoldDefault =
689
+ entries.hook === 0 &&
690
+ entries.reveal === 90 &&
691
+ entries.body === 540 &&
692
+ entries.anchor === 1200 &&
693
+ entries.cta === 1620;
694
+
695
+ // Heuristic 2: ≥3 entries and ALL values are multiples of 30.
696
+ const allMultiplesOf30 =
697
+ values.length >= 3 && values.every((n) => n % 30 === 0);
698
+
699
+ placeholderBeats = isScaffoldDefault || allMultiplesOf30;
700
+ }
701
+ } catch {}
702
+
703
+ const palette = Math.max(0, 10 - Math.min(10, accentViols));
704
+ const motion = Math.max(0, 10 - motionViols * 2);
705
+ const timing = Math.max(
706
+ 0,
707
+ 10 - audioViols * 5 - (unEasedInterp > 0 ? 1 : 0) - (placeholderBeats ? 4 : 0),
708
+ );
709
+ const hierarchy = Math.max(0, 10 - spacingViols * 1 - heroOverflowViols * 3);
710
+ const brand = Math.max(
711
+ 0,
712
+ 10 - bannedFontViols * 5 - blackTextViols * 2 - aiPurpleViols * 5 - emojiViols * 3 - placeholderViols * 2,
713
+ );
714
+
715
+ const keep = [];
716
+ if (motionViols === 0) keep.push("Motion floor met across all sequences (≥3 layers, ≥4 in opener).");
717
+ if (audioViols === 0 && !placeholderBeats && /\bBEATS\s*=\s*\{/.test(src)) {
718
+ keep.push("Audio-locked beats present — motion choreographed to whisper SRT.");
719
+ }
720
+ if (countCode(v, "SAFE_ZONE_BREACH") === 0) keep.push("IG safe zones respected (no top/bottom-band breaches).");
721
+ if (bannedFontViols === 0 && blackTextViols === 0 && aiPurpleViols === 0) keep.push("Typography + palette stay on-family — no LLM-default fonts or AI-purple hexes.");
722
+ if (heroOverflowViols === 0) keep.push("Hero text fits the 1080 width gutter.");
723
+ if (keep.length === 0) keep.push("(No clean wins detected — work the punch list below.)");
724
+
725
+ const severityOrder = [
726
+ "AUDIO_NOT_LOCKED",
727
+ "BANNED_FONT",
728
+ "AI_PURPLE_ACCENT",
729
+ "MOTION_FLOOR_VIOLATION",
730
+ "HERO_TEXT_OVERFLOW",
731
+ "SAFE_ZONE_BREACH",
732
+ "HAND_DRAWN_BRAND",
733
+ "ACCENT_ALLOWLIST_VIOLATION",
734
+ "PURE_BLACK_TEXT",
735
+ "EMOJI_GLYPH",
736
+ "GENERIC_PLACEHOLDER",
737
+ "MISSING_REDUCE_MOTION",
738
+ ];
739
+ const sorted = [...v].sort(
740
+ (a, b) => severityOrder.indexOf(a.code) - severityOrder.indexOf(b.code),
741
+ );
742
+ const fix = sorted.filter((x) => x.code !== "SPACING_NON_GRID").slice(0, 2);
743
+
744
+ // Synthesize a Fix punch-list item for placeholder BEATS even though it
745
+ // isn't a numbered violation in the lint() output. Prepend so it surfaces
746
+ // above other Fix items.
747
+ if (placeholderBeats) {
748
+ fix.unshift({
749
+ line: 1,
750
+ code: "PLACEHOLDER_BEATS",
751
+ msg: "Audio not locked — BEATS contains placeholder values. Run /reelstack-beats <vo.wav> to lock motion to whisper SRT.",
752
+ });
753
+ // Cap at 2 so we don't blow out the Fix line.
754
+ fix.length = Math.min(fix.length, 2);
755
+ }
756
+
757
+ const quick = v.filter((x) => x.code === "SPACING_NON_GRID").slice(0, 2);
758
+ if (quick.length === 0) {
759
+ const minor = v.filter((x) => x.code === "MISSING_REDUCE_MOTION" || x.code === "GENERIC_PLACEHOLDER").slice(0, 1);
760
+ quick.push(...minor);
761
+ }
762
+
763
+ return {
764
+ scores: { palette, motion, timing, hierarchy, brand },
765
+ palette,
766
+ motion,
767
+ timing,
768
+ hierarchy,
769
+ brandFit: brand,
770
+ keep,
771
+ fix,
772
+ quick,
773
+ };
774
+ }
775
+
776
+ function critique(file) {
777
+ const v = lint(file);
778
+ const src = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
779
+ // lint() returns null when the file is missing; computeCritique already
780
+ // tolerates null/undefined (defaults to []), but be explicit.
781
+ return computeCritique(v || [], src);
782
+ }
783
+
784
+ function renderCritique(file, result) {
785
+ const rel = path.relative(process.cwd(), file);
786
+ const lines = [];
787
+ lines.push("");
788
+ lines.push(`${c.bold("ReelStack Critique")} ${c.gray("·")} ${c.cyan(rel)}`);
789
+ lines.push("");
790
+ const rows = [
791
+ ["Palette ", result.scores.palette],
792
+ ["Motion ", result.scores.motion],
793
+ ["Timing ", result.scores.timing],
794
+ ["Hierarchy", result.scores.hierarchy],
795
+ ["Brand fit", result.scores.brand],
796
+ ];
797
+ for (const [label, score] of rows) {
798
+ const b = bar(score);
799
+ const tag =
800
+ score >= 9 ? c.green(b) : score >= 6 ? c.yellow(b) : c.red(b);
801
+ lines.push(` ${c.bold(label)} ${tag} ${score}/10`);
802
+ }
803
+ lines.push("");
804
+ lines.push(` ${c.green(c.bold("Keep:"))} ${result.keep.join(" ")}`);
805
+ if (result.fix.length > 0) {
806
+ const fixStr = result.fix
807
+ .map((f) => `${f.code} at L${f.line} — ${f.msg}`)
808
+ .join(" | ");
809
+ lines.push(` ${c.yellow(c.bold("Fix:"))} ${fixStr}`);
810
+ } else {
811
+ lines.push(` ${c.yellow(c.bold("Fix:"))} (none — strong reel.)`);
812
+ }
813
+ if (result.quick.length > 0) {
814
+ const quickStr = result.quick
815
+ .map((q) => `${q.code} at L${q.line} — ${q.msg}`)
816
+ .join(" | ");
817
+ lines.push(` ${c.cyan(c.bold("Quick:"))} ${quickStr}`);
818
+ } else {
819
+ lines.push(` ${c.cyan(c.bold("Quick:"))} (no quick fixes.)`);
820
+ }
821
+ lines.push("");
822
+ return lines.join("\n");
823
+ }
824
+
825
+ async function run(argv) {
826
+ const file = argv.find((a) => !a.startsWith("--"));
827
+ const useCritique = argv.includes("--critique");
828
+ if (!file) {
829
+ fail("Usage: reelstack lint <src/MyReel.tsx> [--critique]");
830
+ process.exit(2);
831
+ }
832
+ const abs = path.resolve(process.cwd(), file);
833
+
834
+ if (useCritique) {
835
+ if (!fs.existsSync(abs)) {
836
+ fail(`File not found: ${abs}`);
837
+ process.exit(2);
838
+ }
839
+ const result = critique(abs);
840
+ console.log(renderCritique(abs, result));
841
+ const avg = (result.scores.palette + result.scores.motion + result.scores.timing + result.scores.hierarchy + result.scores.brand) / 5;
842
+ process.exit(avg >= 7 ? 0 : 1);
843
+ }
844
+
845
+ const v = lint(abs);
846
+ // Distinguish "couldn't lint" (null) from "no violations" ([]).
847
+ // lint() already printed the failure reason; just exit non-zero.
848
+ if (v === null) {
849
+ process.exit(2);
850
+ }
851
+ console.log("");
852
+ if (v.length === 0) {
853
+ success(`Lint clean: ${path.relative(process.cwd(), abs)}`);
854
+ process.exit(0);
855
+ }
856
+ warn(`${v.length} violation(s) in ${path.relative(process.cwd(), abs)}:`);
857
+ console.log("");
858
+ for (const item of v) {
859
+ console.log(` ${c.gray(`L${item.line}`)} ${c.yellow(item.code)} ${item.msg}`);
860
+ }
861
+ console.log("");
862
+ process.exit(1);
863
+ }
864
+
865
+ module.exports = { run, lint, critique, computeCritique, renderCritique };