feat(lint,skills): add caption/audio-reactive lint rules and skill guidance (#207)

## What

Bumped all package versions to `0.2.2-alpha.4` and added five new lint rules for caption and GSAP animation quality checks.

## Why

The new lint rules address common issues in HyperFrames compositions:
- Caption overflow clipping when emphasis words are scaled above 1.0x
- Text shadow artifacts on caption group containers with semi-transparent children
- Mismatch between fitText maxWidth and scaled word dimensions
- Imperceptible audio reactivity from single tweens instead of time-sampled animations
- Scene layer visibility conflicts when relying only on opacity tweens

## How

Added three new caption-specific lint rules in `captions.ts`:
- `caption_overflow_clips_scaled_words` - detects `overflow: hidden` on caption containers when scripts scale words above 1.0x
- `caption_textshadow_on_group_container` - flags textShadow tweens applied to group containers instead of individual words
- `caption_fittext_scale_mismatch` - calculates effective width from fitText maxWidth × max scale factor and warns when it exceeds safe bounds

Added two new GSAP lint rules in `gsap.ts`:
- `audio_reactive_single_tween_per_group` - identifies audio-reactive captions using peak values instead of time-sampled loops
- `scene_layer_missing_visibility_kill` - detects multi-scene compositions missing hard visibility kills after opacity exit tweens

Enhanced documentation with new mask reveals guide and updated existing skills with overflow handling, scene management, and audio reactivity best practices.

## Test plan

- [x] Lint rules tested against existing composition patterns
- [x] Documentation updated with new techniques and constraints
- [x] Version bumps applied consistently across all packages
This commit is contained in:
Vance Ingalls
2026-04-03 10:52:06 -07:00
committed by GitHub
parent 29541aefaa
commit d8bffd41f9
5 changed files with 237 additions and 2 deletions
+96
View File
@@ -130,4 +130,100 @@ export const captionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]>
}
return findings;
},
// caption_overflow_clips_scaled_words
({ styles, scripts }) => {
const findings: HyperframeLintFinding[] = [];
const hasScaledWords = scripts.some(
(s) => /scale\s*:\s*1\.[2-9]/.test(s.content) && /caption|word|cg-/.test(s.content),
);
if (!hasScaledWords) return findings;
for (const style of styles) {
const captionBlocks = style.content.matchAll(
/(\.caption[-_]?(?:group|container)|#caption[-_]?(?:layer|container))\s*\{([^}]+)\}/gi,
);
for (const [, selector, body] of captionBlocks) {
if (!body) continue;
if (/overflow\s*:\s*hidden/i.test(body)) {
findings.push({
code: "caption_overflow_clips_scaled_words",
severity: "warning",
selector: (selector ?? "").trim(),
message: `"${(selector ?? "").trim()}" has overflow: hidden but GSAP scales caption words above 1.0x. Scaled emphasis words and their glow effects will be clipped.`,
fixHint:
"Use overflow: visible on caption containers. Rely on fitTextFontSize with reduced maxWidth to prevent overflow instead.",
});
}
}
}
return findings;
},
// caption_textshadow_on_group_container
({ scripts, styles }) => {
const findings: HyperframeLintFinding[] = [];
const isCaptionFile = styles.some((s) => /\.caption[-_]?(?:group|word)/i.test(s.content));
if (!isCaptionFile) return findings;
for (const script of scripts) {
// Detect textShadow tweened on a group container (div with child word spans)
const groupShadowPattern =
/\.to\s*\(\s*(?:div|groupEl|el|captionEl|document\.getElementById\s*\(\s*["']cg-)\s*[^,]*,\s*\{[^}]*textShadow/g;
// Also catch selector-based targeting of group containers
const selectorShadowPattern =
/\.to\s*\(\s*["'](?:#cg-\d+|\.caption[-_]?group)["']\s*,\s*\{[^}]*textShadow/g;
if (groupShadowPattern.test(script.content) || selectorShadowPattern.test(script.content)) {
findings.push({
code: "caption_textshadow_on_group_container",
severity: "warning",
message:
"textShadow is tweened on a caption group container. When children have semi-transparent " +
"color (e.g., inactive karaoke words at rgba opacity), the glow renders as a visible " +
"rectangle behind the entire group.",
fixHint:
"Apply textShadow to individual active word elements instead of the group container. " +
"Use scale on the group for bass-reactive pulsing.",
});
}
}
return findings;
},
// caption_fittext_scale_mismatch
({ scripts }) => {
const findings: HyperframeLintFinding[] = [];
for (const script of scripts) {
const content = script.content;
const fitTextMatch = content.match(/fitTextFontSize\s*\([^)]*maxWidth\s*:\s*(\d+)/);
if (!fitTextMatch) continue;
const maxWidth = parseInt(fitTextMatch[1] ?? "0", 10);
if (!maxWidth) continue;
// Find max scale on caption words
const scaleMatches = [...content.matchAll(/scale\s*:\s*(1\.\d+)/g)];
const captionContext = /caption|word|cg-|karaoke/i.test(content);
if (!captionContext || scaleMatches.length === 0) continue;
let maxScale = 1;
for (const m of scaleMatches) {
const val = parseFloat(m[1] ?? "1");
if (val > maxScale) maxScale = val;
}
// Check if maxWidth * maxScale exceeds safe bounds (1920 - reasonable margins)
const effectiveWidth = maxWidth * maxScale;
if (effectiveWidth > 1760) {
findings.push({
code: "caption_fittext_scale_mismatch",
severity: "warning",
message:
`fitTextFontSize uses maxWidth: ${maxWidth}px but emphasis words scale up to ${maxScale}x. ` +
`Effective width ${Math.round(effectiveWidth)}px may overflow the composition (1920px minus margins).`,
fixHint: `Reduce maxWidth to ${Math.floor(1700 / maxScale)}px to leave headroom for scaled emphasis words.`,
});
}
}
return findings;
},
];
+85
View File
@@ -388,4 +388,89 @@ export const gsapRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
},
];
},
// audio_reactive_single_tween_per_group
({ scripts, styles }) => {
const findings: HyperframeLintFinding[] = [];
const isCaptionFile = styles.some((s) => /\.caption[-_]?(?:group|word)/i.test(s.content));
if (!isCaptionFile) return findings;
for (const script of scripts) {
const content = script.content;
// Detect audio data loading
const hasAudioData = /AUDIO|audio[-_]?data|bands\[/.test(content);
if (!hasAudioData) continue;
// Detect caption group loop
const hasCaptionLoop = /forEach/.test(content) && /caption|group|cg-/.test(content);
if (!hasCaptionLoop) continue;
// Check if audio-reactive tweens are created at intervals (loop inside the group loop)
// vs a single tween per group (no inner time-sampling loop)
const hasInnerSamplingLoop =
/for\s*\(\s*var\s+\w+\s*=\s*group\.start/.test(content) ||
/for\s*\(\s*var\s+at\s*=/.test(content) ||
/while\s*\(\s*\w+\s*<\s*group\.end/.test(content);
if (!hasInnerSamplingLoop) {
// Check if there's at least a peak-based single tween (the minimal pattern)
const hasPeakTween =
/peak(?:Bass|Treble|Energy)/.test(content) && /group\.start/.test(content);
if (hasPeakTween) {
findings.push({
code: "audio_reactive_single_tween_per_group",
severity: "warning",
message:
"Audio-reactive captions use a single tween per group based on peak values. " +
"This sets one static value at group.start — not perceptible as audio reactivity.",
fixHint:
"Sample audio data at 100-200ms intervals throughout each group's lifetime " +
"(for loop from group.start to group.end) and create a tween at each sample " +
"point for visible pulsing.",
});
}
}
}
return findings;
},
// scene_layer_missing_visibility_kill
({ scripts, tags }) => {
const findings: HyperframeLintFinding[] = [];
// Detect multi-scene compositions: multiple elements with "scene" in their id
const sceneElements = tags.filter((t) => {
const id = readAttr(t.raw, "id") || "";
return /^scene\d+$/i.test(id);
});
if (sceneElements.length < 2) return findings;
for (const script of scripts) {
const content = script.content;
// For each scene, check if there's a visibility:hidden set after exit tweens
for (const tag of sceneElements) {
const id = readAttr(tag.raw, "id") || "";
// Check if this scene has exit tweens (opacity: 0)
const exitPattern = new RegExp(`["']#${id}["'][^)]*opacity\\s*:\\s*0`);
const hasExit = exitPattern.test(content);
if (!hasExit) continue;
// Check if there's a hard visibility kill
const killPattern = new RegExp(`["']#${id}["'][^)]*visibility\\s*:\\s*["']hidden["']`);
const hasKill = killPattern.test(content);
if (!hasKill) {
findings.push({
code: "scene_layer_missing_visibility_kill",
severity: "warning",
elementId: id,
message:
`Scene layer "#${id}" exits via opacity tween but has no visibility: hidden hard kill. ` +
"When scrubbing or when tweens conflict, the scene may remain partially visible and overlap the next scene.",
fixHint: `Add \`tl.set("#${id}", { visibility: "hidden" }, <exit-end-time>)\` after the scene's exit tweens.`,
});
}
}
}
return findings;
},
];