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.
8.4 KiB
name, description, metadata
| name | description | metadata | ||
|---|---|---|---|---|
| dynamic-content-sequencing | Auto-calculate timeline start/end times from content length + per-item duration config — longer content gets more screen time without hardcoded numbers. |
|
Dynamic Content Sequencing
A utility pattern (not a motion rule in itself) for scenes that show a SEQUENCE of items (cards, phrases, stats): each item's duration is computed from its content length + per-item config, and the sequencer assigns absolute start/end times automatically — no hardcoded offsets per item. Distinct from discrete-text-sequence (one text element changing states) — this rule swaps between distinct content blocks.
How It Works
A content array of { eyebrow, title, body, speedFactor, hold } entries is reduced once at build time into a flat TIMELINE of { …entry, start, end } — duration per entry is BASE_DURATION + body.length × SEC_PER_CHAR + hold, so longer text earns more reading time. A single linear driver's onUpdate reverse-searches the active entry and swaps the DOM only on transitions (a lastTitle guard — per-frame textContent writes flicker in render); an optional progress bar fills 0→100% across the whole run.
Recipe
<!-- inside a standard scene clip (frames-core) -->
<div class="display">
<div class="eyebrow" id="eyebrow"></div>
<div class="title" id="title"></div>
<div class="body" id="body"></div>
<div class="progress-bar"><div class="progress-fill" id="progress-fill"></div></div>
</div>
.body {
min-height: 160px; /* reserve space — content height varies; without this, layout jumps */
}
.progress-fill {
height: 100%;
width: 0%;
}
// N entries, each with its own pacing (optionally a speedFactor multiplier);
// the final entry uses a larger hold (closing beat).
const CONTENT = [
{ eyebrow: "{eyebrow1}", title: "{title1}", body: "{body1}", hold: HOLD_MID },
// …
{ eyebrow: "{eyebrowN}", title: "{titleN}", body: "{bodyN}", hold: HOLD_FINAL },
];
// Pre-compute absolute start/end ONCE — never in onUpdate.
let cumulative = 0;
const TIMELINE = CONTENT.map((entry) => {
const dur = BASE_DURATION + entry.body.length * SEC_PER_CHAR + entry.hold;
const start = cumulative;
cumulative += dur;
return { ...entry, start, end: cumulative };
});
function entryAt(time) {
for (let i = TIMELINE.length - 1; i >= 0; i--) {
if (time >= TIMELINE[i].start) return TIMELINE[i];
}
return TIMELINE[0];
}
const eyebrowEl = document.getElementById("eyebrow");
const titleEl = document.getElementById("title");
const bodyEl = document.getElementById("body");
const progressEl = document.getElementById("progress-fill");
const TOTAL_DURATION = cumulative + TAIL_PAD;
const driver = { t: 0 };
let lastTitle = "";
tl.to(
driver,
{
t: TOTAL_DURATION,
duration: TOTAL_DURATION,
ease: "none",
onUpdate: () => {
const entry = entryAt(driver.t);
// Swap content only on transitions — no per-frame DOM thrash
if (entry.title !== lastTitle) {
eyebrowEl.textContent = entry.eyebrow;
titleEl.textContent = entry.title;
bodyEl.textContent = entry.body;
lastTitle = entry.title;
}
progressEl.style.width = `${(driver.t / TOTAL_DURATION) * 100}%`;
},
},
0,
);
Variations
- Crossfade between items — return BOTH adjacent entries during an overlap window (
time ≥ e.start − overlap && time ≤ e.end + overlap, overlap ≈ 0.3s) and render them with opacities computed from distance to the boundary. - Per-item motion variation — map an
entry.stylekey to an existing rule per chapter (e.g.3d-text-depth-layers→hacker-flip-3d→counting-dynamic-scale); the sequencer only orchestrates timing. - Auto-extend composition duration — you can set
data-durationfrom the computedTOTAL_DURATIONin script, but HF readsdata-durationat composition load and setting it after init may not take effect — author the duration manually from a rough total.
Accelerating cadence (geometric hold decay)
For rhetorical escalation — "everyone says…", a roll-call, a praise flurry — the beat grid itself accelerates: early entries hold ~1s (read speed), then windows shrink geometrically into a ~0.15–0.3s flurry, braking on an emphasis state before the resolve. The acceleration is pre-computed into the same flat TIMELINE — still content-driven, still deterministic, no speed-up tween anywhere:
// Geometric decay on the hold, clamped at a flurry floor; the brake state holds longest.
const HOLDS = CONTENT.map((entry, i) => Math.max(FLURRY_FLOOR, HOLD_START * Math.pow(DECAY, i)));
HOLDS[CONTENT.length - 1] = HOLD_FINAL;
let cumulative = 0;
const TIMELINE = CONTENT.map((entry, i) => {
// Past ~0.5s states are glanced as motion texture, not read —
// drop the per-char term or you never reach flurry speed.
const readable = HOLDS[i] >= READ_THRESHOLD;
const dur = HOLDS[i] + (readable ? entry.body.length * SEC_PER_CHAR : 0);
const start = cumulative;
cumulative += dur;
return { ...entry, start, end: cumulative };
});
Worked example — praise-chip flurry: ~16 short quotes hard-cut through a chip beside a pinned wordmark. First 3 states at HOLD_START = 1.0 (each reads fully); DECAY = 0.8 shrinks every following window until FLURRY_FLOOR = 0.2 catches it (≈12 states over ~2.5s — a churn of acclaim, individually glanced); the longest phrase takes HOLD_FINAL ≈ 1.6 as the brake before the closing lockup.
Values: HOLD_START 0.8–1.2s; DECAY 0.75–0.88 (higher = longer runway before the flurry bites); FLURRY_FLOOR 0.15–0.3s (below ~0.15s swaps strobe); READ_THRESHOLD ~0.5s; brake ≥ 4× the floor or the stop doesn't register as a beat. The 3–6 entry guidance relaxes here — 12–18 states are legal precisely because flurry states aren't individually read. The hard-cut discipline (lastTitle guard, instant swaps) is what lets 0.2s states render clean.
Values
| token | range | notes |
|---|---|---|
| BASE_DURATION | 0.6–1.5s | minimum per entry regardless of length — even one-word entries get read time |
| SEC_PER_CHAR | 0.03–0.06 s/char | ≈17–33 chars/sec; uniform across the sequence so the pace reads as one engine; lean high for wide-character languages |
| HOLD_MID | 0.5–1.0s | dwell on a non-final entry; < HOLD_FINAL |
| HOLD_FINAL | 1.0–2.0s | climax dwell — must exceed HOLD_MID by a clear margin so the close reads as a beat |
| SPEED_FACTOR | 0.5–2.0 (default 1.0) | per-entry only; if every entry shares a factor, fold it into SEC_PER_CHAR |
| TAIL_PAD | 0.0–1.0s | quiet beat after the last entry; prefer 0 when the next composition owns the breath |
| CONTENT N | 3–6 entries | <3 isn't a sequence; >6 drags (accelerating cadence relaxes this — see above) |
Reference: ../../examples/messaging-multi-phrase.html.
Critical Constraints
- Pre-compute the TIMELINE once at build — never recompute in
onUpdate; the reverse search over the flat array is the whole per-frame cost. - DOM swap only on entry transition (
lastTitle/key guard) — per-frametextContentassignment flickers in HF render. min-heighton the body element — without reservation, downstream elements (progress bar, brand) jitter as content height varies.- Sequential only — for parallel tracks use a different reduction.
- Titles fit one line at the chosen size; bodies fit inside
min-heightafter wrapping.
See also
discrete-text-sequence (per-entry typewriter on the body) · context-sensitive-cursor (cursor color per chapter) · vertical-spring-ticker (animated word swap instead of hard cut) · scale-swap-transition (visual morph between entries).