@devinilabs/reelstack 1.2.0 → 1.3.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/NOTICE +32 -0
- package/cli/init.js +125 -1
- package/cli/lint.js +192 -270
- package/cli/render.js +14 -4
- package/docs/buyers-guide.md +1 -1
- package/docs/design-discipline.md +7 -30
- package/package.json +5 -6
- package/skill/SKILL.md +2 -2
- package/skill/commands/reelstack-capture.md +3 -3
- package/skill/companions/gsap-core/SKILL.md +254 -0
- package/skill/companions/gsap-timeline/SKILL.md +107 -0
- package/skill/companions/reel-capture/SKILL.md +95 -0
- package/utils/ai-purple-blocklist.ts +0 -13
- package/utils/banned-fonts.ts +0 -11
package/cli/lint.js
CHANGED
|
@@ -7,17 +7,19 @@
|
|
|
7
7
|
* - HERO_TEXT_OVERFLOW: counter or hero text wider than canvas - 64px gutter.
|
|
8
8
|
* - AUDIO_NOT_LOCKED: file imports <Audio> but defines no BEAT const block.
|
|
9
9
|
* - HAND_DRAWN_BRAND: inline <svg> longer than 800 chars in a logo-sized element.
|
|
10
|
-
* -
|
|
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.
|
|
10
|
+
* - ACCENT_ALLOWLIST_VIOLATION: (warn) hex color outside the resolved family's ALLOWED_ACCENTS list — intentional sponsor/custom accents are fine, the rule catches accidental token typos.
|
|
15
11
|
* - SPACING_NON_GRID: padding/margin/gap not on the 4px grid.
|
|
16
12
|
* - MISSING_REDUCE_MOTION: component imports useCurrentFrame but lacks a `reduceMotion` prop.
|
|
17
13
|
* - GENERIC_PLACEHOLDER: "Lorem ipsum", "John Doe", "Acme Corp" — leftover stub copy.
|
|
18
|
-
* -
|
|
19
|
-
* -
|
|
20
|
-
* -
|
|
14
|
+
* - HOOK_LATENCY: earliest <Sequence> starts after frame 30 — first second of reel is empty.
|
|
15
|
+
* - CTA_MISSING: (warn) no CTA component or CTA keyword (follow/subscribe/etc.) found anywhere.
|
|
16
|
+
* - NON_HW_ACCEL_PROP: interpolate(frame,…) / spring(…) assigned to top/left/right/bottom/width/height — triggers layout reflow. Catches BOTH inline form (style={{top: interpolate(…)}}) AND variable form (const slide = interpolate(…); style={{top: slide}}).
|
|
17
|
+
* - TEXT_DWELL_TOO_SHORT: (warn) Sequence wraps readable text with durationInFrames < 30 (under 1s).
|
|
18
|
+
* - FILLER_WORD: (warn) JSX text uses AI-copy cliché (Elevate, Seamless, Unleash, …).
|
|
19
|
+
*
|
|
20
|
+
* Violations carry an optional `severity: "error" | "warning"` field. Missing
|
|
21
|
+
* severity = "error" (backward-compat for the 12 original rules). `reelstack
|
|
22
|
+
* lint` exits 1 if any error fires, 0 if only warnings.
|
|
21
23
|
*
|
|
22
24
|
* With `--critique`, the same set of static checks drives a 5-dimension radar
|
|
23
25
|
* (Palette · Motion · Timing · Hierarchy · Brand fit) plus a Keep / Fix / Quick
|
|
@@ -36,34 +38,6 @@ const CANVAS_H = 1920;
|
|
|
36
38
|
const CANVAS_W = 1080;
|
|
37
39
|
const HERO_TEXT_MAX = CANVAS_W - 64; // 1016
|
|
38
40
|
|
|
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
41
|
const FAMILY_ALLOWED_ACCENTS = {
|
|
68
42
|
glass: [
|
|
69
43
|
"#7FE8D4", "#8B7FE8", "#E89BC4", "#F2D88F",
|
|
@@ -89,9 +63,6 @@ const GENERIC_PLACEHOLDERS = [
|
|
|
89
63
|
/placeholder text/i,
|
|
90
64
|
];
|
|
91
65
|
|
|
92
|
-
// Emoji & symbol ranges (covers most emoji + dingbats).
|
|
93
|
-
const EMOJI_REGEX = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{27BF}]/u;
|
|
94
|
-
|
|
95
66
|
function lint(file) {
|
|
96
67
|
if (!fs.existsSync(file)) {
|
|
97
68
|
fail(`File not found: ${file}`);
|
|
@@ -253,129 +224,6 @@ function lint(file) {
|
|
|
253
224
|
}
|
|
254
225
|
});
|
|
255
226
|
|
|
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
227
|
// ─── Accent allowlist (per-family) ───────────────────────────────────
|
|
380
228
|
try {
|
|
381
229
|
const familyMatch = src.match(/from\s+["']@devinilabs\/reelstack\/families\/(\w+)/);
|
|
@@ -389,7 +237,6 @@ function lint(file) {
|
|
|
389
237
|
while ((mA = hexAccentRegex.exec(src)) !== null) {
|
|
390
238
|
const lc = mA[0].toLowerCase();
|
|
391
239
|
if (/^#0{6}$/.test(lc)) continue;
|
|
392
|
-
if (AI_PURPLE_HEXES.includes(lc)) continue;
|
|
393
240
|
if (allowLc.includes(lc)) continue;
|
|
394
241
|
const key = lc + ":" + lineOfIdx(mA.index);
|
|
395
242
|
if (flagged.has(key)) continue;
|
|
@@ -397,7 +244,8 @@ function lint(file) {
|
|
|
397
244
|
violations.push({
|
|
398
245
|
line: lineOfIdx(mA.index),
|
|
399
246
|
code: "ACCENT_ALLOWLIST_VIOLATION",
|
|
400
|
-
|
|
247
|
+
severity: "warning",
|
|
248
|
+
msg: `Hex ${mA[0]} is not in the ${family} family's ALLOWED_ACCENTS list — intentional sponsor/custom accents are fine; check this isn't an accidental typo of a family token.`,
|
|
401
249
|
});
|
|
402
250
|
}
|
|
403
251
|
}
|
|
@@ -494,118 +342,183 @@ function lint(file) {
|
|
|
494
342
|
}
|
|
495
343
|
} catch {}
|
|
496
344
|
|
|
497
|
-
// ───
|
|
498
|
-
//
|
|
499
|
-
//
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
// call-site `style` overrides that re-stretch the pill.
|
|
345
|
+
// ─── Hook latency ────────────────────────────────────────────────────
|
|
346
|
+
// Reels lose viewers around frame 30 if nothing visible has happened.
|
|
347
|
+
// Find the lowest `from={N}` across all <Sequence> tags. Conservative:
|
|
348
|
+
// if ANY `from` uses a dynamic expression (e.g. from={SCENES.S1.start}),
|
|
349
|
+
// skip the rule — we can't reason about its value. Only fire when EVERY
|
|
350
|
+
// from is a literal number AND the smallest is > 30.
|
|
504
351
|
try {
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
352
|
+
if (!isTemplate) {
|
|
353
|
+
const allFromTags = [...src.matchAll(/<Sequence\b[^>]*?\sfrom=\{([^}]+)\}/g)];
|
|
354
|
+
if (allFromTags.length > 0) {
|
|
355
|
+
let allLiteral = true;
|
|
356
|
+
let earliest = Infinity;
|
|
357
|
+
let earliestIdx = 0;
|
|
358
|
+
for (const m of allFromTags) {
|
|
359
|
+
const expr = m[1].trim();
|
|
360
|
+
if (!/^\d+$/.test(expr)) {
|
|
361
|
+
allLiteral = false;
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
const n = Number(expr);
|
|
365
|
+
if (n < earliest) {
|
|
366
|
+
earliest = n;
|
|
367
|
+
earliestIdx = m.index;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (allLiteral && earliest > 30) {
|
|
371
|
+
violations.push({
|
|
372
|
+
line: lineOfIdx(earliestIdx),
|
|
373
|
+
code: "HOOK_LATENCY",
|
|
374
|
+
severity: "error",
|
|
375
|
+
msg: `Earliest <Sequence> starts at frame ${earliest} — viewers scroll past after frame 30. Show something in the first second.`,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
521
378
|
}
|
|
379
|
+
}
|
|
380
|
+
} catch {}
|
|
522
381
|
|
|
523
|
-
|
|
382
|
+
// ─── CTA presence ────────────────────────────────────────────────────
|
|
383
|
+
// Every reel must end on an explicit ask. Pass if EITHER a CTA-named
|
|
384
|
+
// component exists OR any JSX text contains a CTA keyword.
|
|
385
|
+
try {
|
|
386
|
+
if (!isTemplate) {
|
|
387
|
+
const ctaKeywordRe = /\b(follow|subscribe|save|share|DM|comment|link in bio|try|build|book|sign up|download|get started|learn more|watch|ship)\b/i;
|
|
388
|
+
const ctaComponentRe = /<(CTA(?:Scene)?|CallToAction|Outro|Footer)\b/i;
|
|
389
|
+
const jsxText = [...src.matchAll(/>([^<>]+)</g)].map((m) => m[1]).join(" ");
|
|
390
|
+
const hasComponent = ctaComponentRe.test(src);
|
|
391
|
+
const hasKeyword = ctaKeywordRe.test(jsxText);
|
|
392
|
+
if (!hasComponent && !hasKeyword) {
|
|
524
393
|
violations.push({
|
|
525
|
-
line:
|
|
526
|
-
code: "
|
|
527
|
-
|
|
394
|
+
line: 1,
|
|
395
|
+
code: "CTA_MISSING",
|
|
396
|
+
severity: "warning",
|
|
397
|
+
msg: `No CTA found — neither a <CTAScene>/<Outro>/<Footer> component nor a CTA keyword (follow, subscribe, save, share, link in bio, try, build, watch, ship). Add an explicit ask in the final scene — unless this is ambient/brand/educational content where the platform UI handles the ask.`,
|
|
528
398
|
});
|
|
529
399
|
}
|
|
400
|
+
}
|
|
401
|
+
} catch {}
|
|
530
402
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
403
|
+
// ─── Non-hardware-accelerated animation target ──────────────────────
|
|
404
|
+
// interpolate(frame, …) or spring(…) on layout props (top/left/right/
|
|
405
|
+
// bottom/width/height) triggers reflow and tanks render smoothness.
|
|
406
|
+
// Animate transform/opacity only.
|
|
407
|
+
//
|
|
408
|
+
// Catches TWO forms:
|
|
409
|
+
// 1) Inline: `top: interpolate(frame, …)` / `width: spring(…)`
|
|
410
|
+
// 2) Variable: `const slide = interpolate(frame, …); … style={{ top: slide }}`
|
|
411
|
+
//
|
|
412
|
+
// For (2): build a set of identifiers bound to interpolate(frame,…) or
|
|
413
|
+
// spring(…) at top-level (const/let/var). Then for each banned prop in the
|
|
414
|
+
// file, check whether the value expression references any animated name.
|
|
415
|
+
// Conservative: only top-level assignments — destructured / nested-scope
|
|
416
|
+
// assignments skipped to avoid false positives.
|
|
417
|
+
try {
|
|
418
|
+
const bannedProps = ["top", "left", "right", "bottom", "width", "height"];
|
|
538
419
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
420
|
+
// Form 1: inline
|
|
421
|
+
const inlineRe = new RegExp(`\\b(${bannedProps.join("|")})\\s*:\\s*(?:interpolate|spring)\\s*\\(`, "g");
|
|
422
|
+
for (const m of src.matchAll(inlineRe)) {
|
|
423
|
+
violations.push({
|
|
424
|
+
line: lineOfIdx(m.index),
|
|
425
|
+
code: "NON_HW_ACCEL_PROP",
|
|
426
|
+
severity: "error",
|
|
427
|
+
msg: `Animating "${m[1]}" with interpolate()/spring() triggers layout reflow. Use transform: translateY/translateX/scale or opacity instead.`,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Form 2: variable
|
|
432
|
+
const animVars = new Set();
|
|
433
|
+
const assignRe = /\b(?:const|let|var)\s+(\w+)\s*=\s*(?:interpolate\s*\(\s*frame\b|spring\s*\()/g;
|
|
434
|
+
for (const m of src.matchAll(assignRe)) {
|
|
435
|
+
animVars.add(m[1]);
|
|
436
|
+
}
|
|
437
|
+
if (animVars.size > 0) {
|
|
438
|
+
const flagged = new Set(); // dedupe by line+prop+var
|
|
439
|
+
for (const prop of bannedProps) {
|
|
440
|
+
// Match `prop: <expr>` where <expr> goes up to comma/close-brace.
|
|
441
|
+
// Word boundary `\b` ensures `top:` doesn't match `marginTop:` /
|
|
442
|
+
// `borderTop:` / `paddingTop:`.
|
|
443
|
+
const propRe = new RegExp(`\\b${prop}\\s*:\\s*([^,}\\n]+)`, "g");
|
|
444
|
+
for (const m of src.matchAll(propRe)) {
|
|
445
|
+
const valExpr = m[1];
|
|
446
|
+
// Skip if already caught by inline form (avoid double-fire).
|
|
447
|
+
if (/\b(interpolate|spring)\s*\(/.test(valExpr)) continue;
|
|
448
|
+
for (const v of animVars) {
|
|
449
|
+
const refRe = new RegExp(`\\b${v}\\b`);
|
|
450
|
+
if (refRe.test(valExpr)) {
|
|
451
|
+
const line = lineOfIdx(m.index);
|
|
452
|
+
const key = `${line}:${prop}:${v}`;
|
|
453
|
+
if (flagged.has(key)) continue;
|
|
454
|
+
flagged.add(key);
|
|
455
|
+
violations.push({
|
|
456
|
+
line,
|
|
457
|
+
code: "NON_HW_ACCEL_PROP",
|
|
458
|
+
severity: "error",
|
|
459
|
+
msg: `Animating "${prop}" via animated variable "${v}" (assigned from interpolate/spring) triggers layout reflow. Use transform: translateY/translateX/scale or opacity instead.`,
|
|
460
|
+
});
|
|
461
|
+
break;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
545
465
|
}
|
|
546
466
|
}
|
|
547
467
|
} catch {}
|
|
548
468
|
|
|
549
|
-
// ───
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
//
|
|
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.
|
|
469
|
+
// ─── Text dwell time (warning) ───────────────────────────────────────
|
|
470
|
+
// <Sequence durationInFrames={X}> with X < 30 wrapping a text element
|
|
471
|
+
// means viewers can't read it. Warn (not error) — some intentional
|
|
472
|
+
// flash effects are valid.
|
|
556
473
|
try {
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
const propMatch = opener.match(propRe);
|
|
569
|
-
if (!propMatch) continue;
|
|
570
|
-
const value = Number(propMatch[1]);
|
|
571
|
-
if (value > rule.max) {
|
|
474
|
+
if (!isTemplate) {
|
|
475
|
+
const seqRe = /<Sequence\b([^>]*?)>([\s\S]*?)<\/Sequence>/g;
|
|
476
|
+
const textElemRe = /<(span|p|h[1-6]|Counter|HeroText|StaggeredWords|ScaleBlurText|EditorialSerifText|EyebrowPill)\b[^>]*>[^<]*\S/;
|
|
477
|
+
for (const m of src.matchAll(seqRe)) {
|
|
478
|
+
const propsBlock = m[1];
|
|
479
|
+
const inner = m[2];
|
|
480
|
+
const durMatch = propsBlock.match(/durationInFrames=\{(\d+)\}/);
|
|
481
|
+
if (!durMatch) continue;
|
|
482
|
+
const dur = Number(durMatch[1]);
|
|
483
|
+
if (dur >= 30) continue;
|
|
484
|
+
if (textElemRe.test(inner)) {
|
|
572
485
|
violations.push({
|
|
573
|
-
line: lineOfIdx(
|
|
574
|
-
code: "
|
|
575
|
-
|
|
486
|
+
line: lineOfIdx(m.index),
|
|
487
|
+
code: "TEXT_DWELL_TOO_SHORT",
|
|
488
|
+
severity: "warning",
|
|
489
|
+
msg: `<Sequence> wraps readable text with durationInFrames=${dur} (<30 frames / 1s). Increase so viewers can read.`,
|
|
576
490
|
});
|
|
577
491
|
}
|
|
578
492
|
}
|
|
579
493
|
}
|
|
494
|
+
} catch {}
|
|
580
495
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
}
|
|
603
|
-
for (const [anchor, group] of Object.entries(stackCounts)) {
|
|
604
|
-
if (group.length > 2) {
|
|
496
|
+
// ─── Filler words (warning, taste-skill rule) ───────────────────────
|
|
497
|
+
// AI-copy clichés — Elevate, Seamless, Unleash, etc. — read as
|
|
498
|
+
// AI-generated. Warn so buyers can choose intentional uses.
|
|
499
|
+
try {
|
|
500
|
+
if (!isTemplate) {
|
|
501
|
+
const FILLER_WORDS = [
|
|
502
|
+
"elevate", "seamless", "unleash", "next-gen", "revolutionary",
|
|
503
|
+
"game-changing", "empower", "streamline", "cutting-edge",
|
|
504
|
+
"synergy", "disrupt", "harness", "leverage",
|
|
505
|
+
];
|
|
506
|
+
const fillerRe = new RegExp(`\\b(${FILLER_WORDS.join("|")})\\b`, "gi");
|
|
507
|
+
const textRe = />([^<>]+)</g;
|
|
508
|
+
const seen = new Set();
|
|
509
|
+
for (const m of src.matchAll(textRe)) {
|
|
510
|
+
const text = m[1];
|
|
511
|
+
const hit = text.match(fillerRe);
|
|
512
|
+
if (!hit) continue;
|
|
513
|
+
const line = lineOfIdx(m.index);
|
|
514
|
+
const key = `${hit[0].toLowerCase()}:${line}`;
|
|
515
|
+
if (seen.has(key)) continue;
|
|
516
|
+
seen.add(key);
|
|
605
517
|
violations.push({
|
|
606
|
-
line
|
|
607
|
-
code: "
|
|
608
|
-
|
|
518
|
+
line,
|
|
519
|
+
code: "FILLER_WORD",
|
|
520
|
+
severity: "warning",
|
|
521
|
+
msg: `JSX text contains AI-copy cliché "${hit[0]}". Replace with a concrete verb that describes what the product actually does.`,
|
|
609
522
|
});
|
|
610
523
|
}
|
|
611
524
|
}
|
|
@@ -643,10 +556,6 @@ function computeCritique(violations, src = "") {
|
|
|
643
556
|
const audioViols = countCode(v, "AUDIO_NOT_LOCKED");
|
|
644
557
|
const spacingViols = countCode(v, "SPACING_NON_GRID");
|
|
645
558
|
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
559
|
const placeholderViols = countCode(v, "GENERIC_PLACEHOLDER");
|
|
651
560
|
|
|
652
561
|
// interpolate(frame,…) without explicit `easing:` option in same call.
|
|
@@ -709,7 +618,7 @@ function computeCritique(violations, src = "") {
|
|
|
709
618
|
const hierarchy = Math.max(0, 10 - spacingViols * 1 - heroOverflowViols * 3);
|
|
710
619
|
const brand = Math.max(
|
|
711
620
|
0,
|
|
712
|
-
10 -
|
|
621
|
+
10 - placeholderViols * 2,
|
|
713
622
|
);
|
|
714
623
|
|
|
715
624
|
const keep = [];
|
|
@@ -718,22 +627,23 @@ function computeCritique(violations, src = "") {
|
|
|
718
627
|
keep.push("Audio-locked beats present — motion choreographed to whisper SRT.");
|
|
719
628
|
}
|
|
720
629
|
if (countCode(v, "SAFE_ZONE_BREACH") === 0) keep.push("IG safe zones respected (no top/bottom-band breaches).");
|
|
721
|
-
if (
|
|
630
|
+
if (placeholderViols === 0) keep.push("Copy is shipped-ready — no stub placeholder text in the file.");
|
|
722
631
|
if (heroOverflowViols === 0) keep.push("Hero text fits the 1080 width gutter.");
|
|
723
632
|
if (keep.length === 0) keep.push("(No clean wins detected — work the punch list below.)");
|
|
724
633
|
|
|
725
634
|
const severityOrder = [
|
|
726
635
|
"AUDIO_NOT_LOCKED",
|
|
727
|
-
"
|
|
728
|
-
"
|
|
636
|
+
"CTA_MISSING",
|
|
637
|
+
"HOOK_LATENCY",
|
|
638
|
+
"NON_HW_ACCEL_PROP",
|
|
729
639
|
"MOTION_FLOOR_VIOLATION",
|
|
730
640
|
"HERO_TEXT_OVERFLOW",
|
|
731
641
|
"SAFE_ZONE_BREACH",
|
|
732
642
|
"HAND_DRAWN_BRAND",
|
|
733
643
|
"ACCENT_ALLOWLIST_VIOLATION",
|
|
734
|
-
"PURE_BLACK_TEXT",
|
|
735
|
-
"EMOJI_GLYPH",
|
|
736
644
|
"GENERIC_PLACEHOLDER",
|
|
645
|
+
"TEXT_DWELL_TOO_SHORT",
|
|
646
|
+
"FILLER_WORD",
|
|
737
647
|
"MISSING_REDUCE_MOTION",
|
|
738
648
|
];
|
|
739
649
|
const sorted = [...v].sort(
|
|
@@ -853,13 +763,25 @@ async function run(argv) {
|
|
|
853
763
|
success(`Lint clean: ${path.relative(process.cwd(), abs)}`);
|
|
854
764
|
process.exit(0);
|
|
855
765
|
}
|
|
856
|
-
|
|
766
|
+
// Severity split: missing severity = "error" (back-compat for the 12 original rules).
|
|
767
|
+
const errors = v.filter((it) => it.severity !== "warning");
|
|
768
|
+
const warns = v.filter((it) => it.severity === "warning");
|
|
769
|
+
if (warns.length > 0) {
|
|
770
|
+
warn(`${errors.length} error(s), ${warns.length} warning(s) in ${path.relative(process.cwd(), abs)}:`);
|
|
771
|
+
} else {
|
|
772
|
+
warn(`${v.length} violation(s) in ${path.relative(process.cwd(), abs)}:`);
|
|
773
|
+
}
|
|
857
774
|
console.log("");
|
|
858
|
-
|
|
859
|
-
|
|
775
|
+
// Sort errors first, then warnings — within each group preserve original order.
|
|
776
|
+
const sorted = [...errors, ...warns];
|
|
777
|
+
for (const item of sorted) {
|
|
778
|
+
const isWarn = item.severity === "warning";
|
|
779
|
+
const sevTag = isWarn ? c.cyan("WARN") : c.red("ERR ");
|
|
780
|
+
const codeFmt = isWarn ? c.cyan(item.code) : c.yellow(item.code);
|
|
781
|
+
console.log(` ${c.gray(`L${item.line}`)} ${sevTag} ${codeFmt} ${item.msg}`);
|
|
860
782
|
}
|
|
861
783
|
console.log("");
|
|
862
|
-
process.exit(1);
|
|
784
|
+
process.exit(errors.length > 0 ? 1 : 0);
|
|
863
785
|
}
|
|
864
786
|
|
|
865
787
|
module.exports = { run, lint, critique, computeCritique, renderCritique };
|
package/cli/render.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* --palette-optimize Use a 64-color palette quantization for GIF (default 256).
|
|
13
13
|
*
|
|
14
14
|
* Pre-render pipeline:
|
|
15
|
-
* 1. Lint.
|
|
15
|
+
* 1. Lint. Errors abort the render unless --force; warnings print but don't block.
|
|
16
16
|
* 2. Smoke check: render frame 0 / midpoint / last via `remotion still`,
|
|
17
17
|
* check each PNG > 30KB. Warn if any frame looks all-black.
|
|
18
18
|
* 3. Render MP4.
|
|
@@ -310,9 +310,19 @@ async function run(argv) {
|
|
|
310
310
|
if (candidate && !args.force) {
|
|
311
311
|
info(`Linting ${path.relative(process.cwd(), candidate)} before render…`);
|
|
312
312
|
const v = lint(candidate);
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
313
|
+
// Severity-aware gate: warnings print but don't block; errors block.
|
|
314
|
+
// Missing severity = "error" (back-compat with the 12 original rules).
|
|
315
|
+
const errors = v.filter((it) => it.severity !== "warning");
|
|
316
|
+
const warns = v.filter((it) => it.severity === "warning");
|
|
317
|
+
if (warns.length > 0) {
|
|
318
|
+
info(`${warns.length} warning(s) — non-blocking:`);
|
|
319
|
+
warns.forEach((item) =>
|
|
320
|
+
console.log(` ${c.gray(`L${item.line}`)} ${c.cyan(item.code)} ${item.msg}`),
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
if (errors.length > 0) {
|
|
324
|
+
warn(`Lint reported ${errors.length} error(s). Pass --force to render anyway.`);
|
|
325
|
+
errors.forEach((item) =>
|
|
316
326
|
console.log(` ${c.gray(`L${item.line}`)} ${c.yellow(item.code)} ${item.msg}`),
|
|
317
327
|
);
|
|
318
328
|
process.exit(1);
|
package/docs/buyers-guide.md
CHANGED
|
@@ -214,7 +214,7 @@ Re-runs the full readiness gate against the latest npm release. New presets ship
|
|
|
214
214
|
|
|
215
215
|
ReelStack v1.1+ stands on the shoulders of two outside skills, fully baked in (no peer install required):
|
|
216
216
|
|
|
217
|
-
- **[leonxlnx/taste-skill](https://github.com/leonxlnx/taste-skill)** (MIT) — UI design-discipline ruleset by `@leonxlnx`. ReelStack bakes the master rules and per-family variant overlays (Soft / Minimalist / Brutalist) directly into its components, palettes, and lint.
|
|
217
|
+
- **[leonxlnx/taste-skill](https://github.com/leonxlnx/taste-skill)** (MIT) — UI design-discipline ruleset by `@leonxlnx`. ReelStack bakes the master rules and per-family variant overlays (Soft / Minimalist / Brutalist) directly into its components, palettes, and lint. Max-1-accent rule, hardware-accel-only animation, `prefers-reduced-motion` parity, 4-px grid alignment — all enforced by default. Full rule citations at [`docs/design-discipline.md`](design-discipline.md).
|
|
218
218
|
- **[alchaincyf/huashu-design](https://github.com/alchaincyf/huashu-design)** (Personal Use Only) — design productivity skill by `@AlchainHust` (花叔). ReelStack adopts huashu-design's UX patterns (Design Direction Advisor, 5-dimension critique, multi-format export) without copying any code or text. The Warm Signature family's `huashu` preset is named for this same project as a credit hook.
|
|
219
219
|
|
|
220
220
|
If ReelStack helps you ship, please consider starring both upstream skills.
|