@devinilabs/reelstack 1.2.0 → 1.3.1

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/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
- * - 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.
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
- * - 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.
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
- msg: `Hex ${mA[0]} is not in the ${family} family's ALLOWED_ACCENTS list. Use the palette tokens instead.`,
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
- // ─── 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.
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
- 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
- });
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
- if (/\balignSelf:\s*["'`]stretch["'`]/.test(styleBlock)) {
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: lineOfIdx(idx),
526
- code: "PILL_TAG_OVERSTRETCHED",
527
- msg: `<${componentName}> has alignSelf: "stretch" — tag/pill components must hug content. Remove or set "flex-start".`,
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
- 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
- }
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
- 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
- });
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
- // ─── 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.
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
- 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) {
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(idx),
574
- code: "RIPPLE_TOO_LOUD",
575
- msg: `<${name} ${rule.prop}={${value}}> — ${rule.msg}`,
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
- // 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) {
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: 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.`,
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 - bannedFontViols * 5 - blackTextViols * 2 - aiPurpleViols * 5 - emojiViols * 3 - placeholderViols * 2,
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 (bannedFontViols === 0 && blackTextViols === 0 && aiPurpleViols === 0) keep.push("Typography + palette stay on-family — no LLM-default fonts or AI-purple hexes.");
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
- "BANNED_FONT",
728
- "AI_PURPLE_ACCENT",
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
- warn(`${v.length} violation(s) in ${path.relative(process.cwd(), abs)}:`);
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
- for (const item of v) {
859
- console.log(` ${c.gray(`L${item.line}`)} ${c.yellow(item.code)} ${item.msg}`);
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. Aborts on violations unless --force.
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
- if (v.length > 0) {
314
- warn(`Lint reported ${v.length} violation(s). Pass --force to render anyway.`);
315
- v.forEach((item) =>
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);
@@ -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. Anti-emoji, font lockdown, 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).
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.