@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.
- package/LICENSE +128 -0
- package/README.md +125 -0
- package/cli/beats.js +124 -0
- package/cli/bootstrap.js +124 -0
- package/cli/capture.js +34 -0
- package/cli/direction.js +114 -0
- package/cli/icons.js +49 -0
- package/cli/index.js +101 -0
- package/cli/init.js +253 -0
- package/cli/license.js +168 -0
- package/cli/lint.js +865 -0
- package/cli/preview.js +59 -0
- package/cli/render.js +404 -0
- package/cli/scaffold.js +239 -0
- package/cli/smoke.js +76 -0
- package/cli/update.js +26 -0
- package/cli/utils.js +184 -0
- package/docs/buyers-guide.md +220 -0
- package/docs/design-discipline.md +130 -0
- package/docs/family-galleries/dark.md +95 -0
- package/docs/family-galleries/forbidden.md +78 -0
- package/docs/family-galleries/glass.md +98 -0
- package/docs/family-galleries/paper.md +82 -0
- package/docs/family-galleries/warm.md +86 -0
- package/docs/superpowers/plans/2026-05-09-reelstack-init-readiness-gate.md +1166 -0
- package/docs/superpowers/specs/2026-05-09-reelstack-init-readiness-gate-design.md +233 -0
- package/families/dark/components/DriftingSpotlights.tsx +59 -0
- package/families/dark/components/FilmGrain.tsx +44 -0
- package/families/dark/components/ForestCard.tsx +43 -0
- package/families/dark/components/GridBackground.tsx +29 -0
- package/families/dark/components/RadialVignette.tsx +21 -0
- package/families/dark/components/Scanlines.tsx +35 -0
- package/families/dark/components/SegmentOpacity.ts +37 -0
- package/families/dark/components/index.ts +13 -0
- package/families/dark/index.ts +31 -0
- package/families/dark/palette.ts +98 -0
- package/families/dark/presets/claudedispatch.ts +46 -0
- package/families/dark/presets/codedrop.ts +37 -0
- package/families/dark/presets/gpt55.ts +54 -0
- package/families/dark/presets/notebooklm.ts +50 -0
- package/families/dark/presets/resourcescta.ts +35 -0
- package/families/dark/presets/skills.ts +40 -0
- package/families/dark/presets/stitch.ts +46 -0
- package/families/dark/presets/stitch2.ts +43 -0
- package/families/dark/typography.ts +16 -0
- package/families/forbidden/components/ForbiddenCausticBlobs.tsx +52 -0
- package/families/forbidden/components/NewsprintTexture.tsx +28 -0
- package/families/forbidden/components/TintedShadow.tsx +36 -0
- package/families/forbidden/components/index.ts +38 -0
- package/families/forbidden/index.ts +17 -0
- package/families/forbidden/palette.ts +88 -0
- package/families/forbidden/presets/heretic.ts +44 -0
- package/families/forbidden/typography.ts +18 -0
- package/families/glass/components/BreakdownCard.tsx +158 -0
- package/families/glass/components/CausticBlobs.tsx +49 -0
- package/families/glass/components/Counter.tsx +72 -0
- package/families/glass/components/EyebrowPill.tsx +59 -0
- package/families/glass/components/FilmStrip.tsx +202 -0
- package/families/glass/components/FloatingGlyphs.tsx +78 -0
- package/families/glass/components/GlassCard.tsx +58 -0
- package/families/glass/components/GlassCardBezel.tsx +45 -0
- package/families/glass/components/HairlineGrid.tsx +30 -0
- package/families/glass/components/IridescentRing.tsx +114 -0
- package/families/glass/components/IridescentText.tsx +98 -0
- package/families/glass/components/LightBeam.tsx +46 -0
- package/families/glass/components/ParticleBurst.tsx +62 -0
- package/families/glass/components/SonarRings.tsx +81 -0
- package/families/glass/components/StaggeredWords.tsx +74 -0
- package/families/glass/components/index.ts +20 -0
- package/families/glass/index.ts +31 -0
- package/families/glass/palette.ts +93 -0
- package/families/glass/presets/claudewatch.ts +64 -0
- package/families/glass/presets/claudewatchcta.ts +43 -0
- package/families/glass/presets/graphify.ts +45 -0
- package/families/glass/presets/gstack.ts +48 -0
- package/families/glass/presets/jcode.ts +50 -0
- package/families/glass/presets/lilagents.ts +52 -0
- package/families/glass/presets/paperclip.ts +43 -0
- package/families/glass/typography.ts +15 -0
- package/families/index.ts +49 -0
- package/families/paper/components/CardSpring.tsx +42 -0
- package/families/paper/components/CreamGrid.tsx +26 -0
- package/families/paper/components/EditorialSerifText.tsx +51 -0
- package/families/paper/components/GreenAccentCard.tsx +10 -0
- package/families/paper/components/PaperShadow.tsx +30 -0
- package/families/paper/components/ScaleBlurText.tsx +40 -0
- package/families/paper/components/index.ts +11 -0
- package/families/paper/index.ts +23 -0
- package/families/paper/palette.ts +102 -0
- package/families/paper/presets/designreel.ts +32 -0
- package/families/paper/presets/devini3d.ts +45 -0
- package/families/paper/presets/justdrop.ts +39 -0
- package/families/paper/presets/opus.ts +48 -0
- package/families/paper/typography.ts +17 -0
- package/families/warm/components/AccentGlow.tsx +60 -0
- package/families/warm/components/BentoCell.tsx +56 -0
- package/families/warm/components/BentoGrid.tsx +30 -0
- package/families/warm/components/FilmGrain.tsx +36 -0
- package/families/warm/components/ScaleBlurCounter.tsx +71 -0
- package/families/warm/components/WarmSurface.tsx +35 -0
- package/families/warm/components/index.ts +11 -0
- package/families/warm/index.ts +19 -0
- package/families/warm/palette.ts +81 -0
- package/families/warm/presets/huashu.ts +49 -0
- package/families/warm/presets/mempalace.ts +51 -0
- package/families/warm/typography.ts +17 -0
- package/package.json +85 -0
- package/reference/dark/claudedispatch.tsx +2441 -0
- package/reference/dark/notebooklm.tsx +2316 -0
- package/reference/dark/stitch.tsx +3040 -0
- package/reference/forbidden/heretic.tsx +2636 -0
- package/reference/glass/claudewatch.tsx +3827 -0
- package/reference/glass/graphify.tsx +2418 -0
- package/reference/glass/paperclip.tsx +2218 -0
- package/reference/paper/designreel.tsx +883 -0
- package/reference/paper/justdrop.tsx +1898 -0
- package/reference/paper/opus.tsx +1770 -0
- package/reference/warm/huashu.tsx +3413 -0
- package/reference/warm/mempalace.tsx +2909 -0
- package/skill/SKILL.md +229 -0
- package/skill/commands/reelstack-beats.md +20 -0
- package/skill/commands/reelstack-capture.md +24 -0
- package/skill/commands/reelstack-critique.md +15 -0
- package/skill/commands/reelstack-dark.md +40 -0
- package/skill/commands/reelstack-direction.md +17 -0
- package/skill/commands/reelstack-forbidden.md +25 -0
- package/skill/commands/reelstack-glass.md +39 -0
- package/skill/commands/reelstack-icons.md +22 -0
- package/skill/commands/reelstack-init.md +17 -0
- package/skill/commands/reelstack-lint.md +22 -0
- package/skill/commands/reelstack-paper.md +36 -0
- package/skill/commands/reelstack-render.md +20 -0
- package/skill/commands/reelstack-warm.md +36 -0
- package/templates/dark/template.tsx +115 -0
- package/templates/forbidden/template.tsx +111 -0
- package/templates/glass/template.tsx +201 -0
- package/templates/paper/template.tsx +133 -0
- package/templates/warm/template.tsx +210 -0
- package/utils/ai-purple-blocklist.ts +13 -0
- package/utils/banned-fonts.ts +11 -0
- package/utils/cubic-bezier.ts +36 -0
- package/utils/easing.ts +84 -0
- package/utils/grid.ts +13 -0
- package/utils/render-presets.json +56 -0
- 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 };
|