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;
},
];
+24
View File
@@ -67,6 +67,30 @@ Audio data provides **timing and intensity** for visuals grounded in the content
- **Match the energy.** A corporate explainer needs subtle reactivity. A music video can go hard.
- **Deterministic.** Audio data is pre-extracted — no Web Audio API, no `AnalyserNode`, no runtime mic input. The data is static JSON, the animation is repeatable.
## Sampling Frequency
**One tween per element is not audio reactivity.** Reading peak bass/treble for a time range and creating a single tween at the start sets one static value — the viewer cannot perceive it as reactive. Audio reactivity requires multiple tweens over time so the visual _changes_ with the music.
Sample at **100-200ms intervals** throughout the element's visible lifetime:
```js
// ✓ Perceptible: sample every 150ms, create a tween at each point
for (var t = group.start; t < group.end; t += 0.15) {
var bass = getBass(t);
tl.to(el, { scale: 1 + bass * 0.06, duration: 0.075, ease: "sine.inOut" }, t);
}
// ✗ Imperceptible: one tween from peak values
var peakBass = getPeakBass(group.start, group.end);
tl.to(el, { scale: 1 + peakBass * 0.06, duration: 0.3 }, group.start);
```
## textShadow on Containers
**Never apply `textShadow` to a container that has semi-transparent children.** When a caption group has inactive words at `rgba(255,255,255,0.3)`, a `textShadow` on the parent div renders a visible glow rectangle behind all children — it looks like a gray background, not a glow effect.
Apply `scale` to the group container for bass-reactive pulsing. Apply `textShadow` to individual active word elements only.
## Constraints
- All audio data must be pre-extracted — no runtime audio analysis
+24 -2
View File
@@ -127,6 +127,26 @@ Break groups on sentence boundaries (period, question mark, exclamation), pauses
- Use `position: absolute` — never relative (causes overflow)
- One caption group visible at a time
**Caption layer structure:** Use a full-width container with margins instead of `left: 50%; transform: translateX(-50%)`. The transform-centered approach collapses to content width, causing clipping at composition edges when text is wide or words are scaled.
```css
/* ✓ Full-width with margins — safe for scaled words */
#caption-layer {
position: absolute;
bottom: 80px;
left: 80px;
right: 80px;
text-align: center;
overflow: visible;
}
/* ✗ Collapsed width — clips at edges */
#caption-layer {
left: 50%;
transform: translateX(-50%);
}
```
## Text Overflow Prevention
Use `window.__hyperframes.fitTextFontSize()` to measure actual rendered text width and compute the correct font size. This replaces character-count heuristics with pixel-accurate measurement powered by [pretext](https://github.com/chenglou/pretext).
@@ -155,10 +175,12 @@ GROUPS.forEach(function (group, gi) {
`fontWeight` and `fontFamily` must match the CSS applied to the text elements exactly, or measurements will be inaccurate.
**Scale headroom for emphasis words:** If per-word styling scales words above 1.0x (e.g., `scale: 1.3` on "GOLDEN"), the scaled word occupies more width than `fitTextFontSize` measured. Reduce `maxWidth` to compensate: `maxWidth = safeWidth / maxScale`. For example, with max scale 1.3x on a 1920px composition with 80px margins: `maxWidth = 1760 / 1.3 ≈ 1350`.
**Safety nets (still required in CSS):**
- `max-width: 1600px` (landscape) or `max-width: 900px` (portrait) on caption container
- `overflow: hidden` as a fallback for `fits: false` edge cases
- `max-width` on caption container (reduced from composition width to account for emphasis scale)
- `overflow: visible`**not** `overflow: hidden`. Hidden clips scaled emphasis words and their glow effects. Rely on `fitTextFontSize` with reduced `maxWidth` instead.
- `position: absolute` on all caption elements
- Explicit `height` on caption container (e.g., `200px`)
@@ -71,6 +71,14 @@ Structure compositions in three phases — don't front-load everything:
Don't crowd the build phase. If you have 6 elements, let 2-3 enter, breathe, then bring in the rest. Layers of reveals beat a single wave.
### Multi-Scene Compositions
When a composition has sequential scenes (scene 1 exits → scene 2 enters), each on the same canvas:
- **Hard visibility kills.** After a scene's exit tweens complete, add `tl.set("#scene-layer", { visibility: "hidden" }, exitEndTime)`. Opacity tweens alone can leave scenes partially visible when scrubbing or when tween conflicts prevent full fade-out.
- **Vertical zones.** When captions run throughout, keep scene content above y:800px (bottom 280px reserved for captions). Don't let scene elements and captions compete for the same space.
- **Scene overlap prevention.** Don't rely on opacity alone to separate scenes. If scene 1 exits at t=7 and scene 2 enters at t=7.5, the 0.5s gap needs a hard kill on scene 1 — otherwise both are visible during the gap when scrubbing.
## Sizing
- **Text scale contrast** — headings at 35x body size, not 1.5x. Big contrast reads as cinematic.