Files
zooqueen 62bbc7766c skills: give each skill the directory its name declares
Every SKILL.md already declared the new name — name: frames, frames-cli,
frames-core, and so on — but the directories were never moved, so the tree
carried frames-* in content and hyperframes-* on disk. Everything that
resolves a skill by path missed: the core-skill list, the plugin marketplace
pin, and the docs the CLI reads at runtime. Eight tests failed on main because
the files they open do not exist.

The directories now match what the files say they are, and the guide the
claude-design contract reads takes the name that contract already used.
2026-08-13 18:17:24 -07:00

6.7 KiB

GSAP Effects for Frames

Drop-in animation patterns. Snippets show mechanism only, inside a standard scene clip (frames-core); assume tl exists.

  • Typewriter — character-by-character reveal with optional cursor / backspace / word rotation
  • Audio Visualizer — pre-extract audio data, drive Canvas/DOM rendering from the timeline

Typewriter

Requires GSAP's TextPlugin alongside the core script:

<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/TextPlugin.min.js"></script>
<script>
  gsap.registerPlugin(TextPlugin);
</script>

Basic

const text = "Hello, world!";
const cps = 10; // chars per second — see timing table
tl.to(
  "#typed-text",
  { text: { value: text }, duration: text.length / cps, ease: "none" },
  startTime,
);

Blinking Cursor

Three rules: one cursor visible at a time (hide previous before showing next); cursor must blink when idle (after typing, during holds); no gap between text and cursor (elements flush in HTML).

<span id="typed-text"></span><span id="cursor" class="cursor-blink">|</span>
@keyframes blink {
  0%,
  100% {
    opacity: 1;
  }
  50% {
    opacity: 0;
  }
}
.cursor-blink {
  animation: blink 0.8s step-end infinite;
}
.cursor-solid {
  animation: none;
  opacity: 1;
}
.cursor-hide {
  animation: none;
  opacity: 0;
}

Pattern: blink → solid (typing starts) → type → blink (typing done):

tl.call(() => cursor.classList.replace("cursor-blink", "cursor-solid"), [], startTime);
tl.to("#typed-text", { text: { value: text }, duration: dur, ease: "none" }, startTime);
tl.call(() => cursor.classList.replace("cursor-solid", "cursor-blink"), [], startTime + dur);

Multi-line handoff: hide previous cursor → blink new → brief pause (~0.5s) → solid when typing. Never go hidden → solid (skips the idle blink).

Backspacing

TextPlugin removes from the front — wrong for backspace. Use manual substring removal:

function backspace(tl, selector, word, startTime, cps) {
  const el = document.querySelector(selector);
  const interval = 1 / cps;
  for (let i = word.length - 1; i >= 0; i--) {
    tl.call(
      () => (el.textContent = word.slice(0, i)),
      [],
      startTime + (word.length - i) * interval,
    );
  }
  return word.length * interval;
}

Spacing With Static Text

A typewriter word next to static text (<span>Ship something</span><span style="margin-left:14px"><span id="word"></span><span id="cursor">|</span></span> in a baseline-aligned flex row): use margin-left on the wrapper span. Don't use flex gap (it spaces the cursor from the text) and don't put a trailing space in the static text (it collapses when the dynamic span is empty).

Word Rotation

Type → hold → backspace → next word; cursor blinks during every idle moment:

let offset = 0;
words.forEach((word, i) => {
  const typeDur = word.length / 10;
  // cursor: solid while typing, blink during holds (same call pattern as above)
  tl.to("#typed-text", { text: { value: word }, duration: typeDur, ease: "none" }, offset);
  offset += typeDur + 1.5; // hold
  if (i < words.length - 1) offset += backspace(tl, "#typed-text", word, offset, 20) + 0.3;
});

Appending Words

Build a sentence word-by-word into the same element: keep an accumulated string, each step tweens text: { value: accumulated + " " + word } with duration: newChars / cps, then advances the offset.

Timing Guide

CPS Feel Good for
3-5 Slow, deliberate Dramatic reveals, suspense
8-12 Natural typing Dialogue, narration
15-20 Fast, energetic Tech demos, code
30+ Near-instant Filling long blocks

Audio Visualizer

Pre-extract audio data, drive Canvas / DOM rendering from the timeline. Do not use the Web Audio API at render time — there's no playback during seek.

Extract Audio Data

Bundled extractor (requires ffmpeg + Python numpy):

python skills/frames-creative/scripts/extract-audio-data.py audio.mp3 -o audio-data.json
python skills/frames-creative/scripts/extract-audio-data.py video.mp4 --fps 30 --bands 16 -o audio-data.json

Output: { "fps": 30, "totalFrames": 5415, "frames": [{ "time": 0.0, "rms": 0.42, "bands": [0.8, 0.6, 0.3] }] } — rms (0-1) is overall loudness; bands[] (0-1) are frequency magnitudes, index 0 = bass, each band normalized independently.

Loading (Synchronously)

Inline the JSON for small files (< ~500 KB), or sync XHR for large ones:

const xhr = new XMLHttpRequest();
xhr.open("GET", "audio-data.json", false); // synchronous — deliberate
xhr.send();
const AUDIO_DATA = JSON.parse(xhr.responseText);

Do NOT use async fetch() — Frames reads window.__timelines synchronously after page load; building the timeline inside .then() means it isn't ready when capture starts.

Driving the Timeline

Canvas 2D is the workhorse (bars, waveforms, circles, gradients) — one tl.call per frame:

const ctx = document.getElementById("viz").getContext("2d");
for (let f = 0; f < AUDIO_DATA.totalFrames; f++) {
  tl.call(
    () => {
      const frame = AUDIO_DATA.frames[f];
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      // draw using frame.rms and frame.bands
    },
    [],
    f / AUDIO_DATA.fps,
  );
}

WebGL / Three.js: Frames patches THREE.Clock for deterministic time — update uniforms from audio data each frame. DOM elements: fine under ~20 elements, slower than Canvas beyond that.

Smoothing

let prev = null;
const smoothing = 0.25; // 0.1-0.2 snappy, 0.3-0.5 flowing
function smooth(f) {
  const raw = AUDIO_DATA.frames[f];
  if (!prev) prev = { rms: raw.rms, bands: [...raw.bands] };
  else {
    prev = {
      rms: prev.rms * smoothing + raw.rms * (1 - smoothing),
      bands: raw.bands.map((b, i) => prev.bands[i] * smoothing + b * (1 - smoothing)),
    };
  }
  return prev;
}

Design Guide

  • Spatial mapping — horizontal: bass left, treble right; vertical: bass bottom; circular: bass at 12 o'clock, wrap clockwise (mirror for a full circle).
  • Bass drives big moves (scale, glow, position); treble drives detail (shimmer, flicker, edges); RMS drives globals (background brightness, overall energy).
  • Pick 2-3 animated properties — more looks noisy. Keep minimums above zero so quiet sections still have life.
  • Band count: 4 = background glow/pulse, 8 = bar charts, 16 = detailed EQ (default), 32 = dense radial layouts.
  • Layering: stack canvases with z-index — a background layer driven by bass/rms under a foreground layer driven by individual bands gives depth without per-element complexity.