Compare commits

..
375 changed files with 1842 additions and 51940 deletions

No files matched your search

@@ -0,0 +1,24 @@
# Caption Animation
Turn a transcript or voiceover into word-timed, scroll-stopping captions: each word pops in on the syllable, the active word highlights, and the text stays glued to the narration. Build the kind of captions that drive watch-time on TikTok, Reels, and Shorts — readable, on-beat, and inside the safe area. For short-form creators burning open captions onto vertical video.
## When it activates
- "Add animated captions," "make TikTok captions," or "karaoke captions."
- "Word-by-word subtitles," "auto subtitles," or "highlight the active word."
- "Burn in captions to a video" or "sync captions to a voiceover/narration."
- "Turn an SRT/transcript into animated text."
## Example prompts
- "Turn this Whisper transcript into karaoke captions with a per-word pop."
- "Restyle these auto subtitles into a branded, Hormozi-style reveal."
- "Burn word-level captions onto my voiceover and export a sidecar SRT."
## What's inside
- `SKILL.md` — the transcribe → normalize → page → animate → place → export pipeline.
- `references/word-timed-captions.md` — deeper guidance on word-level timing and animation.
---
Part of **[Short-Form Video Skills](../../)** · Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=tiktok-video-skills&utm_content=skill_footer&utm_term=caption-animation)** — the AI motion agent for editable, on-brand motion graphics.
+177
View File
@@ -0,0 +1,177 @@
---
name: caption-animation
description: This skill should be used when the user asks to "add animated captions", "make TikTok captions", "karaoke captions", "word-by-word subtitles", "auto subtitles", "highlight the active word", "burn in captions to a video", "sync captions to a voiceover/narration", or "turn an SRT/transcript into animated text". Covers word-level timing from Whisper, per-word pop/scale-in, active-word highlight, 9:16 safe-area placement, readable type, and burn-in vs sidecar SRT/VTT.
version: 0.1.0
---
# Caption Animation
Turn a transcript or voiceover into word-timed, scroll-stopping captions: each word pops in on the syllable, the active word highlights, and the text stays glued to the narration. Build the kind of captions that drive watch-time on TikTok, Reels, and Shorts — readable, on-beat, and inside the safe area.
## When to use
- Word-by-word or karaoke captions on short-form vertical video (9:16).
- Burning open captions onto a voiceover/narration track from a transcript.
- Turning a Whisper/SRT transcript into animated text in Remotion or on the web.
- Restyling auto-generated subtitles into a branded, "Hormozi-style" reveal.
## The pipeline
| Stage | Job | Tool |
|---|---|---|
| Transcribe | Audio → word-level timestamps | Whisper (`@remotion/install-whisper-cpp`), AssemblyAI |
| Normalize | → `{ text, startMs, endMs }[]` tokens | `@remotion/captions` `Caption` type |
| Page | Group words into 1–4 word "pages" | `createTikTokStyleCaptions()` |
| Animate | Per-word pop + active-word highlight | Remotion `spring()` / CSS |
| Place | Safe-area, readable type | layout rules below |
| Export | Burn-in (MP4) and/or sidecar SRT/VTT | Remotion render / file emit |
## Two non-negotiable rules
1. **Word-level timing, not line-level.** Karaoke reads as magic only when each word lands on the syllable. Always transcribe to word timestamps; never fake them by splitting a line evenly over its duration — drift is instantly visible.
2. **Readability beats style.** Captions are read on a phone, in sunlight, muted. Bold sans-serif, heavy stroke or shadow, high contrast first; decoration second. A caption nobody can read is decoration, not a caption.
## Word timing from a transcript
Whisper emits word-level timestamps directly. Normalize every source into one flat token shape so the renderer never cares where the words came from:
```ts
// Caption token — the one shape everything downstream consumes
type Token = { text: string; startMs: number; endMs: number };
// Parse an SRT cue block "00:00:01,200 --> 00:00:01,640" into ms
const toMs = (t: string) => {
const [h, m, rest] = t.split(":");
const [s, ms] = rest.split(",");
return ((+h * 60 + +m) * 60 + +s) * 1000 + +ms;
};
```
If only sentence-level cues exist, re-transcribe for word timing — do not interpolate. Whisper's `medium.en` is ~2x faster than `large` and accurate enough for clean voice audio.
## Per-word pop-in (Remotion)
Drive each word's scale and opacity off a spring anchored to its own `startMs`. The micro-overshoot is what makes it feel "alive".
```tsx
import { useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
const Word: React.FC<{ token: Token; active: boolean }> = ({ token, active }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const enter = (token.startMs / 1000) * fps; // word's own entrance frame
const p = spring({ frame: frame - enter, fps, config: { damping: 12, mass: 0.6 } });
const scale = interpolate(p, [0, 1], [0.6, 1]); // pop from 60% → 100% (overshoots)
return (
<span style={{
display: "inline-block",
transform: `scale(${scale})`,
opacity: interpolate(p, [0, 1], [0, 1]),
color: active ? "#FFE45E" : "#FFFFFF", // active-word highlight
transition: "color 80ms linear",
}}>{token.text}&nbsp;</span>
);
};
```
Compute `active` by testing `frame` against each token's `[startMs, endMs]`. Keep the highlight a single accent color or a filled "box" behind the live word — never animate every word's color at once.
## Pages, not walls of text
Show 1–4 words at a time. Use Remotion's `createTikTokStyleCaptions()` (or group manually) and tune `combineTokensWithinMilliseconds`: ~200–500ms for true word-by-word, ~1000–1500ms for short readable phrases.
```ts
import { createTikTokStyleCaptions } from "@remotion/captions";
const { pages } = createTikTokStyleCaptions({
captions, combineTokensWithinMilliseconds: 1200, // ~2–3 words per page
});
```
## Web/CSS pop (no Remotion)
For a DOM/live player, give each word its own `animation-delay` equal to `startMs`:
```css
.word { display:inline-block; opacity:0; animation: pop .26s cubic-bezier(.34,1.56,.64,1) forwards; }
@keyframes pop { from { opacity:0; transform:translateY(.18em) scale(.7) } to { opacity:1; transform:none } }
.word.active { color:#FFE45E; }
```
```js
words.forEach(w => { const el = mk(w.text); el.style.animationDelay = `${w.startMs}ms`; track.append(el); });
```
## Readable type and safe-area placement (9:16, 1080×1920)
| Property | Value | Why |
|---|---|---|
| Font | Bold/ExtraBold sans (Montserrat, Inter, Helvetica) | Reads on small, busy screens |
| Size | 56–80px (≈8% of frame height), min 45px | Legible muted on a phone |
| Stroke | 2–6px solid black outline | Survives any background |
| Shadow | Soft drop shadow as backup to stroke | Separation on bright frames |
| Case | Uppercase or sentence; high contrast fill | Punch + scannability |
| Vertical pos | Center band, ~62–70% down | Above the UI, below the action |
| Bottom safe | Keep clear of bottom ~280px / 15% | Avoids caption/CTA/audio UI |
| Side safe | Keep within center 80% width | Avoids right-rail icons |
Place captions in the center band — not pinned to the very bottom, where the platform UI (username, audio, buttons) lives. Pad text to a max of ~2 lines.
## Sync to a voiceover / narration
The transcript already carries the timing of the exact audio. Lay that same audio under the composition and keep timestamps in the audio's timebase — captions and voice stay locked with zero manual nudging. If the VO is re-recorded, re-transcribe; never hand-shift offsets.
## Burn-in vs sidecar SRT/VTT
| | Burn-in (open) | Sidecar SRT/VTT (closed) |
|---|---|---|
| Where | Pixels in the MP4 | Separate `.srt`/`.vtt` file |
| Social (TikTok/Reels) | Required — guaranteed, styleable | Often ignored by the platform |
| Accessibility (ADA/WCAG) | Does not satisfy alone | Required (toggleable) |
| Best practice | Burn animated captions for social | Also ship a sidecar for the web/SEO |
Emit both: burn the animated captions for social, and write a plain SRT/VTT from the same tokens for accessible/SEO playback.
## Output checklist
- Word-level timestamps (real, from transcription) — no even-split fakery.
- Per-word pop with micro-overshoot; one accent color for the active word.
- 1–4 words per page; never a wall of text.
- Bold sans, 56–80px, 2–6px stroke + shadow, high contrast.
- Center band, clear of bottom ~280px and side rails.
- Audio laid under composition; timestamps in the audio timebase.
- Ship burn-in for social and a sidecar SRT/VTT for accessibility.
## Deliver & verify (rendered stills → MP4)
> **Packaged helper** (`scripts/`): tile your stills with `scripts/contact-sheet.sh sheet.png f-hook.png f-mid.png f-end.png`, then assert the encode with `scripts/probe-mp4.sh out.mp4 [WxH] [fps]`. See `scripts/README.md`.
Captions ship as a Remotion composition (`<Composition>` + zod `schema` + `defaultProps`) — all word motion frame-driven off `useCurrentFrame()`, never `Date.now()` / `Math.random()` / timers. Deliverable = `out/*.mp4` (burned-in) + the project + the sidecar SRT/VTT. 9:16 vertical (1080×1920) is the default.
**Verify loop — render stills → inspect → encode.** Word timing is the thing that breaks; check it at exact frames before you spend an encode.
```bash
# Stills at start / a sampled active-word frame / end — WITH SHIPPED PROPS (real tokens + audio)
npx remotion still Captions out/f-start.png --frame=0 --props='{"captionsSrc":"vo.json"}'
npx remotion still Captions out/f-mid.png --frame=90 --props='{"captionsSrc":"vo.json"}'
npx remotion still Captions out/f-end.png --frame=N --props='{"captionsSrc":"vo.json"}' # N = durationInFrames-1
# Inspect each PNG:
# - the word highlighted at frame 90 is the word whose [startMs,endMs] contains 90/fps (no drift)
# - burn-in legible: bold sans, stroke+shadow holds, no clipping
# - 9:16 safe area: caption sits in the center band, clear of top ~12% and bottom ~20-35% (captions/CTA/audio UI) and the right action rail
npx remotion render Captions out/captions.mp4 --props='{"captionsSrc":"vo.json"}' # encode once stills are right
npx remotion render Captions out/demo.gif --codec=gif # README proof clip
```
Use `npx remotion compositions` to read `durationInFrames`/`fps` and pick the active-word + end frames.
**Before you finish:**
1. Stills render cleanly at frame 0, a mid active-word frame, and last — no missing font/audio.
2. The correct word is highlighted at the sampled frame (frame/fps lands inside its token); no even-split fakery.
3. Burn-in is legible (stroke+shadow) and the caption is fully inside the 9:16 safe area at every checked frame.
4. Frame-driven only — no `Date.now()` / `Math.random()` / timers.
5. Shipped props (real tokens, not just `defaultProps`) render correctly; MP4 + sidecar SRT/VTT emitted, GIF optional.
## Reference files
- `references/word-timed-captions.md` — end-to-end build: Whisper transcription and the `Caption` type, a full SRT parser, Remotion word-timed component with active-highlight, manual paging, an SRT/VTT emitter, per-platform safe-area maps, and a readable-type spec sheet.
@@ -0,0 +1,254 @@
# Word-Timed Captions — End-to-End Build
A complete, runnable path from audio/transcript to animated burn-in captions plus a sidecar SRT/VTT. Code targets Remotion for burn-in and plain JS for the web; the data shapes are framework-agnostic.
## 1. The one data shape
Everything normalizes to a flat array of word tokens. Get to this shape and the renderer never cares about the source.
```ts
export type Token = { text: string; startMs: number; endMs: number };
export type Page = { startMs: number; endMs: number; tokens: Token[] };
```
Remotion's `@remotion/captions` uses a compatible `Caption` type:
```ts
type Caption = { text: string; startMs: number; endMs: number; timestampMs: number | null; confidence: number | null };
```
## 2. Transcribe to word-level timing (Whisper)
Word timestamps are non-negotiable for karaoke. Whisper produces them natively.
```bash
npx @remotion/install-whisper-cpp # one-time: installs whisper.cpp + a model
```
```ts
import { transcribe, toCaptions } from "@remotion/install-whisper-cpp";
const { transcription } = await transcribe({
inputPath: "voiceover.wav", // 16kHz mono WAV is ideal for whisper.cpp
model: "medium.en", // medium.en ≈ 2x faster than large, accurate on clean voice
tokenLevelTimestamps: true, // <-- the key flag: per-word, not per-line
whisperPath: "./whisper.cpp",
});
const { captions } = toCaptions({ transcription }); // → Caption[]
```
Tips:
- Feed clean voice audio (denoise/normalize first). Background music wrecks word boundaries.
- `large` is most accurate but 2–3x real-time on a laptop. Use `medium.en` unless accuracy is critical.
- Cloud alternatives (AssemblyAI, Deepgram, OpenAI Whisper API) also return word timing — map their `words[]` into `Token` the same way.
Map any token shape into `Token`:
```ts
const toTokens = (caps: Caption[]): Token[] =>
caps.map(c => ({ text: c.text.trim(), startMs: c.startMs, endMs: c.endMs }));
```
## 3. Parse an existing SRT into tokens
When handed an SRT, parse cues; if cues are sentence-level, re-transcribe for word timing rather than interpolating.
```ts
const toMs = (t: string) => {
const [h, m, rest] = t.trim().split(":");
const [s, ms] = rest.split(",");
return ((+h * 60 + +m) * 60 + +s) * 1000 + +ms;
};
export function parseSrt(src: string): Token[] {
return src.trim().split(/\n\s*\n/).map(block => {
const lines = block.split("\n");
const time = lines.find(l => l.includes("-->"))!;
const [a, b] = time.split("-->");
const text = lines.slice(lines.indexOf(time) + 1).join(" ").trim();
return { text, startMs: toMs(a), endMs: toMs(b) };
});
}
```
## 4. Page the tokens (1–4 words at a time)
Either use Remotion's helper or group manually. Manual grouping by gap + max-words keeps full control:
```ts
export function paginate(tokens: Token[], maxWords = 3, maxGapMs = 1200): Page[] {
const pages: Page[] = [];
let cur: Token[] = [];
for (const t of tokens) {
const span = cur.length ? t.endMs - cur[0].startMs : 0;
if (cur.length >= maxWords || span > maxGapMs) {
if (cur.length) pages.push(pageOf(cur)); cur = [];
}
cur.push(t);
}
if (cur.length) pages.push(pageOf(cur));
return pages;
}
const pageOf = (ts: Token[]): Page => ({ startMs: ts[0].startMs, endMs: ts.at(-1)!.endMs, tokens: ts });
```
Remotion equivalent:
```ts
import { createTikTokStyleCaptions } from "@remotion/captions";
const { pages } = createTikTokStyleCaptions({ captions, combineTokensWithinMilliseconds: 1200 });
// 200–500ms → word-by-word; 1000–1500ms → short readable phrases
```
## 5. Remotion component: word-timed with active highlight
```tsx
import { AbsoluteFill, Audio, Sequence, useCurrentFrame, useVideoConfig,
spring, interpolate, staticFile } from "remotion";
const CAPTION_STYLE: React.CSSProperties = {
fontFamily: "Montserrat, Inter, sans-serif",
fontWeight: 800,
fontSize: 72, // ~8% of 1920px height
color: "#fff",
textAlign: "center",
textTransform: "uppercase",
WebkitTextStroke: "5px #000", // heavy outline survives any background
paintOrder: "stroke fill", // stroke behind fill, not clipping glyphs
textShadow: "0 4px 16px rgba(0,0,0,.55)",
lineHeight: 1.1,
padding: "0 80px", // keep inside center 80% width
};
const Word: React.FC<{ token: Token; active: boolean }> = ({ token, active }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const enter = (token.startMs / 1000) * fps;
const p = spring({ frame: frame - enter, fps, config: { damping: 12, mass: 0.6 } });
return (
<span style={{
display: "inline-block",
transform: `scale(${interpolate(p, [0, 1], [0.6, 1])})`,
opacity: interpolate(p, [0, 1], [0, 1]),
color: active ? "#FFE45E" : undefined,
transition: "color 80ms linear",
}}>{token.text}&nbsp;</span>
);
};
export const Captions: React.FC<{ pages: Page[]; audio: string }> = ({ pages, audio }) => {
const { fps } = useVideoConfig();
const f = (ms: number) => Math.round((ms / 1000) * fps);
const frame = useCurrentFrame();
return (
<AbsoluteFill>
<Audio src={staticFile(audio)} /> {/* same audio the transcript came from */}
{pages.map((pg, i) => (
<Sequence key={i} from={f(pg.startMs)} durationInFrames={Math.max(1, f(pg.endMs - pg.startMs))}>
<AbsoluteFill style={{ justifyContent: "flex-start", alignItems: "center", paddingTop: "62%" }}>
<div style={CAPTION_STYLE}>
{pg.tokens.map((t, j) => {
const now = (frame / fps) * 1000;
return <Word key={j} token={t} active={now >= t.startMs && now < t.endMs} />;
})}
</div>
</AbsoluteFill>
</Sequence>
))}
</AbsoluteFill>
);
};
```
Because the `<Audio>` and the timestamps share one timebase, voice and captions stay locked with no manual nudging. Re-record the VO → re-transcribe → done.
## 6. Web/CSS path (live DOM player)
```js
function renderTrack(tokens, mountEl) {
tokens.forEach(t => {
const el = document.createElement("span");
el.className = "word";
el.textContent = t.text + " ";
el.style.animationDelay = `${t.startMs}ms`; // each word pops on its own start
mountEl.append(el);
});
}
// drive .active off the media element's currentTime
audio.addEventListener("timeupdate", () => {
const ms = audio.currentTime * 1000;
[...track.children].forEach((el, i) =>
el.classList.toggle("active", ms >= tokens[i].startMs && ms < tokens[i].endMs));
});
```
```css
.cap { font: 800 72px/1.1 Montserrat, sans-serif; text-transform: uppercase;
color:#fff; -webkit-text-stroke:5px #000; paint-order:stroke fill;
text-shadow:0 4px 16px rgba(0,0,0,.55); text-align:center; }
.word { display:inline-block; opacity:0; animation: pop .26s cubic-bezier(.34,1.56,.64,1) forwards; }
.word.active { color:#FFE45E; }
@keyframes pop { from { opacity:0; transform:translateY(.18em) scale(.7) } to { opacity:1; transform:none } }
```
## 7. Emit a sidecar SRT / VTT from the same tokens
Burn-in alone does not satisfy ADA/WCAG and many platforms ignore uploaded tracks — so ship both. Group tokens into readable cues and serialize:
```ts
const pad = (n: number, w = 2) => String(n).padStart(w, "0");
const stamp = (ms: number, sep: string) => {
const h = Math.floor(ms / 3.6e6), m = Math.floor(ms % 3.6e6 / 6e4),
s = Math.floor(ms % 6e4 / 1000), x = ms % 1000;
return `${pad(h)}:${pad(m)}:${pad(s)}${sep}${pad(x, 3)}`;
};
export const toSrt = (pages: Page[]) => pages.map((p, i) =>
`${i + 1}\n${stamp(p.startMs, ",")} --> ${stamp(p.endMs, ",")}\n${p.tokens.map(t => t.text).join(" ")}`
).join("\n\n") + "\n";
export const toVtt = (pages: Page[]) => "WEBVTT\n\n" + pages.map(p =>
`${stamp(p.startMs, ".")} --> ${stamp(p.endMs, ".")}\n${p.tokens.map(t => t.text).join(" ")}`
).join("\n\n") + "\n";
```
Use SRT for the broadest platform support; VTT when styling/positioning cues or serving on the web.
## 8. Per-platform safe areas (1080×1920, 9:16)
| Platform | Bottom keep-clear | Top keep-clear | Side keep-clear | Notes |
|---|---|---|---|---|
| TikTok | ~340px | ~120px | ~110px right rail | Captions, audio title, action buttons crowd the bottom-right |
| Instagram Reels | ~280px | ~130px | ~60px | Username/CTA bar at bottom |
| YouTube Shorts | ~240px | ~100px | ~120px right rail | Title + channel at bottom-left |
| Facebook Reels | ~300px | ~130px | ~80px | Similar to Reels |
Safe rule across all: place caption baseline at ~62–70% of frame height, keep text within the center 80% width, and never let it enter the bottom ~15%.
## 9. Readable-type spec sheet
| Property | Recommended | Floor |
|---|---|---|
| Weight | 800 (ExtraBold) | 700 |
| Size @1080p | 56–80px | 45px |
| Stroke | 4–6px solid #000 | 2px |
| Shadow | `0 4px 16px rgba(0,0,0,.55)` | soft 1px |
| Active accent | one color (e.g. #FFE45E) or filled box | — |
| Lines on screen | 1–2 | 2 max |
| Words per page | 1–4 | 5 max |
Set `paint-order: stroke fill` so a thick stroke sits behind the glyph instead of eating it. Test on a bright frame and a busy frame; if it survives both, it ships.
## 10. Render
```bash
npx remotion render src/index.ts Captions out/captions.mp4 \
--props='{"pages": ..., "audio": "voiceover.wav"}'
```
A 10-minute video with animated captions can take 30–60 min to render locally; render long-form in segments, or render on Remotion Lambda for parallel speed.
---
## Built by the team behind iart.ai
This skill is part of an open motion-graphics collection from iart.ai — the AI motion agent that turns data, scripts, and designs into editable motion graphics (Remotion → MP4). If you'd rather not hand-build this, iart.ai can auto-time captions to a narration track and one-click burn them in from a template — change the text/audio and re-export. → [iart.ai](https://iart.ai/?utm_source=github&utm_medium=reference&utm_campaign=tiktok-video-skills&utm_content=ref_footer&utm_term=caption-animation)
+25
View File
@@ -0,0 +1,25 @@
# Countdown Video
Make a countdown that ends exactly when it should. The whole craft is one idea: derive the remaining time from the frame (or a monotonic timestamp), then format and animate that number — never count down with a `setInterval`. Covers livestream "starting soon" screens, launch and flash-sale countdowns, and looping backgrounds. For creators who need a timer that lands on zero on the exact frame.
## When it activates
- "Make a countdown timer video" or "create a countdown animation."
- "Build a stream starting soon countdown" or "do an event/launch/sale countdown."
- "Animate a mm:ss or dd:hh:mm:ss timer."
- "Add digit roll/flip transitions to a counter" or "loop a countdown background."
## Example prompts
- "Build a 10-minute 'be right back' livestream countdown."
- "Make a dd:hh:mm:ss launch countdown to a fixed target date."
- "Add flip transitions to an mm:ss timer that hits zero on the exact frame."
## What's inside
- `SKILL.md` — frame-accurate (drift-free) timers, time formatting, and a configurable duration prop.
- `references/countdown-cookbook.md` — recipes for common countdown scenarios.
- `references/digit-flip.md` — digit flip/roll transition techniques.
---
Part of **[Short-Form Video Skills](../../)** · Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=tiktok-video-skills&utm_content=skill_footer&utm_term=countdown-video)** — the AI motion agent for editable, on-brand motion graphics.
+152
View File
@@ -0,0 +1,152 @@
---
name: countdown-video
description: This skill should be used when the user asks to "make a countdown timer video", "create a countdown animation", "build a stream starting soon countdown", "do an event/launch/sale countdown", "animate a mm:ss or dd:hh:mm:ss timer", "add digit roll/flip transitions to a counter", or "loop a countdown background". Covers frame-accurate (drift-free) timers, digit flip/roll motion, time formatting, and a configurable duration prop.
version: 0.1.0
---
# Countdown Video
Make a countdown that ends exactly when it should. The whole craft is one idea: derive the remaining time from the frame (or a monotonic timestamp), then format and animate that number — never count down with a `setInterval`. Covers livestream "starting soon" screens, launch/sale countdowns, and looping backgrounds.
## When to use
- "Starting soon" / "be right back" livestream screens with a 5–10 min timer.
- Launch and flash-sale countdowns (dd:hh:mm:ss down to a target date; mm:ss for urgency).
- Any animated counter where the number must land on zero on the exact frame.
## The one rule: drive the number from the clock, never a tick
A `setInterval(fn, 1000)` countdown **drifts** — the callback fires *at least* 1000 ms later, never exactly, and stacks up when the tab is throttled or a render frame is slow. Reports of ~1 second lost per minute are common. The fix is the same for rendered video and live web: compute remaining time as a pure function of an authoritative clock, so a late frame self-corrects on the next frame instead of accumulating error.
| Context | Authoritative clock | Remaining time |
|---|---|---|
| Remotion render | `useCurrentFrame()` | `total − frame/fps` |
| Web (live page) | `performance.now()` or `Date.now()` | `targetMs − now` |
| To a fixed date | `Date.now()` | `targetTimestamp − Date.now()` |
Never store remaining time in state and decrement it. Recompute it every frame.
## Frame-accurate countdown (Remotion → MP4)
`useCurrentFrame()` is deterministic: frame N is always rendered at exactly `N/fps` seconds. Subtract from the total and the timer is drift-free by construction.
```tsx
import { useCurrentFrame, useVideoConfig } from "remotion";
export const Countdown: React.FC<{ durationInSeconds: number }> = ({
durationInSeconds,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// remaining seconds, clamped at 0 — ceil so "1" shows for its full second
const elapsed = frame / fps;
const remaining = Math.max(0, Math.ceil(durationInSeconds - elapsed));
return <div style={{ fontVariantNumeric: "tabular-nums" }}>{formatMMSS(remaining)}</div>;
};
```
`durationInSeconds` is a **prop**, not a constant — the same composition renders a 5-minute stream timer or a 30-second sale timer by changing one input. Set the composition's `durationInFrames` to `durationInSeconds * fps` so the video is exactly as long as the countdown.
## Web timer (live page, requestAnimationFrame)
For a live page, drive an rAF loop from `performance.now()` and only repaint when the displayed second changes (no work 60×/sec for a 1 Hz number):
```js
const start = performance.now();
let lastShown = -1;
function tick(now) {
const remaining = Math.max(0, Math.ceil(duration - (now - start) / 1000));
if (remaining !== lastShown) { render(remaining); lastShown = remaining; }
if (remaining > 0) requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
```
Counting down to a fixed date is the same idea with `Date.now()`: `Math.max(0, target - Date.now())`. See `references/countdown-cookbook.md` for the full date-target version with timezone handling.
## Time formatting
Break a remaining-seconds integer into fields with division + modulo, then zero-pad. Pick the format to fit the duration — never show `00d 00h 05m` when minutes is all that matters.
```js
const pad = (n) => String(n).padStart(2, "0");
const formatMMSS = (s) => `${pad(Math.floor(s / 60))}:${pad(s % 60)}`;
const formatDHMS = (s) => {
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60), sec = s % 60;
return `${pad(d)}:${pad(h)}:${pad(m)}:${pad(sec)}`;
};
```
| Format | Use | Drop fields when |
|---|---|---|
| `mm:ss` | stream "starting soon", short timers (<1h) | — |
| `hh:mm:ss` | flash sale, same-day event | hide `dd` while 0 |
| `dd:hh:mm:ss` | launch / multi-day countdown | hide leading 0 units for cleaner read |
Always set `font-variant-numeric: tabular-nums` (or a monospace digit font) so digits keep equal width and the layout does not jitter as numbers change.
## Digit roll / flip transitions
A flat number swap reads as cheap; the rolling "odometer" look sells the countdown. Render each digit as a vertical strip 0–9 and translate it by `-digit × digitHeight`. Animate the *position*, not the text content, so the change is a glide, not a pop.
```css
.reel { height: 1em; overflow: hidden; }
.reel .col { transition: transform .45s cubic-bezier(.22,1,.36,1); }
/* col holds stacked 0..9; shift by -value em */
```
In Remotion, drive the strip offset off the frame instead of a CSS transition (so it is deterministic). The flip-card style (top half folds down over the bottom) and the per-digit reel are both detailed, with runnable components, in `references/digit-flip.md`.
## Looping background
Stream screens hold for minutes, so the background must loop seamlessly. Build motion on a cycle that divides evenly into the timeline (e.g. a gradient or particle drift whose period is `loopFrames`), and read it as `frame % loopFrames` so there is no visible seam at the wrap. Keep it low-contrast and slow — the number is the subject; the background is ambience. Recipes in `references/countdown-cookbook.md`.
## Output checklist
- Remaining time recomputed from `useCurrentFrame()` / `performance.now()` every frame — no `setInterval` decrement.
- Timer hits `00:00` on the exact intended frame; `durationInFrames === durationInSeconds × fps`.
- Duration is an input prop, not a hardcoded constant.
- Digits use `tabular-nums`; layout does not shift as numbers change.
- Format matches the span (mm:ss vs dd:hh:mm:ss); leading zero units dropped when distracting.
- Background loop period divides the timeline evenly; no seam at the wrap.
## Deliver & verify (rendered stills → MP4)
> **Packaged helper** (`scripts/`): tile your stills with `scripts/contact-sheet.sh sheet.png f-hook.png f-mid.png f-end.png`, then assert the encode with `scripts/probe-mp4.sh out.mp4 [WxH] [fps]`. See `scripts/README.md`.
The countdown ships as a Remotion composition (`<Composition>` + zod `schema` + `defaultProps`, `durationInSeconds` a prop) — the timer is a pure function of `useCurrentFrame()`, never `setInterval` / `Date.now()` / `Math.random()`. Set `durationInFrames = durationInSeconds * fps`. Deliverable = `out/*.mp4` + the project. 9:16 vertical (1080×1920) is the default.
**Verify loop — render stills → inspect → encode.** The whole point is frame-accuracy, so check the *digit value* at exact frames before encoding.
```bash
# Stills at start / mid / end — WITH SHIPPED PROPS (the real duration you'll render)
npx remotion still Countdown out/f-start.png --frame=0 --props='{"durationInSeconds":300}'
npx remotion still Countdown out/f-mid.png --frame=N --props='{"durationInSeconds":300}'
npx remotion still Countdown out/f-end.png --frame=L --props='{"durationInSeconds":300}' # L = durationInFrames-1
# Inspect each PNG — the digits must read EXACTLY the frame/fps math:
# - at frame N, displayed = ceil(durationInSeconds - N/fps) (e.g. 300s @ 30fps, frame 4500 → 150s → "02:30")
# - last frame reads 00:00 (or the end-state hold); no drift, no off-by-one second
# - tabular-nums: layout does not jitter between frames
# - 9:16 safe area: the timer + brand block sit clear of top ~12% and bottom ~20-35% UI and the right action rail
npx remotion render Countdown out/countdown.mp4 --props='{"durationInSeconds":300}' # encode once digits verify
npx remotion render Countdown out/demo.gif --codec=gif # README proof clip
```
Use `npx remotion compositions` to confirm `durationInFrames`/`fps`, then compute the frame for any second you want to check.
**Before you finish:**
1. Stills render cleanly at frame 0, mid, and last — no errors.
2. The digit value at frame N equals `ceil(durationInSeconds - N/fps)` exactly; last frame hits 00:00 on the intended frame (no drift / off-by-one).
3. Timer + brand stay inside the 9:16 safe area at every checked frame.
4. Frame-driven only — no `setInterval` / `Date.now()` / timers; `tabular-nums` so layout holds.
5. Shipped `durationInSeconds` (not just `defaultProps`) renders correctly; MP4 encoded, GIF optional.
## Reference files
- `references/countdown-cookbook.md` — complete runnable Remotion `<Countdown>` (configurable duration, mm:ss / dd:hh:mm:ss, end-state hold), the count-down-to-a-fixed-date web component with timezone handling, a "starting soon" stream-screen layout (brand block + social handles + looping gradient background), and the seamless-loop background pattern.
- `references/digit-flip.md` — two deterministic digit-animation components: the odometer reel (vertical 0–9 strip driven by frame) and the flip-card (top-half fold) effect, plus when each suits launch vs sale vs stream.
@@ -0,0 +1,205 @@
# Countdown Cookbook
Complete, runnable recipes. Every value is derived from an authoritative clock (frame or timestamp) so nothing drifts.
## 1. Configurable Remotion countdown (mm:ss + dd:hh:mm:ss)
A single composition that handles any duration and either format. Drop into `src/Countdown.tsx`.
```tsx
import React from "react";
import { useCurrentFrame, useVideoConfig, AbsoluteFill } from "remotion";
const pad = (n: number) => String(n).padStart(2, "0");
const formatTime = (totalSeconds: number, mode: "mm:ss" | "dd:hh:mm:ss") => {
const s = Math.max(0, totalSeconds);
if (mode === "mm:ss") {
return `${pad(Math.floor(s / 60))}:${pad(s % 60)}`;
}
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
// drop leading day field once it is zero, for a cleaner read
return d > 0
? `${pad(d)}d ${pad(h)}:${pad(m)}:${pad(sec)}`
: `${pad(h)}:${pad(m)}:${pad(sec)}`;
};
export type CountdownProps = {
durationInSeconds: number;
format?: "mm:ss" | "dd:hh:mm:ss";
label?: string;
};
export const Countdown: React.FC<CountdownProps> = ({
durationInSeconds,
format = "mm:ss",
label = "",
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// ceil so the last whole second ("1") is shown for its full duration,
// and the display reaches "0" exactly on the final frame.
const remaining = Math.max(0, Math.ceil(durationInSeconds - frame / fps));
const ended = remaining === 0;
return (
<AbsoluteFill
style={{
justifyContent: "center",
alignItems: "center",
background: "#0b0d17",
color: "#fff",
fontFamily: "Inter, system-ui, sans-serif",
}}
>
{label && (
<div style={{ fontSize: 36, opacity: 0.7, marginBottom: 16, letterSpacing: 4 }}>
{label.toUpperCase()}
</div>
)}
<div
style={{
fontSize: 180,
fontWeight: 800,
fontVariantNumeric: "tabular-nums", // equal-width digits → no jitter
lineHeight: 1,
color: ended ? "#ff4d4d" : "#fff",
}}
>
{ended ? formatTime(0, format) : formatTime(remaining, format)}
</div>
</AbsoluteFill>
);
};
```
Register it so `durationInFrames` matches the countdown exactly — the video ends when the timer does:
```tsx
import { Composition } from "remotion";
import { Countdown } from "./Countdown";
const FPS = 30;
const DURATION_SECONDS = 300; // 5-minute "starting soon" timer
export const RemotionRoot = () => (
<Composition
id="Countdown"
component={Countdown}
durationInFrames={DURATION_SECONDS * FPS}
fps={FPS}
width={1920}
height={1080}
defaultProps={{ durationInSeconds: DURATION_SECONDS, format: "mm:ss", label: "Starting soon" }}
/>
);
```
Render any duration/format without touching code — duration is a prop:
```bash
npx remotion render Countdown out/sale.mp4 \
--props='{"durationInSeconds":86400,"format":"dd:hh:mm:ss","label":"Sale ends in"}'
```
To hold a final state (e.g. "WE'RE LIVE") instead of ending on `00:00`, extend `durationInFrames` past the countdown and branch on `ended`.
## 2. Count down to a fixed date (live web component)
For a real-time page that targets a calendar moment. Drift-free because remaining time is `target − Date.now()` recomputed each frame, never a decrement.
```js
// targetISO must include an offset, e.g. "2026-07-01T09:00:00-07:00",
// so the deadline is unambiguous regardless of the viewer's timezone.
function startCountdown(targetISO, el) {
const target = new Date(targetISO).getTime();
const pad = (n) => String(n).padStart(2, "0");
let lastShown = -1;
function tick() {
const remainingMs = Math.max(0, target - Date.now());
const s = Math.floor(remainingMs / 1000);
if (s !== lastShown) {
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
el.textContent = `${pad(d)}:${pad(h)}:${pad(m)}:${pad(s % 60)}`;
lastShown = s;
}
if (remainingMs > 0) requestAnimationFrame(tick);
else el.textContent = "00:00:00:00"; // or swap to an "It's live" state
}
requestAnimationFrame(tick);
}
```
Timezone notes:
- Bake the offset into the target string (`-07:00`, `Z`). A bare `"2026-07-01T09:00:00"` is parsed as the viewer's *local* time and every viewer sees a different deadline.
- To show "ends 9am in *your* timezone everywhere," instead pick the wall-clock time per locale and let each viewer's `Date` resolve it — but that is a different deadline per region. Decide which you want before building.
## 3. "Starting soon" stream screen
Livestream lead-in screens hold 5–10 minutes: a clean brand block, social handles, and a calm looping background, with the timer as the focal point. Keep it low-clutter — chat and music carry the wait; the screen just signals "we're about to start."
```tsx
import { AbsoluteFill, useCurrentFrame, useVideoConfig, interpolate } from "remotion";
export const StartingSoon: React.FC<{ durationInSeconds: number; handle: string; brand: string }> = ({
durationInSeconds,
handle,
brand,
}) => {
const frame = useCurrentFrame();
const { fps, durationInFrames } = useVideoConfig();
const remaining = Math.max(0, Math.ceil(durationInSeconds - frame / fps));
const pad = (n: number) => String(n).padStart(2, "0");
// seamless looping gradient: angle cycles over loopFrames with no seam
const loopFrames = fps * 8;
const angle = interpolate(frame % loopFrames, [0, loopFrames], [0, 360]);
return (
<AbsoluteFill
style={{
background: `linear-gradient(${angle}deg, #141a3a, #0b0d17 60%)`,
color: "#fff",
fontFamily: "Inter, system-ui, sans-serif",
justifyContent: "center",
alignItems: "center",
}}
>
<div style={{ fontSize: 64, fontWeight: 800, marginBottom: 8 }}>{brand}</div>
<div style={{ fontSize: 28, opacity: 0.6, letterSpacing: 6, marginBottom: 40 }}>
STARTING SOON
</div>
<div style={{ fontSize: 200, fontWeight: 800, fontVariantNumeric: "tabular-nums", lineHeight: 1 }}>
{pad(Math.floor(remaining / 60))}:{pad(remaining % 60)}
</div>
<div style={{ position: "absolute", bottom: 60, fontSize: 30, opacity: 0.7 }}>{handle}</div>
</AbsoluteFill>
);
};
```
Export as MP4 and add it as a Media/Video source in OBS, or use a looping playout for a browser source. Common durations: 300s (5 min) or 600s (10 min) at 1920×1080.
## 4. Seamless looping background
Any motion whose period divides the timeline evenly wraps without a seam. Read animation phase as `frame % loopFrames` so the value at the wrap equals the value at frame 0.
```tsx
const loopFrames = fps * 6; // one full cycle every 6 seconds
const phase = (frame % loopFrames) / loopFrames; // 0 → 1, repeats
const driftY = Math.sin(phase * Math.PI * 2) * 40; // returns to start each cycle
```
Use sine/cosine (inherently periodic) or `interpolate(frame % loopFrames, [0, loopFrames], [a, a])` with matching start/end so there is no jump. Keep contrast low and motion slow — the number is the subject.
---
## Built by the team behind iart.ai
This skill is part of an open motion-graphics collection from iart.ai — the AI motion agent that turns data, scripts, and designs into editable motion graphics (Remotion → MP4). If you'd rather not hand-build this, iart.ai can render countdown videos from a template with a set time and brand — change the text/data and re-export. → [iart.ai](https://iart.ai/?utm_source=github&utm_medium=reference&utm_campaign=tiktok-video-skills&utm_content=ref_footer&utm_term=countdown-video)
@@ -0,0 +1,146 @@
# Digit Flip & Roll Transitions
Two ways to animate a digit changing. Both are driven by the frame (deterministic) so they render identically every time — no CSS transition clock that the renderer can disagree with.
Pick by tone:
| Effect | Feel | Best for |
|---|---|---|
| Odometer reel | smooth, continuous glide | launch countdowns, premium/minimal timers |
| Flip card | mechanical "snap", split-flap board | sale urgency, retro/airport-board style |
## Shared idea
A single displayed digit is a stack of glyphs `0..9` laid out vertically. Showing digit `d` means translating the stack so glyph `d` sits in the window: `translateY(-d × digitHeight)`. Animate the *offset*, not the text, and you get a roll. The trick for a countdown is that the offset is continuous: between whole seconds the digit eases from its old value toward its new one.
## Odometer reel (Remotion, frame-driven)
```tsx
import React from "react";
import { useCurrentFrame, useVideoConfig, interpolate, Easing } from "remotion";
const DIGIT_H = 160; // px per glyph
const ROLL_FRAMES = 12; // how long a single digit roll takes
// Animated single digit: rolls from `prev` to `value` over the last ROLL_FRAMES
// of the current second.
const Reel: React.FC<{ value: number; prev: number; secondProgress: number }> = ({
value,
prev,
secondProgress,
}) => {
// secondProgress: 0→1 across the current whole second.
// Start the roll near the end of the second so the number "settles" on change.
const t = interpolate(secondProgress, [0.7, 1], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.bezier(0.22, 1, 0.36, 1),
});
// continuous position between prev and value
const shown = prev + (value - prev) * t;
const offset = -shown * DIGIT_H;
return (
<div style={{ height: DIGIT_H, width: 110, overflow: "hidden", position: "relative" }}>
<div style={{ transform: `translateY(${offset}px)` }}>
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => (
<div
key={n}
style={{
height: DIGIT_H,
lineHeight: `${DIGIT_H}px`,
textAlign: "center",
fontSize: 140,
fontWeight: 800,
fontVariantNumeric: "tabular-nums",
}}
>
{n}
</div>
))}
</div>
</div>
);
};
// Renders an mm:ss reel where each digit rolls.
export const ReelClock: React.FC<{ durationInSeconds: number }> = ({ durationInSeconds }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const elapsed = frame / fps;
const remaining = Math.max(0, Math.ceil(durationInSeconds - elapsed));
const prevRemaining = Math.max(0, remaining + 1); // value one second ago
const secondProgress = elapsed % 1; // 0→1 within the current second
const digits = (s: number) =>
[Math.floor(s / 600) % 10, Math.floor(s / 60) % 10, Math.floor(s / 10) % 10, s % 10];
const cur = digits(remaining);
const prev = digits(prevRemaining);
return (
<div style={{ display: "flex", gap: 8, color: "#fff", background: "#0b0d17", padding: 40 }}>
{cur.map((d, i) => (
<React.Fragment key={i}>
{i === 2 && <div style={{ fontSize: 140, fontWeight: 800 }}>:</div>}
<Reel value={d} prev={prev[i]} secondProgress={secondProgress} />
</React.Fragment>
))}
</div>
);
};
```
Notes:
- Each digit only rolls when it actually changes; a digit whose `prev === value` sits still because `shown` stays constant.
- A `9 → 0` rollover rolls *backward* through the strip (the cheap-looking part). To make tens/units roll the natural way on a carry, render an extra `0` glyph below the `9` and clamp — covered as an exercise; for most timers the backward roll at the tens boundary is unnoticeable at speed.
- `ROLL_FRAMES` is unused above because the roll is tied to `secondProgress`; expose it instead if a fixed-length roll is preferred.
## Flip card (split-flap)
The top half of the old digit folds down over a static bottom half, revealing the new digit. Pure CSS keyframe version — fine for a live web page; for Remotion, drive the `rotateX` off `secondProgress` the same way as the reel so it stays deterministic.
```css
.flip { position: relative; width: 110px; height: 160px; perspective: 600px; }
.flip .card {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
font-size: 140px; font-weight: 800; font-variant-numeric: tabular-nums;
backface-visibility: hidden;
}
.flip .top { clip-path: inset(0 0 50% 0); transform-origin: bottom; }
.flip .bottom { clip-path: inset(50% 0 0 0); }
/* on change, animate the leaf folding down */
.flip .leaf {
position: absolute; inset: 0; transform-origin: bottom;
animation: fold .45s cubic-bezier(.36,0,.66,-0.2) forwards;
}
@keyframes fold {
0% { transform: rotateX(0deg); }
100% { transform: rotateX(-90deg); }
}
```
```js
// swap the digit and replay the fold whenever the value changes
function setFlip(el, newDigit) {
if (el.dataset.v === String(newDigit)) return; // no change → no flip
el.dataset.v = String(newDigit);
el.querySelector(".top").textContent = newDigit;
el.querySelector(".bottom").textContent = newDigit;
const leaf = el.querySelector(".leaf");
leaf.textContent = newDigit;
leaf.style.animation = "none";
void leaf.offsetWidth; // reflow → restart the animation
leaf.style.animation = "";
}
```
Drive `setFlip` from the same `requestAnimationFrame` + `performance.now()` loop in the cookbook (only call it when the displayed second changes), so the flips fire on real time and never drift.
## Choosing
- **Launch / premium:** odometer reel, slow ease, large tabular digits, muted palette.
- **Flash sale:** flip card for the mechanical urgency, or reel with a faster roll and a hot accent color on `mm:ss`.
- **Stream "starting soon":** either works, but keep it calm — a single reel on `mm:ss` reads better than a busy split-flap for a 5–10 minute hold.
+24
View File
@@ -0,0 +1,24 @@
# Lower Thirds
Build a clean, branded lower third — the name/role caption that lives in the bottom band of the frame — that animates in, holds long enough to read, and exits without stealing the shot. The craft is restraint: a crisp reveal, a strong type hierarchy, correct safe-area placement, and one template that drives a whole roster of speakers. For creators producing interviews, panels, podcasts, and broadcast-style titles.
## When it activates
- "Make an animated lower third" or "add lower thirds."
- "Create a name tag/name lower third" or "do a speaker name overlay."
- "Build broadcast titles" or "animate a title/name/role caption."
- "Template lower thirds across a list of names."
## Example prompts
- "Add a lower third with the speaker's name and role, branded to my colors."
- "Build a staggered slide-in name plate for a podcast guest."
- "Template one lower-third design across a roster of 12 panelists."
## What's inside
- `SKILL.md` — enter/exit animation, in/out timing and dwell, safe-area conventions, and brand styling.
- `references/lower-third-component.md` — a reusable lower-third component.
---
Part of **[Short-Form Video Skills](../../)** · Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=tiktok-video-skills&utm_content=skill_footer&utm_term=lower-thirds)** — the AI motion agent for editable, on-brand motion graphics.
+146
View File
@@ -0,0 +1,146 @@
---
name: lower-thirds
description: This skill should be used when the user asks to "make an animated lower third", "add lower thirds", "create a name tag/name lower third", "build broadcast titles", "animate a title/name/role caption", "do a speaker name overlay", or "template lower thirds across a list of names". Covers enter/exit animation (slide/wipe/mask reveal), in/out timing & dwell, safe-area & broadcast conventions, name-vs-role type hierarchy, brand styling, and rendering one design across many names.
version: 0.1.0
---
# Lower Thirds
Build a clean, branded lower third — the name/role caption that lives in the bottom band of the frame — that animates in, holds long enough to read, and exits without stealing the shot. The craft is restraint: a crisp reveal, a strong type hierarchy, correct safe-area placement, and one template that drives a whole roster of speakers.
## When to use
- Speaker/interview name tags, panel rosters, talking-head captions.
- Broadcast-style titles, news straps, podcast/webinar name plates.
- Templating one design across a list of names/titles → many clips (the payoff).
## Anatomy
A lower third is two text lines plus a graphic bed, placed in the lower band:
| Element | Role | Type treatment |
|---|---|---|
| **Name** (primary) | Who | Largest, heaviest, tightest tracking |
| **Role/title** (secondary) | What/where | ~45–60% of name size, lighter weight, often a brand accent color |
| **Bar / panel / accent** | Reads against any background | Solid or 70–85% opacity; an accent rule ties to brand |
Keep it to two lines. A name line + role line is the whole job — extra lines erode the hierarchy and crowd the safe area.
## Three rules that prevent cheap-looking titles
1. **Never hard-cut a title on or off.** A bare appear/disappear reads as a mistake. Even a 12-frame fade or slide makes it feel produced.
2. **Stagger, don't slam.** Animate the bar first, then the name, then the role, each offset by 3–6 frames. Everything arriving at once feels flat; a short cascade feels designed.
3. **Hold long enough to read twice.** Roughly 3–5s on screen (read it aloud twice). Too short and it's missed; too long and it nags. Animate in/out, never mid-segment.
## Enter & exit animations
Pick one motion language and reuse it. The most reliable, premium-reading reveals:
| Reveal | Feel | How |
|---|---|---|
| **Slide + fade** | Clean, default | Translate in ~24–40px while fading; ease-out on enter |
| **Wipe bar** | Broadcast | A bar scales/sweeps in (`scaleX`/clip), text fades behind it |
| **Mask reveal** | Premium | Clip text to a moving rectangle so letters "wipe on" |
Use asymmetric easing: a snappy `ease-out` (`cubic-bezier(.22,1,.36,1)`) on enter, a quicker `ease-in` on exit. Exit faster than you entered (~⅔ the duration) so the title gets out of the way.
```css
/* Mask reveal: text wipes on left→right, no plugin needed */
.lt-name { clip-path: inset(0 100% 0 0); animation: wipe .5s cubic-bezier(.22,1,.36,1) forwards; }
@keyframes wipe { to { clip-path: inset(0 0 0 0); } }
```
## In/out timing & dwell
Think in three phases. Budget frames, not vibes (at 30fps):
| Phase | Budget | Note |
|---|---|---|
| Enter | 12–18f (0.4–0.6s) | Staggered: bar → name → role |
| Hold | 90–150f (3–5s) | The readable dwell; scale with line length |
| Exit | 8–12f (0.27–0.4s) | Faster than enter |
In code, drive every state from the current frame (or a paused timeline), not wall-clock time — a video renderer demands deterministic frames. Express enter/hold/exit as a single progress curve so the same component works live in a browser and frame-by-frame in a renderer.
## Safe area & broadcast conventions
The lower third must survive every screen and platform UI. Compose inside the **title-safe area** (inner ~90% / margin 5%) and respect platform chrome:
| Frame | Anchor | Avoid |
|---|---|---|
| 16:9 (1920×1080) | Left edge at ~5% margin; baseline ~80–85% down | Outer 5% (title-safe), action-safe outer 10% |
| 9:16 (1080×1920) | Same left margin; **lift the band up** | Bottom ~18% (captions/UI), top ~12% |
| 1:1 (1080×1080) | Lower band, center-left | Outer 5% |
Anchor to the **left** and grow rightward so names of different lengths stay pinned and never re-center awkwardly. Keep the role line within the same left margin as the name. For 9:16, do not park the title at the very bottom — platform captions and buttons sit there.
## Type hierarchy & brand styling
Hierarchy is the whole design. Make the name unmistakably dominant:
- **Size contrast** ≥ 1.6× (name vs role). If unsure, push the name bigger, not the role.
- **Weight + color**: name in bold/near-white; role in a lighter weight and a **brand accent** color (one accent, used consistently).
- **Contrast for legibility**: text rides over unknown footage. Use a solid/semi-opaque bar, a subtle scrim, or a soft text shadow so it reads over both bright and dark plates.
- **Brand lock**: pin font, accent color, bar style, and corner radius in one theme object so every title across a series matches.
## Templating one design across a list
The reason to build this in code: change the text/data, re-export — no re-animating. Make name/role/theme **props**, never hardcoded, then map a roster to instances.
```jsx
const ROSTER = [
{ name: "Ada Lovelace", role: "Founder, Analytical Engines" },
{ name: "Grace Hopper", role: "Rear Admiral, US Navy" },
{ name: "Alan Turing", role: "Mathematician" },
];
// One <LowerThird> design; map the list → many instances (sequence them in a renderer)
{ROSTER.map((p, i) => <LowerThird key={p.name} {...p} theme={BRAND} startFrame={i * 150} />)}
```
In a renderer, make the roster an input prop and render once per row (`--props`) to emit one branded clip per person — same animation, same brand, only the text changes. See `references/lower-third-component.md` for the full runnable component and batch script.
## Output checklist
- Two lines only: dominant name, secondary role (≥1.6× size contrast).
- Animated in and out (no hard cut); enter staggered bar→name→role; exit faster.
- Hold 3–5s (readable twice); motion only at the ends, never mid-segment.
- Left-anchored inside title-safe; 9:16 band lifted above the bottom-18% UI zone.
- Text legible over any plate (bar/scrim/shadow); one brand accent throughout.
- Name/role/theme are props — one template renders the whole roster.
## Deliver & verify (rendered stills → MP4)
> **Packaged helper** (`scripts/`): tile your stills with `scripts/contact-sheet.sh sheet.png f-hook.png f-mid.png f-end.png`, then assert the encode with `scripts/probe-mp4.sh out.mp4 [WxH] [fps]`. See `scripts/README.md`.
The lower third ships as a Remotion composition (`<Composition>` + zod `schema` + `defaultProps`, name/role/theme as props) — enter/dwell/exit all driven by `useCurrentFrame()`, never `Date.now()` / `Math.random()` / timers. Deliverable = `out/*.mp4` + the project (re-render per roster row). 9:16 vertical (1080×1920) is the default.
**Verify loop — render stills → inspect → encode.** Sample the three phases so you catch a stagger or safe-area bug before encoding (or batching the whole roster).
```bash
# Stills at enter / dwell / exit — WITH SHIPPED PROPS (a real roster row, not defaults)
npx remotion still LowerThird out/f-enter.png --frame=10 --props='{"name":"Ada Lovelace","role":"Founder, Analytical Engines"}'
npx remotion still LowerThird out/f-dwell.png --frame=75 --props='{"name":"Ada Lovelace","role":"Founder, Analytical Engines"}'
npx remotion still LowerThird out/f-exit.png --frame=N --props='{"name":"Ada Lovelace","role":"Founder, Analytical Engines"}'
# Inspect each PNG — focus the DWELL frame (fully on screen):
# - hierarchy holds: name dominant (>=1.6x role), role in brand accent; both fully revealed, not mid-wipe
# - brand lock: font, accent color, bar/scrim correct; legible over the plate
# - 9:16 safe area: band LIFTED above the bottom ~20-35% UI zone, clear of top ~12% and the right action rail; left-anchored inside title-safe
# - enter/exit frames show partial reveal (animation actually runs), not a hard cut
npx remotion render LowerThird out/lower-third.mp4 --props='{"name":"Ada Lovelace","role":"Founder, Analytical Engines"}'
npx remotion render LowerThird out/demo.gif --codec=gif # README proof clip
```
**Batch roster**: verify ONE row via stills *before* rendering all names — catch a layout/overflow bug once, not N times. Use `npx remotion compositions` for `durationInFrames`/`fps` to pick the dwell + exit frames.
**Before you finish:**
1. Stills render cleanly at enter, dwell, and exit — no missing font/brand assets.
2. At the dwell frame: name dominant over role, brand accent + bar correct, fully (not partly) revealed.
3. The band is inside the 9:16 safe area — lifted above the bottom UI zone, clear of top and right rail — at every checked frame.
4. Frame-driven only — no `Date.now()` / `Math.random()` / timers; enter/exit show motion, never a hard cut.
5. A real roster row (not `defaultProps`) renders correctly; MP4 encoded, GIF optional.
## Reference files
- `references/lower-third-component.md` — a complete runnable lower-third: a Remotion component with staggered spring enter, readable hold, and faster exit driven purely by frame; a typed theme object for brand lock; the list→many-instances sequencing pattern; a per-row batch render script; plus a dependency-free CSS/HTML version of the same design with mask-reveal and wipe-bar variants.
@@ -0,0 +1,268 @@
# Lower-Third Component — runnable code
A complete, production-grade lower third you can drop in. Two paths:
1. **Remotion** (frame-deterministic, renders to MP4, batches over a roster).
2. **CSS/HTML** (no dependencies, for the web or a quick preview) with mask-reveal and wipe-bar variants.
Both share the same design language: a left-anchored bar, a dominant name, a brand-accent role line, a staggered enter, a readable hold, and a faster exit.
---
## 1. Remotion component (props-driven, frame-deterministic)
The animation is a pure function of `frame`, so it renders identically every time and stays in sync with the renderer. Enter staggers the bar, name, then role; exit is computed from the end of the clip so the title leaves faster than it arrived.
```tsx
// LowerThird.tsx
import {
AbsoluteFill, Sequence, interpolate, spring,
useCurrentFrame, useVideoConfig,
} from "remotion";
// --- Brand lock: change once, every title matches -------------------------
export type Theme = {
font: string;
bar: string; // panel/bar color
name: string; // primary text color
role: string; // accent color for the role line
radius: number;
marginPct: number; // left/safe margin as % of width
};
export const BRAND: Theme = {
font: "Inter, system-ui, sans-serif",
bar: "rgba(12,14,22,0.86)",
name: "#FFFFFF",
role: "#6EE7F0", // single brand accent
radius: 10,
marginPct: 5,
};
export type LowerThirdProps = {
name: string;
role: string;
theme?: Theme;
holdSeconds?: number; // readable dwell, default 4s
};
// Staggered enter via spring; exit via a quick fade/slide near the end.
const useEnterExit = (delayFrames: number, holdSeconds: number) => {
const frame = useCurrentFrame();
const { fps, durationInFrames } = useVideoConfig();
// ENTER: spring 0→1, offset by delayFrames (the cascade)
const enter = spring({
frame: frame - delayFrames,
fps,
config: { damping: 200, stiffness: 180, mass: 0.6 },
});
// EXIT: faster than enter — last 10 frames of the clip
const exitStart = durationInFrames - 10;
const exit = interpolate(frame, [exitStart, durationInFrames], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const opacity = enter * exit;
const x = interpolate(enter, [0, 1], [-32, 0]); // slide in from left
return { opacity, x };
};
const Line: React.FC<{
delay: number; holdSeconds: number; children: React.ReactNode;
size: number; weight: number; color: string; tracking?: number;
}> = ({ delay, holdSeconds, children, size, weight, color, tracking = 0 }) => {
const { opacity, x } = useEnterExit(delay, holdSeconds);
return (
<div style={{
opacity,
transform: `translateX(${x}px)`,
fontSize: size, fontWeight: weight, color,
letterSpacing: tracking, lineHeight: 1.15, whiteSpace: "nowrap",
}}>
{children}
</div>
);
};
export const LowerThird: React.FC<LowerThirdProps> = ({
name, role, theme = BRAND, holdSeconds = 4,
}) => {
const { width, height } = useVideoConfig();
const bar = useEnterExit(0, holdSeconds); // bar leads the cascade
const left = (width * theme.marginPct) / 100;
const baseline = height * 0.80; // sit ~80% down, inside title-safe
return (
<AbsoluteFill style={{ fontFamily: theme.font }}>
<div style={{
position: "absolute", left, top: baseline,
opacity: bar.opacity,
transform: `translateX(${bar.x}px)`,
}}>
{/* panel behind text guarantees legibility over any plate */}
<div style={{
background: theme.bar, borderRadius: theme.radius,
padding: "14px 22px 16px",
borderLeft: `4px solid ${theme.role}`, // accent rule ties to brand
backdropFilter: "blur(6px)",
}}>
<Line delay={4} holdSeconds={holdSeconds} size={46} weight={800} color={theme.name} tracking={-0.5}>
{name}
</Line>
<Line delay={8} holdSeconds={holdSeconds} size={26} weight={500} color={theme.role}>
{role}
</Line>
</div>
</div>
</AbsoluteFill>
);
};
```
Why each choice matters:
- **`spring` per line, offset by `delay`** gives the bar→name→role cascade. Same offsets everywhere keep titles consistent.
- **Exit derived from `durationInFrames`** means any clip length exits cleanly without per-instance tuning, and it's quicker than the enter (10f vs the spring's settle).
- **`whiteSpace: nowrap` + left anchor** keeps long and short names pinned to the same edge — no awkward re-centering across a roster.
- **`borderLeft` accent + semi-opaque panel** is the brand tie plus the legibility guarantee over unknown footage.
### Roster → many instances (one design, a whole panel)
Sequence the same component down a list. Each person gets a slot; only the text changes.
```tsx
// Roster.tsx
import { Series } from "remotion";
import { LowerThird, BRAND } from "./LowerThird";
const ROSTER = [
{ name: "Ada Lovelace", role: "Founder, Analytical Engines" },
{ name: "Grace Hopper", role: "Rear Admiral, US Navy" },
{ name: "Alan Turing", role: "Mathematician" },
];
export const Roster: React.FC = () => (
<Series>
{ROSTER.map((p) => (
<Series.Sequence key={p.name} durationInFrames={150 /* ~5s @30fps */}>
<LowerThird name={p.name} role={p.role} theme={BRAND} />
</Series.Sequence>
))}
</Series>
);
```
### Batch: one branded clip per person
Make the roster an input prop and render once per row, so each speaker becomes a standalone overlay clip — identical animation and brand, only the text differs.
```bash
#!/usr/bin/env bash
# render one transparent-friendly lower-third clip per person in roster.json
# roster.json: [{"name":"Ada Lovelace","role":"Founder, Analytical Engines"}, ...]
set -euo pipefail
mkdir -p out
jq -c '.[]' roster.json | while read -r row; do
slug=$(echo "$row" | jq -r '.name' | tr '[:upper:] ' '[:lower:]-')
npx remotion render LowerThird "out/${slug}.mp4" --props="$row"
done
```
For overlay-ready output with transparency, render the `LowerThird` composition (transparent background) to a codec that keeps alpha, e.g. `--codec=prores --prores-profile=4444`, or `--image-format=png` for a PNG sequence.
---
## 2. CSS / HTML version (no dependencies)
The same design without a framework — for the web, OBS browser sources, or a fast look-test. Includes both a **slide+fade** default and a **mask-reveal** name, plus a **wipe-bar** variant.
```html
<div class="lt" style="--accent:#6EE7F0">
<div class="lt-bar">
<div class="lt-name">Ada Lovelace</div>
<div class="lt-role">Founder, Analytical Engines</div>
</div>
</div>
<style>
:root { font-family: Inter, system-ui, sans-serif; }
.lt {
position: absolute; left: 5%; top: 80%; /* title-safe, left-anchored */
}
.lt-bar {
display: inline-block;
background: rgba(12,14,22,0.86);
backdrop-filter: blur(6px);
border-left: 4px solid var(--accent);
border-radius: 10px;
padding: 14px 22px 16px;
/* bar leads the cascade: slide + fade in, then a faster fade out */
animation: lt-in .5s cubic-bezier(.22,1,.36,1) both,
lt-out .3s ease-in 4.5s forwards; /* hold ~4s, exit faster */
}
.lt-name {
font-size: 46px; font-weight: 800; color: #fff; letter-spacing: -.5px;
line-height: 1.15; white-space: nowrap;
/* MASK REVEAL: letters wipe on left→right, staggered after the bar */
clip-path: inset(0 100% 0 0);
animation: lt-wipe .5s cubic-bezier(.22,1,.36,1) .12s forwards;
}
.lt-role {
font-size: 26px; font-weight: 500; color: var(--accent);
line-height: 1.15; white-space: nowrap;
opacity: 0;
animation: lt-fade .4s ease-out .22s forwards; /* role arrives last */
}
@keyframes lt-in { from { opacity: 0; transform: translateX(-32px); }
to { opacity: 1; transform: none; } }
@keyframes lt-out { to { opacity: 0; transform: translateX(-16px); } }
@keyframes lt-wipe { to { clip-path: inset(0 0 0 0); } }
@keyframes lt-fade { to { opacity: 1; } }
/* Mobile/9:16: lift the band above the bottom UI/caption zone */
@media (max-aspect-ratio: 1/1) { .lt { top: 70%; } }
</style>
```
### Wipe-bar variant (broadcast feel)
Swap the bar animation so a colored bar sweeps in and the text rides behind it:
```css
.lt-bar.wipe {
position: relative; overflow: hidden;
animation: lt-in .45s cubic-bezier(.22,1,.36,1) both,
lt-out .3s ease-in 4.5s forwards;
}
.lt-bar.wipe::before {
content: ""; position: absolute; inset: 0;
background: var(--accent);
transform-origin: left;
animation: lt-sweep .55s cubic-bezier(.76,0,.24,1) both; /* sweep across, then reveal */
}
@keyframes lt-sweep { 0% { transform: scaleX(0); } 60% { transform: scaleX(1); }
100% { transform: scaleX(0); transform-origin: right; } }
```
The CSS clock (`animation`) is fine for live/web playback. For a deterministic MP4, prefer the Remotion path above — a frame renderer and CSS timers disagree, which causes flicker or desync at export.
---
## Quick reference
| Need | Knob |
|---|---|
| Slower/faster reveal | enter duration 12–18f / `.5s` |
| Longer dwell | `holdSeconds` / the `4.5s` exit delay |
| Bigger name dominance | name `fontSize` (keep ≥1.6× the role) |
| Rebrand everything | the `BRAND` theme object / `--accent` |
| 9:16 placement | `baseline 0.80→0.70` / the media query |
---
## Built by the team behind iart.ai
This skill is part of an open motion-graphics collection from iart.ai — the AI motion agent that turns data, scripts, and designs into editable motion graphics (Remotion → MP4). If you'd rather not hand-build this, iart.ai can generate branded lower-thirds from a list of names/titles, one-click — change the text/data and re-export. → [iart.ai](https://iart.ai/?utm_source=github&utm_medium=reference&utm_campaign=tiktok-video-skills&utm_content=ref_footer&utm_term=lower-thirds)
+24
View File
@@ -0,0 +1,24 @@
# Short-Form Video
Engineer a 9:16 video that survives the swipe. Short-form is a retention machine, not a small launch film: a hook that earns the first second, pattern interrupts that reset attention every few seconds, and often a loop that buys watch-time past 100%. The craft is timing, not polish. For creators making Reels, TikToks, and YouTube Shorts (7–60s, vertical).
## When it activates
- "Make a Reels/TikTok/YouTube Shorts video" or "build a short-form video template."
- "Write a hook for a short" or "fix the first 3 seconds."
- "Edit for retention," "add pattern interrupts," or "pace cuts for a vertical video."
- "Make a video loop" or "set up 9:16 safe areas."
## Example prompts
- "Build a reusable 9:16 short-form template — one design, many topics."
- "Fix the drop-off in this Reel: tighten pacing and add pattern interrupts."
- "Write three hook options for a 30-second educational short."
## What's inside
- `SKILL.md` — the retention arc, hook grammar, and pacing rules.
- `references/retention-pacing.md` — deeper guidance on retention editing and pacing.
---
Part of **[Short-Form Video Skills](../../)** · Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=tiktok-video-skills&utm_content=skill_footer&utm_term=short-form-video)** — the AI motion agent for editable, on-brand motion graphics.
+155
View File
@@ -0,0 +1,155 @@
---
name: short-form-video
description: This skill should be used when the user asks to "make a Reels/TikTok/YouTube Shorts video", "build a short-form video template", "write a hook for a short", "edit for retention", "add pattern interrupts", "make a video loop", "fix the first 3 seconds", "pace cuts for a vertical video", or "set up 9:16 safe areas". Covers the hook→retention→loop grammar of vertical short-form (distinct from a launch hype film).
version: 0.1.0
---
# Short-Form Video
Engineer a 9:16 video that survives the swipe. Short-form is not a small launch film — it is a retention machine: a hook that earns the first second, pattern interrupts that reset attention every few seconds, and often a loop that buys watch-time past 100%. The craft is timing, not polish.
## When to use
- Reels / TikTok / YouTube Shorts (7–60s, vertical 9:16).
- Retention editing: fixing drop-off, adding pattern interrupts, tightening pacing.
- Hooks, seamless loops, repeatable short-form templates (one design, many topics).
## Story structure (the retention contract)
Short-form storytelling is a contract with the viewer's thumb: every second must re-earn the next one. The narrative is shaped *backwards from the retention curve*, not forwards from an intro. Lock these before touching the timeline.
- **One idea per video.** A short carries exactly one payoff. A second idea splits attention and the curve sags in the middle — make it a second video instead. If you can't name the single takeaway in a sentence, you don't have a short yet.
- **Open a loop in the first ~3s, close it last.** The hook poses a question or gap (the open loop); the payoff answers it. The viewer stays because the loop is open — so never answer it early, and never bury the payoff under setup.
- **No dead air.** Every beat either advances the idea or resets attention. Cut anything that does neither. Silence, slow ramps, throat-clearing intros ("hey guys, in this video…") are where viewers swipe.
- **Why the curve, not the cut, is the unit of work.** The algorithm distributes whatever holds the per-second retention curve flat; a "good" edit that flattens the curve loses to an ugly one that keeps it up. You are editing the *curve*, and the hook gates everything after it — fix it first.
The narrative spine (the `Hook → Setup → Body → Payoff/loop` arc is budgeted in the table below):
| Move | Story job | Failure if missed |
|---|---|---|
| Hook | Open the loop; promise the payoff | Swipe in first 3s |
| Tension hold | Keep the gap open through the body | Flat middle, mid-drop |
| Payoff | Close the loop — the one idea, delivered | "Wasted my time," no share |
| Loop / CTA | Feed back to frame 0, or one clear ask | No re-watch, no action |
(`The retention arc` table below assigns seconds to each move; this section is the narrative logic and ordering behind it. `Hook grammar` lists the hook archetypes.)
## The one metric that drives everything
**Retention, not production value.** The algorithm distributes whatever holds viewers; ~87% decide to stay or swipe inside 3 seconds, and the threshold for distribution sits near **70%+ average view-through** (Shorts ~73%, TikTok ~78%, Reels ~65%). Every decision below trades against that curve. A flawless shot that bores at 0:04 loses to an ugly one that holds.
## The retention arc
| Beat | Job | Budget (of a 20s short) |
|---|---|---|
| Hook (0–1s) | One frame + one line that creates an open loop | 0–1s |
| Setup | Pay off *why watch* — promise, stakes, or the gap | 1–4s |
| Body | Deliver value in beats; one idea per beat | 4–17s |
| Payoff / loop | Close the loop — or feed it back to frame 0 | 17–20s |
Scale the *body*, never the hook. A 60s short has a longer body and more beats — the same 1-second hook.
## Hook grammar (first ~1s)
The hook is a sentence + a visual that opens a curiosity gap the brain needs closed. Pick one pattern and commit:
| Hook type | Shape | Best for |
|---|---|---|
| Open loop | "The reason your X keeps Y…" (answer delayed) | most content |
| Direct promise | "3 ways to ___ in 30 seconds" | educational / listicle |
| Pattern interrupt | snap-zoom / whip-pan / visual mismatch in frame 1 | entertainment |
| Negation | "Stop doing ___" / "You're ___ wrong" | strong opinions |
Two rules: the hook line is **on-screen as text by frame 1** (85% watch muted), and the most striking *visual* is also the first frame — never a slow ramp-in. Hold a strong opening frame; do not fade up from black.
## Pattern interrupts & cut rhythm
A pattern interrupt is any change that resets attention: a hard cut, zoom punch, b-roll insert, sound effect, caption pop, or angle change. In short-form, density is high — a *visual change every ~2–4 seconds*; videos with an interrupt roughly every 4s hold ~58% vs ~41% for a static talking head. The killer is a flat middle: TikTok throttles distribution when the watch-through curve flattens, so never stitch the body from low-energy takes.
```js
// Drive every interrupt off the FRAME, not wall-clock — renders deterministically.
import { useCurrentFrame } from "remotion";
const fps = 30;
const cuts = [0, 1.2, 3.0, 5.4, 8.0, 11.2, 14.0]; // seconds — uneven on purpose
const frame = useCurrentFrame();
const shot = cuts.findIndex((t, i) => frame < (cuts[i + 1] ?? Infinity) * fps);
```
Vary the interval — a metronome reads as boredom; uneven cuts read as momentum. A punch-in zoom is the cheapest interrupt that needs no new footage:
```jsx
import { useCurrentFrame, interpolate, spring, useVideoConfig } from "remotion";
const PunchIn = ({ at, children }) => {
const frame = useCurrentFrame(); const { fps } = useVideoConfig();
const s = spring({ frame: frame - at * fps, fps, config: { damping: 12, stiffness: 200 } });
const scale = interpolate(s, [0, 1], [1, 1.12]); // snap to 112% on the beat
return <div style={{ transform: `scale(${scale})`, transformOrigin: "50% 45%" }}>{children}</div>;
};
```
## Captions
Most viewers watch muted, so on-screen captions are not optional — they carry the content and double as a pattern interrupt when each phrase pops in. Keep 4–7 words per line, high contrast, inside the safe area. **Do not re-implement caption animation here** — use the `caption-animation` skill for word-by-word timing, karaoke highlight, and styling; this skill only places captions on the retention timeline and inside the 9:16 safe zone.
## Loops & re-watch
A seamless loop counts each re-watch as fresh watch-time — a clean 8s loop watched 3× is 300% view-through, the strongest possible signal. Two techniques: match the **last frame to the first** (same composition, position, color) so the cut is invisible, and write a **sentence loop** — phrase the hook so the payoff feeds back into frame 0 ("…and that's why I never — " → loops to the start). Best length for re-watch is ~7–15s.
## 9:16 safe areas
Compose inside the universal safe zone so platform UI never covers text or faces.
| Zone | Pixels (1080×1920) | Avoid |
|---|---|---|
| Universal safe (all platforms) | center **900×1400** | anything critical outside it |
| Top | keep clear ~120px | profile / sound UI |
| Bottom | keep clear ~320px | captions, CTA, hashtags, audio bar |
| Right | keep clear ~120px | like / comment / share rail |
Put the hook text and key subject in the center 900×1400 box. Reserve the bottom ~320px even if it looks empty in the editor — that is where the platform paints its own captions and buttons.
## Output checklist
- Hook line is on-screen text by frame 1; strongest visual is the first frame (no fade-up).
- One open loop / promise set in the first ~4s.
- A visual pattern interrupt every ~2–4s; intervals uneven; no flat middle.
- Captions present (via `caption-animation`), 4–7 words, inside safe area.
- Loop closed — matched first/last frame or a sentence that feeds frame 0.
- Composed inside the 900×1400 center box; bottom 320px / top 120px / right 120px kept clear.
- Every animated value derived from `useCurrentFrame()` — no CSS/library timers.
## Deliver & verify (rendered stills → MP4)
> **Packaged helper** (`scripts/`): tile your stills with `scripts/contact-sheet.sh sheet.png f-hook.png f-mid.png f-end.png`, then assert the encode with `scripts/probe-mp4.sh out.mp4 [WxH] [fps]`. See `scripts/README.md`.
The short ships as a Remotion composition (`<Composition>` + zod `schema` + `defaultProps`) — every cut, punch-in, and interrupt driven by `useCurrentFrame()`, never `Date.now()` / `Math.random()` / timers. Deliverable = `out/*.mp4` + the project (re-render per topic). 9:16 vertical (1080×1920) is the default.
**Verify loop — render stills → inspect → encode.** Three frames test the three things that make or break retention: the hook, the pacing, and the loop seam.
```bash
# Stills at hook / mid-body / last frame — WITH SHIPPED PROPS (the real topic, not defaults)
npx remotion still Short out/f-hook.png --frame=10 --props='{"topic":"...","hook":"..."}' # ~first 3s reads instantly
npx remotion still Short out/f-mid.png --frame=N --props='{"topic":"...","hook":"..."}'
npx remotion still Short out/f-end.png --frame=L --props='{"topic":"...","hook":"..."}' # L = durationInFrames-1
# Inspect each PNG:
# - HOOK frame (~frame 0-ish / first 3s): hook line is on-screen text and reads instantly; strongest visual already up (no fade-up)
# - LOOP seam: compare f-hook(frame 0) vs f-end(last frame) — same composition/position/color for an invisible loop cut
# - 9:16 safe area: hook + key subject inside the center 900x1400 box; clear of top ~12%, bottom ~20-35% (captions/CTA/audio), right action rail
npx remotion render Short out/short.mp4 --props='{"topic":"...","hook":"..."}' # encode once stills verify
npx remotion render Short out/demo.gif --codec=gif # README proof clip
```
**Per topic / batch**: verify ONE topic via stills *before* rendering all of them. Use `npx remotion compositions` for `durationInFrames`/`fps` to pick the mid + last frames.
**Before you finish:**
1. Stills render cleanly at the hook, mid, and last frame — no errors.
2. The hook frame reads instantly (on-screen text + strongest visual up front, no fade-up); first vs last frame match for a clean loop seam.
3. Hook + subject + captions sit inside the 9:16 center 900×1400 safe box at every checked frame.
4. Frame-driven only — no `Date.now()` / `Math.random()` / timers; cuts uneven, no flat middle.
5. A real topic's shipped props (not `defaultProps`) render correctly; MP4 encoded, GIF optional.
## Reference files
- `references/retention-pacing.md` — a complete runnable Remotion `<Short>` composition: a frame-driven shot scheduler with uneven cuts, the PunchIn pattern interrupt, an on-screen hook, a 9:16 safe-area overlay, and a seamless first/last-frame loop. Plus a hook-pattern library, a retention-curve debugging method, per-platform safe-zone maps, and the template×topic batch-render pattern.
@@ -0,0 +1,209 @@
# Retention Pacing — Short-Form Video Reference
Deep reference for the `short-form-video` skill: a full runnable Remotion short, a hook library, a retention-debugging method, per-platform safe zones, and batch output. All timing is frame-driven so renders are deterministic.
## 1. A complete `<Short>` composition
One self-contained vertical short: frame-driven shot scheduler with uneven cuts, a punch-in pattern interrupt, an on-screen hook, a 9:16 safe-area overlay (dev only), and a seamless loop. Topic content is a prop, so the same file renders many shorts.
```tsx
// Short.tsx — 1080×1920, 30fps. Register at 600 frames (20s) for a clean loop.
import {
AbsoluteFill, useCurrentFrame, useVideoConfig,
interpolate, spring, Sequence, Img,
} from "remotion";
type Beat = { at: number; text: string; src?: string }; // at = seconds
export type ShortProps = {
hook: string; // on-screen by frame 1
beats: Beat[]; // body, one idea each
loopText: string; // sentence that feeds back to frame 0
};
// --- frame-driven shot scheduler: uneven cuts read as momentum ---
const useShot = (cuts: number[]) => {
const { fps } = useVideoConfig();
const frame = useCurrentFrame();
let i = 0;
for (let k = 0; k < cuts.length; k++) if (frame >= cuts[k] * fps) i = k;
const startF = cuts[i] * fps;
const endF = (cuts[i + 1] ?? cuts[i] + 2) * fps;
const local = (frame - startF) / (endF - startF); // 0→1 within shot
return { index: i, local, startF };
};
// --- punch-in: the cheapest pattern interrupt, snaps on the cut ---
const PunchIn: React.FC<{ startF: number; to?: number; children: React.ReactNode }> = ({
startF, to = 1.1, children,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const s = spring({ frame: frame - startF, fps, config: { damping: 13, stiffness: 220 } });
const scale = interpolate(s, [0, 1], [1, to]);
return <AbsoluteFill style={{ transform: `scale(${scale})`, transformOrigin: "50% 45%" }}>{children}</AbsoluteFill>;
};
// --- hook: text present from frame 0, micro-overshoot, no fade-up ---
const Hook: React.FC<{ text: string }> = ({ text }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const pop = spring({ frame, fps, config: { damping: 9, stiffness: 260 } });
const scale = interpolate(pop, [0, 1], [0.9, 1]);
return (
<div style={{
position: "absolute", top: 360, left: 90, right: 90, // inside 900-wide safe box
transform: `scale(${scale})`, transformOrigin: "0 0",
fontFamily: "Inter, sans-serif", fontWeight: 800, fontSize: 76, lineHeight: 1.05,
color: "#fff", textShadow: "0 4px 24px rgba(0,0,0,.55)",
}}>{text}</div>
);
};
export const Short: React.FC<ShortProps> = ({ hook, beats, loopText }) => {
const { fps, durationInFrames } = useVideoConfig();
const frame = useCurrentFrame();
const cuts = [0, ...beats.map((b) => b.at)]; // hook shot + one per beat
const { index, startF } = useShot(cuts);
const current = index === 0 ? null : beats[index - 1];
// seamless loop: match the LAST 0.5s back toward the opening frame
const tailF = durationInFrames - frame;
const loopFade = interpolate(tailF, [0, fps * 0.5], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
return (
<AbsoluteFill style={{ backgroundColor: "#0b0b0f" }}>
<PunchIn startF={startF}>
{current?.src && <Img src={current.src} style={{ width: "100%", height: "100%", objectFit: "cover" }} />}
</PunchIn>
{index === 0 && <Hook text={hook} />}
{/* body caption — placement only; styling/word-timing belongs to caption-animation */}
{current && (
<div style={{
position: "absolute", left: 90, right: 90, bottom: 380, // above the 320px bottom UI band
fontFamily: "Inter, sans-serif", fontWeight: 700, fontSize: 60, color: "#fff",
textAlign: "center", textShadow: "0 3px 18px rgba(0,0,0,.6)",
}}>{current.text}</div>
)}
{/* loop sentence rises as the clip ends, easing the eye back to frame 0 */}
<div style={{
position: "absolute", left: 90, right: 90, bottom: 380, opacity: 1 - loopFade,
fontFamily: "Inter, sans-serif", fontWeight: 800, fontSize: 64, color: "#fff", textAlign: "center",
}}>{loopText}</div>
<SafeZone />{/* remove for final render */}
</AbsoluteFill>
);
};
```
Register it with a duration that *is* the loop length:
```ts
// Root.tsx
import { Composition } from "remotion";
import { Short } from "./Short";
export const Root = () => (
<Composition
id="Short" component={Short}
durationInFrames={600} fps={30} width={1080} height={1920}
defaultProps={{
hook: "You're editing Shorts wrong —",
beats: [
{ at: 1.2, text: "Cut every 2–4 seconds", src: "b1.jpg" },
{ at: 4.0, text: "Never on a fixed grid", src: "b2.jpg" },
{ at: 7.5, text: "Kill the flat middle", src: "b3.jpg" },
{ at: 11.0, text: "Match last frame to first", src: "b4.jpg" },
],
loopText: "…and that's why mine loop forever",
}}
/>
);
```
## 2. The 9:16 safe-area overlay (dev-only)
Render this on top while editing; delete it before the final export.
```tsx
import { AbsoluteFill } from "remotion";
export const SafeZone: React.FC = () => (
<AbsoluteFill style={{ pointerEvents: "none" }}>
{/* universal safe box: center 900×1400 in 1080×1920 */}
<div style={{ position: "absolute", left: 90, top: 260, width: 900, height: 1400, outline: "2px dashed #39d", opacity: .6 }} />
{/* platform UI bands to keep clear */}
<div style={{ position: "absolute", top: 0, left: 0, right: 0, height: 120, background: "rgba(255,0,0,.12)" }} />
<div style={{ position: "absolute", bottom: 0, left: 0, right: 0, height: 320, background: "rgba(255,0,0,.12)" }} />
<div style={{ position: "absolute", bottom: 320, top: 120, right: 0, width: 120, background: "rgba(255,128,0,.10)" }} />
</AbsoluteFill>
);
```
## 3. Hook pattern library
The hook is a sentence that opens a gap the brain must close, plus the most striking frame as frame 0. Copyable shapes:
| Pattern | Template | Note |
|---|---|---|
| Open loop | "The real reason your ___ keeps ___ …" | answer withheld until payoff |
| Direct promise | "___ in 30 seconds, no ___ needed" | name the payoff + the timebox |
| Negation | "Stop ___. You're doing it backwards." | strong-opinion content |
| Pattern interrupt | snap-zoom / whip / hard visual mismatch | entertainment, no text needed |
| Stakes | "This ___ cost me $___ — don't repeat it" | story / case-study |
| Listicle | "3 ___ that ___ (the 3rd one ___ )" | tees up a forward reference |
Rules that matter more than the wording: (1) the hook text is on-screen by frame 1 — most viewers are muted; (2) frame 0 is the best frame, never a fade-up from black; (3) one gap only — stacking two hooks closes neither.
## 4. Retention-curve debugging
Treat the per-second retention graph (TikTok Analytics, YT Studio "Audience retention", Reels) as a profiler.
| Symptom on the curve | Likely cause | Fix |
|---|---|---|
| Cliff at 0:00–0:02 | weak hook / slow first frame | new hook line; cut the ramp-in; lead with the best frame |
| Steady gentle decline | normal — leave it | — |
| Flat-then-drop in the middle | low-energy body, cuts too sparse | add interrupts; tighten cut spacing to 2–4s |
| Bump back up near the end | viewers are looping | lean in — tighten the loop, shorten total length |
| Spike at one timestamp | a re-watched moment | front-load that beat or make it the hook |
Target ≥70% average view-through. Iterate the hook first — it moves the curve more than any other edit because it gates everything after it.
## 5. Per-platform safe zones
All on a 1080×1920 canvas. The universal box (center 900×1400) is the intersection; design inside it and one master fits every platform.
| Platform | Top clear | Bottom clear | Right clear | Notes |
|---|---|---|---|---|
| Universal (use this) | ~120px | ~320px | ~120px | center 900×1400 holds on all |
| TikTok | ~108px | ~320px | ~120px | caption + button rail at bottom-right |
| Instagram Reels | ~120px | ~310px | ~84px | audio bar + caption bottom |
| YouTube Shorts | ~120px | ~300px | ~60px | channel + subscribe bottom band |
| TikTok Ads | ~108px | ~370px | ~120px | extra room for the CTA button |
When unsure, design to the *largest* margin (TikTok Ads bottom 370px) and nothing ever clips.
## 6. Template × topic — batch output
The payoff of code-driven shorts: lock the design once, swap only the props, render N videos. Make topic content a prop (already done above), keep one theme object, and loop over JSON files.
```jsx
// theme.ts — one source of truth so 50 shorts stay on-brand
export const theme = { font: "Inter", hookSize: 76, bodySize: 60, bg: "#0b0b0f", fg: "#fff", accent: "#39d" };
```
```bash
# render the same Short template for every topic file in /topics
for f in topics/*.json; do
name=$(basename "$f" .json)
npx remotion render Short "out/$name.mp4" --props="$f"
done
```
Each `topics/*.json` is one `ShortProps` object (`hook`, `beats`, `loopText`). Keep the hook line, cut points, and loop sentence per-topic; everything else stays fixed so the series reads as one brand.
---
## Built by the team behind iart.ai
This skill is part of an open motion-graphics collection from iart.ai — the AI motion agent that turns data, scripts, and designs into editable motion graphics (Remotion → MP4). If you'd rather not hand-build this, iart.ai can batch-produce short-form videos from one template × data — change the text/data and re-export. → [iart.ai](https://iart.ai/?utm_source=github&utm_medium=reference&utm_campaign=tiktok-video-skills&utm_content=ref_footer&utm_term=short-form-video)
-12
View File
@@ -1,12 +0,0 @@
#!/bin/bash
set -euo pipefail
# Only run in Claude Code cloud sessions.
if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then
exit 0
fi
# Install the rea CLI (used by the reverse-engineer-anything skill) if missing.
if ! command -v rea >/dev/null 2>&1; then
npm install --global rea-agents
fi
-14
View File
@@ -1,14 +0,0 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh"
}
]
}
]
}
}
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/caption-animation
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/countdown-video
-55
View File
@@ -1,55 +0,0 @@
---
name: figma
description: Bring Figma into a HyperFrames video - rendered assets, brand tokens, components, storyboard frames as reconstructed motion (frames read as states, not slides), shaders and connector-assisted motion. Use when the person pastes a figma.com link or asks to use a Figma design, frame, logo or brand.
---
**Plugin installs:** Before setup or freshness commands, follow [plugin execution rules](../hyperframes/references/plugin-installation.md) when this skill is inside a HyperFrames plugin.
# Figma → HyperFrames
Every import happens at build time and freezes a local file with its source recorded under `.media/`, so renders never call Figma and re-running a command re-imports only what changed. Assets, tokens and components go over REST with a token; motion and shader data need a Figma connector, or a native export from the person.
## Access
`FIGMA_TOKEN` in the shell or the project `.env`. Check it exists before the first command; if not, tell the person how to make one and stop: figma.com → Settings → Security → Personal access tokens, read-only **File content** and **File metadata**, plus **Library content** for `tokens` on non-Enterprise plans. They set it themselves; never ask them to paste it into the chat.
| Error | Meaning |
| --- | --- |
| `BAD_TOKEN` (401) | Expired or revoked: make a new one |
| `FORBIDDEN` (403) | The message names the missing scope, or the file isn't visible to the account |
| `REQUIRES_ENTERPRISE` (403, variables) | Not a failure: the published-styles fallback already ran |
| `RATE_LIMITED` (429) | Already retried with backoff; wait a minute or import fewer nodes per call |
## Commands
- A layer, logo or image: `npx hyperframes figma asset '<url or fileKey:nodeId>' [more refs] --format svg|png --scale 2 --description "<what it is>" [--entity "<brand mark>"]`. SVG for vectors and logos, PNG at 2x for raster. Several nodes of one file in one call render in a single request; `--entity` lets `/media-use` find a brand mark later.
- Brand colours and type: `npx hyperframes figma tokens <fileKey>`. Run it before `component` and paste the printed entries into `data-composition-variables`. Without Enterprise it falls back to published styles. A connector can supply variables there instead: cache its raw response in `.media/figma-cache/` and write the bindings to `.media/figma-bindings.jsonl` (`{kind:"binding", figmaId, sourceFileKey, compositionVariableId:"figma:<name>", version}`).
- A frame as editable HTML at exact geometry: `npx hyperframes figma component '<url or fileKey:nodeId>'`, packaged under `compositions/components/<name>/`. A fill bound to an imported token comes out as `var(--slug, #literal)`, so one variable re-brands every import; an unknown token stays a literal marked `data-figma-unresolved`: import that library's tokens and re-import. Ask once which file a library is; never match by hex.
- Check hero components against Figma's pixels (`figma asset <same node> --format png`). Text drift is the usual cause; report drift you can't fix instead of hand-tweaking the import.
## Motion (connector)
Usage beacon: these phases have no CLI touchpoint, so run `npx hyperframes events --skill=figma-motion` when you begin and `npx hyperframes events --skill=figma-motion --event=skill_completed --outcome=success|error` when done. Same for shaders (`figma-shaders`) and storyboards (`figma-storyboard`).
1. Fetch motion context for the parent frame in one recursive request; cache the raw JSON in `.media/figma-cache/`.
2. Normalise with `motionContextToDocs(raw, { selectorFor, repeat })` from `@hyperframes/core/figma`, never by transcribing keyframes. It reads the motion.dev snippets (the CSS snippets stretch durations), drops loop-wrap tail keyframes and keeps bezier eases. `selectorFor` returns the ids from the component import.
3. `motionToGsap(doc)` → `emitTimelineScript(spec)`, injected after the GSAP and CustomEase tags: paused, finite, a literal `window.__timelines` key. Named eases outside the mapped set fall back to linear; say so.
4. Verify against Figma's own export: `node <SKILL_DIR>/scripts/verify-motion.mjs --reference <export.mp4> --render <render.mp4> --crop WxH+X+Y`. It compares motion between samples, so static drift cancels out, and fails under 15 dB (faithful ≈ 20+, diverging ≈ 5). Measure `--crop` from the render's card edges. A fail means fix the translation, not the threshold.
5. A track that won't translate: export it, freeze the MP4 and place it as `<video class="clip">`.
## Shaders
Figma's export path flattens shaders to their base colour, and shader source is only reachable for library-published styles. Ask the person for a native export of that frame (PNG or MP4) and place it as an asset or clip.
## Storyboards: frames are KEYFRAMES, not slides
Two frames holding the same element describe that element's state through time: animate the element between the states. Playing the frames back to back as stills is the failure mode.
1. Scenes are every frame-sized node in the section, named frames and loose full-frame rectangles alike: filter by size, not type or name. Order by `absoluteBoundingBox.x`.
2. Match children across neighbouring frames by name first, then by similar size and position. Tween geometry between states; crossfade two exports in place when the pixels changed. Export one asset per element, not one still per frame. Unmatched children enter or exit at their scene's beat.
3. Text nodes below the strip are motion notes for the scene above them, not on-screen copy. A note about motion inside a scene ("lines reveal one after another") means import that frame as a component and animate it.
4. Export in batches of about 4 scene frames per call: past about 12 ids `/v1/images` times out.
5. Frames that are one product UI in successive states: rebuild the UI as live DOM: code what changes state, freeze what doesn't, and treat each frame change as an interaction to perform (the cursor moves, clicks, the screen responds), not a tween to apply.
6. Stills are the fallback, only for frames with no shared elements.
Never leave a Figma URL in the composition.
@@ -1,153 +0,0 @@
#!/usr/bin/env node
/**
* Objective fidelity gate for figma-motion imports (skill step 2b).
*
* Compares the HyperFrames render against Figma's own `export_video` output
* using MOTION-ENERGY deltas: for each sample window [t, t+interval], the
* frame difference ref(t+i)-ref(t) is compared (PSNR) against
* render(t+i)-render(t). Static import divergence (fonts, rasterized edges,
* subpixel geometry — the hybrid-fidelity ceiling) cancels out of both
* deltas, so the score isolates choreography: trajectories, timing, easing.
*
* Calibration (SDS "Unlocked" card, 2026-07): a faithful translation scored
* min 20.3dB / mean 27.7dB; a diverging one (invented retract keyframes,
* wrong durations) scored min 5.0dB / mean 23.1dB. Default threshold 15dB
* sits between with margin on both sides.
*
* node verify-motion.mjs --reference figma-export.mp4 --render out.mp4 \
* [--crop WxH+X+Y] [--interval 0.2] [--min-motion-psnr 15]
*
* --crop selects the card region inside the (usually larger) composition
* frame. Measure it from the render (the card's left/top edge + scaled
* size), don't guess: a wrong crop reads as motion divergence.
*/
import { execFileSync, spawnSync } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
function arg(name, fallback) {
const i = process.argv.indexOf(`--${name}`);
return i > -1 ? process.argv[i + 1] : fallback;
}
const reference = arg("reference");
const render = arg("render");
if (!reference || !render) {
console.error(
"usage: verify-motion.mjs --reference ref.mp4 --render out.mp4 [--crop WxH+X+Y] [--interval 0.2] [--min-motion-psnr 15]",
);
process.exit(2);
}
const crop = arg("crop", null);
const interval = Number(arg("interval", "0.2"));
const minMotion = Number(arg("min-motion-psnr", "15"));
const ffprobe = (file) =>
Number(
execFileSync("ffprobe", [
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"csv=p=0",
"--",
file,
])
.toString()
.trim(),
);
const refDur = ffprobe(reference);
const renderDur = ffprobe(render);
const end = Math.min(refDur, renderDur) - interval - 0.01;
const dims = execFileSync("ffprobe", [
"-v",
"error",
"-select_streams",
"v",
"-show_entries",
"stream=width,height",
"-of",
"csv=p=0",
"--",
reference,
])
.toString()
.trim()
.split(",")
.map(Number);
const [rw, rh] = dims;
let cropFilter = "";
if (crop) {
const m = crop.match(/^(\d+)x(\d+)\+(\d+)\+(\d+)$/);
if (!m) {
console.error("bad --crop, expected WxH+X+Y");
process.exit(2);
}
cropFilter = `crop=${m[1]}:${m[2]}:${m[3]}:${m[4]},`;
}
const dir = mkdtempSync(join(tmpdir(), "verify-motion-"));
const frame = (src, t, vf, dst) => {
const args = ["-y", "-v", "error", "-ss", String(t), "-i", src, "-frames:v", "1"];
if (vf) args.push("-vf", vf);
execFileSync("ffmpeg", args.concat(dst));
};
const diff = (a, b, dst) =>
execFileSync("ffmpeg", [
"-y",
"-v",
"error",
"-i",
a,
"-i",
b,
"-filter_complex",
"blend=all_mode=difference",
dst,
]);
const psnr = (a, b) => {
// spawnSync with array args (no shell): psnr stats land on stderr
const r = spawnSync("ffmpeg", ["-i", a, "-i", b, "-lavfi", "psnr", "-f", "null", "-"], {
encoding: "utf8",
});
const m = (r.stderr || "").match(/average:([\d.]+|inf)/);
return m ? (m[1] === "inf" ? 99 : Number(m[1])) : NaN;
};
const renderVf = `${cropFilter}scale=${rw}:${rh}`;
const results = [];
for (let t = 0; t <= end; t = Math.round((t + interval) * 1000) / 1000) {
const t1 = Math.round((t + interval) * 1000) / 1000;
frame(reference, t, null, join(dir, "ra.png"));
frame(reference, t1, null, join(dir, "rb.png"));
frame(render, t, renderVf, join(dir, "oa.png"));
frame(render, t1, renderVf, join(dir, "ob.png"));
diff(join(dir, "ra.png"), join(dir, "rb.png"), join(dir, "rd.png"));
diff(join(dir, "oa.png"), join(dir, "ob.png"), join(dir, "od.png"));
results.push({
t,
motion: psnr(join(dir, "rd.png"), join(dir, "od.png")),
abs: psnr(join(dir, "rb.png"), join(dir, "ob.png")),
});
}
rmSync(dir, { recursive: true, force: true });
const min = Math.min(...results.map((r) => r.motion));
const mean = results.reduce((s, r) => s + r.motion, 0) / results.length;
for (const r of results)
console.log(
`window ${r.t.toFixed(2)}s→${(r.t + interval).toFixed(2)}s motion-psnr=${r.motion.toFixed(2)}dB (abs=${r.abs.toFixed(1)}dB)${r.motion < minMotion ? " <-- BELOW THRESHOLD" : ""}`,
);
console.log(
`\nwindows=${results.length} min-motion=${min.toFixed(2)}dB mean-motion=${mean.toFixed(2)}dB threshold=${minMotion}dB`,
);
if (min < minMotion) {
console.log(
"VERDICT: FAIL — choreography diverges from the Figma export (check timings, invented keyframes, durations)",
);
process.exit(1);
}
console.log("VERDICT: PASS — motion matches the Figma export within the static-fidelity ceiling");
-97
View File
@@ -1,97 +0,0 @@
---
name: hyperframes-cli
description: The HyperFrames CLI by job - scaffold, check, look at frames, edit the timeline, preview, render locally or in the cloud, publish, project history - plus reading its JSON and recovering from build or render failures.
---
**Plugin installs:** Before setup or freshness commands, follow [plugin execution rules](../hyperframes/references/plugin-installation.md) when this skill is inside a HyperFrames plugin.
# HyperFrames CLI
Run `npx hyperframes <command>` unless the project gives a wrapper; then use the wrapper. It needs Node.js 22+ and FFmpeg. Every command has `--help`; most take `--json`, and a failure prints `✗ …`, a `Fix:` line and exits non-zero.
## Commands by job
| Job | Commands |
| --- | --- |
| Start | `init <dir>` (blank; `--example <name>` only when asked), `capture <url>` |
| Read a project | `info`, `compositions`, `timeline --json`, `docs <topic>` |
| Check it | `lint` while writing; `check --json` as the gate (lint, runtime, layout, contrast) |
| Look at it | `snapshot` (`--at <t,…>`, `--range START,END`, `--composition <id>`, `--zoom`, `--angle`, `--against <video>`), `keyframes`, `compare`, `audio` |
| Edit the timeline | `timeline` with `move`, `trim`, `split`, `set`, `duplicate`, `delete`, `apply`, `undo`, `ids` |
| Reuse | `catalog`, `add` (`/hyperframes-registry`) |
| Media and sound | `media-treatment`, `grade-compare`, `remove-background`, `tts`, `transcribe`, `beats`, `normalize-audio` (`/media-use`) |
| Show and ship | `preview`, `present` (`/slideshow`), `render`, `publish`, `cloud`, `lambda`, `cloudrun` |
| Desktop app | `open`, `catch-up` |
| Upkeep | `doctor`, `browser`, `upgrade`, `skills`, `usage`, `clean`, `feedback` |
`validate`, `inspect` and `layout` are deprecated aliases of `check`; `events` is telemetry for skills' own scripts. Don't run them.
## The loop
1. Write, then `npx hyperframes lint` after the first pass and after structural changes.
2. `npx hyperframes check --json` and fix every error. While a lint error remains the layout and contrast audits don't run; "0 samples" means nothing was checked. Contrast failures carry a `suggestedColor`. A finding at a single sample (an entrance in flight) is info; a 3 s+ video with no visible change fails `sweep_static`.
3. `npx hyperframes snapshot` and read the PNGs (under `.hyperframes/artifacts/snapshots/`): that is how you see the video. With sub-compositions, snapshot a midpoint of every scene; tiny unstyled content or a missing hero is a mount defect (`../hyperframes/references/format.md`).
4. `npx hyperframes preview --background`, check the URL answers 200, give the person the project URL and wait, unless they asked for the file or for no stops. Passing checks alone is not a reason to render.
5. When it is time to render (step 4): `npx hyperframes render --quality looks --output out.mp4` (`draft` while iterating, `delivery` for the final). Confirm the file is non-empty and its `ffprobe` duration matches the root `data-duration`, then look at decoded frames.
"This element" or the current selection: `npx hyperframes preview --context --json --context-fields selection`; `no-selection` means ask them to click it.
## Edit the timeline
These are the edits Studio itself makes, so the result stays in Studio's shape.
- `timeline --json` lists clips with absolute `absStart`/`absEnd`, owning `file` and the `#id` ref the edit commands take (`timeline ids` stamps missing ids).
- `timeline move|trim|split|set|duplicate|delete <ref> … --plan --json` previews one edit; run it again without `--plan`. `--start`, `--end`, `--duration` set one edge or the length.
- An edit is refused when the clip would overlap a neighbour (`--overwrite`, or move the neighbour first) or end past its composition: raise the root's `data-duration` first. No command changes `data-duration`.
- Several edits at once: a JSON file such as `[{"verb": "move", "ref": "#outro", "time": "9"}]`, then `timeline apply plan.json --json`. Each run prints a `receipt`; save it and `timeline undo receipt.json` reverts it.
<!-- history (trial): remove this block together with the command -->
## Project history
Every write is kept and can be undone. At the start of a turn, `npx hyperframes history begin --who <you> --label "<plan>"`, then `history --since mine --who <you>` to see what the person changed; build on their edits. A check failed or they say it got worse: `history undo --who <you>`, never hand-edit back. End the turn with `history end`.
<!-- /history (trial) -->
## Render where
| Need | Command |
| --- | --- |
| Local | `render --quality looks --output out.mp4` (`draft`, `delivery`) |
| Reproducible container | `render --docker --strict --output out.mp4` |
| Batch from variables | `render --batch rows.json --output "renders/{name}.mp4"` |
| Hosted, no local Chrome or FFmpeg | `cloud render` (`references/cloud.md`) |
| Your own AWS or GCP | `lambda render`, `cloudrun render` (`references/lambda.md`, `references/cloudrun.md`) |
A 30 s film renders in a minute or two: run it in the foreground. Detach only a render longer than your tool's timeout (`HYPERFRAMES_RENDER_DETACHED=1`, its own group, output to a log) and wait with `until grep -qE 'Render (complete|failed)' render.log; do sleep 5; done`, never `pgrep -f`, which matches its own loop and waits until the tool times out.
After a verified render, send one `feedback --rating`, plus a report for anything the person asked for that HyperFrames can't do, unless telemetry is off: `references/feedback.md`.
## When a command fails
| Symptom | Fix |
| --- | --- |
| Chrome missing or won't start | `npx hyperframes browser ensure`; `doctor --json` always exits 0, gate on `.ok` |
| A heavy page isn't ready in time | Raise `--timeout` on `check` or `snapshot` |
| Unusual codec looks blank | Keep `--proxy` on (the default) |
| Render slow on Linux | The summary's second line says `screenshot` + `software gpu`: the slow path |
| `capture` fails or writes `BLOCKED.md` | Take nothing from its partial files; retry into a new `-o` folder |
| Disk tight | `clean --dry-run`, then `clean`; never delete `.hyperframes` (it holds history) |
## References
Read the row before running its command.
| Commands | Read |
| --- | --- |
| `init`, `capture`, `skills` | `references/init-and-scaffold.md` |
| `lint`, `check`, motion sidecars, `snapshot` | `references/lint-validate-inspect.md` |
| `compare`, `grade-compare`, `render --batch` | `references/compare-and-batch.md` |
| `beats` | `references/beats.md` |
| `preview`, `play`, `render`, `publish`, Studio context | `references/preview-render.md` |
| `doctor`, `browser` | `references/doctor-browser.md` |
| `auth`, `cloud`, template variables | `references/cloud.md` |
| `lambda`, `cloudrun` | `references/lambda.md`, `references/cloudrun.md` |
| `info`, `upgrade`, `compositions`, `timeline`, `docs`, `usage`, telemetry | `references/upgrade-info-misc.md` |
| `--strict` flags, run ids, `clean`, shader lint, JSON paths | `references/conventions.md` |
| `keyframes` | `../hyperframes/references/motion.md` (Seeing motion) |
@@ -1,15 +0,0 @@
# Generate a project beat grid
Use `hyperframes beats` when an existing HyperFrames project needs the Studio-compatible beat file for its music track. This is a CLI utility, not a complete video workflow.
```bash
npx hyperframes beats
npx hyperframes beats ./my-video
npx hyperframes beats ./my-video --json
```
The project must contain a local music `<audio>` source. Mark it with `data-timeline-role="music"`; an id containing `music`, `bgm`, or `soundtrack` is also recognized. The command decodes that file with ffmpeg, tracks its beats, and writes `beats/<audio-relative-path>.json`.
If the track has no steady beat, the command fails and writes nothing. If ffmpeg is missing, run `npx hyperframes doctor`.
For a whole video cut to a music track, see `/hyperframes` → `references/film.md`.
@@ -1,163 +0,0 @@
# cloud — HeyGen-hosted rendering (zero-infra)
`hyperframes cloud render` renders a composition on HeyGen's managed cloud. The CLI zips the project, uploads it, runs the render on HeyGen's infrastructure (Chromium + FFmpeg), and downloads the finished video. Nothing to deploy, and no Chrome/FFmpeg/AWS to manage; you pay per credit.
```bash
npx hyperframes auth login # one-time sign-in
npx hyperframes cloud render # zip, upload, render, download
```
## When to use managed cloud, Lambda, Cloud Run, or local
- **`hyperframes render`** (local): fastest iteration loop, use while authoring.
- **`hyperframes cloud render`**: zero-infra. HeyGen runs the render and you pay per credit. This is the default answer to "render in the cloud" when you don't want to manage Chrome/FFmpeg/AWS.
- **`hyperframes lambda render`**: bring-your-own-AWS distributed rendering with chunked parallelism. Only worth it when you've already invested in AWS (see `lambda.md`).
- **`hyperframes cloudrun render`**: bring-your-own-GCP distributed rendering through Cloud Run and Workflows. Use only when GCP ownership is explicit (see `cloudrun.md`).
## Authentication
Cloud rendering needs a HeyGen credential, stored at `~/.heygen/credentials` (`0600`) and shared with the [`heygen` CLI](https://github.com/heygen-com/heygen-cli): sign in with one and the other picks up the session.
```bash
npx hyperframes auth login # OAuth 2.0 + PKCE, opens the browser
npx hyperframes auth login --api-key # CI/headless: hidden prompt, or pipe: echo "$HEYGEN_API_KEY" | ... --api-key
npx hyperframes auth status # active credential source, identity, billing snapshot
# exit 0 = signed in and verified; exit 1 = not signed in,
# or the credential was rejected — signed-out exit 1 is the
# normal offline state (scripts: `auth status || echo offline`),
# not a command failure
npx hyperframes auth refresh # force-refresh an OAuth token before a long job
npx hyperframes auth logout # clear the stored credential
```
Credential resolution order (first match wins): `HEYGEN_API_KEY`, then `HYPERFRAMES_API_KEY`, then `HEYGEN_ACCESS_TOKEN` (an OAuth token a host app injects; never refreshed or saved), then `~/.heygen/credentials`. Point at a different backend with `HEYGEN_API_URL` (default `https://api.heygen.com`).
## The render pipeline
`cloud render` runs end-to-end:
1. **Resolve the project**: a local directory (default `.`), or skip the upload with `--asset-id` / `--url`.
2. **Auto-detect aspect ratio** from the entry HTML's `data-width`/`data-height`.
3. **Zip** the project (same ignore set as `hyperframes publish`, including `.hyperframesignore`).
4. **Upload** the zip through the direct-to-S3 asset flow, yielding an `asset_id`.
5. **Submit** the render to `POST /v3/hyperframes/renders`, yielding a `render_id`.
6. **Poll** `GET /v3/hyperframes/renders/{id}` until it completes or fails (skip with `--no-wait`).
7. **Download** the signed video URL to disk.
## Archive size and `.hyperframesignore`
The direct-upload limit is 200 MB. HyperFrames automatically excludes root-level `renders/` and `snapshots/`, along with its existing development exclusions such as `.git`, `node_modules`, `dist`, `.next`, `coverage`, and dotfiles. Add project-specific gitignore-style rules to `<project>/.hyperframesignore` when other generated or intermediate assets are not required at render time. The same rules affect `hyperframes publish`.
Inspect the exact archive without authenticating, uploading, spending credits, or starting a render:
```bash
npx hyperframes cloud render <project> --dry-run --json
```
The result reports compressed `size_bytes`, `file_count`, the 200 MB limit, and the ten largest included files.
When a cloud upload reports a size-limit error, agents must use this workflow:
1. Run the dry-run command and inspect the largest included files and directories.
2. Classify obvious generated outputs first: old renders, extra snapshot/contact-sheet directories, caches, exported previews, and source media used only to produce final assets.
3. Before excluding anything else, search `src`, `href`, `url()`, `data-composition-src`, JavaScript strings, manifests, and variable-driven paths across every HTML, CSS, and JavaScript entry.
4. Preserve existing `.hyperframesignore` comments and rules. Add the narrowest verified-unneeded root-relative paths; prefer an exact directory or file over a broad wildcard.
5. Never ignore `index.html`, the selected composition, mounted sub-compositions, fonts, images, audio, video, scripts, or manifests merely because they are large. Never ignore all of `assets/`.
6. Rerun dry-run until the archive is below the limit, then run `npx hyperframes check`. Remember that `check` sees the source directory, so it cannot prove a dynamically computed asset path remains in the filtered archive; the reference audit is still required.
Example:
```gitignore
# Additional generated verification passes
/snapshots2/
/snapshots3/
# Master used only to produce the final background clips
/assets/bg-pattern.mp4
```
Rules support comments, globs, and negation. A later rule can override a default, for example `!/snapshots/` when that directory intentionally contains render inputs.
## Render options
| Flag | Default | Meaning |
| ---------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `--fps` | `30` | Frames per second, 1–240. |
| `--quality` | `standard` | `draft`, `standard`, or `high`. |
| `--format` | `mp4` | `mp4`, `webm`, or `mov` (webm/mov carry alpha). |
| `--resolution` | `1080p` | `1080p` or `4k` (4k billed at 1.5×). |
| `--aspect-ratio` | auto | `16:9`, `9:16`, or `1:1`. Auto from a local project's `data-width`/`data-height`; defaults to `16:9` for `--asset-id`/`--url`. |
| `--composition` / `-c` | `index.html` | Entry HTML file inside the zip. |
| `--output` / `-o` | `renders/<render_id>.<ext>` | Local download destination. |
| `--dry-run` | off | Build and inspect a local project zip without authenticating, uploading, or rendering. |
```bash
npx hyperframes cloud render . \
--composition compositions/intro.html \
--output ./renders/intro.mp4
npx hyperframes cloud render --quality high --fps 60
```
`--resolution 4k` cannot combine with `--format webm`/`mov`: the 4k supersampling path has no alpha channel. Render 4k as mp4, or render alpha at native resolution.
## Templates and variables
Cloud rendering supports [composition variables](../../hyperframes/references/format.md#variables): declare `data-composition-variables` on the composition, then fill them at render time.
```bash
npx hyperframes cloud render --variables '{"title":"Q4 Recap","theme":"dark"}'
npx hyperframes cloud render --variables-file ./vars.json
npx hyperframes cloud render --variables '{"title":"Q4 Recap"}' --strict-variables
```
For a **local project** the CLI validates `--variables` against the declared schema _before_ uploading. For `--asset-id`/`--url` the schema lives server-side, so mismatches surface as a `hyperframes_project_invalid` API error.
**Upload once, re-render many** is the idiomatic template loop: render a local project to get its `asset_id`, then re-submit against that asset with new values (no re-zip, no re-upload).
```bash
npx hyperframes cloud render ./card-template # note the asset_id printed on upload
npx hyperframes cloud render --asset-id asst_abc123 --variables '{"name":"Ada"}'
npx hyperframes cloud render --asset-id asst_abc123 --variables '{"name":"Linus"}'
```
For high-volume personalized batches, both self-managed paths provide JSONL fan-out: AWS Lambda (`lambda.md`) and Google Cloud Run (`cloudrun.md`). The full variables schema (types, declarative bindings, sub-composition overrides, precedence) lives in [`/hyperframes` format.md](../../hyperframes/references/format.md#variables).
## Fire-and-forget and webhooks
By default the CLI blocks, polls, and downloads. Combine `--no-wait` (submit and exit with just the `render_id`) with `--callback-url` (HTTPS webhook on terminal status) for true fire-and-forget:
```bash
npx hyperframes cloud render --callback-url https://example.com/hf-hook --no-wait
# Poll later with: hyperframes cloud get hfr_def456
```
| Flag | Meaning |
| ----------------- | --------------------------------------------------- |
| `--no-wait` | Submit and exit immediately; print the `render_id`. |
| `--callback-url` | HTTPS webhook fired when the render terminates. |
| `--callback-id` | Opaque tracking ID echoed in webhook payloads. |
| `--poll-interval` | Poll cadence in seconds (default `2`). |
| `--max-wait` | Max poll duration in minutes (default `60`). |
## Managing renders
```bash
npx hyperframes cloud list # recent renders (--limit, --token, --all)
npx hyperframes cloud get hfr_def456 # full detail + short-lived signed video_url
npx hyperframes cloud delete hfr_def456 # soft-delete (--no-confirm to skip the prompt)
```
`video_url` and `thumbnail_url` are short-lived presigned URLs, so re-fetch with `cloud get` rather than caching them.
## Safe retries
The CLI transparently retries a `401` by force-refreshing the OAuth token and replaying. That's harmless for reads, but the zip upload (`POST /v3/assets`) is **not** idempotent: a blind retry creates a duplicate asset and bills twice. Pass `--idempotency-key` so retries are safe:
```bash
npx hyperframes cloud render . --idempotency-key "$(uuidgen)"
```
The key is forwarded to both upload and submit (the server scopes idempotency per-endpoint, so reusing one value is safe). Use any opaque string in `[A-Za-z0-9_:.-]`, 1–255 chars.
Full flag reference: docs `/deploy/cloud` and `/packages/cli#hyperframes-cloud`.
@@ -1,62 +0,0 @@
# Cloud Run rendering on Google Cloud
Use `hyperframes cloudrun` only when the user explicitly wants self-managed Google Cloud infrastructure. It deploys Cloud Run, Workflows, and Cloud Storage. For a managed default use `hyperframes cloud`; for AWS use `hyperframes lambda`.
## Prerequisites
- `gcloud` is authenticated and the target project has billing enabled.
- Terraform 1.5 or newer is on `PATH`.
- Docker or permission to use Cloud Build is available.
## Lifecycle
```bash
npx hyperframes cloudrun deploy --project <gcp-project> --region us-central1
npx hyperframes cloudrun sites create ./project
npx hyperframes cloudrun render ./project --width 1920 --height 1080 --wait
npx hyperframes cloudrun progress <execution-name>
npx hyperframes cloudrun destroy --project <gcp-project>
```
`deploy` enables the required Google APIs, builds or accepts a container image, applies the bundled Terraform module, and stores the resulting coordinates in `~/.hyperframes/cloudrun-state.json`. Use deploy flags such as `--image`, `--repo`, `--cpu`, `--memory`, `--max-instances`, and `--timeout` only when the infrastructure needs those overrides.
`sites create` uploads a content-addressed project archive for reuse by `render-batch`; pass its `--site-id` to that command to skip the batch upload. Single `cloudrun render` currently resolves the project from its directory and does not consume `--site-id`. Both render commands require `--width` and `--height`; supported output formats are `mp4`, `mov`, `png-sequence`, and `webm`. Use `--output-resolution 4k` to supersample an authored composition without changing its layout dimensions.
Common render flags are `--fps 24|30|60`, `--quality draft|standard|high`, `--codec h264|h265` for MP4, `--chunk-size`, `--max-parallel-chunks`, `--target-chunk-frames`, `--render-id`, `--output-key`, `--wait`, and `--wait-interval-ms`. Use `--json` for machine-readable output.
For a variable-driven single render:
```bash
npx hyperframes cloudrun render ./template \
--width 1920 --height 1080 \
--variables-file ./alice.json \
--strict-variables \
--wait
```
Use exactly one of `--variables` and `--variables-file`. Read [Variables](../../hyperframes/references/format.md#variables) for the composition-side contract.
## JSONL batches
```bash
npx hyperframes cloudrun render-batch ./template \
--batch ./users.jsonl \
--width 1920 --height 1080 \
--max-concurrent 10 \
--site-id <site-id> \
--json
```
Each nonblank line must contain an `outputKey`; `variables` is optional:
```json
{ "outputKey": "renders/alice.mp4", "variables": { "name": "Alice" } }
```
- `--max-concurrent` defaults to `50` and limits in-flight executions. `--max-parallel-chunks` separately limits chunks inside one render.
- `--dry-run` parses the file and prints `would-start` rows without starting executions.
- The template uploads once unless `--site-id` is supplied.
- Per-entry start errors remain visible and make the command exit nonzero.
- Do not rely on `--strict-variables` for `cloudrun render-batch`: the current command accepts the flag but does not validate batch rows. Validate the JSONL variable objects against the composition schema before dispatch. The strict gate does work for single `cloudrun render`.
`render` without `--wait` returns an execution name. Use `cloudrun progress <execution-name>` until it succeeds, then verify the reported GCS output. `destroy` removes the Terraform-managed stack and its scratch bucket; preserve any deliverables that must outlive the stack.
@@ -1,105 +0,0 @@
# Compare and batch rendering
Use these commands for deliberate visual comparison or variable-driven template output. They do not replace `lint`, `check`, final preview approval, or output verification.
## Contents
- [Compare projects or variants](#compare-projects-or-variants)
- [Compare color grades](#compare-color-grades)
- [Batch template renders](#batch-template-renders)
## Compare projects or variants
Render the same timestamp from two or more project directories or HTML files into one labeled contact sheet:
```bash
npx hyperframes compare <path-a> <path-b> [<path-c> ...] \
--at <seconds> \
--labels baseline,candidate \
--out compare.png \
--cols 2
```
Useful options:
- `--at <seconds>` selects the shared comparison time.
- `--labels <a,b,...>` labels cells in input order.
- `--out <file>` chooses the sheet path.
- `--cols <n>` controls its grid.
- `--json` returns machine-readable results.
- `--timeout <ms>` changes the per-variant render-ready timeout.
One sheet accepts at most 16 variants. Extra inputs are truncated with a warning; split larger comparisons into several runs.
`compare` is a visual review surface, not a quality gate. Run it when checking a baseline against a candidate, comparing implementation variants, or verifying that a repair preserves the intended look. Inspect the generated image; do not treat command success as visual approval.
## Compare color grades
Create grade candidates from a source frame:
```bash
npx hyperframes grade-compare \
--for frame.png \
--grades grades.json \
--project . \
--out grade-compare.png
```
`grades.json` is an array of labeled HyperFrames grading blocks:
```json
[{ "label": "warm", "grading": { "adjust": { "temperature": 0.2, "contrast": 0.1 } } }]
```
Or compare explicit LUT files:
```bash
npx hyperframes grade-compare \
--for source.mp4 \
--luts warm.cube,cool.cube \
--out grade-compare.png
```
- `--for` accepts an image or a video. For video input, the command extracts the first frame.
- Supply exactly one candidate source: `--grades <json>` or `--luts <a.cube,b.cube>`.
- A neutral baseline is included by default; pass `--no-baseline` only when the baseline is not a useful reference.
- The command accepts at most 16 candidate grades. With the default neutral baseline, the sheet may contain 17 cells. Extra candidates are truncated with a warning; split larger sets into several runs.
- `--timeout <ms>` changes the render-ready timeout for the generated comparison composition.
- Use `--json` for machine-readable output.
This command helps select a grade. It does not apply the selected grade to the composition or replace `/media-use` provenance and LUT validation.
## Batch template renders
`render --batch` accepts either a JSON array of variable objects or an object with a `rows` array:
```json
{
"rows": [
{ "name": "alpha", "headline": "Hello" },
{ "name": "beta", "headline": "Welcome" }
]
}
```
Declare the variables in the composition, then run:
```bash
npx hyperframes render \
--batch rows.json \
--output "renders/{name}.mp4" \
--batch-concurrency 1 \
--strict-variables
```
Batch rules:
- Do not combine `--batch` with `--variables` or `--variables-file`; each row is the variable set for one render.
- If `--output` is omitted, the generated filename includes `{index}` so rows remain unique.
- Output templates support `{index}` and row keys containing letters, numbers, `_`, `.`, or `-`. A placeholder value must be a string, number, or boolean; `null`, objects, and arrays are invalid. Missing placeholders and output collisions are errors.
- `--batch-concurrency` defaults to `1`. Raise it conservatively because each render already uses workers.
- `--batch-fail-fast` stops scheduling after the first failure. Without it, independent rows keep running and failures remain visible in the manifest.
- `--strict-variables` validates every row before rendering and aborts before output when the declared variable contract is violated.
- `--json` emits progress events suitable for agents and CI.
The command writes `manifest.json` in the common output directory and updates it throughout the run. It records each row's variables, status, output, error, and timing. Completion means the manifest has no failed rows and every completed output exists, is non-empty, and has a plausible duration.
@@ -1,17 +0,0 @@
# CLI conventions for agents and CI
- Prefer `--json` for agent and CI calls. Server-mode `render`, `preview`, and `play` do not provide ordinary JSON output; `preview --selection --json` and `preview --context --json` are query-mode exceptions.
- `doctor --json` always exits zero. Gate on its payload:
```bash
npx hyperframes doctor --json | jq -e '.ok' >/dev/null
```
- Non-TTY mode is automatic and scaffolds the centered blank. Pass `--example` only to start from a named example. Use `--non-interactive` to force flag-only mode on a TTY.
- Use one `HYPERFRAMES_RUN_ID` for all commands in the same verification loop.
- When disk is tight, run `npx hyperframes clean` (`--dry-run` to list first); it removes what dead renders left and idle caches that rebuild themselves, never outputs, sources or anything a running render uses. Write disposable QC frames to a temp dir, then remove only that owned directory. Managed snapshot runs retain the newest 10 plus current/latest/rendered evidence; active captures and runs younger than 24 hours are protected. Copy intentional before/after evidence outside managed storage before it ages out. Never delete `.hyperframes` wholesale: it also holds project history.
- Use `--strict`, `--strict-all`, and `--strict-variables` when the corresponding warnings, variables, or CI conditions must gate the render.
- For `shader_unbounded_cosine_pow`, clamp the cosine to `[0, 1]` before `pow(1 - cosine, exponent)`; a lower-only `max` is insufficient. Then preview and export on the affected GPU to confirm. Lint only recognizes static shader expressions, and software rendering can miss hardware-specific invalid pixels.
- JSON paths redact the home directory as `$HOME`; do not try to reverse the redaction.
- When a hosted cloud project approaches or exceeds the 200 MB upload limit, use `cloud render --dry-run --json` and follow the `.hyperframesignore` investigation in `references/cloud.md`. Never ignore an asset merely because it is large.
- Never render merely because checks pass. Pause at the final preview and wait for approval.
@@ -1,80 +0,0 @@
# doctor, browser
Environment diagnosis and bundled-Chrome management. Run these first when a render or preview fails.
## doctor
```bash
npx hyperframes doctor
npx hyperframes doctor --json # CI / agent output (always exit 0; gate on payload `ok`)
```
Runs independent checks and reports each as ok/warn/fail:
- **Version** — installed CLI vs latest on npm (hints upgrade when stale)
- **Node.js** — ≥ 22 required
- **CPU**, **Memory**, **Disk** — host resources
- **Environment** — env vars that affect the renderer
- **FFmpeg** / **FFprobe** — found, version, codecs
- **Chrome** — bundled or system, version, path
- **Docker** / **Docker running** — required only for `render --docker`
- **/dev/shm** — inside containers only
Run `doctor` first when:
- `render` fails with a Chrome or FFmpeg error.
- `preview` opens but the composition fails to load.
- A fresh machine has never run HyperFrames.
Common issues:
- **Missing FFmpeg** — install via `brew install ffmpeg` (macOS) or your package manager, or [configure existing binaries](#configure-ffmpeg-without-a-package-manager).
- **Missing bundled Chrome** — run `npx hyperframes browser ensure`.
- **Low memory** — close other Chromes, reduce `--workers`, or use `--quality draft`.
- **Chrome exits instantly inside an agent sandbox (macOS)** — seatbelt-style sandboxes
(e.g. codex `workspace-write`) block Chromium's Mach port bootstrap
(`MachPortRendezvous`; openai/codex#21292), so every Chrome — bundled, system, or
headless shell — dies at startup. This is a host-level block, not a HyperFrames or
Chrome install problem: compile checks and audio still pass, only rendering is
unavailable. State the blocker and deliver the checked composition; render outside the
sandbox or via `render --docker` / cloud rendering where available. **Do not build a
substitute rasterizer** (magick/PIL/SVG frame pipelines) — on a blocked-browser host
the deliverable IS the checked composition plus this blocker note, and rendering is
handed to `--docker`, cloud, or the user. Write your final summary the moment the
blocker is identified, BEFORE any optional fallback work: a later session failure must
not erase the report of work already done.
### Configure FFmpeg without a package manager
Get FFmpeg and FFprobe binaries for your operating system and architecture from the [FFmpeg download page](https://ffmpeg.org/download.html). Both must be executable. You can use them without Homebrew or a system-wide installation:
```bash
export HYPERFRAMES_FFMPEG_PATH="/absolute/path/to/ffmpeg"
export HYPERFRAMES_FFPROBE_PATH="/absolute/path/to/ffprobe"
npx hyperframes doctor --json
```
Set both variables in the environment that launches HyperFrames. On Windows, point them at `ffmpeg.exe` and `ffprobe.exe` using your shell's environment-variable syntax. A configured path takes precedence over automatic discovery; a missing configured file is reported as missing.
Alternatively, put the binaries in `.hyperframes/bin/ffmpeg` and `.hyperframes/bin/ffprobe` (`.exe` on Windows), then run HyperFrames from that project's directory. Discovery checks `PATH` before this project-local directory, so use the environment variables when you need a particular build.
Verify the **FFmpeg** and **FFprobe** entries in `doctor --json` report `ok: true` and the expected paths. To gate only these encoding dependencies with `jq`:
```bash
npx hyperframes doctor --json | jq -e \
'[.checks[] | select(.name == "FFmpeg" or .name == "FFprobe")] | length == 2 and all(.ok)'
```
The report's top-level `ok` also includes optional tools such as Docker and local voice providers; those failures do not require installing them for a local video render. Check Chrome separately with the browser commands below.
## browser
```bash
npx hyperframes browser ensure # find or download the pinned Chrome
npx hyperframes browser path # print the browser executable path (for scripting)
npx hyperframes browser clear # remove the cached Chrome download
```
Manage the Chrome build HyperFrames uses for rendering. The pinned version exists because pixel output drifts across Chrome versions — using the bundled build keeps rendered output reproducible across machines.
Use `path` to embed the binary in scripts: `$(npx hyperframes browser path)`.
@@ -1,14 +0,0 @@
# Feedback reports
After verifying a successful render, send one feedback report unless telemetry is disabled or the user opted out:
```bash
npx hyperframes feedback --rating <0-10> --comment "<specific result or friction>"
```
Keep clean-run feedback concise. For any bug or friction, capture a **reproduction packet** before submitting; do not send only a symptom summary. Include the rerunnable command (relative to the project directory — feedback is submitted to a public channel, so do **not** paste absolute paths, home-directory prefixes, or user/machine identifiers), expected versus actual behavior, exact error (also strip absolute paths from stack traces — keep basename + line, drop the leading directory), whether output completed/fell back/failed, workaround, and repro-project status. For a rating ≤ 7 that describes a visual defect (black frame, flicker, corrupt output, wrong frame, blank output, other visual anomaly), also include a `COMPOSITION_STRUCTURE:` block — a privacy-preserving structural anatomy (element census + attribute presence + timeline shape) so maintainers can pattern-match against known bug families without the composition ZIP. Agents auto-fill this via the composition-census helper; the human user does not fill it by hand. If the issue did not reproduce again, say so and still include the last failing command and logs. Use `--file-issue` only with consent: it publishes a minimal reproduction to a public URL. The required packet format and privacy warning live in `references/preview-render.md`.
Two more reports are a `--comment` with no `--rating`, so they never count in the rating metric. Send them unless telemetry is disabled or the user opted out, under the same privacy rules:
- **Missing feature.** Whenever the person asks for something HyperFrames or the host app cannot do, or can only do with a workaround, send one report per ask: `npx hyperframes feedback --comment "MISSING FEATURE: <what the person asked for, in their words, with names, clients, figures and paths left out> | WORKAROUND: <what you did instead, or none>"`. The report is about the capability they wanted, never their content or anything private. A catalog search that found nothing goes to `--search-miss` instead.
- **Host app.** When `HYPERFRAMES_CLIENT` is set, the CLI is running inside a host app. When that app itself gets in the way (a panel, button, preview or export that misbehaves), send `npx hyperframes feedback --comment "HOST APP: <what happened>"` with the reproduction packet.
@@ -1,78 +0,0 @@
# init, capture, skills
<!-- registry-items: allow=blank,landscape-4k,portrait-4k,square-4k,media-use -->
Scaffolding commands. Use these instead of creating files by hand — they set up the right file structure, copy media, run transcription, and install AI coding skills.
## init
```bash
npx hyperframes init my-video # centered blank (TTY: wizard)
npx hyperframes init my-video --example warm-grain # pick an example
npx hyperframes init my-video --resolution portrait
npx hyperframes init my-video --video clip.mp4 # with video file
npx hyperframes init my-video --audio track.mp3 # with audio file
npx hyperframes init my-video --tailwind # Tailwind v4 browser runtime
npx hyperframes init my-video --non-interactive # CI — flag-only, same blank
```
**Default depends on TTY**: in a terminal, the CLI prompts for example/options (default: centered blank). Outside a TTY (CI, agents, piped output) it auto-switches to non-interactive and scaffolds that blank. Pass `--example` only to start from a named example. Pass `--non-interactive` to force flag-only mode on a TTY.
Templates: `blank`, `warm-grain`, `play-mode`, `swiss-grid`, `vignelli`, `decision-tree`, `kinetic-type`, `product-promo`, `nyt-graph`. (The closed set of `hyperframes:example` items in `registry/registry.json` plus the bundled `blank` template. `hyperframes catalog` does not list examples — its `--type` takes only `block` or `component` — so this list has no live equivalent and is checked by `bun run lint:skills`.)
Other useful flags:
- `--resolution` — preset: `landscape` (1920×1080), `portrait` (1080×1920), `landscape-4k`, `portrait-4k`, `square` (1080×1080), `square-4k`. Aliases: `1080p`, `4k`, `uhd`, `1080p-square`, `4k-square`.
- `--skill=<slug>` — record the owning skill (e.g. `hyperframes`) in `hyperframes.json`, so every later render of this project — re-renders, `npm run render`, `--batch` — is attributed to it on anonymous telemetry without re-passing the flag. You rarely pass it by hand.
- `--skip-skills` — **temporarily ignored**: `init` always checks AI coding skills against GitHub while the skills.sh registry catches up. To opt out (CI/tests), set the `HYPERFRAMES_SKIP_SKILLS=1` env var instead.
- `--skip-transcribe` — don't auto-transcribe `--audio` / `--video` with Whisper.
- `--model`, `--language` — Whisper model / language for the auto-transcription.
**Tailwind** (`--tailwind`): the scaffold pins the Tailwind v4 browser runtime, not Studio's Tailwind v3 setup.
- Keep the pinned runtime; never swap in `cdn.tailwindcss.com`. Renders wait for `window.__tailwindReady` before frame 0. For offline or locked-down renders, compile to CSS and ship the stylesheet.
- Theme and custom utilities go in `<style type="text/tailwindcss">` with `@theme` and `@utility`. No v3 `@tailwind base/components/utilities`, no `tailwind.config.js`; `@config` and `@plugin` abort the browser compile.
- Write complete class tokens in the HTML. The runtime only sees classes it can scan, so never build a render-critical class name at seek time (`bg-${color}-500`); vary items through CSS variables (`style="--i: 2"`) or data variants.
- Tailwind is for static layout and style; timing stays in GSAP or another seekable runtime.
When `--audio` or `--video` is supplied, `init` transcribes the file with Whisper. For voice/model selection see the `media-use` skill.
## capture
```bash
npx hyperframes capture https://stripe.com # scaffold from a website
npx hyperframes capture https://linear.app -o linear-video # custom output directory
npx hyperframes capture https://example.com --json # JSON output for agents
npx hyperframes capture https://example.com --skip-assets # skip image/SVG download
npx hyperframes capture https://example.com --skip-vision # skip optional AI captions
npx hyperframes capture https://example.com --max-screenshots 12
npx hyperframes capture https://example.com --timeout 60000 # page-load timeout in ms
npx hyperframes capture https://example.com --capture-budget 90000 # post-navigation budget
```
Captures a live URL as an editable HyperFrames project: screenshots become layered scenes, assets are downloaded locally, and the result is a normal project you can `lint` / `preview` / `render`. Use this when the user supplies a URL as the starting point for a video.
`--timeout` bounds page navigation; `--capture-budget` is the separate cooperative budget for work
after navigation (fonts, assets, vision, and contact sheets). The latter is not a hard wall-clock
watchdog and cannot interrupt native work already in flight. An outer caller deadline is therefore a
third, distinct timeout. An outer caller timeout leaves the capture result unknown; it does not prove
HyperFrames hung or that the navigation timeout should be increased. Preserve the last phase and
classify the boundary that fired. `--skip-vision` disables only optional AI image captioning.
For agents, use `--json`. The result includes `ok`, warnings, and `lastPhase`. The command also emits
stable `HYPERFRAMES_CAPTURE_PHASE` records so a watchdog can report the last started, completed, or
degraded phase without retaining sensitive payloads.
Treat a non-zero exit, JSON `ok: false`, or an output `BLOCKED.md` as a **hard stop**. Do not render,
build, or infer brand/design data from partial files in a blocked capture. A successful capture may
degrade an optional phase within budget, but its structural output still has to be usable. Exit zero and file existence alone are not semantic success: require the current
invocation's JSON `ok: true`, no `BLOCKED.md`, and artifacts usable for the video. Run each retry
into a fresh output directory; never merge or reuse a blocked attempt's partial output.
## skills
```bash
npx hyperframes skills # install HyperFrames skills for AI coding tools
```
One-time setup that adds the HyperFrames skill pack (/hyperframes with its references, /hyperframes-cli, /hyperframes-registry, /media-use, /figma, /slideshow, /remotion-to-hyperframes) to the local AI coding environment so agents follow the framework conventions. Re-run after major HyperFrames upgrades.
@@ -1,185 +0,0 @@
# Lambda rendering on AWS
Use `hyperframes lambda` when the user explicitly wants self-managed AWS infrastructure or needs distributed rendering. It wraps `@hyperframes/aws-lambda` and AWS SAM.
## Contents
- [Choose Lambda or local rendering](#choose-lambda-or-local-rendering)
- [Prerequisites](#prerequisites)
- [Deploy](#deploy)
- [Upload a reusable site](#upload-a-reusable-site)
- [Render one composition](#render-one-composition)
- [Render a JSONL batch](#render-a-jsonl-batch)
- [Inspect progress](#inspect-progress)
- [Destroy the stack](#destroy-the-stack)
- [IAM policies](#iam-policies)
- [State, cost, and cleanup](#state-cost-and-cleanup)
The basic lifecycle is:
```bash
npx hyperframes lambda deploy
npx hyperframes lambda render ./my-project --width 1920 --height 1080 --wait
npx hyperframes lambda destroy
```
## Choose Lambda or local rendering
- **Local `render`** — dev-loop iteration, single host, anything under a few minutes at 1080p.
- **`lambda render`** — long videos, 4K, large parallel batches, or anything where local Chrome would time out / exhaust RAM. Pay-per-invocation, no idle cost.
For one-off short renders Lambda is not worth the deploy overhead.
## Prerequisites
- AWS credentials configured (env vars, `~/.aws/credentials`, SSO, or IMDS).
- AWS SAM CLI on `PATH`.
- `bun` on `PATH` (builds the Lambda handler ZIP).
## Deploy
```bash
npx hyperframes lambda deploy \
--stack-name=hyperframes-prod \
--region=us-east-1 \
--concurrency=8 \
--memory=10240
```
Builds `packages/aws-lambda/dist/handler.zip` and SAM-deploys the stack (Lambda + Step Functions + S3 + IAM). Idempotent — re-running on the same `--stack-name` is a no-op when nothing changed. Writes `<cwd>/.hyperframes/lambda-stack-<name>.json` so later subcommands don't need to call `describe-stacks`.
| Flag | Default | Description |
| ----------------- | ------------------------------- | -------------------------------------- |
| `--stack-name` | `hyperframes-default` | CloudFormation stack name |
| `--region` | `AWS_REGION` env or `us-east-1` | AWS region |
| `--profile` | `AWS_PROFILE` env | Named AWS credentials profile |
| `--concurrency` | `8` | Lambda reserved concurrency |
| `--chrome-source` | `sparticuz` | `sparticuz` or `chrome-headless-shell` |
| `--memory` | `10240` | Lambda memory in MB |
| `--skip-build` | off | Reuse existing `handler.zip` |
## Upload a reusable site
```bash
npx hyperframes lambda sites create ./my-project
# → siteId: abc1234deadbeef0 (stable across re-runs of the same tree)
npx hyperframes lambda render ./my-project --site-id=abc1234deadbeef0 ...
```
Tars + uploads `<projectDir>` to S3 with a content-addressed key. Returns a stable `siteId` you can reuse — re-renders of the same tree skip the upload.
## Render one composition
```bash
npx hyperframes lambda render ./my-project \
--width 1920 --height 1080 --fps 30 --format mp4 \
--chunk-size 240 --max-parallel-chunks 16 \
--wait
```
Starts a Step Functions execution. Returns immediately with a `renderId` unless `--wait` is set, in which case the CLI blocks until completion and streams per-chunk progress lines. Add `--json` for machine-parseable output.
| Flag | Description |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--width` / `--height` | Output dimensions in pixels |
| `--output-resolution` | Supersampling preset (engages Chrome `deviceScaleFactor`) — `landscape` / `landscape-4k` / `portrait` / `portrait-4k` / `square` / `square-4k`, plus aliases (`1080p`, `4k`, `uhd`, `hd`, `1080p-portrait`, `4k-portrait`, `1080p-square`, `4k-square`). Use this to render an authored-at-1080p composition at 4K without re-laying-out — see footgun below. |
| `--fps` | `24` / `30` / `60` |
| `--format` | `mp4` / `mov` / `png-sequence` / `webm` (default `mp4`) |
| `--codec` | `h264` / `h265` (mp4 only) |
| `--quality` | `draft` / `standard` / `high` |
| `--chunk-size` | Frames per chunk (default `240`) |
| `--max-parallel-chunks` | Max concurrent chunks (default `16`) |
| `--target-chunk-frames` | Cap frames per chunk and let the planner add chunks up to the parallel limit |
| `--site-id` | Reuse an existing site (skip upload) |
| `--execution-name` | Explicit Step Functions execution name |
| `--output-key` | Explicit final S3 object key |
| `--variables` | Inline JSON object with composition variable values |
| `--variables-file` | JSON file containing one composition variable object |
| `--strict-variables` | Fail when supplied variables are undeclared or have the wrong type |
| `--wait` | Block until completion, stream progress |
| `--wait-interval-ms` | Poll cadence while waiting (default `5000`) |
| `--json` | Machine-parseable progress snapshot |
**`--width` / `--height` footgun.** Setting `--width 3840 --height 2160` against a composition whose `data-width="1920"` silently produces 1080p — the runtime lays out the page at the composition's authored dimensions and the CLI flags are ignored for layout. To actually output at 4K, use `--output-resolution 4k` (supersamples via `deviceScaleFactor`). The CLI now prints a warning when CLI dimensions disagree with the composition's `data-width` / `data-height` and `--output-resolution` is not set; the warning is suppressed when `--json` is on or `index.html` isn't on disk (`--site-id` flows).
For variable-driven templates, declare the schema in the composition and pass either `--variables` or `--variables-file`, never both. `--strict-variables` checks local project input before any render starts. Also read [Variables](../../hyperframes/references/format.md#variables).
## Render a JSONL batch
Use `render-batch` to upload one template once and start one Step Functions execution per nonblank JSONL line:
```bash
npx hyperframes lambda render-batch ./template \
--batch ./users.jsonl \
--width 1920 --height 1080 \
--max-concurrent 10 \
--strict-variables \
--json
```
Each line must be an object with a non-empty `outputKey`. Choose unique keys to prevent outputs from overwriting one another. `variables` and `executionName` are optional:
```json
{
"outputKey": "renders/alice.mp4",
"variables": { "name": "Alice" },
"executionName": "alice-video"
}
```
Batch rules:
- The project is uploaded once unless `--site-id` reuses an earlier upload.
- `--max-concurrent` defaults to `50` and limits in-flight render executions. `--max-parallel-chunks` separately limits chunks inside each render.
- `--strict-variables` checks every entry, reports all variable issues, and aborts before AWS calls.
- `--dry-run` performs no upload or AWS render call. Every manifest row becomes `would-invoke`.
- The emitted manifest preserves input order and records `inputLine`, `outputKey`, `executionArn`, and `status` (`started`, `would-invoke`, or `failed-to-start`), plus an error when applicable.
- A per-entry start failure does not hide other rows. Human-output mode exits nonzero when a row fails to start. In `--json` mode the current CLI prints the manifest and exits zero, so gate on every row's `status`, not the process code alone. Dispatch success is not render completion; inspect each execution with `progress`.
## Inspect progress
```bash
npx hyperframes lambda progress hf-render-abcd1234
npx hyperframes lambda progress arn:aws:states:us-east-1:...:execution:...
```
Prints one snapshot — overall percent, frames rendered, Lambda invocations, accrued cost, and any errors. Accepts a bare `renderId` (resolved against the stack's state-machine ARN) or a full SFN execution ARN.
## Destroy the stack
```bash
npx hyperframes lambda destroy
```
Calls `sam delete --no-prompts` and drops the local state file. **The render S3 bucket is configured `Retain`** so it survives stack destruction — empty + delete it via the AWS console / CLI if you want the storage back.
### Non-retryable errors
A subset of failures the Step Functions state machine short-circuits instead of running through its 4× 15-min retry budget. `progress` surfaces these immediately with the error class name; do not re-issue `lambda render` blindly when you see one.
- **`ChromeBinaryUnavailableError`** — `@sparticuz/chromium` returned an empty/missing executable path. A prior chunk hit `Sandbox.Timedout` mid-extraction and the warm instance is wedged until the execution environment recycles. Remedy: bump a Lambda env var (forces a new exec env) or `lambda deploy` again. Not a transient render failure; retries will burn budget on the same wedged instance.
- **`FFMPEG_VERSION_MISMATCH`** / **`PLAN_HASH_MISMATCH`** — planner / executor version drift. Re-deploy.
## IAM policies
Print or validate the minimum IAM permissions the CLI needs.
```bash
npx hyperframes lambda policies user # inline policy for an IAM user
npx hyperframes lambda policies role # { TrustRelationship, InlinePolicy }
npx hyperframes lambda policies validate ./infra/iam/hf-deploy.json # CI gate
```
`validate` reads a JSON policy doc and checks the union of its `Effect: Allow` actions (expanding `s3:*` / `s3:Get*` / `*` wildcards) against the CLI's required action set. Missing actions print to stderr; the command exits non-zero. Wire it into CI to catch policy drift before the next deploy fails.
The default action set is deliberately broad (`Resource: "*"`) because CloudFormation creates new ARNs on every adopter's first deploy. Tighten `Resource` after that first run if security posture requires it.
## State, cost, and cleanup
`hyperframes lambda` stores per-stack metadata under `<cwd>/.hyperframes/lambda-stack-<name>.json` (bucket name, state-machine ARN, region). Not secret, but AWS-account-identifying. Commit it to a repo or `.gitignore` it per your workflow.
- `lambda destroy` removes the SAM stack but **leaves the S3 bucket** (`Retain`). Delete it manually if you want the storage back.
- Lambda billing is per-invocation + duration. `progress` reports the accrued cost.
- `--concurrency` caps parallel Lambda invocations — keep it aligned with your account quota.
- `--chunk-size` and `--max-parallel-chunks` trade off per-chunk overhead against parallelism; larger chunks reduce coordinator overhead, smaller chunks parallelize more aggressively.
@@ -1,155 +0,0 @@
# lint, check, snapshot
Use `lint` for fast static feedback while iterating. Use `check` as the required final gate: it reruns the same linter, then audits runtime, layout, motion, and contrast in one browser session. Do not chain a redundant standalone `lint` immediately before `check`. `snapshot` is the standalone utility for capturing still frames and zoomed crops. `validate`, `inspect`, and `layout` still run but are deprecated: `check` covers all of them in one invocation.
## Discipline (motion-heavy work)
When the composition is animation-driven, run the checks before you reach for `preview` or `render`:
- Run `lint` after the first HTML pass for early feedback. It is an iteration aid, not a separate final gate.
- Run `check --snapshots` at the first full pass: the overview frames and per-finding crops show you what the auditor saw.
- Look at the PNGs before tuning automated warnings: your eye catches what the auditor misses, and the auditor catches what your eye misses.
- Treat layout errors as defects unless a snapshot proves the layering is intentional, in which case mark it with `data-layout-allow-overflow` / `data-layout-allow-overlap` / `data-layout-allow-occlusion` / `data-layout-allow-caption-zone` (caption band only).
- State motion intent in a `*.motion.json` sidecar so `check` verifies it automatically (entrances firing under seek, stagger order, in-frame, liveness). This is the closest automated proxy for "watch the MP4" and catches render-vs-preview bugs the eye misses (see **Motion verification** below).
## lint
```bash
npx hyperframes lint # current directory
npx hyperframes lint ./my-project # specific project
npx hyperframes lint --verbose # info-level findings
npx hyperframes lint --json # machine-readable
```
Lints `index.html` and all files in `compositions/`. Reports errors (must fix), warnings (should fix), and info (with `--verbose`). Catches missing `data-composition-id`, overlapping tracks on the same `data-track-index`, unregistered timelines, and GSAP/CSS transform conflicts.
`<video>`/`<audio>` work at any nesting depth, including inside a `compositions/*.html` sub-composition or a wrapper `<div>`: the runtime discovers media with a flat DOM query and seeks/decodes it wherever it lives (`packages/core/src/runtime/{media,startResolver}.ts`). After a render, `snapshot` each scene that has a video and confirm the panel actually shows footage (a blank/black panel where a clip should play is a real bug, not a placeholder).
## check
```bash
npx hyperframes check # current directory: the full browser gate
npx hyperframes check ./my-project # specific project
npx hyperframes check --json # agent-readable envelope {ok, browserSkipped, lint, runtime, layout, motion, contrast, craft, hdr, snapshots}
npx hyperframes check --snapshots # also write overview frames (annotated) + per-finding crops
npx hyperframes check --samples 15 # denser timeline sweep (default 9)
npx hyperframes check --at 1.5,4,7.25 # explicit hero-frame timestamps
npx hyperframes check --at-transitions # also sample every tween start/end boundary
npx hyperframes check --tolerance 4 # allowed overflow px before reporting (default 2)
npx hyperframes check --timeout 30000 # initial render-ready + navigation minimum in ms (defaults: 3000 / 10000)
npx hyperframes check --no-contrast # skip the WCAG audit while iterating
npx hyperframes check --strict # exit non-zero on warnings too (default: only errors)
```
One command, one Chrome boot. `check` runs the linter first and skips the browser entirely when lint reports errors. When the browser never ran (lint errors, a linter crash, or a browser launch failure), `browserSkipped` is `true` and the `layout`, `motion` and `contrast` sections are empty, not clean. Otherwise it loads the bundled composition once, wires runtime listeners before navigation, and sweeps one seek grid running every audit per sample:
- **Runtime**: JavaScript console errors, unhandled exceptions, failed network requests (media-file `ERR_ABORTED` filtered out), HTTP 4xx/5xx.
- **Layout**: text extending outside its container or the canvas, text clipped by its own box, held text overlaps and occlusion (with an approximate covered fraction), children escaping clipping containers. Text under a transparent `<video>` (a `remove-background` cutout) is measured per word against the frame's alpha: partial cover is info with the share hidden, a word 80% hidden or two neighbouring words each half hidden is an error, and a frame that cannot be read falls back to opaque with a `video_alpha_unread` note.
- **Motion**: `*.motion.json` sidecar assertions against the same seeked timeline (see below).
- **Contrast**: WCAG AA on visible text, sampled at 5 grid points. Failures are **errors** and each finding carries the sampled fg/bg colors, measured vs required ratio, and a suggested compliant color in the same palette direction, so most contrast fixes need no screenshot at all.
- **Craft**: warnings only, even under `--strict` (a film may break one on purpose), measured on an 8 fps sweep of the whole film and skipped under `--range`. Each message names the fix and the reference to read.
- `line_unread`: a reading-size line (≥ 30 px tall at 1080) on screen under its floor: 0.4 s for 1–2 words, otherwise ¾ of 0.5 s + 0.3 s a word (3 words 1.05 s, 6 words 1.73 s); the film's last line needs 2 s. Edges are re-measured to ~0.01 s. Captions-track text and `data-layout-ignore` are exempt.
- `dead_stretch`: more than 3 s where the `keepsMoving` classifier sees nothing change. The final 3 s and any time a `data-hold` element is on screen are exempt; it stays quiet when `sweep_static` fires or a whole-composition `keepsMoving` assertion sets the limit.
- `text_below_floor`: text under 2.6% of the frame's short side (28 px on 1080) held 0.5 s or more, one finding per file listing every element.
- `first_frame_empty`: frame 0 paints nothing but the background (no text, image, video, canvas or svg); it is the thumbnail on social players.
- `film_silent`: a film over 15 s with no unmuted `<audio>` or audible `<video>`.
Every finding carries a selector, the element's `data-*` identity, the composition source file, a bbox, and the sample time: jump straight from the JSON to the HTML you must edit and re-run.
**Severity is persistence-aware.** A dynamic issue observed at a single grid sample (an entrance/exit transient) demotes to info and never gates. Issues held across samples gate the exit code, a held `content_overlap` is an error, and a held, partially-visible `canvas_overflow` breaching ≥5% of the canvas promotes to warning. Coordinate-frame findings (`escaped_container`, `panel_out_of_canvas`, `connector_detached`) flag geometry computed in one frame but rendered in another — an element far outside its offset parent, a painted panel stuck across the canvas edge, a connector line detached from every node. Text drawn into a `<canvas>` has no DOM box, so `canvas_overflow` cannot see it; `canvas_content_at_edge` warns when a canvas's pixels show sharp content (drawn text, hard shapes) along the frame edge — mark intentional full-bleed art (particles, photos) with `data-layout-allow-overflow`. If a 3s+ composition shows no visible change across every sample, `check` fails with `sweep_static`: a frame that never changes while a GSAP timeline or tween, or a CSS or Web animation, holds the same time without ever finishing means the seek is not driving the page, so it refuses to pass. Any other frame that never changes (a still title card, an audio-only advance) is a warning that `--strict` fails; a composition meant to be still can take `data-no-timeline` on its root to skip it. The fingerprint includes per-element opacity, so opacity-only reveals (code typing, staggered fades) count as motion — but only while they're still in flight at the sampled times. The classic trap is a reveal that completes early and then holds a static frame for the rest of the duration: every sample lands on the settled state and check warns that nothing on screen moved (`--strict` fails it). Spread the reveal across the timeline or give the hold a real next action (the cursor heading to the next control, the camera's next move); don't bolt on a slow drift, a blinking caret or a loop just to appease the check.
**Escape hatches** (mark intent in the HTML, then re-run):
- `data-layout-allow-overflow` — overflow is intentional (entrance/exit travel).
- `data-layout-allow-overlap` — deliberate text layering (e.g. a demo cursor label over a heading). Applies only to the marked text block; it is not inherited. Mark the specific layering participant, never a scene/root wrapper, so unrelated descendant collisions remain auditable.
- `data-layout-allow-occlusion` — an element is meant to cover text.
- `data-layout-allow-caption-zone` — intentional lower-third / caption-band copy under `--caption-zone`. Applies to the marked element and every descendant (`closest`); silences only `caption_zone_collision` (not overflow/overlap/occlusion). Prefer the narrowest wrapper that owns the intentional band copy.
- `data-layout-ignore` — decorative element that should never be audited.
- `data-hold` — deliberate stillness: while this element is on screen, `dead_stretch` does not count the time.
**Opt-in pipeline gates** (used by orchestrators; off by default):
```bash
npx hyperframes check --caption-zone "x0=0;y0=.82;x1=1;y1=1;severity=error;seek=.25,1"
npx hyperframes check --frame-check # media (img/svg/video/canvas) out-of-frame detection
```
`--caption-zone` takes fractional band geometry (`x0/y0/x1/y1` required, 0-1 fractions of the composition's own canvas, portrait included) with optional `severity` and comma-separated `seek` fractions; it flags a text element's DOM box (`getBoundingClientRect`) that overlaps the band. Waive intentional lower-third copy with `data-layout-allow-caption-zone` on the element or its nearest wrapper (see Escape hatches). `--frame-check` reports media elements breaching the canvas beyond `max(120px, 6% of the min canvas dimension)`.
**Fixing contrast errors** — thresholds are 4.5:1 for normal text, 3:1 for large text (24px+, or 19px+ bold). The finding's `suggestedColor` already picks the nearest compliant color in the right direction (brighten on dark backgrounds, darken on light); apply it or adjust within the palette family, then re-run `check`.
## Motion verification (`*.motion.json` sidecar)
`check` verifies **motion intent** against the same seeked timeline the renderer uses — the closest automated proxy for "render the MP4 and watch it". It catches render-vs-preview bugs layout sampling can't: an entrance reveal the seek lands past, a broken stagger order, an element drifting off-frame mid-tween, a frozen shot.
Drop a `*.motion.json` sidecar next to the composition (matching the html basename when several compositions share a dir). `check` discovers it automatically — no flag, no authoring-framework changes. With no sidecar, `check` behaves exactly as before.
```json
{
"duration": 6,
"assertions": [
{ "kind": "appearsBy", "selector": "#headline", "bySec": 0.5 },
{ "kind": "before", "a": "#headline", "b": "#cta" },
{ "kind": "staysInFrame", "selector": ".card" },
{ "kind": "keepsMoving", "withinSelector": ".scene" }
]
}
```
| Assertion | Fails (code) when |
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------- |
| `appearsBy(selector, bySec)` | not visible (opacity ≥ 0.5) by `bySec` — `motion_appears_late` |
| `before(a, b)` | `a` does not first appear strictly before `b` — `motion_out_of_order` |
| `staysInFrame(selector)` | once visible, its box leaves the canvas — `motion_off_frame` |
| `keepsMoving(withinSelector?)` | a fully-static window exceeds `maxStaticSec` (default 2s); the film's final 3 s (an end card) never count — `motion_frozen` |
`duration`, `withinSelector`, and `maxStaticSec` are optional. Findings are **errors by default** and appear in the same human and `--json` output as layout findings. A selector that matches nothing is reported as `motion_selector_missing` rather than silently passing — a typo'd selector fails loudly. Use this in the feedback loop instead of eyeballing the render: assert what the motion is supposed to do, and let `check` tell you when the seek diverges from intent.
## snapshot
```bash
npx hyperframes snapshot # 5 key frames as PNG
npx hyperframes snapshot ./my-project # specific project
npx hyperframes snapshot --frames 10 # evenly-spaced N frames
npx hyperframes snapshot --range 3:5 --frames 8 # 8 frames within root-timeline seconds 3–5
npx hyperframes snapshot --composition intro --frames 8 # 8 frames within this data-composition-id
npx hyperframes snapshot --review # beat sheet, seam strips, phone sheet
npx hyperframes snapshot --review --json # same, with paths and times as JSON
```
Still PNGs. Each run writes `.hyperframes/artifacts/snapshots/<id>/` unless `--output` is set. `snapshot` is the standalone capture; `check --snapshots` is the gate.
One of `--at`, `--range START:END` (seconds), or `--composition <data-composition-id>` (not a CSS selector). `--frames` defaults to 5. Range and composition sample just before the end and add no extra end frame; `--at` adds one unless `--no-end`.
`--review` picks the frames that show a film over time. Scenes are the composition's top-level timed clips and sub-composition hosts, as `timeline` lists them (captions and audio excluded); `--composition` scopes to one. It writes `review-beats.jpg` (each scene at its start + 0.3 s and at its middle, and each timed `data-beat="name"` element at its start + 0.3 s, labelled with time and id), `review-seam-NN.jpg` (frames at −0.2, −0.1, 0, +0.1, +0.2 s around every cut) and `review-phone.jpg` (the beat frames 360 px wide, to judge legibility at phone size), beside the `beat-*.png` and `seam-*.png` stills. With fewer than two scenes the beats spread evenly. `--json` prints `{ok, outputDir, frames[{path,time,role,label}], contactSheets, review{window, scenes, beats, seams[{time,from,to,times,paths,sheet}], sheets, notes}}`.
### Keep verification clean and inspect the actual pixels
Inspect full-resolution frames in the opening 30 seconds, each mounted scene, and every reported problem interval. Contact sheets locate moments; use close-ups to judge thin edges, glass, particles, and WebGL geometry. Check the live preview and decoded render at matching times. Canvas shader defects can survive a successful DOM/layout audit. Record the browser/GPU and capture path when results differ across machines; isolate the failing material or pass before changing rendering defaults.
Managed snapshot storage keeps the newest 10 run folders, the newest evidence for the current revision and retained rendered revisions, and the latest snapshot. Live captures and folders younger than 24 hours are also preserved. Automatic cleanup removes whole eligible old UUID folders, including contact sheets and descriptions; arbitrary `--output` folders are untouched. Save intentional before/after evidence outside this managed storage.
For disposable checks, allocate a temporary output folder and remove that folder after inspecting it. Use `hyperframes clean --dry-run` to inspect broader cleanup. `clean --snapshots` explicitly discards snapshot evidence; use it only when that evidence is no longer needed. Never recommend removing all of `.hyperframes`, which also contains project history and other state.
### Zooming into a reported finding
`hyperframes check --snapshots` already writes a `finding-NN-<code>.png` crop for every error finding that carries a bbox, but the same zoom is available standalone once you know what to look at:
```bash
npx hyperframes check --snapshots # reports a finding, e.g. content_overlap on "#cta"
npx hyperframes snapshot --zoom "#cta" # crop the element to verify the defect, at 3x density
npx hyperframes snapshot --zoom "100,50,400,300" --zoom-scale 2 # or an exact pixel region
# fix the composition HTML, then re-check:
npx hyperframes check
```
`--zoom` takes a CSS selector or an exact `x,y,w,h` pixel region and always produces a real high-density crop (a raised `deviceScaleFactor`, never CSS zoom or a viewport resize), so the composition's layout — and its render determinism — is untouched. A selector matching nothing is a loud error, not a silent full-frame fallback, and a frame where the target has no visible box (collapsed or animated off-canvas) is skipped with a note instead of written as a sliver.
## Deprecated: validate, inspect, layout
All three keep working, print a deprecation notice on stderr, and mark `_meta.deprecated: true` in `--json`. Their functionality lives in `check`:
- `validate` (runtime errors + contrast) → `check` (contrast failures are now gating errors with fix payloads, not warnings).
- `inspect` / `layout` (layout sweep + motion sidecar) → `check` (same flags: `--samples`, `--at`, `--at-transitions`, `--tolerance`, `--strict`).
Migrate scripts by replacing the sequence with the single `check` invocation; scaffolded projects' `npm run check` already points there.
@@ -1,199 +0,0 @@
# preview, play, render, publish
Serve, render, and share commands.
## preview
```bash
npx hyperframes preview # foreground on a TTY; persistent in agent shells
npx hyperframes preview --background # explicit persistent session
npx hyperframes preview --foreground --json # ready JSON, then remain attached
npx hyperframes preview --background --port 4567 # agent-safe custom port (default 3002)
npx hyperframes preview --selection --json # print the current Studio selection and exit
npx hyperframes preview --context --json # print compact agent context from Studio
```
Hot-reloads on file changes. Opens Studio in the browser automatically — the full timeline editor, where the user can play the video and edit anything by hand before rendering. This is the review surface, not just a viewer.
When handing a project back to the user, use the Studio project URL, not the source `index.html` path:
```text
http://localhost:<port>/#project/<project-name>
```
Use the actual port and project directory name; treat `index.html` as source-code context, not the preview surface. For example, after `npx hyperframes preview --background --port 3017` in `codex-openai-video`, report `http://localhost:3017/#project/codex-openai-video`.
Two ways a handed URL turns out dead — check both before handing it back: the URL is missing its `#project/<project-name>` hash (Studio loads but has no project to open), or the server is not actually running. Bare `preview` automatically creates a managed persistent session in a non-TTY agent shell; `--background` remains the clearest explicit form. Verify the printed URL returns HTTP 200, keep it alive for the whole review, and stop it explicitly with `npx hyperframes preview --stop` afterward. Use the printed URL as-is: HyperFrames URL-encodes project names that contain route metacharacters.
### Agent context from Studio selection
`preview --context` and `preview --selection` are the agent bridge into a running Studio session. They do **not** start a new server; they find the active preview server for the current project, read agent-useful state from Studio, print it, and exit.
Use it when the user gives deictic edit instructions like "change this", "move the selected element", "make the card I clicked bigger", or "fix the current selection":
```bash
npx hyperframes preview --context --json --context-fields selection
```
The compact context payload includes the selected element's source file, composition path, current timeline time, `data-hf-id` / selector target, bounding box, text content, and a thumbnail URL for the selected element. Prefer `selection.target.hfId` when present; fall back to `selection.target.selector` only when no stable `data-hf-id` exists. If `selection` is `null`, inspect `errors.selection.code` (for example, `no-selection`).
Keep agent context small by asking only for the slices you need:
```bash
npx hyperframes preview --context --json --context-fields selection
npx hyperframes preview --context --json --context-fields lint
npx hyperframes preview --context --json --context-fields selection,lint
```
Use `--context-detail full` only when the edit genuinely needs heavy selection fields such as `computedStyles`, `inlineStyles`, `dataAttributes`, or editable text-field metadata:
```bash
npx hyperframes preview --context --json --context-fields selection --context-detail full
```
`preview --selection --json` remains available when you explicitly want the full selected-element payload and do not need lint/server context.
Failure modes:
| Code | Meaning |
| -------------------------- | -------------------------------------------------------------------------- |
| `preview-not-running` | Start Studio first with `npx hyperframes preview --background`. |
| `ambiguous-preview-server` | Multiple matching Studio servers are open; rerun with one listed `--port`. |
| `preview-port-mismatch` | The requested `--port` is not one of the matching Studio servers. |
| `no-selection` | Studio is open, but the user has not selected an element yet. |
| `selection-unavailable` | The running preview server does not expose selection context cleanly. |
If there is no selection, ask the user to click the target element in Studio and rerun the command. If the server error lists candidate ports, rerun the same command with `--port <candidate>`. Do not infer the target from a screenshot when the CLI can give a stable element target.
## play (lightweight player)
```bash
npx hyperframes play # current project, port 3003
npx hyperframes play ./my-video # specific project
npx hyperframes play --port 8080 # custom port
```
`play` serves the composition through the embeddable `<hyperframes-player>` web component instead of the full Studio UI. Use it when sharing a preview link or when Studio is heavier than needed (no editor, no panels). `play` reports the plain `http://localhost:<port>` URL — no `#project/<name>` fragment (that's a Studio routing convention only `preview` uses).
The player's `playback-rate` attribute (preview speed control, drives the timeline's `timeScale`) is clamped to `[0.1, 5]`; values `≤ 0` or non-finite fall back to `1`. This is a preview/playback knob, not a composition `data-*` attribute — authored motion still renders at `1×`.
### Launching with an external browser (preview + play)
Both `preview` and `play` can open inside an explicit Chromium-compatible browser instead of the OS default. Two use cases: isolated Chromium profile, or external CDP attach (DevTools / Playwright / Puppeteer / browser-MCP). **HyperFrames itself does not own CDP automation** — this only exposes the endpoint; whatever connects to it is your problem. Not to be confused with `--browser-gpu` (a `render` flag controlling Chrome GPU access during capture).
| Flag | Type | Notes |
| ------------------------- | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--browser-path` | path | Absolute path to a Chromium-compatible executable (`/usr/bin/chromium`, `/Applications/Brave Browser.app/...`). |
| `--user-data-dir` | path | Chromium-compatible profile directory. Requires `--browser-path`. Use a throwaway directory to keep state out of your main profile. |
| `--remote-debugging-port` | integer 1-65535 | Open a Chromium CDP endpoint on the given port. **Requires both** `--browser-path` and `--user-data-dir` — refused otherwise, so a CDP endpoint cannot leak into your main profile by accident. |
```bash
# Open preview in an isolated Chromium profile
npx hyperframes preview --background --browser-path /usr/bin/chromium --user-data-dir /tmp/hf-profile
# Same plus a CDP endpoint on :9222 (attach DevTools / Playwright / etc.)
npx hyperframes play --browser-path /usr/bin/chromium --user-data-dir /tmp/hf-profile --remote-debugging-port 9222
```
Validation runs before any server boots, so an invalid value exits cleanly without leaving a listening socket behind.
## render
> Render after the user has reviewed in `preview` and approved, or when they asked for the file or to work without stopping. Passing checks alone is not a reason to render.
```bash
npx hyperframes render # standard MP4 from cwd
npx hyperframes render ./my-video --output ./out.mp4 # render from outside the project dir
npx hyperframes render --output final.mp4 # named output (no timestamp)
npx hyperframes render -c compositions/intro.html -o intro.mp4 # render a specific sub-composition file
npx hyperframes render --quality draft # fast iteration
npx hyperframes render --quality looks # first real encode (default)
npx hyperframes render --fps 60 --quality delivery # final delivery
npx hyperframes render --format webm # transparent WebM
npx hyperframes render --docker # byte-identical
```
> Default `--output` is `renders/<project-name>_<YYYY-MM-DD>_<HH-MM-SS>.<ext>` — timestamped per render so successive runs don't clobber each other. Pass `--output` to get a stable name.
| Flag | Options | Default | Notes |
| ------------------------------------ | -------------------------------------------------------------------------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `dir` (positional) | path | cwd | Project directory. Omit to use current working directory. |
| `--composition`, `-c` | path to composition file | `index.html` | Render a specific composition file (e.g. `compositions/intro.html`) instead of the project's `index.html`. |
| `--output`, `-o` | path | `renders/<project>_<ts>.<ext>` | Output path. Default is timestamped (`<project-name>_YYYY-MM-DD_HH-MM-SS.<ext>`). |
| `--fps` | 24, 30, 60 | 30 | 60fps doubles render time |
| `--quality` | draft, looks, delivery, standard, high | looks | `looks` is CRF 16 on the standard preset. `delivery` is `high`. draft for iterating |
| `--format` | mp4, webm, mov, gif, png-sequence, hls | mp4 | WebM/MOV render with transparency; gif for inline autoplay in GitHub PRs/READMEs/docs (two-pass palette encode, fps capped at 30 — prefer `--fps 15` — no audio, 1-bit transparency only, HDR falls back to SDR); png-sequence writes RGBA frames to a directory (AE/Nuke/Fusion ingest); hls writes an HLS VOD directory (master.m3u8 + video/audio playlists + MPEG-TS segments), SDR only, rejects `--gpu`, unavailable on lambda/cloudrun |
| `--gif-loop` | 0-65535 | 0 | GIF loop count; `0` loops forever. Only with `--format gif`. |
| `--hls-segment-seconds` | 1-60 | 4 | HLS target segment length in whole seconds; also locks the encoder GOP so every segment starts on a keyframe. Only with `--format hls`. |
| `--resolution` | landscape, portrait, landscape-4k, portrait-4k, square, square-4k (+ aliases `1080p`, `4k`, `uhd`) | — | Supersample via Chrome `deviceScaleFactor`. Aspect ratio must match composition; scale must be an integer. Not with `--hdr`. |
| `--crf` | 0-51 | — | Encoder CRF (lower = higher quality). Mutually exclusive with `--video-bitrate`. |
| `--video-bitrate` | e.g. `10M`, `5000k` | — | Target bitrate. Mutually exclusive with `--crf`. |
| `--hdr` | flag | off | Force HDR output even with SDR sources. MP4 only. |
| `--sdr` | flag | off | Force SDR even with HDR sources. |
| `--workers` | number or `auto` | auto | Each worker spawns Chrome (~256 MB) |
| `--docker` | flag | off | Reproducible output across hosts |
| `--gpu` | flag | off | GPU-accelerated FFmpeg encoding (NVENC / VideoToolbox / VAAPI / QSV) |
| `--browser-gpu` / `--no-browser-gpu` | flag | auto (local), off (docker) | Host GPU for Chrome/WebGL capture |
| `--browser-timeout` | seconds (0.001–86400) | 60 | Puppeteer page-navigation timeout for the entry HTML. Raise when heavy compositions (many videos / fonts / remote assets) can't reach `domcontentloaded` within the 60s default. |
| `--quiet` | flag | off | Suppress verbose output |
| `--strict` | flag | off | Fail on lint errors |
| `--strict-all` | flag | off | Fail on lint errors AND warnings |
| `--variables` | JSON object | — | Override values declared in `data-composition-variables` |
| `--variables-file` | path | — | JSON file with variable values (alternative to `--variables`) |
| `--strict-variables` | flag | off | Fail render on undeclared keys or type mismatches in `--variables` |
**Quality guidance:** `draft` while iterating, `looks` (the default) for the first real encode, `delivery` for final delivery. `standard` and `high` still work.
**Read the summary's second line.** After the output path and size, `render` prints the capture path, GPU mode, and per-stage timings, e.g. `beginframe capture · software gpu · compile 0.7s · setup 1.1s · capture 8.1s · encode 5.8s`. `capture` is the frame loop and `setup` is calibration plus session init; the capture path reads `a|b` when parallel workers diverged, and `encode (during capture)` means the streaming encoder overlapped the loop, so do not add it to the wall time. Local GPU `auto` (the default) requests BeginFrame. If the line still says `screenshot capture` with `software gpu` on Linux, a hint under it says BeginFrame did not run (needs chrome-headless-shell, no `--resolution` upscale). It only appears when the GPU was auto-probed, never under `--docker` or `--no-browser-gpu`, where software GL is the point. Heavy compositions can stall on software GL. Do not reach for `--debug` just to learn which path ran.
**Parametrized renders:** the composition declares its variables on the `<html>` root with **`data-composition-variables`** — a JSON **array of declarations** (`{id, type, label, default}` per entry) that defines the schema. Scripts inside read the resolved values via `window.__hyperframes.getVariables()`. The CLI `--variables '{"title":"Q4 Report"}'` is a JSON **object keyed by id** that overrides those declared defaults for one render; missing keys fall through, so the same composition runs unchanged in dev preview and in production. Sub-comp hosts can also override per-instance with `data-variable-values`. See `../../hyperframes/references/format.md` for the full pattern.
### feedback (report after rendering)
After a render is verified, send one feedback line per task. This is the maintainers' primary signal — a render that finishes silently tells them nothing.
```bash
npx hyperframes feedback --rating 10 # clean run, no notes
npx hyperframes feedback --rating 6 --comment "bg <video> renders grey in multi-scene; worked around with --format png-sequence"
```
`--rating` is an integer from 0-10; `--comment` is free text. A `--comment` with no `--rating` is a ratingless report (`MISSING FEATURE:` and `HOST APP:` in `SKILL.md`) that never counts in the rating metric. Feedback is anonymous and attaches a `doctorSummary` (OS/Node/CPU/mem/ffmpeg) automatically, so don't repeat those fields. A clean run needs only a short result. Before sending any bug, workaround, or confusing behavior, collect this compact reproduction packet:
```text
REPRO COMMAND: <HF_*/PRODUCER_* env> npx hyperframes <exact command> # run from the project directory; do NOT paste absolute paths
EXPECTED / ACTUAL: <expected behavior> / <observed behavior and isolated trigger>
EXACT ERROR: <verbatim error or warning; include frame/timestamp for visual defects>
OUTCOME: <output correct | output corrupt | fallback succeeded | hard exit | command hung>
WORKAROUND: <exact workaround, or none>
COMPOSITION_STRUCTURE:
elements: video=<n> audio=<n> img=<n> svg=<n> canvas=<n> subComps=<n>
attributes: <comma-joined subset of clip-path, filter, mix-blend-mode, transform, mask, position:fixed, overflow:hidden, z-index, data-has-audio, data-duration, data-start, data-composition-src, background-image:url, mask-image:url — or "(none present)">
timeline: <flat | nested (<n> sub-comps)>; driver=<gsap | data-timeline | gsap+data-timeline | none>
delta: <what differs between the working workaround-render and the broken default render>
defect: <spatial location + frame index range, e.g. top-left / frames 0-30 — omit for non-visual defects>
```
`COMPOSITION_STRUCTURE:` is a privacy-preserving structural anatomy: counts + presence flags only, no file paths, no src URLs, no user text. It lets maintainers pattern-match the report against known bug families (e.g. "sub-comp mount + clip-path", "GSAP timeline + z-index") without receiving the composition ZIP. Required for any rating ≤ 7 that describes a visual defect (black frame, flicker, corrupt output, wrong frame, blank output, other visual anomaly); optional but appreciated on higher ratings. Agents on this skill can auto-fill the block by calling `buildCompositionCensus(html)` and `renderCompositionCensusBlock(census)` from `packages/cli/src/utils/compositionCensus.ts` against the composition HTML they already have access to — the human user does not fill this out by hand.
**Feedback is submitted to a public channel — anonymize before sending.** Redact absolute paths (which leak user home directory + machine identity), any user or project names embedded in paths, secrets, and credentials. Path arguments in the command should stay relative to the project directory (`./renders/out.mp4`, not `/Users/<user>/Documents/…/out.mp4`; `.hf-tmp/`, not `/home/<user>/projects/<real-name>/.hf-tmp/`). Similarly strip absolute paths from `EXACT ERROR:` stack traces and log excerpts — keep the file basename and line number, drop the leading directory. Preserve flags and relevant `HF_*` / `PRODUCER_*` variables verbatim. If the failure no longer reproduces, include the last failing command and log excerpt (redacted the same way). Share a project link only when one is already available and safe to share.
The `hyperframes feedback` command soft-warns when a non-10 `--comment` is missing `REPRO COMMAND:`, and when a rating-≤-7 visual-defect comment is missing `COMPOSITION_STRUCTURE:`. The warnings print above the submission ack and do not block — some legitimate reports (a one-line "cloudrun quota bumped yesterday, fine now") won't fit the mold. Fix the packet and rerun to silence them.
Hit a reproducible bug? Add `--file-issue` (optionally `--dir <project>` and `--yes` for non-interactive shells) to also publish a minimal repro to a public URL and open a pre-filled GitHub `bug` issue draft for a maintainer to file. This publishes the project publicly, so it is opt-in and consent-gated; the issue is never auto-submitted.
## publish
```bash
npx hyperframes publish # upload current project privately, return stable URL
npx hyperframes publish ./my-video # specific project
npx hyperframes publish --public # allow anyone with the URL to view the claimed project
npx hyperframes publish --yes # skip the confirmation prompt (scripts/CI)
```
Uploads the project's source (HTML + assets) and returns a stable hosted URL that renders in the browser. A fresh publish is private by default and requires authentication plus access to view. Use `--public` to allow anyone with the URL to view the claimed project. Updating a project in place keeps its existing visibility: re-publishing without `--public` never turns a public project private. `--yes` only skips the confirmation prompt; it does not change visibility. A signed-out publish returns an authentication-required claim URL rather than a public playback URL. Lint findings are surfaced before upload but do not block.
## Where to render, and skill attribution
Use cloud rendering when the user wants hosted rendering without local Chrome, FFmpeg, or AWS. Use Lambda only when AWS ownership is a requirement. Use Cloud Run only when GCP ownership is a requirement. Read the matching reference before running any cloud path.
Skill attribution is automatic — the examples above need no `--skill`. A project scaffolded with `hyperframes init --skill=<slug>` records its owning skill in `hyperframes.json`, and every later render inherits it on anonymous telemetry: re-renders, `npm run render`, and `--batch` alike. Pass `--skill=<slug>` explicitly only to stamp a project that was not scaffolded with one (its first render then persists it).
@@ -1,142 +0,0 @@
# info, upgrade, compositions, timeline, docs, benchmark, telemetry, asset preprocessing
Catch-all reference for commands that don't fit the main dev loop.
## info
```bash
npx hyperframes info # project metadata
npx hyperframes info ./my-video # specific project
npx hyperframes info --json
```
Prints **project** metadata: name, resolution, duration, element counts by type, track count, and total project size. Project-level — not environment. For environment health use `doctor`.
## upgrade
```bash
npx hyperframes upgrade # check + interactive prompt
npx hyperframes upgrade --check # check and exit, no prompt (agent-friendly)
npx hyperframes upgrade --check --json # machine-readable: current / latest / updateAvailable
npx hyperframes upgrade --yes # print upgrade commands without prompting
```
Compares the installed CLI version against npm latest.
`--project [dir]` bumps a **project's** pinned scripts instead of the global install: it rewrites every `npx …hyperframes@<version>…` in `<dir>/package.json` (default cwd) to npm-latest. Always invoke it unpinned (`npx hyperframes@latest upgrade --project`) — a project scaffolded on an old CLI stays frozen otherwise. `--project . --check` reports the delta without writing; add `--json` for `{ changed, from, to, path }`. Pass the dir explicitly whenever another flag follows `--project` — on older releases a bare `--project` consumes the next flag as its directory value.
## timeline
```bash
npx hyperframes timeline [project-dir] # tracks and clips as a table with bars
npx hyperframes timeline [project-dir] --json
```
Reach for `timeline` instead of opening `index.html` and each `data-composition-src` file when you need to know what is on the timeline: which clips exist, when they start and end, what they play, and how loud. It reads the project's files statically (no browser).
Text output is `timeline <N>s`, then one block per track kind (`video`, `graphics`, `captions`, `audio`) with one row per clip of that kind, ordered by absolute start:
```
graphics (2: 1 top-level, 1 nested)
|██████ | sec-connector 0-6.7s src=compositions/connector-morph.html
|██████████████ | box 15.67-17.99s (local 0-2.32s) nested in sec-connector compositions/connector-morph.html
audio (1)
| █ | vo 1.6-3.6s src=vo.mp3 vol=0.5 group=vo volume[0:0.2 2:1]
```
- The bar is 40 columns over the whole timeline. Times are seconds.
- `src=`, `vol=`, `rate=` (playback rate, only when not 1), `group=` (audio group), and `<target>[t:v ...]` (automation lane points, `t` in seconds from the clip start) appear only when the clip has them.
- The header counts every clip of that kind, nested ones included: `video (6: 2 top-level, 4 nested)`. The list is in absolute order, so the Nth row is the Nth clip of that kind on the main timeline. A clip inside a sub-composition prints its absolute start-end, then `(local <start>-<end>s)` (time inside that sub-composition), then `nested in <host row id> <file that declares it>`. Only one level of nesting is read: a sub-composition inside a sub-composition is listed as a row saying `children=unread`, and the clips inside it are not counted.
- With no `data-duration`/`data-end`, a media row still gets a resolved length and says where it came from: `duration=media` means ffprobe measured the source (with playback start and rate applied); `duration=default` means an `img` got the 3s default; `duration=inferred` means a composition host summed its children; `pending: <reason>` (dotted bar, `duration` 0) means the source could not be probed (missing file, remote `src`, ffprobe error). A non-media leaf with nothing to resolve prints no source.
- `lanes unreadable: ...` means the clip's `data-automation` or `data-fx-chain` did not parse; fix the attribute.
`--json` prints `{ timeline: { duration, tracks: [{ kind, rows: [...] }] } }`. A track's `rows` are every clip of that kind, nested ones included, by `absStart`; read them, never only the top level. Each row has `id`, `kind` (tag), `trackKind`, `start`, `duration`, `end` (local to the row's own file), **`absStart`, `absEnd`, `file`** (main-timeline time and the project-relative file that declares the clip — use these, not `start`/`end`, to compare clips across nesting), `trackIndex`, `src`, `sourceFile`, `volume`, `lanes`, `playbackRate`, `audioGroup`, `durationAuthored`, **`durationSource`** (`"authored" | "media" | "default" | "inner" | "pending"`, or `null` for a non-media leaf with nothing to resolve), **`pendingReason`** (why nothing resolved; `null` unless `durationSource` is `"pending"`), `laneError`, **`index`** (the row's position in its kind's `rows`; a position, not an id), **`nested`** (declared inside a sub-composition), **`host`** (plain id of the row that hosts a nested clip, else `null`), **`hostRow`** (`{kind, index}` of that host row), and `children` (`{kind, index}` pointers to the sub-composition's clips, one level; every clip is already a full row in its kind's `rows`, so nothing needs following).
### Query one-liners (jq, node fallback if jq is absent)
```bash
TL=$(npx hyperframes timeline --json)
# 1. what plays at absolute time T=12.5
jq --argjson t 12.5 '[.timeline.tracks[].rows[] | select(.absStart<=$t and .absEnd>$t)]' <<<"$TL"
node -e 'const t=12.5,j=JSON.parse(require("fs").readFileSync(0,"utf8"));j.timeline.tracks.forEach(tr=>tr.rows.forEach(x=>{if(x.absStart<=t&&x.absEnd>t)console.log(x.id,x.file)}))' <<<"$TL"
# 2. find a clip by id or src -> file, track, absStart, absEnd
jq --arg q tsfx-pet2 '[.timeline.tracks[].rows[] | select(.id==$q or .src==$q)] | .[] | {file,trackKind,absStart,absEnd}' <<<"$TL"
node -e 'const q="tsfx-pet2",j=JSON.parse(require("fs").readFileSync(0,"utf8"));j.timeline.tracks.forEach(tr=>tr.rows.forEach(x=>{if(x.id===q||x.src===q)console.log(x.file,x.trackKind,x.absStart,x.absEnd)}))' <<<"$TL"
# 3. every clip of one kind, nested included, in absolute order
jq --arg k video '.timeline.tracks[] | select(.kind==$k).rows[] | {id,absStart,absEnd}' <<<"$TL"
node -e 'const k="video",j=JSON.parse(require("fs").readFileSync(0,"utf8"));j.timeline.tracks.find(t=>t.kind===k).rows.forEach(r=>console.log(r.id,r.absStart,r.absEnd))' <<<"$TL"
# 4. gaps and overlaps within a kind (positive = gap, negative = overlap)
jq --arg k video '(.timeline.tracks[] | select(.kind==$k).rows) as $r|[range(0;($r|length)-1)|{a:$r[.].id,b:$r[.+1].id,delta:($r[.+1].absStart-$r[.].absEnd)}]' <<<"$TL"
node -e 'const k="video",j=JSON.parse(require("fs").readFileSync(0,"utf8"));const r=j.timeline.tracks.find(t=>t.kind===k).rows;for(let i=0;i<r.length-1;i++)console.log(r[i].id,r[i+1].id,r[i+1].absStart-r[i].absEnd)' <<<"$TL"
# 5. the Nth clip of a kind by absolute start (N=2)
jq --arg k video --argjson n 2 '.timeline.tracks[] | select(.kind==$k).rows[$n-1] | {index,id,absStart,file,nested}' <<<"$TL"
node -e 'const k="video",n=2,j=JSON.parse(require("fs").readFileSync(0,"utf8"));const r=j.timeline.tracks.find(t=>t.kind===k).rows[n-1];console.log(r.index,r.id,r.absStart,r.file)' <<<"$TL"
```
## compositions, docs
```bash
npx hyperframes compositions # list compositions in project
npx hyperframes compositions --json
npx hyperframes docs # list available topics
npx hyperframes docs rendering # print one topic inline in the terminal
```
`compositions` lists every `data-composition-id` in the project (including sub-comps) with duration, resolution, and element count.
`docs` prints inline documentation **in the terminal** — it does not open a browser. Topics: `data-attributes`, `examples`, `rendering`, `gsap`, `troubleshooting`, `compositions`. Run without a topic to see the list.
## benchmark
```bash
npx hyperframes benchmark # run the preset matrix in current project
npx hyperframes benchmark ./my-video # specific project
npx hyperframes benchmark --runs 5 # repeat each config N times (default 3)
npx hyperframes benchmark --json
```
Renders the project with 5 preset configurations — `30fps draft 2w`, `30fps standard 2w`, `30fps high 2w`, `30fps standard 4w`, `60fps standard 4w` — and prints a comparison of render speed and output file size. Use it to find the fastest acceptable preset for your machine. Not a single-render-with-stage-breakdown.
## telemetry
```bash
npx hyperframes telemetry status # show telemetry state
npx hyperframes telemetry disable # disable anonymous usage telemetry
npx hyperframes telemetry enable # re-enable telemetry
```
Telemetry is anonymous usage counters only. Disable globally with `HYPERFRAMES_NO_TELEMETRY=1` if env-var control is preferred over the subcommand.
Events include two fingerprint properties used to distinguish managed-sandbox runs from real laptops — no PII, no env-var **values**, only existence checks:
- **`sandbox_runtime`**: `gvisor` / `firecracker` / `docker` / `kvm` / `wsl` / `null`. gVisor via kernel string + `/proc/version`. Firecracker via `/dev/vsock` + DMI sys_vendor. Docker via `/.dockerenv` + cgroup.
- **`agent_runtime`**: `claude_code` / `codex` / `cursor` / `copilot_agent` / `jules` / `replit` / `devin` / `aider` / `gemini_cli` / `hermes` / `openclaw` / `null`. Detected by the existence of well-known vendor env vars; the values themselves are never read.
- **`client`**: set only when an app launched the CLI with a `HYPERFRAMES_CLIENT` tag (`<app>/<version>/<channel>`), sent as-is when it is a short slug.
## Asset Preprocessing
```bash
npx hyperframes tts
npx hyperframes transcribe
npx hyperframes remove-background
```
These produce assets (narration audio, word-level transcripts, transparent video) that get dropped into a composition. Each may download its own model on first run.
For voice selection, Whisper model rules, output format choice, and the TTS → transcript → captions chain, invoke the `media-use` skill. This skill stays focused on the dev loop.
## Remaining harness usage
Run `npx hyperframes usage --json` at the start of a video workflow and again at milestones such as after drafting and before rendering. A fresh read at handoff can serve as the start read. Use `--harness claude-code`, `--harness codex`, or `--harness grok` to select explicitly.
Known results contain `status: "known"`, `harness`, `planTier`, `session`, `weekly`, and `spend`. Each available window contains `usedPercent`, `remainingPercent`, and `resetsAt`; an unavailable window is `null`. `planTier` is the readable subscription tier when available, otherwise `null`. Claude Code reports its shared five-hour and weekly windows; a spend-metered plan (such as Enterprise) has neither and reports `spend`, the share of its billing-period spend limit already used, with `resetsAt: null`. Codex reports its shared session and weekly windows when available. Grok reports its included weekly allowance with `session: null`. Only Claude Code reports `spend`; it is `null` elsewhere. When no window is available the result is `status: "unknown"` with `reason: "no_usage_windows"`.
Unknown results contain `status: "unknown"` and a token-free `reason`. Missing, expired, unsupported, ambiguous, or unreadable logins and unavailable provider responses return unknown. Report the unknown state without guessing allowance. The command reads existing credentials without refreshing or rewriting them and emits no tokens or telemetry. Usage is a snapshot; it does not reserve allowance or estimate the next video's cost. Keep scope and workflow choices with the user.
## Remaining harness usage
Run `npx hyperframes usage --json` at the start of a video workflow and again at milestones such as after drafting and before rendering. A fresh read at handoff can serve as the start read. Use `--harness claude-code`, `--harness codex`, or `--harness grok` to select explicitly.
Known results contain `status: "known"`, `harness`, `planTier`, `session`, `weekly`, and `spend`. Each available window contains `usedPercent`, `remainingPercent`, and `resetsAt`; an unavailable window is `null`. `planTier` is the readable subscription tier when available, otherwise `null`. Claude Code reports its shared five-hour and weekly windows; a spend-metered plan (such as Enterprise) has neither and reports `spend`, the share of its billing-period spend limit already used, with `resetsAt: null`. Codex reports its shared session and weekly windows when available. Grok reports its included weekly allowance with `session: null`. Only Claude Code reports `spend`; it is `null` elsewhere. When no window is available the result is `status: "unknown"` with `reason: "no_usage_windows"`.
Unknown results contain `status: "unknown"` and a token-free `reason`. Missing, expired, unsupported, ambiguous, or unreadable logins and unavailable provider responses return unknown. Report the unknown state without guessing allowance. The command reads existing credentials without refreshing or rewriting them and emits no tokens or telemetry. Usage is a snapshot; it does not reserve allowance or estimate the next video's cost. Keep scope and workflow choices with the user.
@@ -1,56 +0,0 @@
---
name: hyperframes-registry
description: About 450 ready-made HyperFrames blocks and components installed as editable source - transitions, overlays, titles, kinetic type, captions, charts, map overlays, 3D, camera moves. Use it before hand-building any named visual (CRT scanlines, glitch, film grain, a chart); also for hyperframes add and catalog.
---
**Plugin installs:** Before setup or freshness commands, follow [plugin execution rules](../hyperframes/references/plugin-installation.md) when this skill is inside a HyperFrames plugin.
# HyperFrames registry
Starting from an item is faster and usually better than writing the effect yourself, and an installed item is ordinary source in your project: change it, cut it down, restyle it.
## Find and install
```bash
npx hyperframes catalog --query "<what the beat should do, in English>" --json
npx hyperframes add <name> --json # prints the written files and the mount snippet
```
- Query in English even when the video is not: the catalog is indexed in English, and another script returns `No searchable words in query`, which is not a gap.
- Search is local and sends nothing. The default `words` tier only finds items that reuse your words; `--on-device` ranks by meaning after a one-time ~33 MB download. Offer that download and let the person decide; never enable it silently. The `--json` envelope names the tier that answered.
- Narrow with `--type block|component` and `--tag <tag>`. `add <tag>` installs every block with that tag; dependencies install first. `add` needs the network even for an item installed before; search works offline from the cached manifest.
- Nothing fits: report it before you hand-build, on either tier. `catalog --query` prints the line pre-filled (`report_gap` in `--json`): `npx hyperframes feedback --search-miss "<query>" --wanted "<the move you needed>" --tier <tier>`. Describe the effect, not an item name you imagined.
Tiers, `dropped` and `unindexed`, stale indexes, offline behaviour: `references/search.md`.
## What's in it
| Blocks: a scene with its own timeline | Examples |
| --- | --- |
| Transitions | `beat-freeze-cut`, `chromatic-radial-split`, `cinematic-zoom` |
| Overlays and lower thirds | `camcorder-hud`, `editorial-flash-overlay`, `lt-clean-bar` |
| Title cards and kinetic type | `canopy-part-title`, `code-slice-hero` |
| Social, chat and UI cards | `ai-chat-reveal`, `chatgpt-exchange` |
| Code, charts, data and maps | `code-diff`, `bar-chart-race`, `data-chart` |
| 3D, shader and backgrounds | `app-showcase`, `aurora`, `blob` |
| Components: a snippet merged into your scene | Examples |
| --- | --- |
| Motion primitives and text effects | `agent-cursor`, `blur-in`, `bottom-up-letters` |
| Caption styles | `caption-clip-wipe`, `caption-editorial-emphasis` |
| Camera moves | `camera-shake`, `camera-scan-gate` |
| Product demo and UI props | `before-after-wipe`, `browser-device-stage` |
| Textures, particles and effects | `ascii-render-pass`, `grain-overlay` |
## Wiring
- **Block**: installs to `compositions/<name>.html`. Mount it with `data-composition-src`, `data-composition-id` matching the block's own id, `data-start` and the `data-duration` you need; the snippet `add` prints is the starting point. `references/wiring-blocks.md`.
- **Component**: installs to `compositions/components/<name>.html`. Copy its markup, `<style>` and script into your scene, and follow its comment block if it hooks into your timeline. `references/wiring-components.md`.
- Take the largest unit that fits, then edit it. Keep an installed item's ids prefixed as they are, so two items can't collide.
- An item keeps its own variables; set them for one mount with `data-variable-values` on its host (`add --vars '{"title":"…"}'` writes that), and point its colour and font variables at your brand's.
- Beat-synced blocks take hit times from `npx hyperframes beats --json`: the beat times minus the mount's `data-start`.
- Install paths come from `hyperframes.json` (`references/install-locations.md`).
## Contributing
Authoring a new block or component for an upstream PR: `references/contributing.md`, starter templates in `references/templates.md`, quality bar in `references/component-quality-bar.md`.
@@ -1,51 +0,0 @@
# Worked Example: Adding a Block
## Scenario
User has an existing HyperFrames project and wants to add an animated chart alongside their video content.
## Steps
### 1. Install the block
```bash
hyperframes add data-chart
```
### 2. Wire into index.html
```html
<div id="stage" data-composition-id="main" data-width="1920" data-height="1080" data-duration="30">
<video
id="speaker"
src="speaker.mp4"
data-start="0"
data-duration="30"
data-track-index="0"
style="position: absolute; width: 60%; height: 100%; left: 0; top: 0; object-fit: cover;"
></video>
<!-- Data chart appears at 5s in the right 40% of the screen -->
<div
data-composition-id="data-chart"
data-composition-src="compositions/data-chart.html"
data-start="5"
data-duration="15"
data-track-index="1"
data-width="1920"
data-height="1080"
style="position: absolute; right: 0; top: 0; width: 40%; height: 100%;"
></div>
</div>
```
### 3. Lint and preview
```bash
hyperframes lint
hyperframes preview
```
### 4. Customize (optional)
Edit `compositions/data-chart.html` — data arrays are at the top of the script, colors are in the CSS rules scoped under `[data-composition-id="data-chart"]`.
@@ -1,73 +0,0 @@
# Worked Example: Adding a Component
## Scenario
User wants to add a shimmer light sweep effect to their title text.
## Steps
### 1. Install the component
```bash
hyperframes add shimmer-sweep
```
### 2. Read the snippet
Open `compositions/components/shimmer-sweep.html` and read the comment header.
### 3. Wire into your composition
**HTML** — wrap target elements:
```html
<div class="shimmer-sweep-target" style="--shimmer-color: rgba(255, 255, 255, 0.5)">
<h1 class="title">AI-Powered Video</h1>
</div>
```
**CSS** — paste the `.shimmer-sweep-target` and `.shimmer-mask` rules from the snippet.
**JS** — paste the auto-injection script (before timeline code):
```js
document.querySelectorAll(".shimmer-sweep-target").forEach((el) => {
if (!el.querySelector(".shimmer-mask")) {
const mask = document.createElement("div");
mask.className = "shimmer-mask";
el.appendChild(mask);
}
});
```
**Timeline** — add the sweep:
```js
tl.fromTo(
".shimmer-sweep-target",
{
"--shimmer-pos": "-20%",
},
{
"--shimmer-pos": "120%",
duration: 1.2,
ease: "power2.inOut",
stagger: 0.15,
},
1.5,
);
```
### 4. Lint and preview
```bash
hyperframes lint
hyperframes preview
```
### 5. Customize
- `--shimmer-color`: highlight color per element
- `--shimmer-width`: light band width (default 20%)
- `--shimmer-angle`: sweep direction (default 120deg)
- Timeline `duration`, `ease`, `stagger`: control speed and feel
@@ -1,250 +0,0 @@
# Component quality bar
What a catalog component has to be for us to keep shipping it. Every criterion here comes from a
defect found and verified on this branch, not from taste.
A registry component is a motion primitive an author installs into their own composition and
ships. The catalog page is marketing; the installed file is the product. Every criterion below is
therefore evaluated against **the item's own `<name>.html`, mounted alone**, never against
`demo.html` and never against the catalog page, because both of those carry scaffolding the author
does not receive.
Read this before auditing, scoring or cutting anything. It exists so several people auditing in
parallel reach the same verdict on the same item.
## The one rule
> An item earns its place when the file the author installs, mounted by itself on the ground it
> was designed for, renders the subject its name promises and moves the way its description says.
> Anything that fails that and cannot be fixed into something no other item already does is cut.
## How an audit runs
Two passes, in this order. The mechanical pass is free and runs across every item in seconds; the
visual pass costs a browser and eyes, so it is spent only on what the mechanical pass could not
decide. A mechanical signal is a **candidate**, never a verdict.
| Pass | Cost | Decides |
| -------------- | -------------- | ---------------------------------------------------------------------------------------- |
| **Mechanical** | grep and hash | duplicates, missing timeline, banned hexes, empty markup, name gaps, unbounded variables |
| **Visual** | render and eye | renders at all, implements its description, legible, deterministic |
`hyperframes check` is not a visual gate. It passes compositions that render nothing: a blank plot
produces no error, no warning and no layout finding, because an empty render is a valid render. No
criterion below may rest on `check` alone.
### The mount harness
Three ways to get a false verdict from a working item, all of them the harness's fault. Build the
shell like this or the audit invents defects.
1. **Two shapes of item, two ways to mount.** If the file, with HTML comments stripped, contains a
`data-composition-id`, it is a sub-composition: mount it with
`data-composition-src="./<name>.html"` on a clip. If it does not, it is a snippet: paste it
inline inside a `class="clip"` div. Inlining a sub-composition nests a document in a document
and renders black, which reads exactly like a dead item.
2. **Use the item's own ground.** Take the background off its `demo.html` body rule. A snippet
whose ink defaults to `#18181b` is a 16:1 headline on its own `#f7f7f8` and an invisible 1.5:1
smudge on a dark stage. The stage is not evidence.
3. **Load GSAP and register a paused root timeline**, then snapshot with
`hyperframes snapshot . --at 0.05,1.2,2.5,4.0 --no-end` and read the contact sheet.
An item whose own `data-duration` is shorter than the shell's will be blank in the last frames.
That is arithmetic, not a defect.
## Fatal, cut the item
Fatal means there is nothing worth keeping underneath the defect: no edit short of writing a
different item fixes it, or the fix produces something the catalog already ships. Cite the named
evidence; a fatal verdict without it does not count.
**F1. Does not implement its own description.** The markup contains no trace of the subject the
item is named and described for. Not "renders badly", but "the thing is absent from the file".
`ecosystem-constellation`, `hero-device-assemble` and `terminal-to-browser-deploy` are the same
file holding empty card divs with different headings.
_Check:_ read the markup, then swap the name for any other item's name. If nothing in the file
would have to change, the name is a label on a generic shell.
_Evidence:_ the named subject has no element (no nodes in a constellation, no terminal in a
terminal deploy).
**F2. Redundant duplicate.** Same **motion fingerprint** and same **markup skeleton** as another
item that survives. Fingerprint is the gsap call list with selectors neutralised, keeping props,
durations and eases; skeleton is the tag sequence with classes and text stripped. One wipe
currently ships eight times with the same properties, durations and easings; one word-stagger
ships seven times.
_Evidence:_ both hashes match a sibling, and the sibling wins the tie-break below.
**F3. Renders nothing.** Frames are blank, or the named subject never appears, with the item
mounted correctly on its own ground and its recipe applied.
_Evidence:_ four blank frames plus the cause, in the item rather than the harness: a missing
sibling asset, a `ReferenceError` in the console, a subject that never enters the viewport. A
frame-capture artifact that renders correctly live is a false alarm, so confirm on a real page
before recording it.
**F4. The description is a different item.** The frames show the promised event never happening: a
wipe that never reveals its second panel, a chart that draws no series. Not a wording gap.
**F5. Cannot be made seekable.** Frame N genuinely depends on frame N-1 with no closed form and no
bounded replay, and making it seekable would make it a different effect. Rare. Most accumulators
have a trivial rewrite, so reach for this only after establishing there is none; a seeded,
index-derived replacement for `Math.random()` is X7, not F5.
_Evidence:_ two snapshots of the same timestamp reached by different seek paths differ.
## Fixable, keep and repair
Real defects, but the item has a reason to exist that nothing else covers and the repair is
bounded. Log the specific fix, never "needs polish".
**X1. No timeline of its own.** No `__timelines` registration, so the installed artifact renders a
still frame while the catalog page looks fine, because the generator transplants the demo's
timeline into the preview. 97 of the 213 new components are in this state.
_Repair:_ fold the trailing `Timeline integration:` recipe into a real `<script>` that builds a
paused timeline and registers it. Roughly 10 to 15 minutes for a single-element item.
_Escalates to fatal_ only when there is no motion anywhere to fold in, which usually means F1 too.
**X2. Name claims a technique the code lacks.** Grep the code region, never the doc header: the
header's prose is full of the exact words you are looking for, and will report a match on an item
that has none.
| Name pattern | Must contain |
| -------------------------------- | -------------------------------------------------- |
| `spring-*` | `elastic`, `back.`, `bounce` or a custom spring ease |
| `mask-*`, `*-mask*` | `mask` or `clip-path` |
| `frosted*`, `*glass*` | `backdrop-filter` |
| `*3d*`, `*depth*`, `*orbit*`, `*camera*` | `perspective`, `rotateX`, `rotateY`, `translateZ` |
| `*-draw`, `*-trace`, `*stroke*` | `stroke-dash` or `pathLength` |
_Repair:_ add the technique, or rename the item. Renaming is often the honest fix.
**X3. Illegible.** At 1920x1080 on its own ground: text under 4.5:1, or a subject whose smallest
meaningful feature is under about 24px.
_Repair:_ one value step, per `placeholder-material.md`. Text never sits below L1.
**X4. Placeholder gradients.** The purple and blue palette standing in for content.
_Repair:_ the monochrome ramp. Already done across the catalog, so a new instance is a regression,
not a legacy defect.
**X5. Hardcoded ink, no theme token.** A literal colour on the item's own text or subject with no
`var(--...)` fallback chain, so it disappears when an author drops it on the opposite theme.
_Repair:_ route it through the theme token with the literal as fallback.
**X6. No markup of its own.** The file is a `<style>` and a `<script>` and nothing else, so
mounting it renders an empty box.
_Repair:_ ship sample markup, or declare it an attachment snippet in `registry-item.json` and give
the demo a host element.
**X7. Unseeded randomness.** Scatter derived from `Math.random()` rather than the element index.
_Repair:_ derive from the index.
**X8. Declared bounds it cannot honour.** A number variable with no `min`/`max`, so the control
offers values the item cannot express, or a default it can never return to.
_Repair:_ declare real bounds, or use a numeric field instead of a slider.
## Duplicates, which one survives
A group is the set of items sharing both hashes from F2. Exactly one survives, chosen in order:
1. **The one whose name describes what the shared motion actually does.** A group where one member
is a directional wipe and the rest borrowed it keeps the directional wipe.
2. **Then the one with subject-specific markup.** More elements that only make sense for that
name, not more elements.
3. **Then the one already on `origin/main`.** Removing a shipped item breaks installs.
4. **Never a member whose name claims something the shared implementation does not do.**
`frosted-glass-wipe` has no `backdrop-filter`, `spring-scale-in` has no spring, `mask-reveal-up`
has no mask, so none of those three is the survivor. Such a member is F2 and X2 at once, and X2
cannot be fixed without breaking the group. If no member is honest, keep the plainest name.
**If every member of a group fails F1, the group is cut entire.** Do not preserve a survivor to
soften the count. Twelve names on one empty card shell is one bad item, not twelve, and keeping one
of them keeps the bad item.
Same motion with genuinely different subjects is not a duplicate. A bar chart, a line chart and a
dashboard populate can share a stagger; the subject is the item.
## Never cut
Protection is per criterion, not blanket. A protected item still answers every other row.
**N1. Load-bearing colour** is exempt from X4 only. `chromatic-aberration-wipe` (the RGB split is
the effect), `confetti` (multi-hue is the celebration), `matrix-decode` (green is its identity),
`mesh-gradient-bg` (the gradient is the subject), `multi-device-splay`. `us-map`'s gradient is a
sequential choropleth scale, which is colour carrying data.
**N2. Real product depiction** is exempt from X4 and F1. A Figma logo inside a Figma mock is not
slop; the HyperFrames wordmark in `logo-brand-close` is the subject. Judge the placeholder content,
not the depicted product.
**N3. Deliberate static** is exempt from X1. An item whose description promises no motion is not
failing X1. A style snippet or a passive overlay is allowed to sit still.
**N4. Environment sets** are exempt from F3. An item that is a backdrop rather than a shot is not
failing F3 for being calm. Measured PSNR between frames separates the two: sets score 45 or higher,
things that genuinely run score 17 to 24. Judge against the description.
**N5. A rest state that is the recipe's `from` state** is exempt from F3. `confetti` ships
`.particle` spans sitting at opacity 0 until the timeline fans them out. Still is not dead.
**N6. Attachment snippets** are exempt from F3. A text splitter has no markup by design. Grade X6.
**N7. The 36 items already on `origin/main`** are out of audit scope.
## Mechanical first pass
| Signal | How | Maps to |
| ------------------- | ------------------------------------------------------------------------------------- | ------- |
| No timeline | `grep -L __timelines` over each composition | X1 |
| Duplicate | motion fingerprint AND markup skeleton hashes, matched pairwise | F2 |
| Empty shell | markup skeleton matches an unrelated item, or is `<h3>` + `<p>` + generic panels only | F1 |
| No markup at all | element count of the comment-, style- and script-stripped file is 0 | X6 |
| Placeholder palette | grep the six banned hexes | X4 |
| Name gap | the X2 table, grepped over the code region only | X2 |
| Unbounded number | read `min`/`max` in `registry-item.json` | X8 |
| Non-determinism | grep `Math.random`, `Date.now`, `performance.now`, `requestAnimationFrame` | X7, F5 |
## Visual pass, required for a verdict
Render at least four frames across the duration from the **composition**, not the demo, mounted per
the harness rules above, and look at them. Then, for anything not scoring clean, confirm on the
real catalog page before recording it.
Record per item: the criteria it fails, the evidence you saw, and fatal or fixable. An unviewed
item is not a pass.
## Calibration
Ten items scored with this rubric, frames rendered and looked at. Three of them corrected the
rubric rather than the other way round.
| Item | Expected | Frames actually showed | Verdict |
| ---------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------ |
| `ecosystem-constellation` | fatal | Sidebar with three pill buttons and three empty white panels. No nodes, no edges. Identical at all four timestamps. | **Cut** F1, F2, F4 |
| `terminal-to-browser-deploy` | fatal | Pixel-identical to the above, only the `<h3>` and one subtitle differ. No terminal, no browser. | **Cut** F1, F2, F4 |
| `frosted-glass-wipe` | fatal | One card reading "Before", static, forever; the "After" panel stays clipped. Recipe byte-identical to `directional-wipe`. | **Cut** F2, F4, X2 |
| `char-slam-explode` | pass | Letters scattered at 0.05s, assembled into "Impact" by 1.2s, held. Real per-character motion. | **Keep** |
| `echo-trail` | pass | Card travels left to right with a decaying blurred echo trail behind it. Legible on its own light ground. | **Keep** |
| `logo-brand-close` | pass | "H" resolves into the full wordmark, tagline and URL land after it. Staged, legible. | **Keep** |
| `blur-in` | borderline | Still, but the still is the correct rest state: legible headline, unique implementation, theme-token ink. | **Keep**, X1 |
| `spring-scale-in` | borderline | Legible on its own `#f7f7f8`. Ease is `power3.out`, no spring anywhere. Shares its recipe with six others. | **Cut** F2, X2 |
| `confetti` | borderline | Card and mesh, no particles visible. Source ships `.particle` spans the recipe fans out from opacity 0. | **Keep**, X1 |
| `bottom-up-letters` | borderline | Four blank frames. The file is a splitter with no markup at all. | **Keep**, X6 |
Three corrections the calibration forced, all of them false cuts:
- `spring-scale-in` first scored X3 at roughly 1.5:1. That was the harness's dark stage, not the
item. Hence "use the item's own ground".
- `confetti` first scored F3. Its rest state is its recipe's `from` state. Hence N5.
- `char-slam-explode` first scored F3 with four black frames. It is a sub-composition and was being
inlined. Hence the two-shapes rule.
A rubric that cuts a working item is wrong even when the verdict is convenient.
## Checklist
Per item, in order. Stop at the first fatal.
- [ ] Mounted the right way for its shape, on its own ground, with GSAP and a paused root.
- [ ] F1: the markup contains the named subject.
- [ ] F2: motion fingerprint and markup skeleton are not both shared with a survivor.
- [ ] F3: it renders something with the recipe applied, or it is protected by N3 to N6.
- [ ] F4: the frames show the event the description promises.
- [ ] F5: frame N is computed from N.
- [ ] X1 through X8 logged with the specific fix.
- [ ] Verdict cites the evidence the criterion names, not an impression.
@@ -1,180 +0,0 @@
# Contributing a Block or Component to the Registry
Guide the user from idea to merged PR for a new registry block or component.
## Workflow
```
1. Clarify → 2. Scaffold → 3. Build → 4. Validate → 5. Preview → 6. Ship
```
### Step 1: Clarify
Ask what they're building. The registry has two item types:
- **Block** (`registry/blocks/`, type `hyperframes:block`) — a full standalone composition with fixed dimensions and duration. Caption styles, VFX effects, title cards, lower thirds.
- **Component** (`registry/components/`, type `hyperframes:component`) — a reusable snippet with no fixed dimensions or duration. CSS effects, text treatments, overlays that adapt to any composition size.
Then ask:
- One-sentence description of the effect
- Visual reference (URL, screenshot, or description)
- Who uses this and when?
### Step 2: Scaffold
Create the registry structure:
**For blocks:**
```
registry/blocks/{block-name}/
{block-name}.html
registry-item.json
```
**For components:**
```
registry/components/{component-name}/
{component-name}.html
registry-item.json
```
**Naming convention:**
| Item name | ID prefix | Example IDs |
| ---------------- | --------- | ---------------------- |
| `cap-hormozi` | `hz` | `hz-cg-0`, `hz-cw-3` |
| `cap-typewriter` | `tw` | `tw-cg-0`, `tw-ch-0-5` |
| `vfx-chrome` | `vc` | `vc-canvas` |
Use a 2-3 letter prefix. ALL element IDs must use this prefix to avoid collisions in sub-compositions.
**registry-item.json** — use the canonical templates in [templates.md](templates.md) (block and component variants, both with all required fields).
### Step 3: Build
Apply the correct template based on type. See [templates.md](templates.md) for copy-paste starters.
#### Caption blocks
**Non-negotiable caption rules:**
- Font: **96px minimum** for proportional fonts. **64-72px acceptable for monospace** (wider characters need less size).
- Readability: `-webkit-text-stroke: 2-3px` OR multi-layer `text-shadow`
- Overflow: call `window.__hyperframes.fitTextFontSize()` on every group
- Karaoke: highlight active word via `tl.to(wordEl, { color/scale }, WORDS[wi].start)`
- Hard kill: `tl.set(groupEl, { opacity: 0, visibility: "hidden" }, g.end)` on EVERY group
- **Never use `tl.from(el, { opacity: 0 })` at the same position as `tl.set(el, { opacity: 1 })`** — the from clobbers the set. Use `tl.to` instead.
**Per-character animation** (typewriter, scramble):
- Wrap each character in `<span>` with ID `{prefix}-ch-{group}-{char}`
- Stagger via `tl.set` at computed intervals from word timestamps
- Cursors/decorative elements: use `tl.set` at intervals — NOT CSS animation (not seekable)
**Positioning variants:**
- Centered: `display: flex; align-items: center; justify-content: center;`
- Lower-third: `position: absolute; bottom: 100px; left: 0; width: 100%; text-align: center;`
- Left-aligned: `position: absolute; bottom: 100px; left: 120px; text-align: left;`
#### VFX blocks (Three.js)
- Use `three@0.147.0` from CDN (global script)
- `tl.eventCallback("onUpdate", renderScene); renderScene();` — NO requestAnimationFrame
- State proxy pattern: GSAP animates plain JS object, render function reads it
- Seeded PRNG (`mulberry32`) for randomness
#### All types
- **Load files the block ships through `__hyperframes.assetUrl("assets/...")`** whenever a script
builds the path (textures, fonts, JSON, models). Attributes like `src` and `href` are rebased
when the block is mounted; strings in a script are not, so a bare `"assets/..."` 404s once the
block lives under `compositions/<name>/`. A separate `.js` file gets the URLs from the block's
inline script. A block whose assets install to the project's own `assets/` (target
`compositions/<name>.html`) keeps plain project-root paths; `assetUrl` would look beside the file.
- **Placeholder content is monochrome** — read
[placeholder-material.md](placeholder-material.md) before choosing a single colour. Stand-in
screens, images, cards, avatars, logos and chart series use four alpha steps of the
composition's ink; accent marks one element and never a placeholder.
- `data-composition-id` MUST match `window.__timelines["id"]`
- All element IDs prefixed with block abbreviation
- `gsap.timeline({ paused: true })` — always paused
- No `Math.random()`, no `Date.now()`
### Step 4: Validate
```bash
hyperframes lint # 0 errors required
hyperframes check --no-contrast # 0 console errors required
```
### Step 5: Preview
```bash
# Render preview video
hyperframes render -o preview.mp4
# Snapshot for visual QA
hyperframes snapshot --at "1.0,3.0,5.0,7.0"
# Publish to hyperframes.dev for review
npx hyperframes publish
```
**Catalog preview image** — For the default PNG preview, save your snapshot at `docs/images/catalog/{kind}/{name}.png` in the repository checkout (`{kind}` is `blocks` or `components`). After upload, the catalog serves it from `https://static.heygen.ai/hyperframes-oss/docs/images/catalog/{kind}/{name}.png`. If `registry-item.json` declares `preview`, the card uses its `poster` URL; a `preview` without `poster` has no image fallback.
- **HeyGen internal contributors:** run `scripts/upload-docs-images.sh` from the repository root (requires AWS profile `engineering-767398024897`)
- **External contributors:** attach the preview MP4 to your PR description. A maintainer will generate and upload the catalog image before merging.
### Step 6: Ship
**All steps are required. Missing any one produces a broken catalog entry.**
`{kind}` is `blocks` or `components` depending on what you built in Step 1.
```bash
# 1. Create branch
git checkout -b feat/registry-{name}
# 2. Format HTML
npx oxfmt registry/{kind}/{name}/*.html
# 3. Regenerate registry/registry.json from the item directories.
# Do not hand-edit it: an entry added by hand survives until the next
# regeneration and then vanishes, and one left behind for a directory that
# no longer exists is worse, because `hyperframes add <name>` resolves the
# name and then fails on missing files.
npx tsx scripts/generate-registry-items.ts
# 4. Generate catalog docs page
npx tsx scripts/generate-catalog-pages.ts
# 5. Publish to hyperframes.dev so reviewers can preview
npx hyperframes publish
# 6. Stage everything
git add registry/{kind}/{name}/ registry/registry.json docs/catalog/
# 7. Commit
git commit -m "feat(registry): add {name} — {one sentence}"
# 8. Push and open PR with hyperframes.dev link
git push origin feat/registry-{name}
gh pr create --title "feat(registry): {name}" --body "preview: {hyperframes.dev-url}"
```
**If you don't have a GitHub account:** you need one to open a PR. Sign up at https://github.com/signup, then run `gh auth login`.
## Quality Gate
- [ ] `hyperframes lint` → 0 errors
- [ ] `hyperframes check` → 0 console errors
- [ ] `npx oxfmt --check` passes
- [ ] `registry/registry.json` updated with new entry
- [ ] `scripts/generate-catalog-pages.ts` run (docs page generated)
- [ ] `npx hyperframes publish` run (claim your project URL)
- [ ] Preview MP4 attached to PR (external) or catalog PNG uploaded (internal)
- [ ] All IDs unique and prefixed
@@ -1,54 +0,0 @@
# The demo.html Convention
## Why components ship demo.html
Every component in the registry ships a companion `demo.html` file alongside its snippet. The demo serves two purposes:
1. **Preview fixture** — the CI preview pipeline renders the demo to generate thumbnail images and preview videos for the catalog docs page.
2. **Usage example** — the demo shows the component effect applied to representative content, serving as a working reference.
## Demo structure
A demo is a complete, standalone HTML composition:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Component Name — Demo</title>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
/* reset + canvas size */
</style>
</head>
<body>
<div data-composition-id="<name>-demo" data-width="1920" data-height="1080" data-duration="N">
<!-- Demo content showing the effect -->
<!-- Component snippet inlined here -->
</div>
<script>
// GSAP timeline demonstrating the effect
window.__timelines = window.__timelines || {};
window.__timelines["<name>-demo"] = tl;
</script>
</body>
</html>
```
Key conventions:
- `data-composition-id` is `<component-name>-demo` to avoid collisions
- The demo is self-contained — all CSS and JS from the snippet is inlined
- The GSAP timeline is registered on `window.__timelines`
- Duration should be long enough to showcase the effect (typically 5-8 seconds)
## Blocks don't need demo.html
Blocks are already standalone compositions that can be rendered directly. Only components need the demo wrapper.
## Demos are not installed
The `demo.html` is NOT installed by `hyperframes add` — it exists only in the registry for preview generation and as a reference.
@@ -1,217 +0,0 @@
# Registry discovery
<!-- registry-items: allow=data-composition-id,data-start,data-track-index -->
## Use the catalog command first
```bash
npx hyperframes catalog
npx hyperframes catalog --type block
npx hyperframes catalog --type component
npx hyperframes catalog --type block --tag social
npx hyperframes catalog --json
npx hyperframes catalog --human-friendly
```
- Default output is a readable table. It does not install anything.
- `--type` accepts `block` or `component`; `--tag` may narrow either result.
- `--json` is the deterministic agent and CI surface. Select a name, then run `npx hyperframes add <name>`.
- `--human-friendly` opens a picker and installs the selected item immediately.
## Read the registry manifest as a fallback
When the CLI is unavailable, the top-level `registry.json` lists all available items:
```bash
curl -s https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry/registry.json
```
Each entry has `name` and `type` (`hyperframes:example`, `hyperframes:block`, or `hyperframes:component`).
## Reading an item's manifest
Each item has a `registry-item.json` with full metadata:
```
<base>/<type-dir>/<name>/registry-item.json
```
Where `<type-dir>` is `examples`, `blocks`, or `components`.
## Item manifest fields
| Field | Type | Required | Description |
| ---------------------- | -------- | -------- | ---------------------------------------------- |
| `name` | string | yes | Kebab-case identifier |
| `type` | string | yes | `hyperframes:block` or `hyperframes:component` |
| `title` | string | yes | Human-readable title |
| `description` | string | yes | One-line description |
| `tags` | string[] | no | Filter tags (e.g., `["data", "chart"]`) |
| `dimensions` | object | blocks | `{ width, height }` — blocks only |
| `duration` | number | blocks | Duration in seconds — blocks only |
| `files` | array | yes | Files to install (`path`, `target`, `type`) |
| `registryDependencies` | string[] | no | Other registry items this depends on |
## Available items
### Blocks
For an always-current list run `npx hyperframes catalog --type block`. **The search is the source of truth; the tables below are a hand-maintained sample and under-cover by design** — they group 97 of the registry's 180 blocks by category, so a block missing from them is not evidence the registry lacks it. Only an empty `npx hyperframes catalog --query "<what you want>" --json` is that evidence. Do not grow these tables toward the full set; search instead. **Block name ≠ shader name**: shader-transition blocks (e.g. `domain-warp-dissolve`) wrap a HyperShader runtime whose internal name omits the `-dissolve`/`-warp` suffix — see the showcase HTML installed alongside the block for the canonical name.
#### Shader transitions (14)
Single-shader blocks; each installs one HyperShader runtime + a showcase composition. Use ≤2 per video.
| Name | Description |
| ------------------------ | ------------------------------------------------------------------------ |
| `chromatic-radial-split` | Chromatic aberration radial split |
| `cinematic-zoom` | Dramatic zoom blur |
| `cross-warp-morph` | Cross-warped morphing |
| `domain-warp-dissolve` | Fractal noise domain warping |
| `flash-through-white` | White flash crossfade (rarely a neutral default — see SKILL.md guidance) |
| `glitch` | Digital glitch artifacts |
| `gravitational-lens` | Gravitational lensing distortion |
| `light-leak` | Cinematic light leak overlay |
| `ridged-burn` | Ridged turbulence burn |
| `ripple-waves` | Concentric ripple wave distortion |
| `sdf-iris` | Signed-distance-field iris reveal |
| `swirl-vortex` | Swirling vortex distortion |
| `thermal-distortion` | Heat-haze thermal distortion |
| `whip-pan` | Fast camera whip-pan |
#### Transition galleries (13)
Showcase compositions grouping multiple CSS / GSAP transition styles by family. Use as reference for picking a CSS scene transition; not meant to embed as-is.
| Name | Description |
| ------------------------- | ----------------------------------- |
| `transitions-3d` | 3D perspective flip and rotate |
| `transitions-blur` | Blur-based scene transitions |
| `transitions-cover` | Cover / uncover slide |
| `transitions-destruction` | Destructive break-apart |
| `transitions-dissolve` | Dissolve and fade |
| `transitions-distortion` | Warp and distortion |
| `transitions-grid` | Grid-based tile |
| `transitions-light` | Light-based glow and flash |
| `transitions-mechanical` | Mechanical shutter and iris |
| `transitions-other` | Misc creative (VHS, gravity, morph) |
| `transitions-push` | Push and slide |
| `transitions-radial` | Radial wipe and reveal |
| `transitions-scale` | Scale and zoom |
#### Liquid Glass (7)
WebGPU + html-in-canvas frosted-glass surfaces. **Require Brave / Chrome canary** with WebGPU enabled — set `PRODUCER_HEADLESS_SHELL_PATH` to point at the browser; engine auto-passes `--enable-unsafe-webgpu`. See `/hyperframes` → `references/runtimes.md`.
| Name | Description |
| --------------------------- | --------------------------------------------------------------------------------------- |
| `ios26-liquid-glass` | 3D iPhone (GLTF) + iOS 26 home screen, glass app icons, shader wallpaper, notifications |
| `macos-tahoe-liquid-glass` | 3D MacBook (GLTF) + macOS Tahoe-style desktop, glass menu bar, Finder, dock |
| `liquid-glass-widgets` | Frosted stat cards, showcase panel, pill chips over aurora shader |
| `liquid-glass-notification` | Frosted notification cards floating over aurora shader |
| `vfx-liquid-glass` | Bare VFX composition shell for liquid-glass effects |
#### VFX (6)
HTML-in-canvas + WebGL composition blocks. See `/hyperframes` → `references/runtimes.md` for the underlying runtimes.
| Name | Description |
| ----------------------- | ------------------------------------------------------------------------------------------------------- |
| `vfx-iphone-device` | GLTF iPhone 15 Pro Max + MacBook Pro with live HTML-in-canvas screens, glass-lens morph, 360° turntable |
| `vfx-liquid-background` | Organic liquid sim — vertex displacement on subdivided plane, HTML floats above |
| `vfx-magnetic` | VFX shell (magnetic field-line treatment) |
| `vfx-portal` | VFX shell (portal reveal) |
| `vfx-shatter` | VFX shell (shatter into fragments) |
| `vfx-text-cursor` | Cursor glow + chromatic shadow rays + spectral edges on a black stage |
#### Showcases (6)
Story-driven showcase compositions — narrated YouTube-style inserts. Most include bundled SFX.
| Name | Description |
| -------------------------- | ----------------------------------------------------------------- |
| `app-showcase` | Three floating smartphone screens, fitness app product showcase |
| `apple-money-count` | Counter $0 → $10,000, green flash, money-icon burst, SFX |
| `blue-sweater-intro-video` | Warm AI-creator intro resolving into an X follow card |
| `north-korea-locked-down` | Map zoom with red scribble circle, locked-down pop-up label |
| `nyc-paris-flight` | Map animation, plane NYC → Paris, marker circle, landing pop, SFX |
| `vpn-youtube-spot` | App-store scroll, VPN install flow, SFX |
#### Maps + data viz (8)
D3 + GSAP animated geographies and charts.
| Name | Description |
| ---------------------------------- | -------------------------------------------------------------------------------------------------- |
| `us-map` | US choropleth, staggered state reveals, value labels, gradient legend |
| `us-map-bubble` | US bubble map — proportional city markers, callouts, connection lines |
| `us-map-flow` | US flow map — animated origin-destination arcs |
| `us-map-hex` | US hex-grid map — each state as equal-weight hex with data fill |
| `spain-map` | Spain choropleth by autonomous community — D3 conic conformal |
| `world-map` | World choropleth + rotating globe inset, D3 Natural Earth |
| `data-chart` | Animated bar + line chart, staggered reveal, NYT-style typography |
| `flowchart` / `flowchart-vertical` | Decision tree, SVG connectors, sticky-note nodes, cursor + typing correction (vertical = portrait) |
#### Social overlays (7)
Platform-recognizable UI overlays. Stamp on top of a beat or use as a beat closer.
| Name | Description |
| -------------------- | ------------------------------------------------ |
| `instagram-follow` | Profile card + follow button |
| `tiktok-follow` | Profile card + follow button |
| `yt-lower-third` | YouTube subscribe lower third with avatar |
| `x-post` | X/Twitter post card with engagement metrics |
| `reddit-post` | Post card with upvotes and comments |
| `spotify-card` | Now-playing card with album art and progress bar |
| `macos-notification` | macOS-style banner with app icon and message |
#### Branding + 3D UI (2)
| Name | Description |
| -------------- | ------------------------------------------------------------------- |
| `logo-outro` | Piece-by-piece logo assembly, glow bloom, tagline fade-in, URL pill |
| `ui-3d-reveal` | Perspective 3D reveal for UI elements |
#### Code snippets (24)
A code/terminal window that **types a code or shell session per-character**. Theme = visual chrome only; structure, wiring, and install are identical across all 24 — pick one by name, wire it like any block (`data-composition-id` + `data-start` + `data-track-index`, see `wiring-blocks.md`). Two chrome families:
**Apple Terminal (12)** — macOS Terminal.app window typing a shell session. Profile = window colors:
| Name | Look |
| --------------------------------- | -------------------------------- |
| `code-snippet-apple-terminal-pro` | Black bg, grey text, lime cursor |
#### Code Animations (9)
The richer, motion-first counterpart to the static `code-snippet-*` window themes above: each is a self-contained 1920×1080 block (~5–8s) with a paused, deterministic GSAP timeline that _animates_ code — typing, diffing, morphing, spotlighting, or GPU hero reveals — rather than typing a fixed snippet inside editor/terminal chrome. **Reuse-first**: `npx hyperframes add <name>`, then customize the baked code/diff content in place; hand-author only when no block covers the motion you need.
**DOM / text reveal (6):**
| Name | Description |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `code-typing` | Token-streamed typing reveal, caret tracks the frontier (no CSS animation) — live-coding on screen |
| `code-diff` | An edit shown as a colored diff: removed lines collapse red, added expand green — before/after at line level |
| `code-morph` | One snippet transforms into another, tokens glide between positions (Shiki Magic Move) — a refactor / one state to another |
| `code-highlight` | A highlight band sweeps a target line while surrounding context dims — spotlight one line |
| `code-scroll` | Camera scrolls a long file to center + spotlight a target line — walk through a real module |
**GPU / WebGL hero reveals (3):** heavier, for a title-card / hero code moment.
| Name | Description |
| ------------------------ | --------------------------------------------------------------------------------------------------------------- |
| `code-3d-extrude` | Syntax-highlighted code on a lit beveled 3D slab that rotates through real space and settles (true WebGL depth) |
| `code-shader-dissolve` | Code resolves out of seeded noise with a chromatic dissolve front + edge glow, then holds crisp |
| `code-particle-assemble` | Thousands of GPU points fly to the exact glyph pixels and resolve into readable syntax-highlighted code |
### Components
| Name | Description | Tags |
| -------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------ |
| `grain-overlay` | Animated film grain texture overlay | texture, grain, overlay, film |
| `shimmer-sweep` | CSS gradient light sweep for AI accents | text, shimmer, highlight, effect |
| `morph-text` | Gooey text morph cycling an editable word list (SVG threshold + GSAP blur) | text, text-effect, typography, morph, gooey |
| `grid-pixelate-wipe` | Grid dissolve transition between scenes | transition, wipe, grid, pixelate |
| `parallax-zoom` | Center card scales up to fill the frame while siblings parallax outward (single `--pz-progress` 0→1) | transition, zoom, parallax, grid, hero |
| `parallax-unzoom` | Reverse of `parallax-zoom` — focus card shrinks from full frame as siblings parallax in (`--pu-progress`) | transition, reveal, unzoom, parallax, grid, hero |
@@ -1,45 +0,0 @@
# Install Locations
## Default paths
| Item type | Default install path | Configured by |
| --------- | ------------------------------------- | ----------------------------------- |
| Block | `compositions/<name>.html` | `hyperframes.json#paths.blocks` |
| Component | `compositions/components/<name>.html` | `hyperframes.json#paths.components` |
## How path remapping works
The `target` field in each item's `registry-item.json` specifies a default install path. The `add` command remaps the prefix based on `hyperframes.json#paths`:
- Block targets starting with `compositions/` get remapped to `<paths.blocks>/`
- Component targets starting with `compositions/components/` get remapped to `<paths.components>/`
## hyperframes.json
Created automatically by `hyperframes init`. If it doesn't exist when you run `add`, the CLI creates it with defaults:
```json
{
"$schema": "https://hyperframes.heygen.com/schema/hyperframes.json",
"registry": "https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry",
"paths": {
"blocks": "compositions",
"components": "compositions/components",
"assets": "assets"
}
}
```
## Custom layouts
To install blocks into a `scenes/` directory instead of `compositions/`:
```json
{
"paths": {
"blocks": "scenes"
}
}
```
Then `hyperframes add data-chart` writes to `scenes/data-chart.html` instead of `compositions/data-chart.html`. The snippet output reflects the remapped path.
@@ -1,149 +0,0 @@
# Placeholder material
Registry items ship with stand-in content: a rectangle where the user's screenshot goes, a
bar where their number goes, a chip where their teammate's face goes. That stand-in is
**placeholder material**, and it is monochrome. No hue, no colour ramps, no purple-to-blue
diagonal fill.
The rule exists because placeholder colour is always arbitrary. A violet card, a blue logo
tile and a rainbow avatar row assert a brand the composition does not have; across 250 items
the same three arbitrary hues become the catalog's identity instead of the author's. Value
says everything colour was saying here, and says it truthfully.
## The one rule
> Placeholder content carries no hue. It is built from four alpha steps of the composition's
> own ink over the composition's own ground, plus a hairline. Accent marks one element per
> composition, and never a placeholder.
## The ramp
Four fill steps and a hairline. All of them are the ink at an alpha, so the ramp inverts for
free on dark themes and needs no second table.
| Step | Ink alpha | What it is for |
| ------ | --------- | ---------------------------------------------------------------------------- |
| `ink` | 100% | Real content: headline, number, label. Not placeholder. |
| `L1` | 72% | The subject. Chart bars, a filled logo mark, a play glyph, a front avatar. |
| `L2` | 45% | Support. A comparison series, avatars behind the front one, secondary icons. |
| `L3` | 18% | Media fill and skeleton text lines. "Content lives here." |
| `L4` | 8% | Recessed plate interiors. The tray a card sits in. |
| `hair` | 14% | Every 1px boundary. Replaces the separation a gradient edge was doing. |
Write them the way the file already writes colour:
```css
/* theme-token primitives (--fg / --surface available) */
background: color-mix(in srgb, var(--fg, #f8fafc) 18%, transparent);
/* fixed-palette ports (local --hf-ink, no theme tokens) */
background: rgba(17, 24, 39, 0.18);
```
**Four steps, not eight.** Grays go muddy when adjacent steps sit close together, so every
step is at least 1.6x the alpha of the one below it. Reaching for a fifth value is the signal
that the layout, not the palette, needs the work. Snap to the nearest step instead.
Contrast, measured against both shipped grounds (`neutral` light `#fcfcfd`, `bold` dark
`#1b1230`): L1 is 7.7:1 / 8.9:1, L2 is 3.1:1 / 4.4:1, L3 and L4 are below 3:1 by design.
Therefore **text never goes below L1**, and L2 is the floor for any graphic a viewer has to
compare (a chart series, a state indicator). L3 and L4 are for surfaces only.
## Depth without colour
In priority order. Reach for the first one that works.
1. **Value step.** Nothing sits on the same step as its ground. One full step of separation
minimum. This alone resolves most stacking.
2. **Hairline.** `1px` of `hair` on every plate boundary. A border separates two surfaces more
cleanly than a gradient ever did, and survives video compression that eats a soft edge.
3. **Negative space.** Padding is elevation. A plate with `--space-2` inside it reads as raised
without any fill difference at all.
4. **Texture, at the finest grain only.** A `repeating-linear-gradient` at `L3`-and-below,
period 2-4px, achromatic. This is the one thing value cannot say: a flat gray rectangle
reads as an empty box, the same rectangle finely hatched reads as a surface with content on
it. Use it to mark media, nothing else.
Shadows are allowed and must be achromatic and diffuse: `0 Npx 3Npx rgba(0,0,0,0.18)`. Never a
coloured glow; a glow is an accent wearing a shadow's clothes.
## Reading each kind in black and white
A gray rectangle has to still say _screen_ and not _image_. Shape, aspect, glyph and texture
carry the meaning that hue was carrying badly.
| Kind | Signature |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| Screen / device | Device aspect + `--radius` + `hair` + scanline texture at L3 + inset vignette. The chrome is the signal; the fill is L3 and stays quiet. |
| Video footage | Screen signature plus a centred play triangle in a ring at L1. The glyph is what makes it video rather than a static screen. |
| Image | 4:3 or 3:2 plate at L3 with a horizon-and-disc glyph at L2, offset toward a corner, never centred. Offset is what reads as "photo". |
| Card | Plate at L4, `hair` border, two skeleton rules at L3 (100% and 62% width). Two rules of unequal length is the whole idiom. |
| Avatar / person | **Circle** at L2 with initials knocked out in the ground colour. Stacks differ by step (L1 front, L2, L3), never by hue. |
| Logo / brand | **Rounded square** at L1 with the mark knocked out in the ground colour. Every mark on the same step: a logo wall means "many, equal". |
| Chart series | Flat fills. Primary series L1, comparison series L2, gridlines `hair`. Never a gradient inside a bar. |
| Text block | Rounded rules at L3, widths 100 / 86 / 62%. The ragged right edge is what reads as prose. |
Circle means person, rounded square means app or brand. Keeping those two shapes distinct is
what lets both live at the same value step without ambiguity.
Why no gradient inside a chart bar: a vertical light-to-dark fill makes every bar lighter at
its top, so the tallest bar reads _palest_ exactly where the eye lands to compare heights. The
decoration contradicts the data. Flat fills are both plainer and more honest.
## Accent
The accent enum stays. `green` rides `--brand`, `blue` rides `--accent`, `violet` rides
`--accent-2`, exactly as before, and every declared `accent` or `tone` variable keeps working
unchanged. What changes is where accent is allowed to land:
> Accent marks at most **one** element per composition, and only where the accent _is_ the
> meaning: the selected tier, the current step, the figure a count-up lands on, the ink of a
> stroke being drawn. Placeholder content is never accent-coloured.
Fixed-palette ports have no theme tokens, so they get the accent through their own
variable with a neutral fallback:
```css
/* was: --hf-accent: #2563eb; */
--hf-accent: var(--accent, #18181b);
```
Unthemed, the item renders in pure black and white. Drop it into a themed composition and
the author's accent lands on that one element and nowhere else. Where the accent is chosen by
an enum, the lookup table maps to tokens, never to hex: `green` to `var(--brand, <neutral>)`,
`blue` to `var(--accent, <neutral>)`, `violet` to `var(--accent-2, <neutral>)`. The enum's
declared options and default are unchanged, so no mount breaks.
"One element" means one role, not one node: an app mark repeated on three devices of the same
mock is still one element.
Killing accent outright was the alternative and it is worse. Accent is already load-bearing in
exactly the cases where it is correct, and it is the only hook an author has for their own
brand; removing it would push those cases into value tricks for a job one hue does better with
one element. The failure was never that accent existed, it was that accent had become the
default fill for every placeholder. Capping it at one element fixes the failure with no
variable migration.
## Gradients that stay
The ban is on **hue-carrying fills**, not on the CSS function. These are material, not
placeholder, and flattening them breaks the thing the item exists to do:
- **Physical surfaces** — device bezels, brushed metal, glass, a screen's inner vignette.
- **Sheens and sweeps** — `linear-gradient(90deg, transparent, rgba(255,255,255,0.75), transparent)`
driven across an element. That is motion, not decoration.
- **Scrims and vignettes** — `rgba(0,0,0,α)` ramps that buy text contrast over media.
- **Effects whose subject is the gradient** — aurora, liquid glass, chromatic aberration,
grain fields, shader transitions. The gradient is the product.
All four are achromatic or physically motivated. If a gradient is neither, it is placeholder
slop and it goes.
## Checklist
- [ ] No `#7c3aed`, `#2563eb`, `#6366f1`, `#8b5cf6`, `#a855f7`, `#4f46e5` anywhere in the item.
- [ ] Every placeholder fill is `ink` at 72 / 45 / 18 / 8 / 14 percent, and nothing else.
- [ ] At most one accent-coloured element, and it means something.
- [ ] Text sits at L1 or above; L3 and L4 carry no text.
- [ ] Every remaining gradient is a surface, a sweep, a scrim, or the effect itself.
- [ ] `hyperframes check` passes, and a rendered frame was looked at.
@@ -1,67 +0,0 @@
# Searching the catalog
How `hyperframes catalog` ranks, what its `--json` envelope means, offline behaviour, and reporting a miss.
Use the CLI as the primary discovery surface. **Search by intent before browsing:** the registry holds more items than you can scan by eye, so listing them and matching on names or tags is the slow path, and it fails whenever the author's wording differs from yours.
```bash
# Rank the whole catalog against what the beat should do
npx hyperframes catalog --query "reveal a headline one line at a time"
npx hyperframes add caption-clip-wipe
```
Search is local and sends nothing. By default it ranks on vocabulary shared with the item's name, title and description, so it only finds items that reuse your words; `--on-device` ranks by meaning instead, after a one-time model download. With `--json` the envelope names which tier answered, so check that rather than assuming a ranking happened.
**Always query in English, whatever language the video is in.** The catalog is written in English and both tiers index it that way (the on-device model is English-only too). A query in another script produces no searchable terms and returns nothing at all. This is easy to get wrong on a Japanese or Chinese project, where the brief, the captions and the narration are all in that language and the query naturally follows: describe the _move_ in English, then write the on-screen copy in whatever language the video needs. If a query does come back with `No searchable words in query`, that is this rule, not a missing component, and it is not worth a gap report.
Installability is applied after ranking, not before it: a name the vectors carry but this registry cannot serve is dropped from the results and counted in `dropped`, so a non-zero `dropped` means the two are different generations. The offline tier, the consent gate and refreshing a stale index are under "Search tiers" below.
To browse or filter instead of search:
```bash
npx hyperframes catalog
npx hyperframes catalog --type block
npx hyperframes catalog --type component
npx hyperframes catalog --type block --tag social
npx hyperframes catalog --json
npx hyperframes catalog --human-friendly
```
The normal table and `--json` modes only list matches; install a selected name with `hyperframes add <name>`. `--human-friendly` opens an interactive picker and installs the selected item immediately. In CI or agent workflows, prefer `--json` followed by an explicit `add`.
### Report what the catalog does not have
When the search comes back and nothing in it does the job, say so before you hand-author the move:
```bash
npx hyperframes feedback --search-miss "<the query you ran>" --wanted "<the move you needed>" --tier <the tier that answered>
```
`catalog --query` prints this line for you, pre-filled, and `--json` carries it as `report_gap` — so it is already in hand at the moment you decide nothing fits.
**Report whenever nothing in the results does the job, on either tier.** Do not wait for the on-device tier to have answered: it needs a consented 33 MB download, so an agent run is on `words` unless it explicitly opted in, and gating on `on-device` would silence almost every report. The `--tier` value rides along so a vocabulary miss stays distinguishable from a meaning miss when these are read. Describe the effect you wanted, not the item name you imagined: what comes back is a list of moves worth building, and a report naming a non-existent item teaches nothing. This is the only path that sends a query anywhere, which is exactly why it is a separate deliberate command rather than something the search does on its own. It carries no rating and never lands in the rating metric.
This is the whole demand signal for the catalog. Skipping it means the gap you hit gets guessed at from install counts instead, which cannot see a move nobody could install.
If the CLI cannot reach the configured registry, inspect the raw manifest as a fallback:
```bash
curl -s https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry/registry.json
```
A registry the CLI cannot reach does **not** empty the catalog for **discovery**: a previously fetched manifest keeps serving past its 24h refresh window whenever revalidation fails, so `catalog` and `catalog --query` still list and rank against the last copy on disk.
**`add` still needs the network, even for an item you installed yesterday.** Only manifests are cached; the item's actual files are fetched on every install. So offline you can search, and you can see what an item is, but installing it fails at the file fetch. Do not promise a user an offline install.
Each item's `registry-item.json` contains: name, type, title, description, tags, dimensions (blocks only), duration (blocks only), and file list.
See [discovery.md](./discovery.md) for details on filtering by type and tags.
## Search tiers
- **Search the catalog before writing motion by hand.** `npx hyperframes catalog --query "<the beat, in plain English>"`. Search is entirely local: there is no hosted tier, no account, and the query text is never sent anywhere. By default it ranks on vocabulary shared with the item's name, title, description and tags, which misses any phrasing that does not reuse the catalog's own wording. Add `--on-device` to rank by meaning instead (see the offline tier below).
- **Query in English even when the video is not.** Both tiers index an English catalog, so a query in another script produces no searchable terms and returns nothing. Describe the move in English; the on-screen copy stays in whatever language the video needs. `No searchable words in query` means exactly this and is not a missing component, so do not report it as a catalog gap.
- **Read which tier answered; never infer it from results appearing.** With `--json` the envelope carries `query`, `tier` (`on-device` or `words`), `tier_detail`, `dropped`, `unindexed`, `shown`, `total` and `results`, plus `top_score` when the answering tier produces one and `warnings` when a tier was asked for and could not run, or when a search returned nothing and a better tier is still waiting on someone's consent. A weak result on `words` is expected; the same result on `on-device` is a bug. `top_score` is on-device only and has no threshold behind it: the ranker returns the whole catalog in some order for every query, so read it as evidence rather than as a pass or fail.
- **`dropped` and `unindexed` are opposite skews between the registry and the on-device index, and rewording the query fixes neither.** `dropped` counts ranked names this registry cannot install, so the strongest matches are the ones being lost. `unindexed` counts registry moves the index cannot see at all, which no query can ever return. Refreshing the registry is not the answer to either: its manifest carries a 24h TTL and heals itself, while the vectors are a separately published artifact fetched into `~/.hyperframes/catalog/`. Re-running with `--on-device` refetches that index when `unindexed` is above zero, so that is the remedy to hand the user. A pure over-coverage skew (`dropped` above zero while `unindexed` is zero) does not trigger the refetch; clearing `~/.hyperframes/catalog/` is the only way out of that one. Both counts are of names rather than of results, so either can exceed `total`.
- **When a search comes back with nothing worth installing, say so.** `npx hyperframes feedback --search-miss "<the query you ran>" --wanted "<the move you needed>" --tier <the tier that answered>`. You do not have to assemble that line: `catalog --query` prints it pre-filled, and every `--json` search envelope carries it as `report_gap` with the query and tier already correct — fill in `--wanted` and send. This is the only path that sends a query anywhere, and it is a separate deliberate command precisely so plain `catalog --query` keeps its promise of sending nothing. **Report on either tier**, whenever the results do not do the thing; do not hold out for the on-device tier, which needs a consented 33 MB download and is therefore off in most agent runs — waiting for it means never reporting at all. The tier rides along in the report, so a vocabulary miss stays distinguishable from a meaning miss without you having to judge which one you hit. What comes back is a list of moves the catalog does not have yet, read directly rather than guessed from install counts, so the phrasing that matters is the effect you wanted, not the item name you imagined. It carries no rating and never lands in the rating metric.
- **Offer the offline tier; never enable it silently.** A one-time ~33 MB download (a quantized ONNX build of `bge-small-en-v1.5` plus its tokenizer, pinned to a fixed revision) and the catalog vectors from the registry, both cached under `~/.hyperframes/`, neither added to the project or any package. Once cached it ranks by meaning with nothing sent. Say the size out loud and let the person decide, then pass `--on-device` (with `-y` to skip the prompt) once they agree. The interactive offer only fires on a TTY. Under `--json` there is no prompt, but a search that found nothing puts the same ask in `warnings`, so read that array and put the decision to the user yourself. When the person asks what the download is, why this model, or what leaves the machine, point them to https://hyperframes.heygen.com/developers/catalog-search.
@@ -1,417 +0,0 @@
# Contribute Templates
Copy-paste starter templates for each component type. These embed the proven patterns that pass `lint` and `check`.
## Caption Template
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link
href="https://fonts.googleapis.com/css2?family=Montserrat:wght@800;900&display=swap"
rel="stylesheet"
/>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #111;
overflow: hidden;
}
#root-BLOCKNAME {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
background: #111;
}
.cap-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.cg {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
gap: 32px;
max-width: 1700px;
overflow: visible;
opacity: 0;
visibility: hidden;
}
.cw {
font-family: "Montserrat", sans-serif;
font-weight: 900;
font-size: 128px;
color: #ffffff;
text-transform: uppercase;
line-height: 1;
display: inline-block;
-webkit-text-stroke: 3px rgba(0, 0, 0, 0.8);
paint-order: stroke fill;
text-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
}
</style>
</head>
<body>
<div
id="root-BLOCKNAME"
data-composition-id="BLOCKNAME"
data-start="0"
data-duration="9"
data-width="1920"
data-height="1080"
>
<div class="cap-container" id="cc-BLOCKNAME"></div>
<div
id="drv-BLOCKNAME"
class="clip"
data-start="0"
data-duration="9"
data-track-index="0"
style="position:absolute;width:1px;height:1px;opacity:0;pointer-events:none"
></div>
</div>
<script>
(function () {
window.__timelines = window.__timelines || {};
// REPLACE with actual transcript data
var WORDS = [
{ text: "Welcome", start: 0.3, end: 0.65 },
{ text: "to", start: 0.65, end: 0.8 },
{ text: "the", start: 0.8, end: 0.95 },
{ text: "future", start: 0.95, end: 1.4 },
// ... add all words
];
var GROUPS = [
{ start: 0.3, end: 1.3, wordStart: 0, wordEnd: 3, text: "Welcome to the future" },
// ... add all groups
];
var container = document.getElementById("cc-BLOCKNAME");
GROUPS.forEach(function (g, gi) {
var groupEl = document.createElement("div");
groupEl.id = "PREFIX-cg-" + gi;
groupEl.className = "cg";
for (var wi = g.wordStart; wi <= g.wordEnd; wi++) {
var wordEl = document.createElement("span");
wordEl.id = "PREFIX-cw-" + wi;
wordEl.className = "cw";
wordEl.textContent = WORDS[wi].text;
groupEl.appendChild(wordEl);
}
// Pretext overflow prevention
if (window.__hyperframes && window.__hyperframes.fitTextFontSize) {
var _fit = window.__hyperframes.fitTextFontSize(g.text.toUpperCase(), {
fontFamily: "Montserrat",
fontWeight: 900,
maxWidth: 1550,
baseFontSize: 128,
minFontSize: 48,
});
if (_fit.fontSize < 128) {
for (var _fi = 0; _fi < groupEl.children.length; _fi++) {
groupEl.children[_fi].style.fontSize = _fit.fontSize + "px";
}
}
}
container.appendChild(groupEl);
});
var tl = gsap.timeline({ paused: true });
GROUPS.forEach(function (g, gi) {
var groupEl = document.getElementById("PREFIX-cg-" + gi);
// SHOW — set opacity to 1 (never use tl.from with opacity:0 here)
tl.set(groupEl, { opacity: 1, visibility: "visible" }, g.start);
// ENTRANCE — customize this per style
tl.from(groupEl, { scale: 1.3, duration: 0.15, ease: "back.out(2)" }, g.start);
// KARAOKE — highlight each word
for (var wi = g.wordStart; wi <= g.wordEnd; wi++) {
var wordEl = document.getElementById("PREFIX-cw-" + wi);
tl.to(wordEl, { color: "#FFD700", scale: 1.1, duration: 0.06 }, WORDS[wi].start);
tl.to(wordEl, { color: "#FFFFFF", scale: 1, duration: 0.08 }, WORDS[wi].end);
}
// EXIT
tl.to(groupEl, { opacity: 0, scale: 0.9, duration: 0.1 }, g.end - 0.1);
// HARD KILL (mandatory)
tl.set(groupEl, { opacity: 0, visibility: "hidden" }, g.end);
});
window.__timelines["BLOCKNAME"] = tl;
})();
</script>
</body>
</html>
```
**Replace checklist:**
- `BLOCKNAME` → your block name (e.g., `cap-swoosh`)
- `PREFIX` → short unique prefix for IDs (e.g., `sw`)
- Font family, weight, size → your style's typography
- Entrance animation → your style's entrance
- Karaoke highlight → your style's active word treatment
- Colors → your style's palette
---
## VFX Template
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.147.0/build/three.min.js"></script>
<style>
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #030308;
overflow: hidden;
}
#root-BLOCKNAME {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
background: #030308;
}
#canvas-BLOCKNAME {
position: absolute;
top: 0;
left: 0;
width: 1920px;
height: 1080px;
}
</style>
</head>
<body>
<div
id="root-BLOCKNAME"
data-composition-id="BLOCKNAME"
data-start="0"
data-duration="10"
data-width="1920"
data-height="1080"
>
<canvas id="canvas-BLOCKNAME" width="1920" height="1080"></canvas>
<div
id="drv-BLOCKNAME"
class="clip"
data-start="0"
data-duration="10"
data-track-index="0"
style="position:absolute;width:1px;height:1px;opacity:0;pointer-events:none"
></div>
</div>
<script>
(function () {
window.__timelines = window.__timelines || {};
// Seeded PRNG — NEVER use Math.random()
function mulberry32(a) {
return function () {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var rng = mulberry32(42);
var W = 1920,
H = 1080;
var canvas = document.getElementById("canvas-BLOCKNAME");
var renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: true });
renderer.setSize(W, H);
renderer.setPixelRatio(1);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
var scene = new THREE.Scene();
scene.background = new THREE.Color(0x030308);
var camera = new THREE.PerspectiveCamera(50, W / H, 0.1, 100);
camera.position.set(0, 0, 8);
// YOUR SCENE SETUP HERE
// - lights
// - geometry
// - materials
// State proxy — GSAP animates this, render reads it
var st = {
rotY: 0,
camZ: 8,
// add your animated properties
};
var tl = gsap.timeline({ paused: true });
// YOUR TWEENS HERE
tl.to(st, { rotY: Math.PI * 2, duration: 10, ease: "none" }, 0);
window.__timelines["BLOCKNAME"] = tl;
function renderScene() {
// Apply state to Three.js objects
camera.position.z = st.camZ;
// mesh.rotation.y = st.rotY;
renderer.render(scene, camera);
}
// Render via onUpdate — NO requestAnimationFrame
tl.eventCallback("onUpdate", renderScene);
renderScene();
})();
</script>
</body>
</html>
```
**Replace checklist:**
- `BLOCKNAME` → your block name (e.g., `vfx-chrome-blob`)
- Scene setup → your geometry, lights, materials
- State proxy → your animated properties
- Tweens → your animation timeline
- renderScene → apply state to your Three.js objects
---
## registry-item.json Templates
**For blocks:**
```json
{
"$schema": "https://hyperframes.heygen.com/schema/registry-item.json",
"name": "BLOCKNAME",
"type": "hyperframes:block",
"title": "Human-Readable Title",
"description": "One sentence: what it does and who uses it",
"dimensions": { "width": 1920, "height": 1080 },
"duration": 10,
"tags": ["category", "subcategory"],
"files": [
{
"path": "BLOCKNAME.html",
"target": "compositions/BLOCKNAME.html",
"type": "hyperframes:composition"
}
]
}
```
**For components** (no `dimensions` or `duration`):
```json
{
"$schema": "https://hyperframes.heygen.com/schema/registry-item.json",
"name": "COMPONENTNAME",
"type": "hyperframes:component",
"title": "Human-Readable Title",
"description": "One sentence: what it does",
"tags": ["category"],
"files": [
{
"path": "COMPONENTNAME.html",
"target": "compositions/components/COMPONENTNAME.html",
"type": "hyperframes:snippet"
}
]
}
```
Tags by category:
- Captions: `captions`, `viral`, `professional`, `karaoke`, `minimal`
- VFX: `three-js`, `particles`, `shader`, `gpu`
- Transitions: `transition`, `shader`, `wipe`, `dissolve`
- Blocks: `lower-third`, `social`, `title-card`, `data-viz`
- Components: `effect`, `overlay`, `text-treatment`
---
## Component Template
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: transparent;
overflow: hidden;
}
.COMPNAME-wrap {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
}
</style>
</head>
<body>
<div class="COMPNAME-wrap">
<!-- Your reusable effect/overlay here -->
</div>
<script>
(function () {
// Component snippet — no data-composition-id, no __timelines.
// The parent composition controls timing.
// Keep all class names and IDs prefixed with COMPNAME.
})();
</script>
</body>
</html>
```
**Replace checklist:**
- `COMPNAME` → your component name (e.g., `shimmer-sweep`)
- Background should be `transparent` so it overlays cleanly
- No `data-composition-id` or `window.__timelines` — the parent owns timing
@@ -1,61 +0,0 @@
# Wiring Blocks
Blocks are standalone compositions with their own `data-composition-id`, dimensions, duration, and GSAP timeline. Include them in a host composition using `data-composition-src` on a `<div>`.
## Basic wiring
After `hyperframes add data-chart`, wire it into your `index.html`:
```html
<div id="stage" data-composition-id="main" data-width="1920" data-height="1080" data-duration="20">
<video id="a-roll" src="video.mp4" data-start="0" data-duration="20" data-track-index="0"></video>
<!-- Block: appears at 2s, plays for 15s, on layer 1 -->
<div
data-composition-id="data-chart"
data-composition-src="compositions/data-chart.html"
data-start="2"
data-duration="15"
data-track-index="1"
data-width="1920"
data-height="1080"
></div>
</div>
```
## Required attributes
| Attribute | Description |
| ---------------------- | -------------------------------------------------------------------- |
| `data-composition-src` | Path to the block HTML file (relative to index.html) |
| `data-composition-id` | Unique ID matching the block's internal composition ID |
| `data-start` | When the block appears in the host timeline (seconds) |
| `data-duration` | How long the block plays (seconds, at most the block's own duration) |
| `data-track-index` | Layer ordering — higher numbers render in front |
| `data-width` | Block canvas width (match the block's dimensions) |
| `data-height` | Block canvas height (match the block's dimensions) |
## Timeline coordination
The block's internal GSAP timeline runs independently from the host timeline. The HyperFrames runtime loads the sub-composition, finds its `window.__timelines` registration, and seeks the block in sync with the host, offset by `data-start`. You do NOT need to reference the block's timeline in your host's GSAP code.
## Positioning blocks
To position a block in a specific area of the screen, add CSS:
```html
<div
data-composition-id="data-chart"
data-composition-src="compositions/data-chart.html"
data-start="2"
data-duration="15"
data-track-index="1"
data-width="1920"
data-height="1080"
style="position: absolute; right: 0; top: 0; width: 40%; height: 100%;"
></div>
```
## Multiple blocks
Add additional `<div data-composition-src="...">` siblings with non-overlapping or overlapping `data-start` values — each block's timeline is independent and seeked in sync by the runtime.
@@ -1,39 +0,0 @@
# Wiring Components
Components are effect snippets — HTML, CSS, and optionally JS that you merge directly into an existing composition. Unlike blocks, components have no standalone timeline; they participate in the host composition's timeline.
## General process
1. Run `hyperframes add <component-name>`
2. Open the installed file (e.g., `compositions/components/grain-overlay.html`)
3. Read the comment header for usage instructions
4. Copy the parts into your host composition:
- **HTML elements** — inside your `<div data-composition-id="...">`
- **CSS styles** — into your composition's `<style>` block
- **JS setup** — into your composition's `<script>`, before your timeline code
- **Timeline calls** — into your GSAP timeline (if the component exposes them)
## Example: grain-overlay (CSS-only, no timeline integration)
```html
<!-- Paste the overlay div into your composition -->
<div
id="grain-overlay"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 100;"
>
<div class="grain-texture"></div>
</div>
```
Then paste the CSS keyframes and `.grain-texture` rule into your styles. No GSAP timeline calls needed — the grain animates via CSS `@keyframes`.
## Example: shimmer-sweep (needs timeline integration)
See `examples/add-component.md` for the full shimmer-sweep walkthrough (HTML wrapping, CSS, JS setup, and timeline call).
## Key principles
- Components inherit the host composition's dimensions and duration
- Place component HTML at the appropriate z-index relative to your content
- Read the comment header in each snippet for customizable values
- Run `hyperframes lint` after wiring to catch structural issues
-74
View File
@@ -1,74 +0,0 @@
---
name: hyperframes
description: Read first to make, edit, animate or render any video with HyperFrames (HTML rendered to video), including launch films, explainers, music videos, charts, maps, vertical clips, title cards, captions and overlays. Holds the render contract, the work loop and what each kind of video needs.
---
**Plugin installs:** Before setup or freshness commands, follow [plugin execution rules](references/plugin-installation.md) when this skill is inside a HyperFrames plugin.
# HyperFrames
HyperFrames renders HTML to video by seeking a paused timeline frame by frame, so every render is identical; the person edits the same files in the Studio preview. Use it unless another framework or a plain browser recording is asked for. Run the CLI as `npx hyperframes <command>` (`/hyperframes-cli`); at the start of a new video, `npx hyperframes usage --json` reports the person's remaining plan usage (say "unknown" when it is).
`index.html` is the root composition: it declares the video's variables and mounts one scene per sub-composition from `compositions/`. Media and fonts live in `assets/`. A new project: `npx hyperframes init <dir>`. A website as the subject: `npx hyperframes capture <url> --json`.
Build what was asked: no voiceover or captions and no new aspect ratio unless the request or the source calls for them; a film over ~15 s that tells something gets music and sound on its visible actions unless the person asked for silence (`references/sound.md`). A new video is 1920×1080 at 30 fps. A question, a hold or a loose idea gets an answer or a proposal, not an edit. Before hand-writing a named effect, transition, caption style, chart or card, search the registry (`/hyperframes-registry`). Any media (a presenter or avatar, footage, voice, music): `/media-use`, which says when HeyGen is needed and what is free. Show real things from their source (the product's screens, the brand's logo, a real place's imagery); draw only what has none.
## Contract
`npx hyperframes check --json` reports most violations with a `Fix:` line.
1. Every timed element has `class="clip"`, `data-start` and `data-duration` in seconds, and shows over `[start, start + duration)`: land an end state just before its end. `data-track-index` is only a Studio lane; paint order is `z-index`.
2. The video's length is the root's `data-duration`: overrun is cut, an early end holds the last frame.
3. Each composition builds one paused timeline and registers it after its tweens: `window.__timelines["<data-composition-id>"] = tl`.
4. No `Date.now()`, unseeded `Math.random()`, network fetches, timers or `repeat: -1`. Everything visible is a function of timeline time.
5. Never tween `display`, `visibility` or `autoAlpha` on a `.clip`; animate a child.
6. No CSS `transform` on an element GSAP transforms; animate transforms, not `left`/`top`/`width`/`height`.
7. Never `play()`, `pause()` or seek media. A `<video>` keeps its sound with `data-has-audio="true"`, else it is `muted`; music, voice, replacement sound and J/L cuts are a separate `<audio>`.
8. Every `<audio>` has an `id` or it is left out of the mix. No `crossorigin` on media. Time a `<video>` or its wrapper, never both.
9. Only the bundled fonts (`references/format.md`) resolve by name; any other family needs `@font-face` to a file in `assets/fonts/`.
10. Asset paths are root-relative (`assets/...`), in sub-compositions too.
11. Grades, grain, blur and vignettes on footage go through `npx hyperframes media-treatment` (`/media-use`), never CSS filters.
12. A scene file puts its styles, markup and scripts inside `<template>` and styles its root as `#root`. Intended overflow or overlap: `data-layout-allow-overflow`, `data-layout-allow-overlap`.
## Keep it editable
Studio edits the tags you write.
- One scene per sub-composition, short readable ids; the root holds only scene hosts, media and audio.
- Timed elements are written in the HTML, not made by script, each with a stable `id`.
- Copy, brand colours and swappable media are variables (`data-var-text`, `data-var-src`, `var(--id)`).
- One kind per track: `data-track-kind="graphics"` on scene and overlay hosts, `captions` on the one host that holds every caption group.
- Content stays inside the 90% action-safe box, captions and key text inside 80% title-safe; Studio draws the guides, never add them.
## Work loop
- An existing project: `npx hyperframes timeline --json` shows what plays when; before any change, `npx hyperframes history --since mine --who <you>` shows what the person changed since your last turn: build on it (`/hyperframes-cli`).
- A new film over ~15 s that tells something: its type's file (below), then `references/story.md`: three storylines that are three different films, in words only; the person picks unless they asked for the file or no stops. Then `SPEC.md` (`references/spec.md`): every adjective as a number, the defaults banned by name, the shots by frame.
- After your last edit: `npx hyperframes check --json`; fix every error, weigh every warning. A film: `npx hyperframes snapshot --review` and `references/review.md`, ending in `REVIEW.md`. A small edit: snapshot what you changed (`--at`, `--range`, `--composition`) and look before you reply.
- Render when the person approves the Studio preview, asks for the file or asks for no stops, after your check and review pass; otherwise stop at the preview and wait.
## The HyperFrames desktop app
Call it "the HyperFrames desktop app", never "Studio"; offer it as the better way to keep editing, not the only one.
- Before the delivery render of a new video, open the Studio preview, even in no-questions mode, and say they can edit there. If `preview` printed "Keep editing by chatting with Framey", add that the **Edit with Framey** button opens the app.
- After the delivery render, pass on its Framey line in your own words: offer to run the `hyperframes open` it names or give its download link; else say nothing (autonomous: the delivery note).
- When any command says to run `catch-up`, run `npx hyperframes catch-up [dir]` before your next change; what it lists is their work, not a new request.
## What to read
| The request | Read |
| --- | --- |
| Launch, promo, feature | `references/types/launch.md` |
| Explainer, tutorial | `references/types/explainer.md` |
| Music video | `references/types/music.md` |
| Chart, numbers | `references/types/data.md` |
| Vertical or social clip | `references/types/vertical.md` |
| 3D, simulation, generative | `references/types/3d.md` |
| Character, mascot, story short | `references/types/character.md` |
| A real place or map; a website, PR or script; kinetic type | `references/film.md` |
| Captions; cards over a talking head | `references/captions.md`; `references/overlays.md` |
| Motion: eases, titles, cuts, punch-ins, speed, holds | `references/motion.md` |
| Scenes, attributes, media, lint; GSAP, Lottie, 3D, canvas | `references/format.md`; `references/runtimes.md` |
Decks: `/slideshow`. Remotion source: `/remotion-to-hyperframes`. Figma: `/figma`. Installing, refreshing or upgrading skills and the pinned CLI: `references/skill-lifecycle.md`.
@@ -1,289 +0,0 @@
---
version: alpha
name: Biennale Yellow — Frame (video / frame layer)
description: >
Video-first companion to Biennale Yellow's design.md. The unit is the frame (1920×1080). Atoms
are identical and sacred — warm parchment grounds, a single deep indigo ink, solar yellow deployed
as bloom / panel / tile underprint, Instrument Serif display + Archivo sans + JetBrains Mono data,
1px hairline rules as the only border, atmospheric depth (no shadows), and the bottom-right
pagenum. Composition + frame scale rewritten. Restraint is the rule; motion out of scope.
unit: the frame — 1920×1080 primary; 9:16 and 1:1 documented
principle: atoms are sacred · composition is free · numbers come from the script
colors:
paper: "#E9E5DB"
paper-deep: "#DCD6C4"
sun: "#F1EE2E"
sun-soft: "#F8F39B"
haze: "#F0DA7C"
ink: "#1B2566"
ember: "#E26B4A"
typography:
# — reading + data ramp —
body: { fontFamily: "Archivo", cqw: 0.85, weight: 400, lineHeight: 1.5, color: "ink" }
body-lede:{ fontFamily: "Archivo", cqw: 0.95, weight: 400, lineHeight: 1.55 }
micro-label:{ fontFamily: "Archivo", px: 13, weight: 600, tracking: "0.18em", upper: true }
rail-label:{ fontFamily: "Archivo", px: 13, weight: 600, tracking: "0.32em", upper: true }
mono-data:{ fontFamily: "JetBrains Mono", cqw: 0.73, weight: 400, tracking: "0.04em" }
pagenum: { fontFamily: "JetBrains Mono", px: 13, weight: 400, tracking: "0.08em", opacity: 0.75 }
# — display ramp (Instrument Serif 400, tight, negative tracking) —
ledger-title:{ fontFamily: "Instrument Serif", cqw: 1.55, weight: 400, lineHeight: 1.15 }
strand-title:{ fontFamily: "Instrument Serif", cqw: 1.7, weight: 400, lineHeight: 1.1 }
headline-sm:{ fontFamily: "Instrument Serif", cqw: 2.9, weight: 400, lineHeight: 1.0 }
headline:{ fontFamily: "Instrument Serif", cqw: 4.6, weight: 400, lineHeight: 1.06, tracking: "-0.005em" }
date-rail:{ fontFamily: "Instrument Serif", cqw: 5.0, weight: 400, lineHeight: 0.96, tracking: "-0.005em" }
display-it:{ fontFamily: "Instrument Serif", cqw: 7.0, weight: 400, italic: true, lineHeight: 1.04, tracking: "-0.005em" }
numeral-md:{ fontFamily: "Instrument Serif", cqw: 7.5, weight: 400, lineHeight: 0.92, tracking: "-0.01em" }
display:{ fontFamily: "Instrument Serif", cqw: 14.6, weight: 400, lineHeight: 0.86, tracking: "-0.018em" }
numeral-jumbo:{ fontFamily: "Instrument Serif", cqw: 28.0, weight: 400, lineHeight: 0.84, tracking: "-0.04em" }
spacing:
pad-edge: "4cqw"
pad-region: "4.2cqw"
gap-region: "2.5cqw"
components:
sun-bloom:
background: "radial gradient {colors.sun} core → {colors.sun-soft} → {colors.haze} → transparent on {colors.paper}"
size: "42–70% of the frame, off-center or behind the focal element"
description: "The primary depth layer. One per frame; a flat parchment frame reads as broken."
ember-bloom:
background: "radial {colors.ember} at 15–22% opacity"
placement: "corner opposite the sun-bloom"
description: "Subordinate counter-temperature balance; never dominant."
block-tile:
backgroundColor: "{colors.sun} at 40–70% opacity"
layout: "rectangles on an 8×4 grid behind cover/colophon"
description: "A layered poster underprint."
yellow-panel:
backgroundColor: "{colors.sun}"
textColor: "{colors.ink}"
rounded: "0"
border: "none (meets paper directly)"
description: "Full-bleed column/third — the strongest color statement, ink on top."
hairline-rule:
rule: "1px solid {colors.ink} (soft variant: {colors.ink} at 18–20%)"
description: "The ONLY border — header underlines, footer tops, ledger separators. No thicker rule exists."
strand-row:
borderBottom: "1px {colors.ink} 18–20%"
typography: "{typography.strand-title} + {typography.body}"
description: "Serif numeral + serif title + sans body. Numbered editorial lists."
ledger-row:
borderBottom: "1px {colors.ink} 18–20%"
typography: "{typography.mono-date} · {typography.ledger-title} · {typography.body} · {typography.mono-data}"
description: "4-col tabular — mono date · serif title · sans venue · mono duration (right)."
footer-band:
borderTop: "1px solid {colors.ink} per cell"
typography: "{typography.micro-label} + {typography.body-sm}"
description: "4-column metadata strip at the foot of cover/colophon."
vertical-rail:
typography: "{typography.rail-label}"
transform: "rotated up the left edge"
description: "Section marker on chapter/divider frames."
pagenum:
typography: "{typography.pagenum}"
color: "{colors.ink} at 75%"
placement: "bottom-right"
description: "The only persistent chrome."
---
# Biennale Yellow — Frame (video / frame layer)
## Overview
Biennale Yellow at frame scale is a **literary-editorial system** in the register of an art
biennale catalogue: warm parchment, a single deep indigo ink, and solar yellow as atmosphere. No
cards, no buttons, no shadows, no rounded corners — the structural vocabulary is just **paper, ink,
and yellow.** Depth is delivered by soft radial **sun blooms**, not elevation.
The voice is three faces in rigid roles: **Instrument Serif** (weight 400, tight line-height,
negative tracking) carries every display, numeral, and quote from 40px to 720px+; **Archivo** carries
body and the wide-tracked uppercase micro-label; **JetBrains Mono** carries every date, figure, and
the pagenum. Text is **always ink** — contrast comes from size and weight, never color. The mood
sits between a folded museum brochure and a slow-reading literary quarterly: confident, atmospheric,
deeply restrained.
**Key characteristics at frame scale:**
- **Warm parchment ground** on every frame; never white, never gray.
- **Single ink** (`{colors.ink}`) for all type and all rules; **solar yellow** as bloom / panel / tile.
- **Instrument Serif 400** display (tight, negative-tracked); **Archivo** body + micro-labels; **JetBrains Mono** data.
- **1px hairline rules** are the only border — no thicker weight exists; **no shadows, no rounded corners**.
- **Sun bloom** is the primary depth layer on every frame; an ember counter-bloom adds warm-cool tension.
- **Editorial-restrained** — sparse reads as elegant; crowding breaks the catalogue feel.
## The Frame
### Frame Craft Bar
Three eyeball tests gate every frame before any structural check:
- **Squint** — one Instrument Serif moment dominates at 3–6× its neighbor; the sun bloom centers the eye.
- **Silence** — frames read **55–60% empty**; the **ledger is the one dense exception** (density via quiet hairline repetition, not richness).
- **Restraint** — **one ink color** for all type and rules; **one sun bloom** per frame (+ optional subordinate ember); never invert (no yellow text on ink).
- **Reference** — aim at an **art-biennale catalogue / slow exhibition poster / literary quarterly**; failure looks like a **flat CMS template** (no bloom) or a **bordered-card deck**.
- **Primary:** 1920×1080 (16:9). Display authored in **`cqw`** (`px ÷ 1920 × 100 = cqw`).
- **Vertical:** 1080×1920 (9:16). **Square:** 1080×1080 (1:1).
- **Safe area:** `pad-edge` 4cqw (40–76px equivalent) — the elegance depends on edge negative space; only blooms, tiles, and panels bleed.
**The container law (load-bearing).** Every frame ground sets `container-type: size`; ALL
frame-relative units are `cqw`/`cqh` against it — never `vw`. Hairlines stay 1px; bloom sizes scale
as `%` of the frame.
## Colors
Tokens identical to the source. `{colors.paper}` is the universal ground; `{colors.ink}` (deep
indigo navy) is **every line of type and every rule** — there is no secondary text color.
`{colors.sun}` deploys three ways: the **bloom** (radial atmosphere), the **yellow-panel**
(full-bleed poster fill, ink on top), and the **block-tile** underprint. `{colors.sun-soft}`/`haze`
are bloom mid/outer stops. `{colors.ember}` appears **only** as a 15–22% counter-bloom — never a
fill, never text. **The system never inverts** — ink on sun is correct; yellow text on ink does not exist.
## Typography
Two ramps. The **reading/data ramp** (Archivo body 0.85cqw ink, micro-labels in px, JetBrains Mono
data) carries copy + chrome; the **display ramp** (Instrument Serif `ledger-title` 1.55cqw →
`numeral-jumbo` 28cqw) carries every headline, numeral, and quote.
- **Legibility floor:** any load-bearing line ≥ **1.4cqw**; mono/labels in px are chrome only.
- **Fit-to-measure:** size the headline to its length. Cap the block at **≤ 78cqw**; ≤3 words → `display`; 4–6 → `headline`; 7+ → `headline-sm`. The jumbo numeral is a divider's whole point — don't shrink it.
- **Instrument Serif is weight 400 only** (its contrast is the weight signal), tight line-height (0.84–1.06), negative tracking; **micro-labels uppercase Archivo 600, ≥0.16em**; **mono for all numerals/dates**. No bold serif, no second text color, no mono in body/display.
## Depth & Surface
Atmospheric, not structural. Depth from:
- **Sun bloom** — the primary layer: a layered radial (sun 70–95% core → sun-soft → haze 18–22% → paper 0%), 42–70% of the frame. One per frame.
- **Ember bloom** — a 15–22% peach counter-bloom in the opposite corner; always subordinate.
- **Block-tile underprint** — translucent yellow rectangles on an 8×4 grid (cover/colophon).
- **Yellow panel** — the one "hard" color statement: a flooded column/third, ink on top.
- **Hairline rules** — 1px ink for structural separation (soft 18–20% variant for dense rows).
**Ceiling:** zero box-shadow, zero text-shadow, zero rounded corner, no border thicker than 1px.
## Shapes
- **0 radius on everything** — strict rectangles. Blooms are edgeless (they fade into paper).
## Components
- **sun-bloom / ember-bloom / block-tile** — the atmospheric depth set. **yellow-panel** — the poster-fill statement.
- **hairline-rule** — the only border (1px ink; soft variant for dense rows).
- **strand-row / ledger-row / footer-band** — the editorial list, tabular calendar, and metadata strip.
- **vertical-rail** — rotated chapter marker. **pagenum** — the bottom-right mono chrome.
## Frame Treatments
> Recipe: ground · container · composes · focal · chrome · accent · silence · Fixed/Free · density.
> A sun bloom + bottom-right pagenum on every frame; sparse is the default.
### 1 · Cover (identity · move: display + sun bloom · left)
**Ground** paper + a large sun-bloom (left-of-center) + an ember counter-bloom (opposite corner).
**Composes** micro-label, display, date-rail, footer-band, pagenum. **Focal** a 2-line Instrument
Serif `display` (italic key word) in ink, left, under a micro-label. **Chrome** a serif date-rail
top-right; a 4-column footer-band at the foot. **Accent** the sun bloom + yellow. **Silence** the
bloom holds the open space. **Fixed** ink type, one bloom, 1px footer rules, no shadow. **Free**
title, date, footer cells. **Density** low.
### 2 · Chapter Divider (section · move: jumbo numeral · vertical rail)
**Ground** paper + a corner-anchored sun-bloom. **Composes** vertical-rail, numeral-jumbo,
headline-sm. **Focal** a single huge Instrument Serif `numeral-jumbo` (≈28cqw) dominating, with a
serif title beneath. **Chrome** a rotated vertical-rail label up the left edge; pagenum. **Accent**
the bloom behind the numeral. **Silence** ~60%. **Fixed** serif 400, jumbo numeral, rail label.
**Free** the ordinal, title, rail text. **Density** low.
### 3 · Ledger (catalog · move: hairline tabular rows · the dense frame)
**Ground** paper (bloom optional, subtle). **Composes** headline-sm + micro-label topbar, ledger-rows.
**Focal** a 4-column tabular calendar — mono date · serif title · sans venue · mono duration —
separated by hairline-soft rules under a 1px ink header rule. **Chrome** pagenum. **Accent** none —
density through quiet repetition, not color. **Silence** tight — the density exception (achieved by
repetition, not richness). **Fixed** hairline rules, mono dates, serif titles. **Free** rows, venues.
**Density** dense-exception.
### 4 · Manifesto / Quote (quote · move: italic serif · centered bloom)
**Ground** paper + a centered sun-bloom. **Composes** numeral-lg quote mark, display-it, attribution.
**Focal** a 2-line Instrument Serif **italic** `display-it` quote in ink, centered, under an
oversized serif quote mark. **Chrome** a micro-label attribution. **Accent** the centered bloom.
**Silence** ~60% — deliberately open. **Fixed** italic serif, one bloom, ink. **Free** quote,
attribution. **Density** low.
### 5 · Poster Panel (statement · move: yellow panel · split)
**Ground** paper with a full-bleed `{colors.sun}` `yellow-panel` (column or third). **Composes**
yellow-panel, micro-label, headline. **Focal** an Instrument Serif `headline` in ink sitting **on
the sun panel** (ink-on-yellow — a signature). **Chrome** micro-label; pagenum. **Accent** the panel
itself. **Silence** the paper side stays open. **Fixed** ink-on-sun, panel meets paper directly (no
border), no shadow. **Free** headline, panel side/width. **Density** low.
### 6 · Strand List (programme · move: numbered editorial rows · left)
**Ground** paper + a subtle sun-bloom. **Composes** micro-label, headline-sm, strand-rows. **Focal**
a numbered list — serif numeral + serif title + sans body, hairline-soft separators. **Chrome**
micro-label; pagenum. **Accent** the bloom. **Silence** moderate; rows breathe. **Fixed** serif
numerals, hairline-soft rules. **Free** items, copy. **Density** standard.
## Composition Rules
### Do
- Start on **warm parchment**; add **one sun bloom** (optionally an ember counter-bloom) — atmosphere is the depth.
- Set every line in **ink**; use **Instrument Serif 400** (tight, negative-tracked) for display, **Archivo** body, **JetBrains Mono** for all numerals/dates.
- Make every separator a **1px ink hairline** (soft variant for dense rows); flood a **yellow panel** for poster moments (ink on top).
- Keep micro-labels **uppercase Archivo 600, 0.16–0.32em**; pin the **pagenum** bottom-right.
- Lean sparse; left/asymmetric on cover/chapter/list, centered on manifesto.
### Don't
- No drop shadows, no rounded corners, no bordered cards, no border thicker than 1px.
- No second text color; no bold Instrument Serif; no inverted ink grounds.
- No mono for body/display; no font substitutes.
- Don't crowd the canvas — sparse reads as elegant; don't omit the bloom (flat parchment reads as a CMS template).
- Don't blow a headline edge-to-edge — step the ramp down.
## Aspect-Ratio Behavior
| Treatment | 16:9 | 9:16 | 1:1 |
| --------------- | ---------------------------------------------- | -------------------------- | -------------------------- |
| Cover | display left, date-rail top-right, footer foot | display top, footer stacks | display upper, footer foot |
| Chapter Divider | jumbo numeral, rail left | numeral centered, rail top | numeral centered |
| Ledger | 4-col rows | drop venue col → 3-col | 3-col |
| Manifesto | centered italic | centered, taller | centered |
| Poster Panel | side panel + paper | top/bottom panel band | panel third |
| Strand List | numbered rows | rows (tighter) | rows |
`pad-edge` holds on the short edge; display clamps use the shorter axis so portrait doesn't blow out
headlines. Keep load-bearing lines ≥ 1.4cqw. Ledger/strand fixed first columns may tighten on 9:16.
## Approved Entities
No real customers, logos, or vendors are defined in the source — render any such mark as a
placeholder. Programme titles, venues, and dates are content; the system supplies paper, ink, yellow.
## Numerals & Claims (hard rule)
Never invent figures, dates, durations, or counts at frame scale. Render slots as `— figure —`,
`{metric}`, `NN JUN`, `N m`. Ledger dates/durations and chart values especially carry placeholders
until the script supplies them. Chapter ordinals (01, 02…) are decorative.
## Pre-Render Self-Audit
- **Squint** — one serif display moment dominates; the bloom centers the eye.
- **Silence** — sparse frames 55–60% open; only the ledger runs dense (via repetition, not richness).
- **One color** — ink for all text + rules; sun for bloom/panel/tile; ember counter-bloom only; no inversion.
- **Type** — Instrument Serif 400 tight negative-tracked, fit-to-measure; micro-labels uppercase 0.16em+; mono numerals; ≥1.4cqw floor.
- **Depth** — 0 shadow, 0 rounded corner, 1px hairlines only; one sun bloom present.
- **Anchor** — left on cover/chapter/list, centered on manifesto; pagenum bottom-right.
- **Fabrication** — every numeral/date traces to the script, else placeholder.
## Known Gaps
- **Motion intentionally out of scope.** frame.md specifies composition only; the source's 280ms crossfade is a deck mechanic.
- **Instrument Serif + Archivo + JetBrains Mono via Google Fonts.** CJK: Smiley Sans (display) / Noto Serif SC (body) / Noto Sans SC (labels); italic serif and mono tabular figures have no exact Hanzi equal — keep ledger dates Latin.
- **9:16 / 1:1 are guidance**; the `min(vw,vh)` clamp pattern keeps display from blowing out — verify per ratio.
- Sun/ember blooms, block tiles, yellow panels, and hairline rules are CSS-only; no external imagery is required.
@@ -1,220 +0,0 @@
<!--
Biennale Yellow — caption skin (preset-local source).
This is the preset's own lower-third karaoke caption look: a caption skin to read or
copy, not to mount as is. A copy used as compositions/captions.html fills its three
reserved holes and wraps the fragment in a <template>:
· the empty GROUPS array → the engine's scene-aware word groups
· the 0 duration + 0 width/height → real total + canvas size
· the empty data-brand-tokens style → :root tokens derived from the project's frame.md
Token contract — reference ONLY this fixed vocab (set it from the
project's frame.md; each var has a biennale literal
fallback for standalone preview):
--cap-ink · --cap-canvas · --cap-accent · --cap-accent-2 · --font-display ·
--font-body · --cap-band-top · --cap-band-height
Hooks (do not rename): .caption-group / .caption-word + states .is-active / .is-spoken,
root data-composition-id="captions", timeline at window.__timelines["captions"].
State changes use gsap.set({className}) (applied on the engine's frame-by-frame seek);
never tl.call() callbacks (seek does not fire them → wrong state on render).
Visual: a warm-parchment card on a single 1px hairline indigo-ink border — biennale's
ONLY border — square corners, NO shadow (biennale's depth is atmospheric, never cast).
Instrument Serif, sentence case, ink glyphs. Upcoming words sit in faint ink; the
current word gets a solar-yellow underprint (the brand's signature "sun as panel /
underprint", a hard square block, no blur) with ink reading on top; spoken words
settle to solid ink, underprint cleared.
-->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<!-- the project's :root tokens go here -->
<style data-brand-tokens></style>
<style>
#captions-root {
position: absolute;
inset: 0;
pointer-events: none;
}
.caption-layer {
position: absolute;
inset: 0;
z-index: 20;
pointer-events: none;
}
/* the keep-out band, from captionBand(H) — frame content stays above it */
.caption-stage {
position: absolute;
left: 0;
right: 0;
top: var(--cap-band-top, 900px);
height: var(--cap-band-height, 180px);
display: flex;
align-items: center;
justify-content: center;
}
.caption-group {
/* every group is absolutely stacked over the SAME band — exactly one shown at a
time via opacity. Must be absolute: in-flow groups would lay out side-by-side. */
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
}
/* THE parchment card — single 1px hairline ink border (biennale's only border),
square corners, NO shadow */
.caption-pill {
max-width: 78%;
padding: 20px 44px 24px;
background: var(--cap-canvas, #e9e5db);
border: 1px solid var(--cap-ink, #1b2566);
border-radius: 0;
}
.caption-line {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.1em 0.34em;
font-family: var(--font-display, "Instrument Serif"), Georgia, serif;
font-weight: 400;
font-size: clamp(40px, 4.4vw, 58px);
line-height: 1.16;
letter-spacing: -0.01em;
}
/* upcoming — faint indigo ink, no underprint */
.caption-word {
display: inline-block;
padding: 0 0.06em;
color: color-mix(in srgb, var(--cap-ink, #1b2566) 42%, var(--cap-canvas, #e9e5db));
}
/* current word — THE solar-yellow underprint (box-shadow spread = a hard square
block w/o layout shift; no blur), indigo ink reading on top */
.caption-word.is-active {
color: var(--cap-ink, #1b2566);
background: var(--cap-accent, #f1ee2e);
box-shadow: 0 0 0 0.06em var(--cap-accent, #f1ee2e);
}
/* already spoken — solid indigo ink, underprint cleared */
.caption-word.is-spoken {
color: var(--cap-ink, #1b2566);
background: transparent;
box-shadow: none;
}
/* optional per-word accents (dormant until a word-classer tags them) */
/* brand / key term — Instrument Serif italic, biennale's only display inflection */
.caption-word.cap-brand {
font-style: italic;
}
/* numerals / data — the mono voice, slightly recessed */
.caption-word.cap-num {
font-family: var(--font-body, "JetBrains Mono"), ui-monospace, monospace;
color: color-mix(in srgb, var(--cap-ink, #1b2566) 72%, var(--cap-canvas, #e9e5db));
}
/* emphasis — an ember-red hairline underline (the counter-temperature accent) */
.caption-word.cap-cta {
text-decoration: underline;
text-decoration-thickness: 2px;
text-decoration-color: var(--cap-accent-2, #e26b4a);
text-underline-offset: 0.1em;
}
@media (max-aspect-ratio: 9/16) {
.caption-pill {
max-width: 92%;
}
.caption-line {
font-size: clamp(34px, 5.4vw, 48px);
}
}
</style>
<div
id="captions-root"
data-composition-id="captions"
data-timeline-locked
data-start="0"
data-duration="0"
data-fps="30"
data-width="0"
data-height="0"
>
<div class="caption-layer" aria-hidden="true">
<div id="caption-stage" class="caption-stage"></div>
</div>
</div>
<script>
// the scene-aware word groups + real duration go here.
var GROUPS = [];
var DURATION = 0;
(function () {
var stage = document.getElementById("caption-stage");
// build DOM: one .caption-group per group, .caption-word per word
GROUPS.forEach(function (group, g) {
var groupEl = document.createElement("div");
groupEl.className = "caption-group";
groupEl.id = "caption-group-" + g;
var pill = document.createElement("div");
pill.className = "caption-pill";
var line = document.createElement("div");
line.className = "caption-line";
(group.words || []).forEach(function (w, i) {
var span = document.createElement("span");
span.className = "caption-word";
span.id = "caption-word-" + g + "-" + i;
span.textContent = String(w.text);
line.appendChild(span);
});
pill.appendChild(line);
groupEl.appendChild(pill);
stage.appendChild(groupEl);
});
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
GROUPS.forEach(function (group, g) {
var groupEl = document.getElementById("caption-group-" + g);
var words = group.words || [];
var next = GROUPS[g + 1];
var isLast = g === GROUPS.length - 1;
var start = Math.max(0, Number(group.start));
var end = isLast ? DURATION : Math.min(Number(next.start), Number(group.end) + 0.3);
if (end <= start) end = start + 0.01;
// group on/off — exactly one group visible at a time
tl.set(groupEl, { opacity: 1 }, start);
tl.set(groupEl, { opacity: 0 }, end);
// 3-state karaoke via className SETS — seek-safe (gsap.set applies on the
// engine's frame-by-frame seek; .call callbacks would NOT fire).
words.forEach(function (w, i) {
var el = document.getElementById("caption-word-" + g + "-" + i);
var at = Math.max(start, Number(w.start));
tl.set(el, { className: "caption-word" }, start); // upcoming (also resets on reverse-seek)
tl.set(el, { className: "caption-word is-active" }, at); // current
tl.fromTo(el, { scale: 0.97 }, { scale: 1, duration: 0.14, ease: "power2.out" }, at);
if (i + 1 < words.length) {
var nextAt = Math.max(start, Number(words[i + 1].start));
tl.set(el, { className: "caption-word is-spoken" }, nextAt); // demote when next activates
}
});
if (words.length) {
var lastEl = document.getElementById("caption-word-" + g + "-" + (words.length - 1));
var lastSpoken = Math.min(end, Number(words[words.length - 1].end) + 0.1);
tl.set(lastEl, { className: "caption-word is-spoken" }, lastSpoken);
}
});
// full-span anchor so the sub-comp timeline spans the whole video
tl.to({}, { duration: DURATION }, 0);
window.__timelines["captions"] = tl;
})();
</script>
@@ -1,275 +0,0 @@
---
version: alpha
name: BlockFrame — Frame (video / frame layer)
description: >
Video-first companion to BlockFrame's design.md. The unit is the frame (1920×1080). Atoms are
identical and sacred — 4px black borders + 8px hard offset shadows, the five-pastel candy palette
(pink/blue/green/yellow/cream) plus black/white/off-white, Inter 800–900 uppercase display +
Space Grotesk label chrome, square corners, label-pills, tilted decorations, star bursts, stripe
blocks, dot grids. Composition + frame scale rewritten. Motion out of scope.
unit: the frame — 1920×1080 primary; 9:16 and 1:1 documented
principle: atoms are sacred · composition is free · numbers come from the script
colors:
black: "#000000"
white: "#FFFFFF"
offwhite: "#FFFDF5"
pink: "#FE90E8"
blue: "#C0F7FE"
green: "#99E885"
yellow: "#F7CB46"
cream: "#FFDC8B"
borders: { primary: "4px solid black", thin: "3px solid black" }
shadows: { default: "8px 8px 0 black", small: "4px 4px 0 black", hover: "6px 6px 0 black", close-yellow: "12px 12px 0 yellow", close-white: "6px 6px 0 white" }
typography:
# — reading + chrome ramp —
body: { fontFamily: "Inter", cqw: 0.95, weight: 500, lineHeight: 1.6 }
card-title:{ fontFamily: "Inter", cqw: 1.15, weight: 700, upper: true, lineHeight: 1.2 }
label: { fontFamily: "Space Grotesk", px: 13, weight: 600, tracking: "0.08em", upper: true }
counter: { fontFamily: "Space Grotesk", px: 14, weight: 700, tracking: "0.1em", upper: true }
# — display ramp (Inter 800–900, uppercase, negative tracking) —
heading-md:{ fontFamily: "Inter", cqw: 2.1, weight: 700, lineHeight: 1.1, tracking: "-0.01em", upper: true }
quote-text:{ fontFamily: "Inter", cqw: 2.7, weight: 900, lineHeight: 1.15, tracking: "-0.02em", upper: true }
stat-number:{ fontFamily: "Inter", cqw: 3.3, weight: 900, lineHeight: 1.0 }
heading-lg:{ fontFamily: "Inter", cqw: 3.3, weight: 800, lineHeight: 1.0, tracking: "-0.02em", upper: true }
close-title:{ fontFamily: "Inter", cqw: 4.2, weight: 900, lineHeight: 0.95, tracking: "-0.03em", upper: true }
heading-xl:{ fontFamily: "Inter", cqw: 5.0, weight: 900, lineHeight: 0.95, tracking: "-0.03em", upper: true }
spacing:
slide-pad: "3.1cqw" # 60px @1920
gap-md: "1.7cqw"
components:
card-elevated:
backgroundColor: "{colors.white}"
border: "0.4cqw solid {colors.black}"
rounded: "0"
shadow: "0.8cqw 0.8cqw 0 {colors.black}"
description: "Primary card. Border/shadow coupled: 4px↔8px, 3px↔4px."
card-small:
backgroundColor: "{colors.white}"
border: "0.3cqw solid {colors.black}"
rounded: "0"
shadow: "0.4cqw 0.4cqw 0 {colors.black}"
description: "Stat cards, team cards, timeline steps."
label-pill:
border: "0.3cqw solid {colors.black}"
backgroundColor: "any pastel / {colors.white}"
rounded: "9999px"
shadow: "0.4cqw 0.4cqw 0 {colors.black}"
typography: "{typography.label}"
description: "The universal eyebrow — never plain text."
button-primary:
backgroundColor: "{colors.yellow}"
textColor: "{colors.black}"
border: "0.3cqw solid {colors.black}"
rounded: "0"
shadow: "0.4cqw 0.4cqw 0 {colors.black}"
typography: "Inter 700"
description: "The CTA."
star-burst:
backgroundColor: "any pastel"
border: "0.3cqw solid {colors.black}"
clip: "10-point clip-path star"
description: "Corner attention-grabber."
stripe-block:
backgroundImage: "45° {colors.black} + pastel diagonal stripes"
border: "0.3cqw solid {colors.black}"
description: "Poster decoration."
bg-dot-grid:
backgroundImage: "radial-dot ~1.2px dots, 24px grid, {colors.black}"
description: "Faint corner/ground overlay."
tilt-deco:
transform: "rotate(±2°–12°)"
description: "Rotated rectangle/badge/star. Stat cards alternate −2/+2°. The grid-puncture signature."
stat-deco-dot:
backgroundColor: "any pastel"
border: "2px solid {colors.black}"
rounded: "50%"
size: "12px"
description: "The ONLY round shape, pinned to stat cards."
close-frame:
backgroundColor: "{colors.black}"
textColor: "{colors.white}"
border: "0.4cqw solid {colors.white}"
rounded: "0"
shadow: "1.2cqw 1.2cqw 0 {colors.yellow}"
description: "Inverted closer — the only colored shadow."
---
# BlockFrame — Frame (video / frame layer)
## Overview
BlockFrame at frame scale is a **maximalist neobrutalist** system on five laws: every region has a
4px black border, every elevated element an 8px hard offset shadow, every corner square, every
accent a saturated pastel, and every layout allowed to be a little crooked. The joy is the
deliberate collision — bordered cards meeting bordered cards, shadows stacking, tilted decorations
puncturing the grid.
The voice is **Inter** at weight 800–900 in tight uppercase with negative tracking (display) +
weight 500 sentence body, and **Space Grotesk** weight 600 uppercase 0.08em as the label/chrome
voice. Five candy pastels (pink/blue/green/yellow/cream) cycle as full-bleed grounds across frames
— the color cycling is the primary rhythm. Depth is **hard offset shadow** (8px/4px, solid black,
zero blur, bottom-right); the close-frame's 12px yellow shadow is the one colored exception.
**Key characteristics at frame scale:**
- **4px black borders + 8px hard shadows** on primary cards; 3px + 4px on chrome (weights coupled).
- **Five-pastel palette** cycled as full-bleed grounds; black/white/off-white structural.
- **Inter 800–900 uppercase** negative-tracked display; **Space Grotesk** label chrome.
- **Square corners** everywhere (only the stat-deco dot is round); **tilted decorations** puncture the grid.
- **Label-pills** open every region; star bursts, stripe blocks, dot grids are reusable attention units.
- **Comfortably dense** — packed reads as authoritative; empty corners read as broken.
## The Frame
### Frame Craft Bar
Three eyeball tests gate every frame before any structural check:
- **Squint** — one Inter display moment dominates at 3–6× its neighbor; cards read as a system, not rivals.
- **Silence** — cover/quote/close keep air (decorations, not content); the **feature-card and stat grids are the dense exception**.
- **Restraint** — the 4px→08px / 3px→4px coupling holds; black borders only (white on close); pastel ground **cycles** one per frame; no sixth pastel.
- **Reference** — aim at a **zine / 1990s sticker-book / toy-packaging spread** (bordered blocks, tilted decorations); failure looks like a **flat, borderless, blurred-shadow web card grid**.
- **Primary:** 1920×1080 (16:9). Display authored in **`cqw`** (`px ÷ 1920 × 100 = cqw`).
- **Vertical:** 1080×1920 (9:16). **Square:** 1080×1080 (1:1).
- **Safe area:** `slide-pad` ~3.1cqw; decorations may bleed off edges.
**The container law (load-bearing).** Every frame ground sets `container-type: size`; ALL
frame-relative units are `cqw`/`cqh` against it — never `vw`. Borders/shadows scale in `cqw` so the
4px↔8px coupling holds proportionally; corners stay square.
## Colors
Tokens identical to the source. `{colors.offwhite}` is the default ground, but frames **cycle**
through `{colors.cream}`/`blue`/`pink`/`green`/`yellow` grounds — the cycle is the rhythm.
`{colors.black}` is every border + structural text; `{colors.white}` is card fills. The five pastels
are interchangeable with **no semantic meaning** — pair by juxtaposition (pink+blue+green trio,
cream+yellow warm pair). `{colors.yellow}` is the CTA + the one colored (close) shadow;
`{colors.black}` ground is the close surface. **No sixth pastel.**
## Typography
Two ramps. The **reading/chrome ramp** (Inter body 0.95cqw weight 500, Space Grotesk labels in px)
carries copy + chrome; the **display ramp** (Inter `heading-md` 2.1cqw → `heading-xl` 5cqw, weight
800–900 uppercase) carries every headline + stat.
- **Legibility floor:** any load-bearing line ≥ **1.4cqw**; px labels are chrome only.
- **Fit-to-measure:** size the headline to its length. Cap the block at **≤ 78cqw**; ≤3 words → `heading-xl`; 4–6 → `heading-lg`; 7+ → `heading-md`.
- **Inter display is uppercase, weight 800–900, negative-tracked** (−0.01..−0.03em); body weight 500 sentence case; **Space Grotesk labels uppercase 0.08em**. No sentence-case display, no untracked display.
## Depth & Surface
Hard offset shadow, solid black, zero blur, bottom-right:
- **0.8cqw (8px)** primary cards; **0.4cqw (4px)** chrome; **0.6cqw (6px)** hover.
- **Border-based depth** — the 4px/3px borders do much of the lift; shadow makes it "elevated."
- **Tilt** — ±2°–12° rotation breaks the grid for perceived dimension.
- **Inverted close** — 12px YELLOW shadow (and 6px white on close-btn) — the only colored shadows.
**Ceiling:** no blurred shadow, no rounded corner (save the stat-deco dot), no gradient depth.
## Shapes
- **0 radius everywhere** except the 12px stat-deco dot (50%). Square corners are the structural identity.
## Components
- **card-elevated / card-small** — the bordered+shadowed content cards (weight-coupled).
- **label-pill** — the universal eyebrow (border+shadow+pastel). **button-primary** — yellow CTA.
- **star-burst / stripe-block / bg-dot-grid / tilt-deco** — the reusable decoration units (one per frame min).
- **stat-deco-dot** — the lone round shape. **close-frame** — the inverted black+white+yellow-shadow closer.
## Frame Treatments
> Recipe: ground · container · composes · focal · chrome · accent · silence · Fixed/Free · density.
> Cycle the ground color; add ≥1 decoration per frame; open with a label-pill.
### 1 · Cover (identity · move: decorations puncture · left)
**Ground** `{colors.cream}` (or offwhite) + faint dot-grid. **Composes** label-pill, heading-xl, tilt-deco rect, star-burst, counter. **Focal** a 2–3 line Inter `heading-xl` uppercase, left, under a label-pill; tilted pastel rects + a star burst puncture the right. **Chrome** counter pill. **Accent** the decorations' pastels. **Silence** right third holds decorations. **Fixed** 4px/8px coupling, uppercase display, square. **Free** title, decoration placement/colors. **Density** comfortable.
### 2 · Feature Cards (catalog · move: 3-up bordered grid · blue ground)
**Ground** `{colors.blue}`, `slide-pad`. **Composes** label-pill, heading-lg, 3× card-elevated (icon-square + card-title + body). **Focal** three white bordered+shadowed cards. **Chrome** label-pill eyebrow. **Accent** the pastel icon-squares (pink/green/yellow). **Silence** tight — dense by design. **Fixed** 4px border + 8px shadow, square, uppercase card-titles. **Free** card content, icon hues. **Density** dense-exception.
### 3 · Stat Grid (data · move: tilted stat cards · green ground)
**Ground** `{colors.green}`, `slide-pad`. **Composes** label-pill, heading-lg, 3× card-small (tilted −2/+2°, stat-deco dot, stat-number + label). **Focal** three tilted bordered stat cards. **Accent** the deco-dots' pastels + black stat numerals. **Silence** moderate. **Fixed** alternating tilt, 3px+4px, round deco-dot only. **Free** figures (from script), dot hues. **Density** dense-exception.
### 4 · Closing Plate (closer · move: inverted black · centered)
**Ground** `{colors.black}`. **Composes** close-frame (4px white border, 12px yellow shadow), label-pill (inverted), close-title, star-burst. **Focal** a white `close-title` inside the white-bordered frame with the yellow offset shadow; a pink star punctures a corner. **Accent** the yellow shadow + pink star. **Silence** ~50%. **Fixed** white-on-black, 12px yellow shadow (only here), square. **Free** sign-off, star placement. **Density** low.
### 5 · Quote (quote · move: bordered quote frame · pink ground)
**Ground** `{colors.pink}` (or offwhite). **Composes** label-pill, quote-text in a card-elevated, attribution. **Focal** an Inter `quote-text` (900 uppercase) inside a white bordered+shadowed frame. **Accent** the ground + one decoration. **Silence** moderate. **Fixed** uppercase quote, 4px/8px. **Free** quote, ground. **Density** comfortable.
### 6 · Timeline (process · move: stepped bordered cards · offwhite)
**Ground** `{colors.offwhite}`. **Composes** label-pill, heading-lg, 3–4 card-small steps + step-connectors. **Focal** a row of bordered step cards linked by black connector bars, each a pastel + step-num. **Accent** the step pastels. **Silence** moderate. **Fixed** 3px+4px steps, square, connectors. **Free** steps, hues. **Density** standard.
## Composition Rules
### Do
- Pair **4px borders with 8px shadows**, **3px with 4px** — the coupling is non-negotiable.
- **Cycle pastel grounds** across frames; keep the deck visually rhythmic.
- Set Inter display **uppercase, 800–900, negative-tracked**; open every region with a **label-pill**.
- Render shadows **solid black, zero blur, bottom-right**; add **≥1 decoration** (tilt/star/stripe/dots) per frame.
- Use **yellow** for CTAs; use the **inverted black + 12px yellow shadow** close-frame for the closer.
- Tilt decorations ±2°–12°; lean left on most frames.
### Don't
- No rounded corners (save the stat-deco dot); no blurred shadows.
- No colored borders (black only, save the close-frame white); no sixth pastel.
- No sentence-case Inter display; no untracked display; no label as plain text.
- Don't keep everything perfectly aligned (tilt is the signature); don't leave corners empty.
- Don't blow a headline edge-to-edge — fit to measure.
## Aspect-Ratio Behavior
| Treatment | 16:9 | 9:16 | 1:1 |
| ------------- | ----------------------------- | ---------------------------- | ------------------------ |
| Cover | title left, decorations right | title top, decorations below | title, fewer decorations |
| Feature Cards | 3 across | 3 stacked | 2+1 |
| Stat Grid | 3 tilted across | 3 tilted stacked | 2×2 |
| Closing | centered close-frame | centered, taller | centered |
| Quote | quote frame | quote stacked | centered |
| Timeline | horizontal steps | vertical (connectors hidden) | 2×2 |
`slide-pad` holds on the short edge; re-step display above the 1.4cqw floor. Decoration count drops
on tighter ratios so the frame stays bordered, not cluttered.
## Approved Entities
No real customers, logos, or vendors are defined in the source — render any such mark as a
placeholder. Pastel fills, decorations, and tilts are content-agnostic.
## Numerals & Claims (hard rule)
Never invent figures, stats, or counts at frame scale. Render slots as `— figure —`, `{metric}`,
`N×`. Stat cards and charts carry placeholders until the script supplies values. Slide counters and
list numbers are decorative.
## Pre-Render Self-Audit
- **Squint** — one Inter display moment dominates; cards read as a system.
- **Silence** — only feature/stat grids run dense; cover/quote/close keep air with decorations.
- **Borders/shadows** — 4px↔8px / 3px↔4px coupling holds; solid black, zero blur.
- **Color** — pastel ground cycles; black borders only (white on close); no sixth pastel.
- **Type** — Inter uppercase 800–900 negative-tracked, fit-to-measure; Space Grotesk labels 0.08em; ≥1.4cqw floor.
- **Shape** — square corners (only stat-deco dot round); ≥1 tilt/decoration per frame.
- **Fabrication** — every numeral traces to the script, else placeholder.
## Known Gaps
- **Motion intentionally out of scope.** frame.md specifies composition only; the source toggles slides via display, no transition.
- **Inter + Space Grotesk via Google Fonts.** CJK: Noto Sans SC 900 (sentence case — the uppercase signal drops); lean harder on borders/shadows/decoration to carry the brutalist identity.
- **9:16 / 1:1 are guidance**; verify the floor and that decoration count scales down.
- Star bursts (clip-path), stripe blocks, dot grids, and tilts are CSS-only; no external imagery is required.
@@ -1,223 +0,0 @@
<!--
BlockFrame — caption skin (preset-local source).
This is the preset's own lower-third karaoke caption look: a caption skin to read or
copy, not to mount as is. A copy used as compositions/captions.html fills its three
reserved holes and wraps the fragment in a <template>:
· the empty GROUPS array → the engine's scene-aware word groups
· the 0 duration + 0 width/height → real total + canvas size
· the empty data-brand-tokens style → :root tokens derived from the project's frame.md
Token contract — reference ONLY this fixed vocab (set it from the
project's frame.md; each var has a blockframe literal
fallback for standalone preview):
--cap-ink · --cap-canvas · --cap-accent · --cap-accent-2 · --font-display ·
--font-body · --cap-band-top · --cap-band-height
Hooks (do not rename): .caption-group / .caption-word + states .is-active / .is-spoken,
root data-composition-id="captions", timeline at window.__timelines["captions"].
State changes use gsap.set({className}) (applied on the engine's frame-by-frame seek);
never tl.call() callbacks (seek does not fire them → wrong state on render).
Visual: a white card carved by a 4px black border with an 8px hard offset shadow
(solid black, zero blur, bottom-right — blockframe's sacred depth), square corners.
Inter 900, UPPERCASE, negative-tracked. Upcoming words sit in faded ink; the current
word gets a saturated-yellow stamp-block — a hard square highlight (box-shadow spread,
no blur) with black ink reading on top, blockframe's signature stamp gesture; spoken
words settle to solid black ink, stamp cleared. The word pops in with a snappy
back.out overshoot — loud, a little crooked.
-->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<!-- the project's :root tokens go here -->
<style data-brand-tokens></style>
<style>
#captions-root {
position: absolute;
inset: 0;
pointer-events: none;
}
.caption-layer {
position: absolute;
inset: 0;
z-index: 20;
pointer-events: none;
}
/* the keep-out band, from captionBand(H) — frame content stays above it */
.caption-stage {
position: absolute;
left: 0;
right: 0;
top: var(--cap-band-top, 900px);
height: var(--cap-band-height, 180px);
display: flex;
align-items: center;
justify-content: center;
}
.caption-group {
/* every group is absolutely stacked over the SAME band — exactly one shown at a
time via opacity. Must be absolute: in-flow groups would lay out side-by-side. */
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
}
/* THE bordered card — 4px black border + 8px hard offset shadow (solid black,
zero blur, bottom-right — blockframe's sacred depth), square corners */
.caption-pill {
max-width: 80%;
padding: 18px 40px 22px;
background: var(--cap-canvas, #ffffff);
border: 4px solid var(--cap-ink, #000000);
border-radius: 0;
box-shadow: 8px 8px 0 var(--cap-ink, #000000);
}
.caption-line {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.12em 0.32em;
font-family: var(--font-display, "Inter"), system-ui, sans-serif;
font-weight: 900;
text-transform: uppercase;
font-size: clamp(38px, 4.2vw, 56px);
line-height: 1.12;
letter-spacing: -0.02em;
}
/* upcoming — faded black ink, no stamp */
.caption-word {
display: inline-block;
padding: 0 0.1em;
color: color-mix(in srgb, var(--cap-ink, #000000) 40%, var(--cap-canvas, #ffffff));
}
/* current word — THE yellow stamp-block (box-shadow spread = a hard square block
w/o layout shift; no blur), black ink reading on top — blockframe's signature stamp */
.caption-word.is-active {
color: var(--cap-ink, #000000);
background: var(--cap-accent, #f7cb46);
box-shadow: 0 0 0 0.08em var(--cap-accent, #f7cb46);
}
/* already spoken — solid black ink, stamp cleared */
.caption-word.is-spoken {
color: var(--cap-ink, #000000);
background: transparent;
box-shadow: none;
}
/* optional per-word accents (dormant until a word-classer tags them) */
/* brand / key term — heavier ink weight, blockframe's display is already 900 */
.caption-word.cap-brand {
color: var(--cap-ink, #000000);
}
/* numerals / data — the body voice, slightly recessed */
.caption-word.cap-num {
font-family: var(--font-body, "Inter"), ui-monospace, monospace;
color: color-mix(in srgb, var(--cap-ink, #000000) 74%, var(--cap-canvas, #ffffff));
}
/* emphasis — a cream hard underline bar (the second-chroma accent) */
.caption-word.cap-cta {
text-decoration: underline;
text-decoration-thickness: 4px;
text-decoration-color: var(--cap-accent-2, #ffdc8b);
text-underline-offset: 0.12em;
}
@media (max-aspect-ratio: 9/16) {
.caption-pill {
max-width: 92%;
}
.caption-line {
font-size: clamp(34px, 5.4vw, 48px);
}
}
</style>
<div
id="captions-root"
data-composition-id="captions"
data-timeline-locked
data-start="0"
data-duration="0"
data-fps="30"
data-width="0"
data-height="0"
>
<div class="caption-layer" aria-hidden="true">
<div id="caption-stage" class="caption-stage"></div>
</div>
</div>
<script>
// the scene-aware word groups + real duration go here.
var GROUPS = [];
var DURATION = 0;
(function () {
var stage = document.getElementById("caption-stage");
// build DOM: one .caption-group per group, .caption-word per word
GROUPS.forEach(function (group, g) {
var groupEl = document.createElement("div");
groupEl.className = "caption-group";
groupEl.id = "caption-group-" + g;
var pill = document.createElement("div");
pill.className = "caption-pill";
var line = document.createElement("div");
line.className = "caption-line";
(group.words || []).forEach(function (w, i) {
var span = document.createElement("span");
span.className = "caption-word";
span.id = "caption-word-" + g + "-" + i;
span.textContent = String(w.text);
line.appendChild(span);
});
pill.appendChild(line);
groupEl.appendChild(pill);
stage.appendChild(groupEl);
});
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
GROUPS.forEach(function (group, g) {
var groupEl = document.getElementById("caption-group-" + g);
var words = group.words || [];
var next = GROUPS[g + 1];
var isLast = g === GROUPS.length - 1;
var start = Math.max(0, Number(group.start));
var end = isLast ? DURATION : Math.min(Number(next.start), Number(group.end) + 0.3);
if (end <= start) end = start + 0.01;
// group on/off — exactly one group visible at a time
tl.set(groupEl, { opacity: 1 }, start);
tl.set(groupEl, { opacity: 0 }, end);
// 3-state karaoke via className SETS — seek-safe (gsap.set applies on the
// engine's frame-by-frame seek; .call callbacks would NOT fire).
words.forEach(function (w, i) {
var el = document.getElementById("caption-word-" + g + "-" + i);
var at = Math.max(start, Number(w.start));
tl.set(el, { className: "caption-word" }, start); // upcoming (also resets on reverse-seek)
tl.set(el, { className: "caption-word is-active" }, at); // current
tl.fromTo(el, { scale: 0.86 }, { scale: 1, duration: 0.2, ease: "back.out(2.4)" }, at);
if (i + 1 < words.length) {
var nextAt = Math.max(start, Number(words[i + 1].start));
tl.set(el, { className: "caption-word is-spoken" }, nextAt); // demote when next activates
}
});
if (words.length) {
var lastEl = document.getElementById("caption-word-" + g + "-" + (words.length - 1));
var lastSpoken = Math.min(end, Number(words[words.length - 1].end) + 0.1);
tl.set(lastEl, { className: "caption-word is-spoken" }, lastSpoken);
}
});
// full-span anchor so the sub-comp timeline spans the whole video
tl.to({}, { duration: DURATION }, 0);
window.__timelines["captions"] = tl;
})();
</script>
File diff suppressed because it is too large. Load diff
@@ -1,307 +0,0 @@
---
version: alpha
name: Blue Professional — Frame (video / frame layer)
description: >
Video-first companion to Blue Professional's design.md. The unit is the frame (1920×1080). Atoms
are identical and sacred — the warm cream canvas, a single saturated cobalt (#1e2bfa) as the only
accent, the three-step gray text ladder, Space Grotesk (display/numerals/chrome) + Inter (body),
soft cobalt-tinted cards (4% fill / 20% border / 10–14px radius) with NO shadows, pill chrome, and
the cobalt progress bar. Composition + frame scale rewritten. Motion out of scope.
unit: the frame — 1920×1080 primary; 9:16 and 1:1 documented
principle: atoms are sacred · composition is free · numbers come from the script
colors:
bg: "#fdfae7"
primary: "#1e2bfa"
text: "#111111"
text-muted: "#6b6b6b"
text-light: "#9a9a9a"
accent-light: "rgba(30,43,250,0.08)"
accent-medium: "rgba(30,43,250,0.15)"
border: "rgba(30,43,250,0.2)"
card-bg: "rgba(30,43,250,0.04)"
positive: "#059669"
negative: "#dc2626"
radii:
pill: "100px"
card-lg: "14px"
card-md: "12px"
card-sm: "10px"
bar: "6px"
circle: "50%"
typography:
# — reading ramp (Inter body + Space Grotesk chrome) —
body: { fontFamily: "Inter", cqw: 0.85, weight: 400, lineHeight: 1.6, color: "text-muted" }
h4-eyebrow:{ fontFamily: "Space Grotesk", cqw: 0.8, weight: 600, tracking: "0.08em", upper: true, color: "primary" }
tag: { fontFamily: "Space Grotesk", px: 12, weight: 500, color: "primary" }
counter: { fontFamily: "Space Grotesk", px: 13, weight: 500, tracking: "0.05em", color: "text-muted" }
# — display / numerical ramp (Space Grotesk, near-black headings / cobalt numerals) —
h3: { fontFamily: "Space Grotesk", cqw: 1.25, weight: 500, lineHeight: 1.3, tracking: "-0.02em", color: "text" }
stat-num:{ fontFamily: "Space Grotesk", cqw: 1.9, weight: 700, lineHeight: 1.0, color: "primary" }
blockquote:{ fontFamily: "Space Grotesk", cqw: 2.4, weight: 500, lineHeight: 1.35, color: "text" }
h2: { fontFamily: "Space Grotesk", cqw: 2.6, weight: 600, lineHeight: 1.1, tracking: "-0.02em", color: "text" }
metric-value:{ fontFamily: "Space Grotesk", cqw: 3.0, weight: 700, lineHeight: 1.0, color: "primary" }
h1: { fontFamily: "Space Grotesk", cqw: 4.2, weight: 700, lineHeight: 1.08, tracking: "-0.02em", color: "text" }
quote-mark:{ fontFamily: "Space Grotesk", cqw: 8.0, weight: 700, lineHeight: 0.5, color: "primary", opacity: 0.15 }
spacing:
pad-x: "5cqw"
pad-y-top: "5cqw"
gap-cards: "1.4cqw"
accent-line: "60px × 4px"
components:
card-tinted:
backgroundColor: "{colors.card-bg}"
border: "1.5px solid {colors.border}"
rounded: "{radii.card-lg}"
shadow: "none"
description: "Universal content card. Never solid-colored, never opaque-bordered, NO shadow."
metric-card:
backgroundColor: "{colors.card-bg}"
border: "1.5px solid {colors.border}"
rounded: "{radii.card-lg}"
typography: "{typography.metric-value} ({colors.primary}) + {typography.metric-label} + {typography.metric-desc}"
description: "+ optional inline ↑/↓ change chip ({colors.positive}/{colors.negative} text, no fill)."
tag-pill:
backgroundColor: "{colors.accent-light}"
textColor: "{colors.primary}"
rounded: "{radii.pill}"
typography: "{typography.tag}"
description: "Top-right of the slide-header."
cta-button:
backgroundColor: "{colors.primary}"
textColor: "{colors.bg}"
rounded: "{radii.pill}"
typography: "Space Grotesk 600"
shadow: "soft cobalt on hover only — the system's only shadow"
description: "The one solid element."
accent-line:
backgroundColor: "{colors.primary}"
size: "60×4, 2px radius"
description: "Above cover titles / eyebrow separators."
bar-track:
backgroundColor: "{colors.accent-light}"
fill: "{colors.primary} (display:block so width resolves)"
rounded: "{radii.bar}"
description: "28px track; fill carries the value."
step-circle:
backgroundColor: "{colors.primary}"
textColor: "{colors.bg}"
rounded: "50%"
size: "56px"
description: "Sequential steps fade opacity 1.0→0.85→0.7→0.55."
split-highlight:
backgroundColor: "{colors.accent-light}"
borderLeft: "4px solid {colors.primary}"
rounded: "{radii.card-md}"
description: "Inline pull-quote callout."
slide-header:
typography: "{typography.h4-eyebrow} (cobalt) left, tag-pill right; {typography.h2} below"
description: "Top band of every content frame."
atmosphere:
elements: "clipped diagonal cobalt-tint panel, 3×3 cobalt dot grid, concentric closing rings"
description: "Cover/closing only. Never on content frames."
progress-bar:
backgroundColor: "{colors.primary}"
size: "3px tall, bottom edge, width grows with index"
description: "Persistent progress strip."
---
# Blue Professional — Frame (video / frame layer)
## Overview
Blue Professional at frame scale is a **consulting-grade system: restraint with one strong
commitment.** A warm cream canvas and a single saturated cobalt that carries every accent —
eyebrow, metric, CTA, chart fill, progress bar. No secondary brand color, no pastels, just cream,
cobalt, and a tight ladder of grays. The register is investment-research / McKinsey briefing:
measured, data-dense without crowding, executive-readable at distance.
The voice is two faces in fixed roles: **Space Grotesk** (display, every numeral, all chrome —
eyebrows uppercase 0.08em) and **Inter** (body, muted gray, line 1.6). Headlines are near-black;
cobalt is reserved for accent moments. Depth is **soft and tinted** — 4% cobalt card fills with 20%
cobalt borders and 10–14px radii — never shadowed. The lack of harsh shadows is the premium signal.
**Key characteristics at frame scale:**
- **Warm cream ground** on every frame; **single cobalt** as the only accent.
- **Space Grotesk** (display/numerals/chrome) + **Inter** (body) — near-black headlines, cobalt numerals.
- **Tinted cards** — cobalt 4% fill, cobalt 20% 1.5px border, 10–14px radius, **no shadow**.
- **Pill chrome** (100px) — tag pills + the one solid cobalt CTA; cobalt **progress bar**.
- **Soft rounded corners everywhere** (no square corners save the progress bar).
- **Atmosphere** (diagonal panel, dot grid, concentric rings) on cover/closing only.
## The Frame
### Frame Craft Bar
Three eyeball tests gate every frame before any structural check:
- **Squint** — one **near-black headline or cobalt numeral** dominates at 3–6× its neighbor.
- **Silence** — content frames read **balanced, not crowded**; the **dashboard is the one dense exception**.
- **Restraint** — a **single cobalt accent** carries everything; headlines stay near-black (never cobalt); no shadows (tinted cards do the lift); positive/negative inline-text only.
- **Reference** — aim at an **investment-research / McKinsey quarterly briefing**; failure looks like a **heavy-outlined, multi-color dashboard**.
- **Primary:** 1920×1080 (16:9). Display authored in **`cqw`** (`px ÷ 1920 × 100 = cqw`).
- **Vertical:** 1080×1920 (9:16). **Square:** 1080×1080 (1:1).
- **Safe area:** `pad-x` 5cqw; bottom reserves room for the counter + progress bar.
**The container law (load-bearing).** Every frame ground sets `container-type: size`; ALL
frame-relative units are `cqw`/`cqh` against it — never `vw`. Card radii stay px (10–14px); the
pill radius stays 100px; borders stay 1–1.5px.
## Colors
Tokens identical to the source. `{colors.bg}` cream is the universal ground; `{colors.primary}`
cobalt is the **only** accent — every eyebrow, numeral, CTA, chart fill, progress bar, and the 4px
highlight left-rule. Headlines are `{colors.text}` near-black (never cobalt); body is
`{colors.text-muted}`; tertiary is `{colors.text-light}`. Cards fill `{colors.card-bg}` (4%) with
`{colors.border}` (20%) borders. `{colors.positive}`/`{colors.negative}` appear **only inline** on
directional change chips — never as fills. **No second accent color.**
## Typography
Two ramps. The **reading ramp** (Inter body 0.85cqw muted; Space Grotesk eyebrow uppercase 0.08em
cobalt) carries copy + chrome; the **display/numerical ramp** (Space Grotesk `h3` 1.25cqw → `h1`
4.2cqw near-black; numerals `stat-num`/`metric-value` in cobalt) carries headings and figures.
- **Legibility floor:** any load-bearing line ≥ **1.4cqw**; px chrome (tag/counter) is colophon only.
- **Fit-to-measure:** size the headline to its length. Cap the block at **≤ 78cqw**; ≤3 words → `h1`; 4–6 → `h2`; 7+ → `h3`. Cobalt numerals scale `metric-value`→`stat-num` by card size.
- **Headlines near-black, −0.02em**; **eyebrows cobalt, uppercase, 0.08em**; **numerals cobalt 600–700**; **body Inter 400 muted, line 1.6**. No italic, no uppercase body, no cobalt headline.
## Depth & Surface
Soft and tinted — never offset. Depth from:
- **Tinted cards** — 4% cobalt fill + 20% cobalt 1.5px border + 10–14px radius reads as lifted without offset.
- **Border-left accent** — the 4px cobalt rule on split-highlight blocks pulls a callout forward.
- **Rounded corners** — the 10–14px radius is part of the softness; square corners break it.
**Ceiling:** zero box-shadow on content (the only shadow is a soft cobalt CTA _hover_); no opaque cobalt borders; no harsh outlines.
## Shapes
- **100px** — tag pills, CTA, nav buttons (pill chrome).
- **14/12/10px** — cards by size (large metric / standard stat / detail + mini).
- **6px** — bar tracks + fills. **50%** — step circles, nav circles, dots, closing rings.
- **0** — only the progress bar. No square-cornered content.
## Components
- **card-tinted / metric-card** — the universal soft-tint content cards (no shadow).
- **tag-pill / cta-button / accent-line** — the cobalt pill chrome + the one solid CTA + the 60×4 rule.
- **bar-track / step-circle / split-highlight** — cobalt data + sequence + callout patterns.
- **slide-header** (eyebrow + tag pill) — the structural rhythm; **atmosphere** (diagonal/dots/rings) on cover/closing only; **progress-bar** on every frame.
## Frame Treatments
> Recipe: ground · container · composes · focal · chrome · accent · silence · Fixed/Free · density.
> Atmosphere only on cover/closing; content frames carry the slide-header rhythm.
### 1 · Cover (identity · move: diagonal accent · left)
**Ground** cream + the clipped diagonal cobalt-tint panel (right ~36%) + a 3×3 cobalt dot grid.
**Composes** accent-line, meta, h1, body sub. **Focal** a 2-line Space Grotesk `h1` near-black, left,
under a cobalt accent-line + meta. **Chrome** counter + progress bar. **Accent** the cobalt line +
diagonal panel. **Silence** the diagonal panel holds the right third. **Fixed** near-black h1, cobalt
accents, atmosphere here only. **Free** title, meta. **Density** low.
### 2 · Dashboard (data · move: 3-up metric grid · the dense frame)
**Ground** cream, `pad-x`. **Composes** slide-header (eyebrow + tag-pill), h2, 3× metric/tinted card.
**Focal** a row of tinted cards — cobalt `metric-value` + Inter label + muted desc + optional
green/red change chip. **Chrome** eyebrow left, tag-pill right; progress bar. **Accent** the cobalt
numerals. **Silence** tight — the density exception. **Fixed** 4% tint cards, 20% borders, no shadow,
cobalt numerals. **Free** figures (from script), labels. **Density** dense-exception.
### 3 · Bar Ranking (data · move: cobalt bars · left)
**Ground** cream, `pad-x`. **Composes** eyebrow, h2, bar-track rows. **Focal** 3–5 labeled
cobalt-fill bars on cobalt-8% tracks with cobalt percentages. **Chrome** eyebrow; progress bar.
**Accent** the cobalt fills + figures. **Silence** moderate. **Fixed** 6px tracks, cobalt fills.
**Free** rows, values (from script). **Density** standard.
### 4 · Pull Quote (quote · move: concentric rings · centered)
**Ground** cream, centered, with faint concentric closing-rings behind. **Composes** quote-mark,
blockquote, cite. **Focal** a Space Grotesk `blockquote` near-black under a 15%-opacity cobalt
quote-mark; an uppercase cobalt-muted cite beneath. **Accent** the faint rings + quote-mark. **Silence**
~55%. **Fixed** near-black quote, soft rings. **Free** quote, cite. **Density** low.
### 5 · Split + Highlight (content · move: asymmetric split · left)
**Ground** cream, two columns. **Composes** eyebrow, h2, body, split-highlight block. **Focal** an
Inter body column beside a cobalt-8% highlight block (4px cobalt left rule) carrying an inline pull
quote. **Accent** the highlight's left rule. **Silence** generous gutter. **Fixed** tinted highlight,
4px cobalt rule. **Free** body, callout. **Density** standard.
### 6 · Closing / CTA (closer · move: centered rings + CTA)
**Ground** cream + concentric closing-rings. **Composes** accent-line, h1, body, cta-button. **Focal**
a Space Grotesk `h1` near-black, centered, with the one solid cobalt `cta-button` pill below. **Accent**
the CTA + rings. **Silence** ~60%. **Fixed** one CTA, near-black h1, soft rings. **Free** sign-off, CTA
label. **Density** low.
## Composition Rules
### Do
- Start every frame on **warm cream**; let **cobalt carry every accent** (eyebrow, numeral, CTA, bar, progress).
- Set headlines **near-black, −0.02em**; eyebrows **cobalt uppercase 0.08em**; numerals **cobalt 600–700**.
- Use **tinted cards** (4% fill, 20% border, 10–14px radius, no shadow); body Inter 400 muted, line 1.6.
- Keep all chrome **pill-shaped (100px)**; one solid cobalt CTA per closing frame.
- Reserve **atmosphere** (diagonal panel, dots, rings) for cover/closing; content frames keep the slide-header rhythm.
- Lean left on cover/dashboard/split, centered on quote/closer.
### Don't
- No second accent color; no cobalt headlines.
- No drop shadows on content (only the soft cobalt CTA hover); no opaque cobalt borders.
- No square corners (save the progress bar); no font substitutes; no uppercase body.
- Don't use the green/red change chips as general accents — directional comparisons only.
- Don't fill space with heavier borders — add substance; don't blow a headline edge-to-edge.
## Aspect-Ratio Behavior
| Treatment | 16:9 | 9:16 | 1:1 |
| ----------------- | -------------------------- | ------------------------------ | ------------------------ |
| Cover | title left, diagonal right | title top, diagonal band below | title upper, dots corner |
| Dashboard | 3 cards across | 3 stacked | 2×2 |
| Bar Ranking | 3–5 bars | 3–5 bars (tighter) | 3 bars |
| Pull Quote | centered, rings behind | centered, taller | centered |
| Split + Highlight | side-by-side | stacked | stacked |
| Closing / CTA | centered + CTA | centered + CTA | centered + CTA |
`pad-x` holds on the short edge; re-step display above the 1.4cqw floor. The diagonal cover panel
becomes a top/bottom band on 9:16. Numerals stay Latin Arabic digits in CJK builds.
## Approved Entities
No real customers, logos, or vendors are defined in the source — render any such mark as a
placeholder. Figures, metrics, and quotes are content; the system supplies cream + cobalt + grays.
## Numerals & Claims (hard rule)
Never invent figures, financials, percentages, or dates at frame scale. Render slots as `— figure —`,
`{metric}`, `+NN%`, `↑ —`. Metric cards, bar values, and stat cells especially carry placeholders
until the script supplies them. Directional chips require a real comparison from the script.
## Pre-Render Self-Audit
- **Squint** — one near-black headline or cobalt numeral dominates per frame.
- **Silence** — content frames balanced, not crowded; only the dashboard runs dense.
- **Single accent** — cobalt only; headlines near-black; positive/negative inline only.
- **Type** — Space Grotesk headings −0.02em near-black, cobalt eyebrows 0.08em + numerals; Inter body muted line 1.6; ≥1.4cqw floor.
- **Depth** — tinted cards (no shadow), soft rounded corners, 20% cobalt borders; no square content corners.
- **Anchor** — left on cover/dashboard/split, centered on quote/closer; atmosphere on cover/closing only.
- **Fabrication** — every numeral traces to the script, else placeholder.
## Known Gaps
- **Motion intentionally out of scope.** frame.md specifies composition only; the source's 500ms translateX transitions + bar-fill animations are deck mechanics.
- **Space Grotesk + Inter via Google Fonts.** CJK pairing (Noto Sans SC 700 display / Noto Serif SC 400 body) carries over; the eyebrow's uppercase+tracking signal weakens in CJK — pair it with the accent-line.
- **9:16 / 1:1 are guidance**; verify the floor and that the diagonal panel reflows to a band.
- Diagonal panel (clip-path), dot grid, concentric rings, and bars are CSS-only; no external imagery is required.
@@ -1,222 +0,0 @@
<!--
Blue Professional — caption skin (preset-local source).
This is the preset's own lower-third karaoke caption look: a caption skin to read or
copy, not to mount as is. A copy used as compositions/captions.html fills its three
reserved holes and wraps the fragment in a <template>:
· the empty GROUPS array → the engine's scene-aware word groups
· the 0 duration + 0 width/height → real total + canvas size
· the empty data-brand-tokens style → :root tokens derived from the project's frame.md
Token contract — reference ONLY this fixed vocab (set it from the
project's frame.md; each var has a blue-professional
literal fallback for standalone preview):
--cap-ink · --cap-canvas · --cap-accent · --cap-accent-2 · --font-display ·
--font-body · --cap-band-top · --cap-band-height
Hooks (do not rename): .caption-group / .caption-word + states .is-active / .is-spoken,
root data-composition-id="captions", timeline at window.__timelines["captions"].
State changes use gsap.set({className}) (applied on the engine's frame-by-frame seek);
never tl.call() callbacks (seek does not fire them → wrong state on render).
Visual: a soft cobalt-tinted card on warm cream — a 4% cobalt fill inside a 1.5px
20%-cobalt border, soft 14px rounded corners, NO shadow (the system's premium signal
is the absence of cast depth; the tinted card does the lift). Space Grotesk, sentence
case, near-black glyphs. Upcoming words sit in muted gray; the current word becomes a
solid cobalt chip — the brand's signature single-accent gesture, a pill-rounded fill
with cream reading on top; spoken words settle to near-black ink, chip cleared.
-->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<!-- the project's :root tokens go here -->
<style data-brand-tokens></style>
<style>
#captions-root {
position: absolute;
inset: 0;
pointer-events: none;
}
.caption-layer {
position: absolute;
inset: 0;
z-index: 20;
pointer-events: none;
}
/* the keep-out band, from captionBand(H) — frame content stays above it */
.caption-stage {
position: absolute;
left: 0;
right: 0;
top: var(--cap-band-top, 900px);
height: var(--cap-band-height, 180px);
display: flex;
align-items: center;
justify-content: center;
}
.caption-group {
/* every group is absolutely stacked over the SAME band — exactly one shown at a
time via opacity. Must be absolute: in-flow groups would lay out side-by-side. */
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
}
/* THE tinted card — 4% cobalt fill, 1.5px 20%-cobalt border, soft 14px corners,
NO shadow (the tinted card does the lift; cast depth is forbidden) */
.caption-pill {
max-width: 78%;
padding: 20px 44px 24px;
background: color-mix(in srgb, var(--cap-accent, #1e2bfa) 4%, var(--cap-canvas, #fdfae7));
border: 1.5px solid color-mix(in srgb, var(--cap-accent, #1e2bfa) 20%, transparent);
border-radius: 14px;
}
.caption-line {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.1em 0.34em;
font-family: var(--font-display, "Space Grotesk"), system-ui, sans-serif;
font-weight: 600;
font-size: clamp(40px, 4.4vw, 58px);
line-height: 1.16;
letter-spacing: -0.02em;
}
/* upcoming — muted gray, no chip */
.caption-word {
display: inline-block;
padding: 0 0.18em;
border-radius: 0.32em;
color: color-mix(in srgb, var(--cap-ink, #111111) 52%, var(--cap-canvas, #fdfae7));
}
/* current word — THE solid cobalt chip (the single-accent gesture; pill-rounded
fill, NO shadow), cream reading on top */
.caption-word.is-active {
color: var(--cap-canvas, #fdfae7);
background: var(--cap-accent, #1e2bfa);
box-shadow: none;
}
/* already spoken — solid near-black ink, chip cleared */
.caption-word.is-spoken {
color: var(--cap-ink, #111111);
background: transparent;
box-shadow: none;
}
/* optional per-word accents (dormant until a word-classer tags them) */
/* brand / key term — cobalt, the system's one accent on a key word */
.caption-word.cap-brand {
color: var(--cap-accent, #1e2bfa);
}
/* numerals / data — cobalt 700, the figure voice */
.caption-word.cap-num {
font-family: var(--font-display, "Space Grotesk"), system-ui, sans-serif;
font-weight: 700;
color: var(--cap-accent, #1e2bfa);
}
/* emphasis — a directional-red hairline underline (inline only, never a fill) */
.caption-word.cap-cta {
text-decoration: underline;
text-decoration-thickness: 2px;
text-decoration-color: var(--cap-accent-2, #dc2626);
text-underline-offset: 0.1em;
}
@media (max-aspect-ratio: 9/16) {
.caption-pill {
max-width: 92%;
}
.caption-line {
font-size: clamp(34px, 5.4vw, 48px);
}
}
</style>
<div
id="captions-root"
data-composition-id="captions"
data-timeline-locked
data-start="0"
data-duration="0"
data-fps="30"
data-width="0"
data-height="0"
>
<div class="caption-layer" aria-hidden="true">
<div id="caption-stage" class="caption-stage"></div>
</div>
</div>
<script>
// the scene-aware word groups + real duration go here.
var GROUPS = [];
var DURATION = 0;
(function () {
var stage = document.getElementById("caption-stage");
// build DOM: one .caption-group per group, .caption-word per word
GROUPS.forEach(function (group, g) {
var groupEl = document.createElement("div");
groupEl.className = "caption-group";
groupEl.id = "caption-group-" + g;
var pill = document.createElement("div");
pill.className = "caption-pill";
var line = document.createElement("div");
line.className = "caption-line";
(group.words || []).forEach(function (w, i) {
var span = document.createElement("span");
span.className = "caption-word";
span.id = "caption-word-" + g + "-" + i;
span.textContent = String(w.text);
line.appendChild(span);
});
pill.appendChild(line);
groupEl.appendChild(pill);
stage.appendChild(groupEl);
});
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
GROUPS.forEach(function (group, g) {
var groupEl = document.getElementById("caption-group-" + g);
var words = group.words || [];
var next = GROUPS[g + 1];
var isLast = g === GROUPS.length - 1;
var start = Math.max(0, Number(group.start));
var end = isLast ? DURATION : Math.min(Number(next.start), Number(group.end) + 0.3);
if (end <= start) end = start + 0.01;
// group on/off — exactly one group visible at a time
tl.set(groupEl, { opacity: 1 }, start);
tl.set(groupEl, { opacity: 0 }, end);
// 3-state karaoke via className SETS — seek-safe (gsap.set applies on the
// engine's frame-by-frame seek; .call callbacks would NOT fire).
words.forEach(function (w, i) {
var el = document.getElementById("caption-word-" + g + "-" + i);
var at = Math.max(start, Number(w.start));
tl.set(el, { className: "caption-word" }, start); // upcoming (also resets on reverse-seek)
tl.set(el, { className: "caption-word is-active" }, at); // current
tl.fromTo(el, { scale: 0.985 }, { scale: 1, duration: 0.18, ease: "power1.out" }, at);
if (i + 1 < words.length) {
var nextAt = Math.max(start, Number(words[i + 1].start));
tl.set(el, { className: "caption-word is-spoken" }, nextAt); // demote when next activates
}
});
if (words.length) {
var lastEl = document.getElementById("caption-word-" + g + "-" + (words.length - 1));
var lastSpoken = Math.min(end, Number(words[words.length - 1].end) + 0.1);
tl.set(lastEl, { className: "caption-word is-spoken" }, lastSpoken);
}
});
// full-span anchor so the sub-comp timeline spans the whole video
tl.to({}, { duration: DURATION }, 0);
window.__timelines["captions"] = tl;
})();
</script>
@@ -1,275 +0,0 @@
---
version: alpha
name: Bold Poster — Frame (video / frame layer)
description: >
Video-first companion to Bold Poster's design.md. The unit is the frame (1920×1080). Atoms are
identical and sacred — the four-color palette (white / brown-black ink / tomato red / off-white),
the three-face stack (Shrikhand display tilted at poster scale, Libre Baskerville serif body,
Space Grotesk mono chrome), the stacked text-shadow on red display, the 3px+1.5px double-border
grid, red leftbar cards, red em-dash bullets, and the red progress bar. Composition + frame scale
rewritten. Motion out of scope.
unit: the frame — 1920×1080 primary; 9:16 and 1:1 documented
principle: atoms are sacred · composition is free · numbers come from the script
colors:
bg: "#FFFFFF"
dark: "#1C1410"
red: "#D8000F"
light: "#F5F2EF"
typography:
# — reading + chrome ramp —
body: { fontFamily: "Libre Baskerville", cqw: 0.85, weight: 400, lineHeight: 1.75, color: "dark" }
body-cell:{ fontFamily: "Libre Baskerville", cqw: 0.7, weight: 400, lineHeight: 1.55 }
label: { fontFamily: "Space Grotesk", px: 10, weight: 600, tracking: "2px", upper: true, color: "red" }
bullet-body:{ fontFamily: "Space Grotesk", cqw: 0.62, weight: 400, lineHeight: 1.45 }
# — display / hero ramp (Shrikhand 400, tilted) —
card-title:{ fontFamily: "Shrikhand", cqw: 1.9, weight: 400, lineHeight: 1.1, color: "dark" }
cell-number:{ fontFamily: "Shrikhand", cqw: 2.7, weight: 400, lineHeight: 1.0, color: "red" }
section-header:{ fontFamily: "Shrikhand", cqw: 3.3, weight: 400, lineHeight: 1.0, color: "dark" }
red-quote:{ fontFamily: "Shrikhand", cqw: 4.7, weight: 400, lineHeight: 1.15, color: "bg", shadow: "stacked" }
hero-title-bottom:{ fontFamily: "Shrikhand", cqw: 10.4, weight: 400, lineHeight: 0.9, color: "dark", rotate: "2deg" }
hero-title:{ fontFamily: "Shrikhand", cqw: 11.5, weight: 400, lineHeight: 0.88, color: "dark" }
hero-title-red:{ fontFamily: "Shrikhand", cqw: 13.5, weight: 400, lineHeight: 0.85, color: "red", rotate: "-4deg" }
close-big:{ fontFamily: "Shrikhand", cqw: 13.5, weight: 400, lineHeight: 0.88, color: "red", rotate: "-5deg" }
stat-big:{ fontFamily: "Shrikhand", cqw: 22.0, weight: 400, lineHeight: 0.82, color: "red", rotate: "-6deg" }
spacing:
pad-slide: "3cqw 3.6cqw"
gap-grid: "1.5cqw 2cqw"
components:
progress-bar:
backgroundColor: "{colors.red}"
size: "0.5cqw tall, bottom edge, width grows with index"
description: "The most prominent chrome."
hero-title-stack:
typography: "{typography.hero-title} + {typography.hero-title-red} + {typography.hero-title-bottom}"
transform: "≥4 tilt (−4°/+2°), ≥1 line in {colors.red}"
description: "A 3-line Shrikhand composition — the signature opener."
stat-big:
typography: "{typography.stat-big}"
color: "{colors.red} (or {colors.bg} on red with stacked shadow)"
transform: "rotate(-6deg)"
description: "Hero numeral at poster scale."
fin-grid:
border: "0.3cqw solid {colors.dark} outer"
rule: "0.15cqw solid {colors.dark} inner (touching at intersections)"
rounded: "0"
typography: "{typography.cell-number} ({colors.red}) + {typography.label} + {typography.body-cell}"
description: "The double-border tabular signature."
red-leftbar-card:
borderLeft: "4px solid {colors.red}"
padding: "0 0 0 ~1cqw"
rounded: "0"
shadow: "none"
typography: "{typography.card-title} + {typography.body} + red em-dash bullets"
description: "Editorial card cantilevered off a red left rule — no outline."
red-panel:
backgroundColor: "{colors.red}"
textColor: "{colors.bg}"
description: "Full-bleed statement surface; display carries the stacked text-shadow."
dark-panel:
backgroundColor: "{colors.dark}"
textColor: "{colors.bg}"
description: "Full-bleed dark statement surface; red accents."
stacked-text-shadow:
shadow: "2px 2px 0 rgba(28,20,16,.25), 4px 4px 0 rgba(28,20,16,.2), 6px 6px 0 rgba(28,20,16,.15)"
appliesTo: "red display text on red panels only"
description: "The ONLY shadow in the system — text-shadow, not box-shadow."
bullet:
marker: "red em-dash (—) or round (•) glyph at absolute-left"
color: "{colors.red}"
description: "No default disc bullets; capped at three."
---
# Bold Poster — Frame (video / frame layer)
## Overview
Bold Poster at frame scale is a **populist editorial poster** — vintage Italian sports-magazine
display, classical serif body, one saturated tomato red, grids ruled in ink. Every frame should
feel _printed_: heavy display type at poster scale, locked to one red accent, on a white/off-white
sheet (or a full red/dark statement panel), with decoration kept to a strict minimum.
The voice is a three-face stack: **Shrikhand** (heavy slab-script, weight 400 only, routinely
tilted −6°..+2°) carries every hero title, section header, stat, and card title; **Libre
Baskerville** (literary serif) carries every body paragraph — it's what makes the system feel
printed; **Space Grotesk** (uppercase, 2–3px tracked) is chrome only — labels, eyebrows, counters,
bullet bodies. The plane is flat; the _only_ shadow is the stacked text-shadow on red display.
**Key characteristics at frame scale:**
- **Four colors only** — white / brown-black ink / tomato red / off-white. Red is the lone accent.
- **Shrikhand display, tilted** (−6° stat, −5° close, −4° hero-red, +2° hero-bottom) — the signature movement.
- **Libre Baskerville serif body** (line 1.75); **Space Grotesk** chrome (uppercase tracked).
- **Double-border grids** (3px outer + 1.5px inner ink); **red leftbar cards**; **red em-dash bullets** (max 3).
- **Stacked text-shadow** on red display — the only shadow; flat plane otherwise; square corners.
- **Red progress bar** at the bottom edge of every frame.
## The Frame
### Frame Craft Bar
Three eyeball tests gate every frame before any structural check:
- **Squint** — **one display moment dominates** at 3–6× everything else (the hero stack, `stat-big`, or `close-big`); nothing competes.
- **Silence** — statement frames reserve **~50–60% negative space**; the **financial grid is the one dense exception**.
- **Restraint** — **four colors only**, red the lone accent; one display moment per frame; the stacked text-shadow on red is the only shadow; bullets capped at three.
- **Reference** — aim at a **vintage Italian sports-magazine cover / mid-century European annual report**; failure looks like a **rounded, soft-shadowed multi-accent slide**.
- **Primary:** 1920×1080 (16:9). Display authored in **`cqw`** (`px ÷ 1920 × 100 = cqw`).
- **Vertical:** 1080×1920 (9:16). **Square:** 1080×1080 (1:1).
- **Safe area:** `pad-slide` ~3cqw — deliberately tight so the poster type crowds the frame.
**The container law (load-bearing).** Every frame ground sets `container-type: size`; ALL
frame-relative units are `cqw`/`cqh` against it — never `vw`. Borders stay px; rotation transforms hold.
## Colors
Tokens identical to the source. `{colors.bg}` white is the default ground; `{colors.dark}` is body,
borders, headers; `{colors.red}` is the only accent — every numeral, section rule, eyebrow,
leftbar, bullet, progress bar, and the full statement-panel ground. `{colors.light}` off-white
stripes alternating panels. **No fifth color** (no green/blue/yellow); categorical difference comes
from position, label, and tilt. Red is never body text, never a tint, never an untexted fill.
## Typography
Two ramps. The **reading/chrome ramp** (Baskerville body 0.85cqw, Space Grotesk labels in px)
carries copy + chrome; the **display ramp** (Shrikhand `section-header` 3.3cqw → `stat-big` 22cqw)
carries every statement and numeral.
- **Legibility floor:** any load-bearing line ≥ **1.4cqw**; mono labels are chrome only.
- **Fit-to-measure:** size the headline to its length. Cap the block at **≤ 78cqw**; ≤2 words → `stat-big`/`hero-title-red`; 3–4 → `hero-title`; 5+ → `section-header`. The hero is a stacked 3-line composition.
- **Shrikhand is weight 400, tilted on statement/hero elements, red on numerals**; **Baskerville body at line ≥1.5**; **Space Grotesk chrome uppercase, 2–3px**. Inline `<strong>` switches face to Space Grotesk 600. No italic display, no untilted red hero.
## Depth & Surface
Flat plane. Depth from:
- **Heavy borders** — 3px+1.5px double-border grids, 2px global-card outline, 4px red leftbar rules.
- **Surface inversion** — full-bleed red or dark statement panels.
- **Tilt** — rotated Shrikhand breaks the baseline for perceived dimension.
- **The single shadow** — stacked text-shadow on red display only (text-shadow, three steps).
**Ceiling:** no box-shadow, no rounded surface (square corners; only the hint pill is 4px), no gradient.
## Shapes
- **0 radius everywhere** except the hint pill (4px). Cards, cells, panels, callouts — sharp rectangles.
## Components
- **hero-title-stack** — the 3-line tilted opener. **stat-big** — the poster numeral.
- **fin-grid** — the double-border tabular signature. **red-leftbar-card** — the cantilevered editorial card.
- **red-panel / dark-panel** — statement surfaces. **stacked-text-shadow** — the one shadow.
- **bullet** — red em-dash (max 3). **progress-bar** — the red bottom strip.
## Frame Treatments
> Recipe: ground · register · composes · focal · chrome · accent · silence · Fixed/Free · density.
> One display moment per frame; statement frames reserve massive negative space.
### 1 · Hero Stack (identity · move: 3-line tilted stack · left)
**Ground** white. **Composes** hero-title-stack, label, body tagline, progress-bar. **Focal** a
3-line Shrikhand stack (e.g. ink / red-tilted / paper) — one red line, one+ tilted. **Chrome** mono
eyebrow + Baskerville tagline; bottom progress bar + counter. **Accent** the red line. **Silence**
right half open. **Fixed** Shrikhand 400, ≥1 tilt + ≥1 red, square. **Free** the words, line sizes.
**Density** low.
### 2 · Hero Stat (statement · move: poster numeral · red panel · centered)
**Ground** full `{colors.red}`. **Composes** stat-big, label, body sub. **Focal** a `stat-big`
numeral rotated −6° in white with the stacked shadow, centered, with a mono label above + Baskerville
sub below. **Accent** the ink stacked-shadow on white. **Silence** ~60%. **Fixed** white-on-red +
stacked shadow, −6° tilt. **Free** the figure (from script), label. **Density** low.
### 3 · Financial Grid (data · move: double-border matrix · the dense frame)
**Ground** white, `pad-slide`. **Composes** label, section-header, fin-grid. **Focal** a 3px-outer/
1.5px-inner ink grid of cells (red Shrikhand numeral + mono label + Baskerville body). **Chrome** mono
eyebrow; progress bar. **Accent** the red numerals. **Silence** tight — the density exception.
**Fixed** double-border, red numerals, serif body. **Free** figures (from script), labels. **Density** dense-exception.
### 4 · Pull Quote (quote · move: tilted/stacked display · red panel · left)
**Ground** full `{colors.red}`. **Composes** red-quote, Baskerville cite. **Focal** a 2-line Shrikhand
quote in white with the stacked shadow; a Baskerville cite beneath. **Accent** the stacked shadow.
**Silence** ~50%. **Fixed** white-on-red + stacked shadow. **Free** quote, cite. **Density** low.
### 5 · Editorial Cards (content · move: red leftbar cards · left)
**Ground** white (or alternating off-white stripes). **Composes** label, section-header, 2–3×
red-leftbar-card. **Focal** cards cantilevered off 4px red rules — Shrikhand title + Baskerville body
- red em-dash bullets (max 3). **Accent** the red left rules + bullets. **Silence** moderate. **Fixed**
4px red leftbar, em-dash bullets, no outline. **Free** card content. **Density** standard.
### 6 · Closing Statement (closer · move: tilted close-big · centered/left)
**Ground** white or `{colors.dark}`. **Composes** close-big, label, body sub, progress-bar. **Focal**
a Shrikhand `close-big` (rotated −5°, red) sign-off; mono eyebrow + Baskerville contact line. **Accent**
the red tilted title. **Silence** ~60%. **Fixed** −5° tilt, red close-big. **Free** sign-off, contact.
**Density** low.
## Composition Rules
### Do
- Stack hero titles in 3 Shrikhand lines — at least one tilted, at least one red.
- Make every numeral **red Shrikhand**; tilt statement display **−5° to −6°**.
- Set eyebrows in **Space Grotesk 600 uppercase, 2–3px**, red; body in **Baskerville, line 1.75**.
- Build data grids with the **3px outer + 1.5px inner double border**; use red leftbar cards elsewhere.
- Use **red em-dash bullets, capped at three**; apply the stacked text-shadow on red display.
- One display moment per frame; reserve negative space on statement frames.
### Don't
- No second accent color; no rounded corners (square only, save the hint pill).
- No drop shadow — the stacked text-shadow on red is the only one.
- No font substitutes; no Shrikhand body, no Baskerville labels, no Space Grotesk headlines.
- No default disc bullets; no untilted red statement display.
- Don't crowd a statement frame; don't blow a long line edge-to-edge (step down).
## Aspect-Ratio Behavior
| Treatment | 16:9 | 9:16 | 1:1 |
| --------------- | ------------------------- | ------------------------ | -------------------- |
| Hero Stack | stack left, tagline below | stack centered, taller | stack, tagline below |
| Hero Stat | numeral centered | numeral centered, taller | centered |
| Financial Grid | 3 cells across | 2 across / stacked | 2×2 |
| Pull Quote | quote left | quote stacked | centered |
| Editorial Cards | 2–3 across | stacked | stacked |
| Closing | close-big centered | close-big stacked | centered |
`pad-slide` stays tight on the short edge; re-step display so the line stays ≤78cqw above the floor.
Tilts hold; the progress bar spans the bottom on every ratio.
## Approved Entities
No real customers, logos, or vendors are defined in the source — render any such mark as a
placeholder. The system supplies type, one red, and ink rules, not brands.
## Numerals & Claims (hard rule)
Never invent figures, financials, percentages, or dates at frame scale. Render slots as `— figure —`,
`{metric}`, `+NN%`. Fin-grid cells and stat-big especially carry placeholders until the script
supplies them. Catalogue numbers / progress are decorative.
## Pre-Render Self-Audit
- **Squint** — one display moment dominates; nothing competes.
- **Silence** — statement frames reserve ~50–60% negative space; only the fin-grid runs dense.
- **Color** — four colors only; red is the lone accent; no red body text.
- **Type** — Shrikhand 400 tilted on statements, red numerals, fit-to-measure; Baskerville body line 1.75; mono chrome uppercase 2–3px; ≥1.4cqw floor.
- **Depth** — flat; the stacked text-shadow on red display is the only shadow; square corners.
- **Bullets** — red em-dash, capped at three.
- **Fabrication** — every numeral traces to the script, else placeholder.
## Known Gaps
- **Motion intentionally out of scope.** frame.md specifies composition only; the source's 550ms slide transitions are deck mechanics.
- **Shrikhand + Libre Baskerville + Space Grotesk via Google Fonts.** CJK: Noto Serif SC 900/400 + Noto Sans SC; Shrikhand's slab-script has no Hanzi equal — keep tilts + red + double-borders to carry the identity.
- **9:16 / 1:1 are guidance**; verify the one big line ≤78cqw per ratio and that tilts don't clip.
- Grids, leftbar cards, and the stacked shadow are CSS-only; no external imagery is required.
@@ -1,229 +0,0 @@
<!--
Bold Poster — caption skin (preset-local source).
This is the preset's own lower-third karaoke caption look: a caption skin to read or
copy, not to mount as is. A copy used as compositions/captions.html fills its three
reserved holes and wraps the fragment in a <template>:
· the empty GROUPS array → the engine's scene-aware word groups
· the 0 duration + 0 width/height → real total + canvas size
· the empty data-brand-tokens style → :root tokens derived from the project's frame.md
Token contract — reference ONLY this fixed vocab (set it from the
project's frame.md; each var has a bold-poster
literal fallback for standalone preview):
--cap-ink · --cap-canvas · --cap-accent · --cap-accent-2 · --font-display ·
--font-body · --cap-band-top · --cap-band-height
Hooks (do not rename): .caption-group / .caption-word + states .is-active / .is-spoken,
root data-composition-id="captions", timeline at window.__timelines["captions"].
State changes use gsap.set({className}) (applied on the engine's frame-by-frame seek);
never tl.call() callbacks (seek does not fire them → wrong state on render).
Visual: a fresh-paper card with bold poster framing — a heavy 1.5px brown-black ink
rule plus a 4px tomato-red leftbar (the brand's cantilevered-card signature), SQUARE
corners, NO drop shadow (the system's only shadow is the stacked text-shadow on red
display). Serif body face, ink glyphs. Upcoming words sit in faint ink; the current
word slams into a tomato-red panel (the brand's signature surface inversion, a hard
square block on paper-white reading, no blur) with the stacked text-shadow on it;
spoken words settle to solid ink, panel cleared.
-->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<!-- the project's :root tokens go here -->
<style data-brand-tokens></style>
<style>
#captions-root {
position: absolute;
inset: 0;
pointer-events: none;
}
.caption-layer {
position: absolute;
inset: 0;
z-index: 20;
pointer-events: none;
}
/* the keep-out band, from captionBand(H) — frame content stays above it */
.caption-stage {
position: absolute;
left: 0;
right: 0;
top: var(--cap-band-top, 900px);
height: var(--cap-band-height, 180px);
display: flex;
align-items: center;
justify-content: center;
}
.caption-group {
/* every group is absolutely stacked over the SAME band — exactly one shown at a
time via opacity. Must be absolute: in-flow groups would lay out side-by-side. */
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
}
/* THE paper card — heavy 1.5px brown-black ink rule + a 4px tomato-red leftbar
(bold poster's cantilevered red-leftbar-card signature), square corners, NO
drop shadow (the only shadow in the system is the stacked text-shadow on red) */
.caption-pill {
max-width: 78%;
padding: 20px 44px 24px;
background: var(--cap-canvas, #ffffff);
border: 1.5px solid var(--cap-ink, #1c1410);
border-left: 4px solid var(--cap-accent, #d8000f);
border-radius: 0;
}
.caption-line {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.1em 0.34em;
font-family: var(--font-display, "Shrikhand"), Georgia, serif;
font-weight: 400;
font-size: clamp(40px, 4.4vw, 58px);
line-height: 1.16;
letter-spacing: -0.01em;
}
/* upcoming — faint brown-black ink, no panel */
.caption-word {
display: inline-block;
padding: 0 0.06em;
color: color-mix(in srgb, var(--cap-ink, #1c1410) 42%, var(--cap-canvas, #ffffff));
}
/* current word — THE tomato-red panel (box-shadow spread = a hard square block
w/o layout shift; no blur), paper-white reading on top carrying the stacked
text-shadow — bold poster's surface-inversion + only-shadow signatures */
.caption-word.is-active {
color: var(--cap-canvas, #ffffff);
background: var(--cap-accent, #d8000f);
box-shadow: 0 0 0 0.06em var(--cap-accent, #d8000f);
text-shadow:
0.03em 0.03em 0 rgba(28, 20, 16, 0.25),
0.06em 0.06em 0 rgba(28, 20, 16, 0.2),
0.09em 0.09em 0 rgba(28, 20, 16, 0.15);
}
/* already spoken — solid brown-black ink, panel cleared */
.caption-word.is-spoken {
color: var(--cap-ink, #1c1410);
background: transparent;
box-shadow: none;
text-shadow: none;
}
/* optional per-word accents (dormant until a word-classer tags them) */
/* brand / key term — Shrikhand display, bold poster's display face inflection */
.caption-word.cap-brand {
font-family: var(--font-display, "Shrikhand"), Georgia, serif;
}
/* numerals / data — the mono chrome voice, slightly recessed */
.caption-word.cap-num {
font-family: var(--font-body, "Libre Baskerville"), ui-monospace, monospace;
color: color-mix(in srgb, var(--cap-ink, #1c1410) 72%, var(--cap-canvas, #ffffff));
}
/* emphasis — a tomato-red em-dash-weight underline (the lone accent) */
.caption-word.cap-cta {
text-decoration: underline;
text-decoration-thickness: 2px;
text-decoration-color: var(--cap-accent-2, #f5f2ef);
text-underline-offset: 0.1em;
}
@media (max-aspect-ratio: 9/16) {
.caption-pill {
max-width: 92%;
}
.caption-line {
font-size: clamp(34px, 5.4vw, 48px);
}
}
</style>
<div
id="captions-root"
data-composition-id="captions"
data-timeline-locked
data-start="0"
data-duration="0"
data-fps="30"
data-width="0"
data-height="0"
>
<div class="caption-layer" aria-hidden="true">
<div id="caption-stage" class="caption-stage"></div>
</div>
</div>
<script>
// the scene-aware word groups + real duration go here.
var GROUPS = [];
var DURATION = 0;
(function () {
var stage = document.getElementById("caption-stage");
// build DOM: one .caption-group per group, .caption-word per word
GROUPS.forEach(function (group, g) {
var groupEl = document.createElement("div");
groupEl.className = "caption-group";
groupEl.id = "caption-group-" + g;
var pill = document.createElement("div");
pill.className = "caption-pill";
var line = document.createElement("div");
line.className = "caption-line";
(group.words || []).forEach(function (w, i) {
var span = document.createElement("span");
span.className = "caption-word";
span.id = "caption-word-" + g + "-" + i;
span.textContent = String(w.text);
line.appendChild(span);
});
pill.appendChild(line);
groupEl.appendChild(pill);
stage.appendChild(groupEl);
});
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
GROUPS.forEach(function (group, g) {
var groupEl = document.getElementById("caption-group-" + g);
var words = group.words || [];
var next = GROUPS[g + 1];
var isLast = g === GROUPS.length - 1;
var start = Math.max(0, Number(group.start));
var end = isLast ? DURATION : Math.min(Number(next.start), Number(group.end) + 0.3);
if (end <= start) end = start + 0.01;
// group on/off — exactly one group visible at a time
tl.set(groupEl, { opacity: 1 }, start);
tl.set(groupEl, { opacity: 0 }, end);
// 3-state karaoke via className SETS — seek-safe (gsap.set applies on the
// engine's frame-by-frame seek; .call callbacks would NOT fire).
words.forEach(function (w, i) {
var el = document.getElementById("caption-word-" + g + "-" + i);
var at = Math.max(start, Number(w.start));
tl.set(el, { className: "caption-word" }, start); // upcoming (also resets on reverse-seek)
tl.set(el, { className: "caption-word is-active" }, at); // current
tl.fromTo(el, { scale: 0.9 }, { scale: 1, duration: 0.16, ease: "back.out(2.2)" }, at);
if (i + 1 < words.length) {
var nextAt = Math.max(start, Number(words[i + 1].start));
tl.set(el, { className: "caption-word is-spoken" }, nextAt); // demote when next activates
}
});
if (words.length) {
var lastEl = document.getElementById("caption-word-" + g + "-" + (words.length - 1));
var lastSpoken = Math.min(end, Number(words[words.length - 1].end) + 0.1);
tl.set(lastEl, { className: "caption-word is-spoken" }, lastSpoken);
}
});
// full-span anchor so the sub-comp timeline spans the whole video
tl.to({}, { duration: DURATION }, 0);
window.__timelines["captions"] = tl;
})();
</script>
File diff suppressed because it is too large. Load diff
@@ -1,284 +0,0 @@
---
version: alpha
name: Broadside — Frame (video / frame layer)
description: >
Video-first companion to Broadside's design.md. The unit is the frame (1920×1080). Atoms are
identical and sacred — the two-register surface system (dark ink-black / fire-orange), massive
Barlow in lowercase weight 900 treated as graphic primitive, IBM Plex Mono chrome (uppercase,
0.14em), the single fire-orange accent, the flat plane, and 1px hairline dividers. Composition +
frame scale rewritten for the frame. Motion out of scope.
unit: the frame — 1920×1080 primary; 9:16 and 1:1 documented
principle: atoms are sacred · composition is free · numbers come from the script
colors:
ink-black: "#111111"
ink-black-alt: "#1A1A18"
fire-orange: "#E85D26"
cream: "#F0ECE5"
cream-muted: "#888880"
cream-hint: "#505048"
border-dark: "#282826"
ink-on-orange-muted: "rgba(17,17,17,0.75)"
ink-on-orange-hint: "rgba(17,17,17,0.55)"
ink-on-orange-faint: "rgba(17,17,17,0.40)"
ink-on-orange-border: "rgba(17,17,17,0.20)"
typography:
# — reading ramp —
body: { fontFamily: "Barlow", cqw: 1.2, weight: 400, lineHeight: 1.6 }
lead: { fontFamily: "Barlow", cqw: 1.6, weight: 400, lineHeight: 1.5 }
caption: { fontFamily: "Barlow", cqw: 0.9, weight: 400, lineHeight: 1.5 }
label: { fontFamily: "IBM Plex Mono", cqw: 0.72, weight: 500, tracking: "0.14em", upper: true }
# — display / hero ramp (Barlow, lowercase, negative tracking) —
h3: { fontFamily: "Barlow", cqw: 2.8, weight: 600, lineHeight: 1.2, lower: true }
quote-text:{ fontFamily: "Barlow", cqw: 3.8, weight: 700, lineHeight: 1.15, tracking: "-0.02em", lower: true }
h2: { fontFamily: "Barlow", cqw: 4.5, weight: 700, lineHeight: 1.1, tracking: "-0.02em", lower: true }
stat-value:{ fontFamily: "Barlow", cqw: 5.5, weight: 900, lineHeight: 1.0, tracking: "-0.04em" }
h1: { fontFamily: "Barlow", cqw: 7.5, weight: 800, lineHeight: 0.9, tracking: "-0.03em", lower: true }
fadelist-item:{ fontFamily: "Barlow", cqw: 7.5, weight: 900, lineHeight: 1.0, tracking: "-0.03em", lower: true }
quote-mark:{ fontFamily: "Barlow", cqw: 10.0, weight: 900, lineHeight: 0.6 }
fadelist-title:{ fontFamily: "Barlow", cqw: 10.5, weight: 900, lineHeight: 0.9, tracking: "-0.04em", lower: true }
display: { fontFamily: "Barlow", cqw: 13.0, weight: 900, lineHeight: 0.88, tracking: "-0.04em", lower: true }
spacing:
pad-x: "5.5cqw"
pad-y: "5.5cqw"
gap-lg: "3.5cqw"
gap-md: "2cqw"
gap-sm: "1cqw"
components:
registers:
dark: "ground {colors.ink-black}, text {colors.cream}, accent {colors.fire-orange}"
orange: "ground {colors.fire-orange}, text {colors.ink-black}"
description: "Two surfaces only — no cream/paper register. One register per frame."
slide-chrome:
rule: "1px solid {colors.border-dark} (dark) / 20% ink (orange)"
placement: "top + bottom bars (label left, number right)"
description: "SUPPRESSED on cover/chapter/statement/quote/end — declarative frames let type fill the field."
kicker:
typography: "{typography.label}"
color: "{colors.fire-orange} (dark) / 55% ink (orange)"
description: "Uppercase mono eyebrow."
rule:
backgroundColor: "{colors.fire-orange} (dark) / {colors.ink-black} (orange)"
size: "36×2px"
description: "Stub accent bar — the system's only ornament."
broadside-num:
typography: "{typography.label}"
placement: "top-left of orange cover/chapter, low opacity"
description: "Mono catalogue numeral."
stat-card:
borderTop: "1px solid {colors.border-dark}"
typography: "{typography.stat-value} (orange on dark / ink on orange) + {typography.body} + {typography.label}"
description: "Top-border-only block, no other borders."
bullet:
marker: "orange `/` mono via ::before"
typography: "{typography.lead}"
description: "Capped at THREE items."
bar-track:
borderLeft: "1px solid {colors.border-dark}"
bars: "{colors.cream-hint}, one .accent {colors.fire-orange}"
typography: "{typography.label} axis"
description: "Vertical bar chart, left axis only."
compare-panel:
layout: "two equal panels split by a 1px vertical rule"
payoff: "right panel may fill {colors.fire-orange}"
description: "Before/after."
fadelist:
typography: "{typography.fadelist-item} ×3 at opacity 1.0/0.5/0.22 + {typography.fadelist-title}"
description: "Three stacked words + one oversized title opposite."
---
# Broadside — Frame (video / frame layer)
## Overview
Broadside at frame scale is a **protest-poster system where type is so large it stops reading as
text and becomes graphic primitive.** Barlow `display` at 13cqw puts a single lowercase word
nearly across the frame. The system runs in **two registers**: a dark ink-black ground with cream
text for documentation, and a fire-orange ground with dark ink for declaration. Fire-orange is the
_only_ color — accent on dark, environment on orange. The plane is flat; hierarchy is weight, size,
and 1px hairlines.
**Barlow** carries every text role from display to body — expressive range from weight (400–900)
and size, not face contrast. **IBM Plex Mono** is chrome only (numbers, kickers, tags, axis labels,
the `/` bullet marker), always uppercase and tracked. Display is **lowercase** — the system's most
distinctive single decision, a deliberate inversion of the brutalist norm.
**Key characteristics at frame scale:**
- **Two registers** — dark (cream text) / orange (ink text). No cream/paper register.
- **Massive lowercase Barlow 900**, negative-tracked, as graphic primitive (display 13cqw).
- **Fire-orange is the only color** — accent on dark, full environment on orange.
- **IBM Plex Mono chrome** — uppercase, 0.14em; the `/` bullet marker; mono catalogue numbers.
- **Flat plane** — no shadow, no radius (save nav dots), no gradient; 1px hairlines carry structure.
- **Low density** — one statement per frame, bullets capped at three, chrome suppressed on declarative frames.
## The Frame
### Frame Craft Bar
Three eyeball tests gate every frame before any structural check:
- **Squint** — exactly **one display moment dominates** at 3–6× everything else; nothing competes.
- **Silence** — declarative frames read **45–55% empty**; the **stat grid is the one dense exception**.
- **Restraint** — **one register per frame**; **fire-orange is the only color** (accent on dark, environment on orange); one display moment; bullets capped at three.
- **Reference** — aim at **broadside printing / a SPACE10 report / a Wim Crouwel grid with one loud color**; failure looks like a **multi-accent corporate slide deck**.
- **Primary:** 1920×1080 (16:9). Type authored in **`cqw`** (`px ÷ 1920 × 100 = cqw`; or carry the source's `vw` 1:1).
- **Vertical:** 1080×1920 (9:16). **Square:** 1080×1080 (1:1).
- **Safe area:** `pad-x`/`pad-y` 5.5cqw — deliberately tight so the massive type crowds the frame edge.
**The container law (load-bearing).** Every frame ground sets `container-type: size`; ALL
frame-relative units are `cqw`/`cqh` against it — never `vw` (a `vw`-sized frame inflates when not
full-screen). 1px hairlines stay 1px.
## Colors
Tokens identical to the source, in two registers. **Dark:** `{colors.ink-black}` ground,
`{colors.cream}` text, `{colors.fire-orange}` accent (kickers, accent stat, bullet `/`, lead bar,
quote mark, rule stub). **Orange:** `{colors.fire-orange}` ground, `{colors.ink-black}` headlines +
body, with the dark-ink overlays (75/55/40/20%) as the muted tones. Choose one register per frame
and commit. **No second accent color** — on orange, emphasis is weight/opacity on the ink, never a
new hue. Cream text on orange does not exist (ink-on-fire is absolute).
## Typography
Two ramps. The **reading ramp** (Barlow body 1.2cqw, mono label 0.72cqw) carries copy + chrome; the
**display ramp** (Barlow `h2` 4.5cqw → `display` 13cqw, weight 700–900) carries every statement.
- **Legibility floor:** any load-bearing line ≥ **1.4cqw**; mono labels are chrome only.
- **Fit-to-measure:** size the headline to its length. Cap the block at **≤ 78cqw**; ≤2 words → `display`; 3–4 → `h1`; 5+ → `h2`. Broadside packs only ONE display moment per frame.
- **Barlow display is lowercase, weight 700–900, negative-tracked** (−0.04em largest, −0.02em h2). **Mono chrome is uppercase, 0.1em+.** No italic, no underline, no uppercase display.
## Depth & Surface
Flat plane, the only technique. Hierarchy from:
- **Weight + size contrast** — the dominant signal (900 lowercase display).
- **1px hairlines** — chrome bars, stat-card top, compare divider, bar-track left, chart baseline.
- **Color shift** — orange on ink, ink on cream, cream-muted on cream.
- **Negative space** — generous, intentional empty regions.
**Ceiling:** no box-shadow, no elevation, no rounded surface (save nav dots), no gradient ground.
## Shapes
- **0 radius everywhere** except nav dots (50%). Cards, panels, tags, stat blocks, bars — sharp rectangles.
## Components
- **registers** — the two-surface system. **slide-chrome** — optional hairline bars, suppressed on declarative frames.
- **kicker** (mono eyebrow) / **rule** (36×2 stub) / **broadside-num** (catalogue mark) — the chrome ornament set.
- **stat-card** (top-border only) / **bullet** (orange `/`, max 3) / **bar-track** (one accent bar) / **compare-panel** (orange payoff) / **fadelist** (1.0/0.5/0.22 stack).
## Frame Treatments
> Recipe: ground · register · composes · focal · chrome · accent · silence · Fixed/Free · density.
> One statement per frame; chrome suppressed on declarative frames.
### 1 · Cover (identity · move: massive type · ORANGE register · left)
**Ground** fire-orange. **Composes** broadside-num, rule, kicker, display, lead. **Focal** a 1–2 word
Barlow `display` (13cqw) lowercase in ink, left-anchored, over a small ink rule stub + mono kicker; a
Barlow lead line beneath in 75% ink. **Chrome** mono catalogue number top-left, mono meta top-right
(no chrome bars). **Accent** the ink itself is the pop on orange. **Silence** ~45%. **Fixed** ink-on-fire,
lowercase 900, flat. **Free** the word, kicker, lead. **Density** low.
### 2 · Statement (declarative · move: type IS composition · DARK register · left)
**Ground** ink-black. **Composes** kicker, display. **Focal** a 2–4 word Barlow `display`/`h1`
lowercase in cream, with ONE clause inked `{colors.fire-orange}`. **Chrome** mono kicker; no bars.
**Accent** the orange clause. **Silence** ~55%. **Fixed** lowercase 900, one orange clause, flat.
**Free** the statement, which clause is orange. **Density** low.
### 3 · Stat Grid (data · move: top-border cards · DARK · the dense frame)
**Ground** ink-black, chrome bars present. **Composes** slide-chrome, kicker, 3× stat-card. **Focal** a
row of three top-border-only stat-cards — big Barlow-900 numeral in `{colors.fire-orange}`, Barlow
label, mono note. **Chrome** top + bottom hairline bars (label + number). **Accent** the orange
numerals. **Silence** moderate — the density exception. **Fixed** top-border-only cards, orange
numerals, 1px hairlines. **Free** figures (from script), labels. **Density** dense-exception.
### 4 · Fadelist (narrative · move: opacity stack · DARK)
**Ground** ink-black. **Composes** fadelist (3 stacked Barlow-900 words at 1.0/0.5/0.22), fadelist-title.
**Focal** the three-stage word stack opposite an oversized display title in `{colors.fire-orange}`
(before/during/after). **Accent** the orange title. **Silence** moderate. **Fixed** the opacity
ladder, lowercase 900. **Free** the three words, the title. **Density** low-moderate.
### 5 · Pull Quote (quote · move: oversized mark · DARK · left)
**Ground** ink-black, chrome suppressed. **Composes** quote-mark, quote-text, attribution. **Focal** a
Barlow `quote-text` (700, lowercase) at ≤78cqw under an oversized fire-orange `quote-mark` (10cqw,
line-height 0.6). **Chrome** mono attribution (name + role). **Accent** the orange quote mark. **Silence**
~50%. **Fixed** orange mark, lowercase quote. **Free** quote, attribution. **Density** low.
### 6 · Compare (argument · move: split + orange payoff · DARK→ORANGE)
**Ground** ink-black left panel + fire-orange right (payoff) panel, 1px divider. **Composes**
compare-panel pair, kicker, h3. **Focal** two panels — left documents (cream on dark), right declares
(ink on orange). **Chrome** mono panel labels. **Accent** the orange payoff panel. **Silence** moderate.
**Fixed** ink-on-fire right panel, 1px divider, flat. **Free** the before/after content. **Density** standard.
## Composition Rules
### Do
- Set every Barlow display in **lowercase weight 900**, negative-tracked — the system's signature.
- Use **fire-orange as full environment** on declarative frames, the **lone accent** on dark.
- Keep chrome in **IBM Plex Mono uppercase, 0.14em**; use the `/` mono bullet marker.
- **Cap bullets at three; one statement per frame**; build hierarchy from weight, size, 1px hairlines.
- Suppress chrome bars on cover/chapter/statement/quote/end; let type fill the field.
- Lean left on most frames; the type IS the composition.
### Don't
- Never uppercase Barlow display; never add a second accent color.
- Never put cream text on orange (ink-on-fire is absolute); never a cream/paper register.
- No drop shadow, no rounded surface (save nav dots), no gradient ground.
- No serif companion; chrome is never Barlow.
- Don't pack two display moments into one frame; don't blow a long line edge-to-edge — step down.
## Aspect-Ratio Behavior
| Treatment | 16:9 | 9:16 | 1:1 |
| ---------- | -------------------------- | -------------------------- | ---------------- |
| Cover | word left, lead below | word top, lead below | centered word |
| Statement | display left | display stacked taller | display centered |
| Stat Grid | 3 across | 3 stacked | 2+1 |
| Fadelist | stack + title side-by-side | stack over title | stack over title |
| Pull Quote | mark + quote left | mark top, quote below | centered |
| Compare | side-by-side panels | stacked (dark over orange) | stacked |
`pad-x` holds tight on the short edge; re-step display so the one big line stays ≤78cqw and above the
1.4cqw floor. Mono chrome stays Latin/digit-only.
## Approved Entities
No real customers, logos, or vendors are defined in the source — render any such mark as a
placeholder (the dashed `img-placeholder` at 55cqh). The system supplies type and one color, not brands.
## Numerals & Claims (hard rule)
Never invent figures, percentages, dates, or counts at frame scale. Render slots as `— figure —`,
`{metric}`, `NN%`. Stat-card numerals and bar heights carry placeholders until the script supplies
them. Catalogue numbers (No. 01) are decorative chrome and may be sequential.
## Pre-Render Self-Audit
- **Squint** — exactly one display moment dominates; nothing competes.
- **Silence** — declarative frames ~45–55% empty; only the stat grid runs dense.
- **Register** — one register per frame; ink-on-fire on orange, cream on dark; no second hue.
- **Type** — Barlow lowercase 900 negative-tracked, fit-to-measure; mono chrome uppercase 0.14em; ≥1.4cqw floor.
- **Depth** — 0 shadow, 0 radius (save nav dots); 1px hairlines only.
- **Bullets** — capped at three, orange `/` marker.
- **Fabrication** — every numeral traces to the script, else placeholder.
## Known Gaps
- **Motion intentionally out of scope.** frame.md specifies composition only; the source's 0.8s deck slide + per-element entry animations are deck mechanics.
- **Barlow + IBM Plex Mono via Google Fonts**; Noto Sans SC is the CJK fallback (the lowercase-display signal has no CJK equivalent — the two-register color system carries the identity, per the source).
- **9:16 / 1:1 are guidance**; verify the one big line stays ≤78cqw and above the floor per ratio.
- Bars, compare panels, and the dashed image placeholder are CSS-only; no external imagery is required.
@@ -1,224 +0,0 @@
<!--
Broadside — caption skin (preset-local source).
This is the preset's own lower-third karaoke caption look: a caption skin to read or
copy, not to mount as is. A copy used as compositions/captions.html fills its three
reserved holes and wraps the fragment in a <template>:
· the empty GROUPS array → the engine's scene-aware word groups
· the 0 duration + 0 width/height → real total + canvas size
· the empty data-brand-tokens style → :root tokens derived from the project's frame.md
Token contract — reference ONLY this fixed vocab (set it from the
project's frame.md; each var has a broadside literal
fallback for standalone preview):
--cap-ink · --cap-canvas · --cap-accent · --cap-accent-2 · --font-display ·
--font-body · --cap-band-top · --cap-band-height
Hooks (do not rename): .caption-group / .caption-word + states .is-active / .is-spoken,
root data-composition-id="captions", timeline at window.__timelines["captions"].
State changes use gsap.set({className}) (applied on the engine's frame-by-frame seek);
never tl.call() callbacks (seek does not fire them → wrong state on render).
Visual: a protest-poster lower third — an ink-black plate on a single 1px fire-orange
hairline, SHARP corners, NO shadow (broadside's plane is flat, never cast). Massive
lowercase Barlow 900 as graphic primitive, cream glyphs on ink. Upcoming words sit in
faint cream; the current word flips register — a hard flat fire-orange block with ink
reading on top (broadside's signature two-register flip, no blur), the brand's lone
accent; spoken words settle to solid cream, the orange block cleared.
-->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<!-- the project's :root tokens go here -->
<style data-brand-tokens></style>
<style>
#captions-root {
position: absolute;
inset: 0;
pointer-events: none;
}
.caption-layer {
position: absolute;
inset: 0;
z-index: 20;
pointer-events: none;
}
/* the keep-out band, from captionBand(H) — frame content stays above it */
.caption-stage {
position: absolute;
left: 0;
right: 0;
top: var(--cap-band-top, 900px);
height: var(--cap-band-height, 180px);
display: flex;
align-items: center;
justify-content: center;
}
.caption-group {
/* every group is absolutely stacked over the SAME band — exactly one shown at a
time via opacity. Must be absolute: in-flow groups would lay out side-by-side. */
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
}
/* THE ink-black plate — single 1px fire-orange hairline (broadside's lone accent),
SHARP corners (0 radius), NO shadow (the plane is flat) */
.caption-pill {
max-width: 80%;
padding: 18px 44px 22px;
background: var(--cap-ink, #111111);
border: 1px solid var(--cap-accent, #e85d26);
border-radius: 0;
}
.caption-line {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.04em 0.3em;
font-family: var(--font-display, "Barlow"), system-ui, sans-serif;
font-weight: 900;
font-size: clamp(38px, 4.2vw, 56px);
line-height: 1.02;
letter-spacing: -0.03em;
text-transform: lowercase;
}
/* upcoming — faint cream, no block (massive lowercase Barlow 900 as graphic primitive) */
.caption-word {
display: inline-block;
padding: 0 0.08em;
color: color-mix(in srgb, var(--cap-canvas, #f0ece5) 42%, var(--cap-ink, #111111));
}
/* current word — THE register flip: a hard flat fire-orange block (box-shadow spread =
a sharp rectangle w/o layout shift; no blur), ink reading on top — the lone accent */
.caption-word.is-active {
color: var(--cap-ink, #111111);
background: var(--cap-accent, #e85d26);
box-shadow: 0 0 0 0.06em var(--cap-accent, #e85d26);
}
/* already spoken — solid cream, the orange block cleared */
.caption-word.is-spoken {
color: var(--cap-canvas, #f0ece5);
background: transparent;
box-shadow: none;
}
/* optional per-word accents (dormant until a word-classer tags them) */
/* brand / key term — fire-orange ink, the cream-on-ink register's only inflection */
.caption-word.cap-brand {
color: var(--cap-accent, #e85d26);
}
/* numerals / data — the IBM Plex Mono chrome voice, uppercase + tracked, recessed */
.caption-word.cap-num {
font-family: var(--font-body, "IBM Plex Mono"), ui-monospace, monospace;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.06em;
color: color-mix(in srgb, var(--cap-canvas, #f0ece5) 72%, var(--cap-ink, #111111));
}
/* emphasis — a fire-orange hairline underline (no second hue; the lone accent again) */
.caption-word.cap-cta {
text-decoration: underline;
text-decoration-thickness: 2px;
text-decoration-color: var(--cap-accent-2, #e85d26);
text-underline-offset: 0.1em;
}
@media (max-aspect-ratio: 9/16) {
.caption-pill {
max-width: 92%;
}
.caption-line {
font-size: clamp(32px, 5.2vw, 46px);
}
}
</style>
<div
id="captions-root"
data-composition-id="captions"
data-timeline-locked
data-start="0"
data-duration="0"
data-fps="30"
data-width="0"
data-height="0"
>
<div class="caption-layer" aria-hidden="true">
<div id="caption-stage" class="caption-stage"></div>
</div>
</div>
<script>
// the scene-aware word groups + real duration go here.
var GROUPS = [];
var DURATION = 0;
(function () {
var stage = document.getElementById("caption-stage");
// build DOM: one .caption-group per group, .caption-word per word
GROUPS.forEach(function (group, g) {
var groupEl = document.createElement("div");
groupEl.className = "caption-group";
groupEl.id = "caption-group-" + g;
var pill = document.createElement("div");
pill.className = "caption-pill";
var line = document.createElement("div");
line.className = "caption-line";
(group.words || []).forEach(function (w, i) {
var span = document.createElement("span");
span.className = "caption-word";
span.id = "caption-word-" + g + "-" + i;
span.textContent = String(w.text);
line.appendChild(span);
});
pill.appendChild(line);
groupEl.appendChild(pill);
stage.appendChild(groupEl);
});
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
GROUPS.forEach(function (group, g) {
var groupEl = document.getElementById("caption-group-" + g);
var words = group.words || [];
var next = GROUPS[g + 1];
var isLast = g === GROUPS.length - 1;
var start = Math.max(0, Number(group.start));
var end = isLast ? DURATION : Math.min(Number(next.start), Number(group.end) + 0.3);
if (end <= start) end = start + 0.01;
// group on/off — exactly one group visible at a time
tl.set(groupEl, { opacity: 1 }, start);
tl.set(groupEl, { opacity: 0 }, end);
// 3-state karaoke via className SETS — seek-safe (gsap.set applies on the
// engine's frame-by-frame seek; .call callbacks would NOT fire).
words.forEach(function (w, i) {
var el = document.getElementById("caption-word-" + g + "-" + i);
var at = Math.max(start, Number(w.start));
tl.set(el, { className: "caption-word" }, start); // upcoming (also resets on reverse-seek)
tl.set(el, { className: "caption-word is-active" }, at); // current
tl.fromTo(el, { scale: 0.92 }, { scale: 1, duration: 0.1, ease: "power3.out" }, at);
if (i + 1 < words.length) {
var nextAt = Math.max(start, Number(words[i + 1].start));
tl.set(el, { className: "caption-word is-spoken" }, nextAt); // demote when next activates
}
});
if (words.length) {
var lastEl = document.getElementById("caption-word-" + g + "-" + (words.length - 1));
var lastSpoken = Math.min(end, Number(words[words.length - 1].end) + 0.1);
tl.set(lastEl, { className: "caption-word is-spoken" }, lastSpoken);
}
});
// full-span anchor so the sub-comp timeline spans the whole video
tl.to({}, { duration: DURATION }, 0);
window.__timelines["captions"] = tl;
})();
</script>
File diff suppressed because it is too large. Load diff
@@ -1,284 +0,0 @@
---
version: alpha
name: Capsule — Frame (video / frame layer)
description: >
Video-first companion to Capsule's design.md. The unit is the frame (1920×1080). Atoms are
identical and sacred — the pill geometry (9999px small / 2rem cards) with a 2px ink outline on
everything, the sun-bleached cream canvas, the nine-color candy palette, Bodoni Moda + Space
Grotesk, soft hard-offset shadows (4/6/8/12px in 8% ink), floating decorative-pill wallpaper,
radial accent glows, and the grain overlay. Composition + frame scale rewritten. Motion out of scope.
unit: the frame — 1920×1080 primary; 9:16 and 1:1 documented
principle: atoms are sacred · composition is free · numbers come from the script
colors:
cream: "#F5F5F0"
ink: "#1A1A1A"
outline: "#1E1E1E"
white: "#FFFFFF"
coral: "#E85D4E"
lime: "#C4D94E"
lavender: "#C5B5E0"
sky: "#8BB4F7"
violet: "#A06CE8"
yellow: "#F2D160"
peach: "#F5B895"
mint: "#A8E6CF"
shadow: "rgba(26,26,26,0.08)"
typography:
# — reading ramp (Space Grotesk) —
body: { fontFamily: "Space Grotesk", cqw: 0.85, weight: 400, lineHeight: 1.6 }
subtitle: { fontFamily: "Space Grotesk", cqw: 0.95, weight: 400, tracking: "0.18em", upper: true }
pill-text: { fontFamily: "Space Grotesk", cqw: 0.75, weight: 600, tracking: "0.12em", upper: true }
label: { fontFamily: "Space Grotesk", px: 14, weight: 500, tracking: "0.1em", upper: true }
# — display / hero ramp (Bodoni Moda, ink, sentence case) —
card-headline:{ fontFamily: "Bodoni Moda", cqw: 1.9, weight: 700, lineHeight: 1.1 }
orbit-numeral:{ fontFamily: "Bodoni Moda", cqw: 2.6, weight: 700, lineHeight: 1.0 }
quote-display:{ fontFamily: "Bodoni Moda", cqw: 4.2, weight: 600, lineHeight: 1.3, tracking: "-0.01em" }
stat-number: { fontFamily: "Bodoni Moda", cqw: 3.6, weight: 800, lineHeight: 1.0, tracking: "-0.03em" }
section-headline:{ fontFamily: "Bodoni Moda", cqw: 4.0, weight: 700, lineHeight: 1.05, tracking: "-0.01em" }
headline: { fontFamily: "Bodoni Moda", cqw: 5.0, weight: 700, lineHeight: 1.05, tracking: "-0.02em" }
closing-display:{ fontFamily: "Bodoni Moda", cqw: 8.5, weight: 800, lineHeight: 0.95, tracking: "-0.03em" }
display: { fontFamily: "Bodoni Moda", cqw: 12.0, weight: 800, lineHeight: 0.88, tracking: "-0.03em" }
spacing:
pad: "5cqw"
gap-md: "2cqw"
card-pad: "2cqw 1.6cqw"
components:
pill:
rounded: "9999px (small) / 2rem (cards)"
border: "0.2cqw solid {colors.outline}"
backgroundColor: "any candy or {colors.white}"
description: "The universal container — every chip/button/label/stat tile/node/bar. No unstroked pill exists."
pill-card:
backgroundColor: "{colors.white}"
border: "0.2cqw solid {colors.outline}"
rounded: "2rem"
shadow: "0.4cqw 0.4cqw 0 {colors.shadow}"
typography: "{typography.card-headline} + {typography.body}"
description: "Holds a circular card-icon, Bodoni headline, Space Grotesk body."
stat-pill:
backgroundColor: "{colors.white}"
border: "0.2cqw solid {colors.outline}"
rounded: "2rem"
shadow: "0.3cqw 0.3cqw 0 {colors.shadow}"
typography: "{typography.stat-number} (COLORED) + {typography.pill-text}"
description: "The one place color touches a numeral; + a 40px accent bar."
title-pill:
backgroundColor: "{colors.yellow}"
border: "0.2cqw solid {colors.outline}"
rounded: "9999px"
shadow: "0.3cqw 0.3cqw 0 {colors.shadow}"
typography: "{typography.pill-text}"
description: "Sits above the display headline on cover/closing."
card-icon:
backgroundColor: "any candy"
border: "0.2cqw solid {colors.outline}"
rounded: "50%"
size: "60px"
typography: "Bodoni numeral/letter in {colors.ink}"
description: "Card mark."
floating-pill:
backgroundColor: "any candy"
border: "0.2cqw solid {colors.outline}"
rounded: "9999px / 50%"
transform: "rotate(−20°..+25°)"
shadow: "none"
typography: "{typography.pill-text}"
description: "Decorative wallpaper confetti, 5–8 per declarative frame. Flat — no shadow."
quote-highlight:
backgroundColor: "{colors.lime} / {colors.sky}"
border: "0.2cqw solid {colors.outline}"
rounded: "9999px"
description: "Inline candy pill wrapping a phrase inside a Bodoni quote — the emphasis mechanism, replacing bold/italic."
bar-track:
backgroundColor: "{colors.cream}"
border: "0.2cqw solid {colors.outline}"
rounded: "9999px"
fill: "child candy pill, value at right edge"
description: "36px pill chart track."
accent-line:
backgroundColor: "{colors.coral}"
rounded: "9999px"
size: "60×4 (80×4 closing)"
description: "Coral pill rule."
atmosphere:
background: "1–3 radial candy glows (6–15% opacity) over {colors.cream} + 4% grain overlay"
description: "Baseline canvas layers on every frame. Never absent."
---
# Capsule — Frame (video / frame layer)
## Overview
Capsule at frame scale is a **playful editorial system where every container is a pill.** The
`border-radius: 9999px` (small) / `2rem` (cards) rule plus a 2px ink outline wraps every chip,
card, icon, bar, and node — inflated, friendly, graphically distinct. The canvas is sun-bleached
cream warmed by soft radial candy glows and a permanent 4% grain overlay.
The voice is a two-face conversation: **Bodoni Moda** (didone serif, weight 700–800, always ink,
always sentence case) carries every headline, stat, and quote; **Space Grotesk** carries body and
all uppercase tracked pill/label text. Nine candy accents fill pills interchangeably with no
semantic meaning. Depth is a **soft hard-offset shadow** (4/6/8/12px in 8% ink) — lifted, not
stamped — reserved for content-bearing containers; decorative floating pills are flat.
**Key characteristics at frame scale:**
- **Pill geometry everywhere** — 9999px small, 2rem cards — each wrapped in a 2px `{colors.outline}` stroke.
- **Bodoni Moda** display (ink, sentence case) + **Space Grotesk** body/pills (uppercase, tracked).
- **Nine candy accents**, interchangeable; color touches stat numerals + pill fills, never headlines.
- **Soft offset shadows** (4/6/8/12px, 8% ink) on content containers only; floating pills are flat.
- **Floating decorative-pill wallpaper** (5–8, tilted) + radial glows + 4% grain on declarative frames.
- **Cream canvas** warmed by atmosphere — bare corners read as broken.
## The Frame
### Frame Craft Bar
Three eyeball tests gate every frame before any structural check:
- **Squint** — one Bodoni headline or stat dominates at **3–6× its nearest neighbor**.
- **Silence** — declarative frames carry **atmosphere, not clutter** (pills as wallpaper, not content); the **pillar-cards and stat grid are the dense exception**.
- **Restraint** — every pill carries the 2px outline; color touches **fills + stat numerals only** (never a headline); soft shadow on content-bearing pills only (floating pills are flat); no tenth accent.
- **Reference** — aim at a **Memphis / ice-cream-parlor editorial spread**; failure looks like a **flat, sticker-less SaaS card grid**.
- **Primary:** 1920×1080 (16:9). Display authored in **`cqw`** (`px ÷ 1920 × 100 = cqw`).
- **Vertical:** 1080×1920 (9:16). **Square:** 1080×1080 (1:1).
- **Safe area:** `pad` (5cqw); floating pills may bleed off edges as wallpaper.
**The container law (load-bearing).** Every frame ground sets `container-type: size` AND carries
the radial-glow + grain atmosphere; ALL frame-relative units are `cqw`/`cqh` against it — never
`vw`. Pill radii stay `9999px`/`2rem` (shape, not scale); shadow offsets scale in `cqw`.
## Colors
Tokens identical to the source. `{colors.cream}` is the ground; `{colors.ink}`/`{colors.outline}`
is type + the universal 2px stroke; the nine candy accents fill pills with **no semantic mapping**
— pair a warm (coral/yellow/peach) with a cool (sky/lavender/violet/mint) with a neutral-bright
(lime); never two same-family adjacent. Color appears on **stat numerals and pill fills only** —
never on a Bodoni headline. Shadow is always `{colors.shadow}` (8% ink). No tenth color.
## Typography
Two ramps. The **reading ramp** (Space Grotesk body 0.85cqw, uppercase tracked pill/label text)
carries copy + chips; the **display ramp** (Bodoni `card-headline` 1.9cqw → `display` 12cqw, all
700–800) carries every headline, stat, and quote.
- **Legibility floor:** any load-bearing line ≥ **1.4cqw**; px labels are chrome only.
- **Fit-to-measure:** size the headline to its length. Cap the block at **≤ 78cqw**; ≤3 words → `display`/`closing-display`; 4–6 → `headline`; 7+ → `section-headline`.
- **Bodoni 700+ in ink, sentence case** (italic via `<em>` only, color allowed on the italic key word as a candy hue); **Space Grotesk pills/labels uppercase, 0.08em+ tracked**; negative-track Bodoni display.
## Depth & Surface
Soft hard-offset shadow is the only depth, in `{colors.shadow}` (8% ink), bottom-right:
- **0.2cqw (4px)** small nodes; **0.3cqw (6px)** stat-pills, orbit pills, diagram nodes; **0.4cqw (8px)** pillar-cards; **0.6cqw (12px)** the visual frame.
- The **2px outline** does most of the lift against cream; the shadow adds the float.
- **Decorative floating pills cast no shadow** — this separates content from atmosphere at a glance.
**Ceiling:** no blurred shadow, no re-colored shadow, no gradient depth.
## Shapes
- **9999px** — all small pills (chips, buttons, bars, nodes, floating pills, quote highlights, accent lines).
- **2rem** — larger cards (pillar-card, stat-pill, chart container, visual frame).
- **50%** — circular pills (card-icon 60px, step-node 56px, orbit-center 160px, nav dots).
- **0** — only the grain overlay and the gradient region inside a visual frame. No sharp-cornered text container exists.
## Components
- **pill** — the universal 2px-outlined container. **pill-card / stat-pill / title-pill** — the white/yellow content pills with soft shadows.
- **card-icon** — circular candy mark; **floating-pill** — flat tilted wallpaper confetti; **quote-highlight** — inline candy emphasis pill.
- **bar-track** — pill-shaped chart bar; **accent-line** — coral pill rule; **atmosphere** — the baseline glow + grain on every frame.
## Frame Treatments
> Recipe: ground · container · composes · focal · chrome · accent · silence · Fixed/Free · density.
> Atmosphere (glow + grain) on every frame; declarative frames carry floating-pill wallpaper.
### 1 · Cover (identity · move: title pill + display · centered)
**Ground** cream + atmosphere, floating-pill wallpaper. **Composes** title-pill, display, accent-line, floating-pills (5–8). **Focal** a 1–2 line Bodoni `display` headline in ink (italic key word in a candy hue), centered, under a yellow title-pill; a coral accent-line below. **Chrome** uppercase Space Grotesk sub. **Accent** the italic word + candy floating pills. **Silence** content centered; pills fill the edges. **Fixed** pill outlines, Bodoni ink, flat floating pills. **Free** title, which candy hues, pill words/positions. **Density** medium-atmospheric.
### 2 · Pillar Cards (catalog · move: 3-up grid · left — the dense frame)
**Ground** cream + atmosphere. **Composes** title-pill (lavender), section-headline, 3× pill-card. **Focal** three white 2rem pill-cards (circular candy card-icon, Bodoni card-headline, Space Grotesk body) with 0.4cqw shadows, under a Bodoni section-headline. **Chrome** a lavender header tag-pill. **Accent** the three card-icon fills (coral/sky/lime sequence). **Silence** tight — the density exception (no floating pills here). **Fixed** 2px outlines, soft shadows, ink headlines. **Free** card content, icon hues. **Density** dense-exception.
### 3 · Stat Grid (data · move: stat pills · centered head)
**Ground** cream + atmosphere. **Composes** section-headline, 3–4× stat-pill. **Focal** a row of white stat-pills, each a COLORED Bodoni stat-number + uppercase label + accent bar. **Accent** the colored numerals + bars (the one place color meets type). **Silence** moderate. **Fixed** 2rem pills, colored numerals only, soft shadow. **Free** figures (from script), hues. **Density** standard.
### 4 · Pull Quote (quote · move: highlight pill · left)
**Ground** cream + atmosphere, a few floating pills. **Composes** quote-display, quote-highlight, accent-line. **Focal** a Bodoni quote in ink with one phrase wrapped in a lime/sky `quote-highlight` pill (the emphasis mechanism — never bold). **Chrome** uppercase attribution. **Accent** the highlight pill. **Silence** ~50%. **Fixed** Bodoni 600, highlight-pill emphasis. **Free** quote, highlight color/phrase. **Density** sparse.
### 5 · Orbit (concept · move: gravitational pills · centered)
**Ground** cream + atmosphere. **Composes** orbit-center (160px lime circle, Bodoni ordinal), 4–6 orbit-pill satellites (candy, tilted, soft shadow). **Focal** the lime center anchored by orbiting candy pills. **Accent** the satellite hues. **Silence** moderate. **Fixed** circular center, outlined satellites with shadow. **Free** ordinal, satellite words/positions. **Density** medium.
### 6 · Closing Plate (closer · move: title pill + display · centered)
**Ground** cream + atmosphere, floating-pill wallpaper. **Composes** title-pill (yellow), closing-display, accent-line, floating-pills. **Focal** a Bodoni `closing-display` sign-off in ink (italic word in violet/candy), centered. **Accent** the italic word + pills. **Silence** content centered. **Fixed** Bodoni ink, flat pills. **Free** sign-off, pill words. **Density** medium-atmospheric.
## Composition Rules
### Do
- Make every text container a **pill** (9999px / 2rem) with the **2px ink outline**.
- Set **Bodoni headlines in ink, sentence case**; color lives on stat numerals + pill fills.
- Use **soft offset shadows** (4/6/8/12px, 8% ink) on content containers; keep floating pills flat.
- Float **5–8 candy pills** + radial glows + grain on declarative frames; bare corners read as broken.
- Wrap inline emphasis in a **candy quote-highlight pill**, never bold/italic-alone.
- Pair warm+cool+neutral-bright accents; lean centered on cover/closer, left on cards/quote.
### Don't
- No sharp-cornered text container; no unstroked pill.
- No colored Bodoni headline; no uppercase Bodoni.
- No blurred or re-colored shadow; no tenth accent color.
- No shadow on decorative floating pills.
- Don't blow a headline edge-to-edge — fit to measure.
## Aspect-Ratio Behavior
| Treatment | 16:9 | 9:16 | 1:1 |
| ------------ | ------------------------------ | --------------------------- | --------------------- |
| Cover | display centered, pills around | display top, pills band | centered, fewer pills |
| Pillar Cards | head + 3-up | head + 3 stacked | head + 2+1 |
| Stat Grid | 3–4 across | 2×2 | 2×2 |
| Pull Quote | quote left | quote top, highlight inline | centered |
| Orbit | center + 4–6 satellites | center + 3 satellites | center + 4 |
| Closing | centered + pills | centered, pills band | centered |
`pad` holds on the short edge; re-step display above the 1.4cqw floor. Floating-pill count drops
on tighter ratios so they stay wallpaper, not clutter.
## Approved Entities
No real customers, logos, or vendors are defined in the source — render any such mark as a
placeholder. Floating-pill words are neutral atmospheres ("VISION", "FUTURE", "NEXT"), never content-specific.
## Numerals & Claims (hard rule)
Never invent figures, stats, or counts at frame scale. Render slots as `— figure —`, `{metric}`,
`N%`. Stat-pill numbers and bar-track widths carry placeholders until the script supplies values.
## Pre-Render Self-Audit
- **Squint** — one Bodoni element dominates at 3–5× its neighbor.
- **Silence** — declarative frames carry atmosphere not clutter; only pillar/stat grids run dense.
- **Pills** — every container is a pill with a 2px outline; no sharp text container.
- **Color** — candy on fills + stat numerals only; headlines ink; no tenth hue.
- **Depth** — soft offset shadow on content only; floating pills flat; no blur.
- **Type** — Bodoni ink sentence-case, fit-to-measure; pills uppercase tracked; ≥1.4cqw floor.
- **Anchor** — centered on cover/closer/orbit, left on cards/quote; no 3 consecutive alike.
- **Fabrication** — every numeral traces to the script, else placeholder.
## Known Gaps
- **Motion intentionally out of scope.** frame.md specifies composition only; the 0.6s fade in the source is a deck mechanic.
- **Bodoni Moda + Space Grotesk via Google Fonts.** CJK pairing (ZCOOL XiaoWei / Yozai) carries over from the source.
- **9:16 / 1:1 are guidance**; verify the floor and that floating-pill count scales down.
- The grain overlay, radial glows, floating pills, and bar fills are CSS-only; no external imagery is required.
@@ -1,223 +0,0 @@
<!--
Capsule — caption skin (preset-local source).
This is the preset's own lower-third karaoke caption look: a caption skin to read or
copy, not to mount as is. A copy used as compositions/captions.html fills its three
reserved holes and wraps the fragment in a <template>:
· the empty GROUPS array → the engine's scene-aware word groups
· the 0 duration + 0 width/height → real total + canvas size
· the empty data-brand-tokens style → :root tokens derived from the project's frame.md
Token contract — reference ONLY this fixed vocab (set it from the
project's frame.md; each var has a capsule literal
fallback for standalone preview):
--cap-ink · --cap-canvas · --cap-accent · --cap-accent-2 · --font-display ·
--font-body · --cap-band-top · --cap-band-height
Hooks (do not rename): .caption-group / .caption-word + states .is-active / .is-spoken,
root data-composition-id="captions", timeline at window.__timelines["captions"].
State changes use gsap.set({className}) (applied on the engine's frame-by-frame seek);
never tl.call() callbacks (seek does not fire them → wrong state on render).
Visual: a cream caption pill — fully-rounded (9999px), wrapped in capsule's universal
2px ink outline, lifted on a soft hard-offset shadow (no blur), Bodoni Moda glyphs in
ink, sentence case. Upcoming words sit in faint ink; the current word lights up as a
rounded candy highlight — a candy fill plus a box-shadow spread halo that follows the
pill radius (a soft candy capsule around the word, no blur, NO layout shift) with ink
reading on top; spoken words settle to solid ink, the candy highlight cleared.
-->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<!-- the project's :root tokens go here -->
<style data-brand-tokens></style>
<style>
#captions-root {
position: absolute;
inset: 0;
pointer-events: none;
}
.caption-layer {
position: absolute;
inset: 0;
z-index: 20;
pointer-events: none;
}
/* the keep-out band, from captionBand(H) — frame content stays above it */
.caption-stage {
position: absolute;
left: 0;
right: 0;
top: var(--cap-band-top, 900px);
height: var(--cap-band-height, 180px);
display: flex;
align-items: center;
justify-content: center;
}
.caption-group {
/* every group is absolutely stacked over the SAME band — exactly one shown at a
time via opacity. Must be absolute: in-flow groups would lay out side-by-side. */
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
}
/* THE caption capsule — cream pill, fully-rounded (9999px), capsule's universal 2px
ink outline, lifted on a soft hard-offset shadow (no blur, 8% ink) */
.caption-pill {
max-width: 80%;
padding: 18px 46px 20px;
background: var(--cap-canvas, #f5f5f0);
border: 2px solid var(--cap-ink, #1a1a1a);
border-radius: 9999px;
box-shadow: 8px 8px 0 color-mix(in srgb, var(--cap-ink, #1a1a1a) 8%, transparent);
}
.caption-line {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.12em 0.36em;
font-family: var(--font-display, "Bodoni Moda"), Georgia, serif;
font-weight: 700;
font-size: clamp(38px, 4.2vw, 56px);
line-height: 1.16;
letter-spacing: -0.02em;
}
/* upcoming — faint ink, no highlight */
.caption-word {
display: inline-block;
padding: 0 0.1em;
border-radius: 9999px;
color: color-mix(in srgb, var(--cap-ink, #1a1a1a) 42%, var(--cap-canvas, #f5f5f0));
}
/* current word — THE rounded candy highlight: a candy fill + a box-shadow spread
halo that follows the pill radius (a soft candy capsule around the word, no blur,
no layout shift), ink reading on top */
.caption-word.is-active {
color: var(--cap-ink, #1a1a1a);
background: var(--cap-accent, #e85d4e);
box-shadow: 0 0 0 0.14em var(--cap-accent, #e85d4e);
}
/* already spoken — solid ink, candy highlight cleared */
.caption-word.is-spoken {
color: var(--cap-ink, #1a1a1a);
background: transparent;
box-shadow: none;
}
/* optional per-word accents (dormant until a word-classer tags them) */
/* brand / key term — Bodoni Moda italic, capsule's display inflection */
.caption-word.cap-brand {
font-style: italic;
}
/* numerals / data — the Space Grotesk voice, slightly recessed */
.caption-word.cap-num {
font-family: var(--font-body, "Space Grotesk"), ui-sans-serif, sans-serif;
color: color-mix(in srgb, var(--cap-ink, #1a1a1a) 72%, var(--cap-canvas, #f5f5f0));
}
/* emphasis — a second-candy rounded underline (the counter-accent) */
.caption-word.cap-cta {
text-decoration: underline;
text-decoration-thickness: 3px;
text-decoration-color: var(--cap-accent-2, #f2d160);
text-underline-offset: 0.12em;
}
@media (max-aspect-ratio: 9/16) {
.caption-pill {
max-width: 92%;
}
.caption-line {
font-size: clamp(34px, 5.4vw, 48px);
}
}
</style>
<div
id="captions-root"
data-composition-id="captions"
data-timeline-locked
data-start="0"
data-duration="0"
data-fps="30"
data-width="0"
data-height="0"
>
<div class="caption-layer" aria-hidden="true">
<div id="caption-stage" class="caption-stage"></div>
</div>
</div>
<script>
// the scene-aware word groups + real duration go here.
var GROUPS = [];
var DURATION = 0;
(function () {
var stage = document.getElementById("caption-stage");
// build DOM: one .caption-group per group, .caption-word per word
GROUPS.forEach(function (group, g) {
var groupEl = document.createElement("div");
groupEl.className = "caption-group";
groupEl.id = "caption-group-" + g;
var pill = document.createElement("div");
pill.className = "caption-pill";
var line = document.createElement("div");
line.className = "caption-line";
(group.words || []).forEach(function (w, i) {
var span = document.createElement("span");
span.className = "caption-word";
span.id = "caption-word-" + g + "-" + i;
span.textContent = String(w.text);
line.appendChild(span);
});
pill.appendChild(line);
groupEl.appendChild(pill);
stage.appendChild(groupEl);
});
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
GROUPS.forEach(function (group, g) {
var groupEl = document.getElementById("caption-group-" + g);
var words = group.words || [];
var next = GROUPS[g + 1];
var isLast = g === GROUPS.length - 1;
var start = Math.max(0, Number(group.start));
var end = isLast ? DURATION : Math.min(Number(next.start), Number(group.end) + 0.3);
if (end <= start) end = start + 0.01;
// group on/off — exactly one group visible at a time
tl.set(groupEl, { opacity: 1 }, start);
tl.set(groupEl, { opacity: 0 }, end);
// 3-state karaoke via className SETS — seek-safe (gsap.set applies on the
// engine's frame-by-frame seek; .call callbacks would NOT fire).
words.forEach(function (w, i) {
var el = document.getElementById("caption-word-" + g + "-" + i);
var at = Math.max(start, Number(w.start));
tl.set(el, { className: "caption-word" }, start); // upcoming (also resets on reverse-seek)
tl.set(el, { className: "caption-word is-active" }, at); // current
tl.fromTo(el, { scale: 0.92 }, { scale: 1, duration: 0.18, ease: "back.out(2)" }, at);
if (i + 1 < words.length) {
var nextAt = Math.max(start, Number(words[i + 1].start));
tl.set(el, { className: "caption-word is-spoken" }, nextAt); // demote when next activates
}
});
if (words.length) {
var lastEl = document.getElementById("caption-word-" + g + "-" + (words.length - 1));
var lastSpoken = Math.min(end, Number(words[words.length - 1].end) + 0.1);
tl.set(lastEl, { className: "caption-word is-spoken" }, lastSpoken);
}
});
// full-span anchor so the sub-comp timeline spans the whole video
tl.to({}, { duration: DURATION }, 0);
window.__timelines["captions"] = tl;
})();
</script>
File diff suppressed because it is too large. Load diff
@@ -1,278 +0,0 @@
---
version: alpha
name: Cartesian — Frame (video / frame layer)
description: >
Video-first companion to Cartesian's design.md. The unit is the frame (1920×1080). Atoms are
identical and sacred — the five-tone warm-stone palette, Playfair Display 400 + Inter, the
universal 1px taupe hairline as the only structural device, compass-drafted geometric rings,
and zero shadow / zero fill. Composition, frame scale, and aspect-ratio behavior are rewritten
for the frame. Restraint is the rule; motion is out of scope.
unit: the frame — 1920×1080 primary; 9:16 and 1:1 documented
principle: atoms are sacred · composition is free · numbers come from the script
colors:
bg-primary: "#EDE8E0"
bg-secondary: "#E2DBD1"
text-primary: "#1A1A1A"
text-secondary: "#5A5A5A"
accent: "#8A8178"
line: "#B8B0A4"
white-overlay: "rgba(255,255,255,0.3)"
typography:
# — reading ramp (Inter) —
body: { fontFamily: "Inter", cqw: 1.0, weight: 400, lineHeight: 1.6, color: "text-secondary" }
body-sm: { fontFamily: "Inter", cqw: 0.85, weight: 400, lineHeight: 1.6 }
subtitle: { fontFamily: "Inter", cqw: 1.3, weight: 400, lineHeight: 1.5 }
label: { fontFamily: "Inter", px: 14, weight: 500, tracking: "3px", upper: true, color: "accent" }
attribution: { fontFamily: "Inter", px: 15, weight: 400, tracking: "2px", upper: true, color: "accent" }
micro: { fontFamily: "Inter", px: 12, weight: 400, tracking: "2px", upper: true, color: "accent" }
# — display / hero ramp (Playfair Display 400, sentence case) —
h3: { fontFamily: "Playfair Display", cqw: 1.8, weight: 400, lineHeight: 1.1 }
timeline-headline:{ fontFamily: "Playfair Display", cqw: 1.9, weight: 400, lineHeight: 1.1 }
card-headline:{ fontFamily: "Playfair Display", cqw: 2.0, weight: 400, lineHeight: 1.15 }
stat-figure: { fontFamily: "Playfair Display", cqw: 3.0, weight: 400, lineHeight: 1.0 }
quote-mark: { fontFamily: "Playfair Display", cqw: 9.0, weight: 400, lineHeight: 0.5, color: "line" }
h2: { fontFamily: "Playfair Display", cqw: 4.0, weight: 400, lineHeight: 1.1 }
h1: { fontFamily: "Playfair Display", cqw: 6.2, weight: 400, lineHeight: 1.06 }
display: { fontFamily: "Playfair Display", cqw: 8.0, weight: 400, lineHeight: 1.04 }
spacing:
pad-x: "7cqw"
pad-y: "5cqw"
gap-xl: "6cqw"
gap-lg: "5cqw"
components:
hairline:
rule: "0.07cqw solid {colors.line}"
description: "The universal structural device — every separator (agenda rule, timeline connector, card border, stats top) is this 1px taupe line. No thick borders exist."
card:
backgroundColor: "{colors.white-overlay}"
border: "0.07cqw solid {colors.line}"
rounded: "0"
shadow: "none"
description: "Faint white-overlay fill (canvas bleeds through) is what makes a card distinct from a bare region."
card-icon:
border: "0.07cqw solid {colors.line}"
rounded: "50%"
size: "40px circle"
textColor: "{colors.accent}"
typography: "Playfair numeral/letter"
description: "Ringed circle mark."
agenda-row:
borderBottom: "0.07cqw solid {colors.line}"
typography: "{typography.h3} (numeral {colors.accent}, label {colors.text-primary})"
description: "Numeral left, label right over a taupe rule."
timeline:
borderTop: "0.07cqw solid {colors.line}"
typography: "{typography.timeline-headline} + {typography.body-sm}"
description: "A single taupe top rule across items — no nodes, no dots."
stats-cluster:
borderTop: "0.07cqw solid {colors.line}"
typography: "{typography.stat-figure} + uppercase {colors.accent} labels"
description: "Inline modest stat figures (no hero numeral)."
geo-ring:
border: "1px solid {colors.line} (inner dashed ::before ~70–80%)"
rounded: "50%"
size: "10–50cqw"
opacity: "0.2–0.5"
description: "Compass-construction rings behind content. 1–2 per frame, never more."
horizontal-accent:
backgroundColor: "{colors.text-primary}"
size: "~18cqw × 1px"
description: "The system's only INK-BLACK rule — a strong terminal accent on cover/closing, sparingly."
vertical-line:
backgroundColor: "{colors.line}"
size: "1px × full height, ~5cqw from edge"
opacity: "0.3–0.4"
description: "Drafting-paper alignment guide. Optional."
image-placeholder:
backgroundColor: "{colors.bg-secondary}"
mark: "crossed +30°/−30° 1px taupe diagonals (an X)"
typography: "small uppercase {typography.micro}"
description: "The signature image-not-wired mark."
team-photo:
backgroundColor: "{colors.bg-secondary}"
border: "0.07cqw solid {colors.line}"
rounded: "50%"
typography: "Playfair initial in {colors.accent}"
description: "Circular portrait frame."
---
# Cartesian — Frame (video / frame layer)
## Overview
Cartesian at frame scale is a **quiet museum-catalog editorial system** — restraint through 1px
lines. Every structural separator is a single 1px taupe hairline; there are no thick borders, no
fills (save the faint white-overlay card), no shadows, no rounded rectangles. Hierarchy comes from
**type contrast and negative space**, and atmosphere from **compass-drafted geometric rings**
drifting behind content.
The voice is a literary pairing: **Playfair Display** at weight 400 (the thin-stroke didone, never
bold, always sentence case) carries every headline, numeral, and quote mark in ink; **Inter**
carries body in warm gray and labels in uppercase taupe with 2–3px tracking. The palette is five
warm stones plus ink — no populist accent color exists. The correct density is **sparse and
breathing**: one clear idea, well-framed, on stone paper.
**Key characteristics at frame scale:**
- **1px taupe hairline** as the universal structural device — every separator is this one line.
- **Playfair Display 400** (ink, sentence case) for display; **Inter** body (gray) + labels (taupe, tracked).
- **Five warm stones + ink** — no red/blue/green; the only "color" is type contrast.
- **Compass-drafted geometric rings** (solid + dashed, 20–50% opacity) behind content for mood.
- **Flat** — zero shadow, zero rounded rectangle (circles only); the lone ink line is the `horizontal-accent`.
- **Sparse and breathing** — generous negative space; crowding reads as broken.
## The Frame
### Frame Craft Bar
Three eyeball tests gate every frame before any structural check:
- **Squint** — one Playfair element dominates at **3–6× its nearest neighbor**; the serif/sans + size contrast carries hierarchy, not weight.
- **Silence** — declarative frames read **55–60% empty**; Cartesian has **no dense frame** — even the agenda/index breathes (the system breaks when crowded).
- **Restraint** — **at most two geo rings** per frame; the single INK-BLACK `horizontal-accent` rule used sparingly; no populist accent color ever.
- **Reference** — aim at a **Vignelli editorial / Cooper Hewitt catalogue / pencil-and-tracing-paper plan**; failure looks like a **shadowed, rounded-card SaaS deck**.
- **Primary:** 1920×1080 (16:9). Display authored in **`cqw`** (`px ÷ 1920 × 100 = cqw`).
- **Vertical:** 1080×1920 (9:16). **Square:** 1080×1080 (1:1).
- **Safe area:** `pad-x` (7cqw) generous gutters; geometry may bleed off an edge.
**The container law (load-bearing).** Every frame ground sets `container-type: size`; ALL
frame-relative units are `cqw`/`cqh` against it — never `vw`. The 1px hairlines and geo rings hold
their proportion against the frame at any render size.
## Colors
Tokens identical to the source. `{colors.bg-primary}` is the ground; `{colors.text-primary}` ink is
headlines and the one black accent rule; `{colors.text-secondary}` gray is body; `{colors.accent}`
taupe is labels, numerals, small text; `{colors.line}` taupe is every 1px structural border.
`{colors.bg-secondary}` is the only secondary fill (placeholders, photo frames). **No populist
accent** — when emphasis is needed, grow the type, switch sans→serif, or add a single
`horizontal-accent` ink line. Headlines are never taupe; small text is never ink.
## Typography
Two ramps. The **reading ramp** (Inter body 1.0cqw gray, labels in px taupe) carries copy + chrome;
the **display ramp** (Playfair `h3` 1.8cqw → `display` 8.0cqw, all weight 400) carries every headline.
- **Legibility floor:** any load-bearing line ≥ **1.4cqw**; px labels are chrome only.
- **Fit-to-measure:** size the headline to its length. Cap the block at **≤ 78cqw**; ≤3 words → `display`/`h1`; 4–6 → `h2`; 7+ → `h3`. Cartesian has no hero-stat numeral — stats stay modest (`stat-figure` 3cqw).
- **Playfair at 400, ink, sentence case** — never bold, never uppercase, never taupe. **Inter labels uppercase, 2–3px tracked, taupe.** Italic via Playfair italic for emphasis only.
## Depth & Surface
The flat plane is the only technique. Hierarchy from:
- **Type contrast** — Playfair serif vs Inter sans; the 8cqw→0.7cqw scale.
- **1px taupe hairlines** — every divider, card outline, timeline rule, photo ring.
- **Tone** — ink vs gray vs taupe.
- **Negative space** — generous padding.
- **Geometric atmosphere** — compass rings that suggest depth without creating it.
**Ceiling:** no box-shadow, no elevated card, no gradient, no rounded rectangle. The single ink line
(`horizontal-accent`) is the only non-taupe rule.
## Shapes
- **50% (circle)** — card-icon, team-photo, nav-dot, every geo ring.
- **0** — everything else; soft-rounded corners do not exist.
## Components
- **hairline** — the universal 1px taupe separator (the identity).
- **card** (1px taupe + white-overlay) / **card-icon** (ringed circle) / **agenda-row** / **timeline** (line, no nodes) / **stats-cluster** — all built on the hairline.
- **geo-ring** — compass decoration (solid + dashed), 1–2 per frame. **horizontal-accent** — the one ink line, sparingly. **vertical-line** — drafting guide.
- **image-placeholder** (crossed-X) / **team-photo** (ringed initial) — the stone-fill placeholders.
## Frame Treatments
> Recipe: ground · container · composes · focal · chrome · accent · silence · Fixed/Free · density.
> Every frame is sparse and breathing; 1–2 geo decorations max.
### 1 · Cover (identity · move: serif + compass ring · left)
**Ground** `{colors.bg-primary}`, `pad-x`. **Composes** geo-ring (right, ~34cqw, solid+dashed), vertical-line (left), label, display/h1. **Focal** a 2–3 line Playfair `display`/`h1` headline in ink (italic on the key word), left-anchored, with a taupe `label` above and an Inter subtitle below. **Chrome** optional bottom meta row (Playfair value + taupe label). **Accent** the geo ring; optionally one `horizontal-accent` ink line (if it clears the meta). **Silence** ~55% empty. **Fixed** Playfair 400 ink sentence-case, ≤2 geo elements, 1px lines. **Free** title, ring placement, meta. **Density** sparse.
### 2 · Agenda / Index (index · move: hairline list · left)
**Ground** `{colors.bg-primary}`, `pad-x`. **Composes** label, h2, agenda-rows. **Focal** a Playfair `h2` over 4–6 agenda rows (Playfair numeral in taupe + Playfair label in ink), each on a 1px taupe rule. **Chrome** taupe `label` eyebrow. **Accent** none — taupe numerals carry it. **Silence** moderate; rows generously spaced. **Fixed** 1px taupe rules, Playfair 400. **Free** items, count. **Density** standard (sparse rows).
### 3 · Pull Quote (quote · move: centered statement · compass ring)
**Ground** `{colors.bg-primary}`, centered. **Composes** geo-ring (centered dashed, ~26cqw), quote-mark, h2/display-quote, attribution. **Focal** a 2-line Playfair quote in ink, centered, under a 50%-taupe Playfair quote-mark; a taupe uppercase attribution beneath. **Accent** the faint centered ring. **Silence** ~60% — deliberately open. **Fixed** Playfair 400, one ring, centered. **Free** quote, attribution. **Density** sparse.
### 4 · Closing Plate (closer · move: centered ring · centered)
**Ground** `{colors.bg-primary}`, centered. **Composes** geo-ring (centered, ~40cqw, solid+dashed), label, h1/display, horizontal-accent. **Focal** a 1–2 line Playfair sign-off in ink (italic key word), centered inside the largest compass ring; a short ink `horizontal-accent` beneath. **Accent** the ring + the one ink line. **Silence** ~60%. **Fixed** Playfair 400, centered, ≤2 geo. **Free** sign-off, ring scale. **Density** sparse.
### 5 · Two-Column Editorial (content · move: asymmetric split · left)
**Ground** `{colors.bg-primary}`, `pad-x`, two columns with `gap-xl`. **Composes** label, h2, body, image-placeholder (crossed-X) or card. **Focal** a Playfair `h2` + Inter body in the text column; an image-placeholder or card in the other. **Accent** none. **Silence** generous gutter. **Fixed** 1px taupe card/placeholder borders, white-overlay fill. **Free** which side is text, body copy. **Density** standard.
### 6 · Stats / Timeline (data · move: hairline rail · left)
**Ground** `{colors.bg-primary}`, `pad-x`. **Composes** label, h3, stats-cluster or timeline. **Focal** a modest Playfair stat row (or a 1px-rule timeline with year + headline + body, no nodes), framed by a 1px taupe top rule. **Accent** none. **Silence** moderate. **Fixed** modest stat scale, hairline rule, no nodes. **Free** figures, phases. **Density** standard.
## Composition Rules
### Do
- Use a **single 1px taupe line** for every separator — the hairline is the identity.
- Set every **Playfair headline at 400, ink, sentence case**; render labels taupe, uppercase, 2–3px tracked.
- Layer **one or two compass rings** (solid + dashed, 20–50% opacity) behind content for atmosphere.
- Let frames **breathe** — sparse, generous negative space; 55–60% empty on declarative frames.
- Lean centered on quote/closer; asymmetric/left on cover/agenda/editorial. Use `bg-secondary` for placeholder fills.
### Don't
- Don't introduce a populist accent color — stone and ink only.
- Don't bold Playfair, render headlines in taupe, or use thick (2px+) borders.
- Don't add shadows, elevated cards, or rounded rectangles (circles only).
- Don't crowd the frame — packed layouts read as broken.
- Never more than two geo decorations per frame; don't blow a headline edge-to-edge.
## Aspect-Ratio Behavior
| Treatment | 16:9 | 9:16 | 1:1 |
| -------------------- | -------------------------- | ----------------------------------- | --------------------------- |
| Cover | headline left, ring right | headline top, ring below | headline upper, ring behind |
| Agenda / Index | h2 + rows | h2 + rows (tighter) | h2 + rows |
| Pull Quote | centered, ring behind | centered, taller | centered |
| Closing Plate | centered in ring | centered, ring scaled | centered |
| Two-Column Editorial | text + visual side-by-side | stacked (collapse) | stacked |
| Stats / Timeline | horizontal rail | vertical stack (drop timeline rule) | compact |
Generous `pad-x` holds on the short edge; re-step display per ratio above the 1.4cqw floor. On
9:16, the timeline rule loses meaning when stacked — switch to a vertical list (per source).
## Approved Entities
No real customers, logos, or vendors are defined in the source — render any such mark as a
placeholder (the crossed-X image-placeholder, or a ringed initial for portraits).
## Numerals & Claims (hard rule)
Never invent figures, dates, or counts at frame scale. Render slots as `— figure —`, `{metric}`.
Stats and timeline years carry placeholders until the script supplies them. Agenda ordinals
(01, 02…) are decorative and may be sequential.
## Pre-Render Self-Audit
- **Squint** — one Playfair element dominates; the serif/sans contrast carries hierarchy.
- **Silence** — declarative frames 55–60% empty; nothing is crowded.
- **Palette** — five stones + ink only; no populist accent; headlines ink, labels taupe.
- **Lines** — every separator is a 1px taupe hairline; the only ink line is `horizontal-accent`.
- **Type** — Playfair 400 sentence-case, fit-to-measure; labels uppercase 2–3px; ≥1.4cqw floor.
- **Depth** — 0 shadow, 0 rounded rectangle; ≤2 geo rings per frame.
- **Anchor** — centered on quote/closer, left/asymmetric on cover/agenda/editorial; no 3 in a row alike.
- **Fabrication** — every numeral traces to the script, else placeholder.
## Known Gaps
- **Motion intentionally out of scope.** frame.md specifies composition only; the 0.6s fade in the source is a deck mechanic.
- **Playfair Display + Inter via Google Fonts.** CJK pairing (Noto Serif SC 700/400) carries over; Playfair has no Hanzi italic — substitute weight/taupe for emphasis.
- **9:16 / 1:1 are guidance**; verify the legibility floor and that the timeline collapses to a vertical list.
- Geo rings, the crossed-X placeholder, and the dashed inner ring are CSS-only; no external imagery is required.
@@ -1,221 +0,0 @@
<!--
Cartesian — caption skin (preset-local source).
This is the preset's own lower-third karaoke caption look: a caption skin to read or
copy, not to mount as is. A copy used as compositions/captions.html fills its three
reserved holes and wraps the fragment in a <template>:
· the empty GROUPS array → the engine's scene-aware word groups
· the 0 duration + 0 width/height → real total + canvas size
· the empty data-brand-tokens style → :root tokens derived from the project's frame.md
Token contract — reference ONLY this fixed vocab (set it from the
project's frame.md; each var has a cartesian literal
fallback for standalone preview):
--cap-ink · --cap-canvas · --cap-accent · --cap-accent-2 · --font-display ·
--font-body · --cap-band-top · --cap-band-height
Hooks (do not rename): .caption-group / .caption-word + states .is-active / .is-spoken,
root data-composition-id="captions", timeline at window.__timelines["captions"].
State changes use gsap.set({className}) (applied on the engine's frame-by-frame seek);
never tl.call() callbacks (seek does not fire them → wrong state on render).
Visual: a museum-catalog lower-third — a warm-stone card on a single 1px taupe
hairline border (cartesian's universal structural device), SHARP corners, ZERO
shadow, ZERO fill beyond the stone canvas. Playfair Display 400, sentence case, ink
glyphs. Upcoming words sit in faint ink; the current word is marked QUIETLY — a faint
stone wash with a 1px taupe hairline underline (restraint, not a loud block); spoken
words settle to solid ink, the wash and underline cleared.
-->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<!-- the project's :root tokens go here -->
<style data-brand-tokens></style>
<style>
#captions-root {
position: absolute;
inset: 0;
pointer-events: none;
}
.caption-layer {
position: absolute;
inset: 0;
z-index: 20;
pointer-events: none;
}
/* the keep-out band, from captionBand(H) — frame content stays above it */
.caption-stage {
position: absolute;
left: 0;
right: 0;
top: var(--cap-band-top, 900px);
height: var(--cap-band-height, 180px);
display: flex;
align-items: center;
justify-content: center;
}
.caption-group {
/* every group is absolutely stacked over the SAME band — exactly one shown at a
time via opacity. Must be absolute: in-flow groups would lay out side-by-side. */
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
}
/* THE stone card — single 1px taupe hairline border (cartesian's universal
structural device), SHARP corners, ZERO shadow, ZERO fill beyond the stone canvas */
.caption-pill {
max-width: 78%;
padding: 20px 44px 24px;
background: var(--cap-canvas, #ede8e0);
border: 1px solid var(--cap-accent-2, #b8b0a4);
border-radius: 0;
}
.caption-line {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.1em 0.34em;
font-family: var(--font-display, "Playfair Display"), Georgia, serif;
font-weight: 400;
font-size: clamp(40px, 4.4vw, 58px);
line-height: 1.16;
letter-spacing: -0.01em;
}
/* upcoming — faint ink, no mark */
.caption-word {
display: inline-block;
padding: 0 0.06em;
border-bottom: 1px solid transparent;
color: color-mix(in srgb, var(--cap-ink, #1a1a1a) 42%, var(--cap-canvas, #ede8e0));
}
/* current word — a QUIET marker: a faint warm-stone wash behind solid ink, plus a
1px taupe hairline underline (restraint, not a loud block; SHARP, NO shadow) */
.caption-word.is-active {
color: var(--cap-ink, #1a1a1a);
background: color-mix(in srgb, var(--cap-accent, #8a8178) 18%, var(--cap-canvas, #ede8e0));
border-bottom: 1px solid var(--cap-accent, #8a8178);
}
/* already spoken — solid ink, wash + underline cleared */
.caption-word.is-spoken {
color: var(--cap-ink, #1a1a1a);
background: transparent;
border-bottom: 1px solid transparent;
}
/* optional per-word accents (dormant until a word-classer tags them) */
/* brand / key term — Playfair Display italic, cartesian's only display inflection */
.caption-word.cap-brand {
font-style: italic;
}
/* numerals / data — the body sans voice, slightly recessed */
.caption-word.cap-num {
font-family: var(--font-body, "Inter"), ui-sans-serif, sans-serif;
color: color-mix(in srgb, var(--cap-ink, #1a1a1a) 72%, var(--cap-canvas, #ede8e0));
}
/* emphasis — a 1px taupe hairline underline (no second color; stone-and-ink only) */
.caption-word.cap-cta {
text-decoration: underline;
text-decoration-thickness: 1px;
text-decoration-color: var(--cap-accent, #8a8178);
text-underline-offset: 0.12em;
}
@media (max-aspect-ratio: 9/16) {
.caption-pill {
max-width: 92%;
}
.caption-line {
font-size: clamp(34px, 5.4vw, 48px);
}
}
</style>
<div
id="captions-root"
data-composition-id="captions"
data-timeline-locked
data-start="0"
data-duration="0"
data-fps="30"
data-width="0"
data-height="0"
>
<div class="caption-layer" aria-hidden="true">
<div id="caption-stage" class="caption-stage"></div>
</div>
</div>
<script>
// the scene-aware word groups + real duration go here.
var GROUPS = [];
var DURATION = 0;
(function () {
var stage = document.getElementById("caption-stage");
// build DOM: one .caption-group per group, .caption-word per word
GROUPS.forEach(function (group, g) {
var groupEl = document.createElement("div");
groupEl.className = "caption-group";
groupEl.id = "caption-group-" + g;
var pill = document.createElement("div");
pill.className = "caption-pill";
var line = document.createElement("div");
line.className = "caption-line";
(group.words || []).forEach(function (w, i) {
var span = document.createElement("span");
span.className = "caption-word";
span.id = "caption-word-" + g + "-" + i;
span.textContent = String(w.text);
line.appendChild(span);
});
pill.appendChild(line);
groupEl.appendChild(pill);
stage.appendChild(groupEl);
});
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
GROUPS.forEach(function (group, g) {
var groupEl = document.getElementById("caption-group-" + g);
var words = group.words || [];
var next = GROUPS[g + 1];
var isLast = g === GROUPS.length - 1;
var start = Math.max(0, Number(group.start));
var end = isLast ? DURATION : Math.min(Number(next.start), Number(group.end) + 0.3);
if (end <= start) end = start + 0.01;
// group on/off — exactly one group visible at a time
tl.set(groupEl, { opacity: 1 }, start);
tl.set(groupEl, { opacity: 0 }, end);
// 3-state karaoke via className SETS — seek-safe (gsap.set applies on the
// engine's frame-by-frame seek; .call callbacks would NOT fire).
words.forEach(function (w, i) {
var el = document.getElementById("caption-word-" + g + "-" + i);
var at = Math.max(start, Number(w.start));
tl.set(el, { className: "caption-word" }, start); // upcoming (also resets on reverse-seek)
tl.set(el, { className: "caption-word is-active" }, at); // current
tl.fromTo(el, { scale: 0.985 }, { scale: 1, duration: 0.2, ease: "power1.out" }, at);
if (i + 1 < words.length) {
var nextAt = Math.max(start, Number(words[i + 1].start));
tl.set(el, { className: "caption-word is-spoken" }, nextAt); // demote when next activates
}
});
if (words.length) {
var lastEl = document.getElementById("caption-word-" + g + "-" + (words.length - 1));
var lastSpoken = Math.min(end, Number(words[words.length - 1].end) + 0.1);
tl.set(lastEl, { className: "caption-word is-spoken" }, lastSpoken);
}
});
// full-span anchor so the sub-comp timeline spans the whole video
tl.to({}, { duration: DURATION }, 0);
window.__timelines["captions"] = tl;
})();
</script>
File diff suppressed because it is too large. Load diff
@@ -1,265 +0,0 @@
---
version: alpha
name: Cobalt Grid — Frame (video / frame layer)
description: >
Video-first companion to Cobalt Grid's design.md. The unit is the frame (1920×1080). Atoms
are identical and sacred — warm cream paper, electric cobalt ink (the only ink), the permanent
graph-paper grid, Newsreader serif 400 + Hanken Grotesk + DM Mono, the top/bottom cobalt
hairlines, and the pixel-glitch + QR-block signatures. Composition, frame scale, and
aspect-ratio behavior are rewritten for the frame. Motion is out of scope.
unit: the frame — 1920×1080 primary; 9:16 and 1:1 documented
principle: atoms are sacred · composition is free · numbers come from the script
colors:
paper: "#F0EBDE"
paper-2: "#E6E0CE"
ink: "#1F2BE0"
ink-soft: "#5560E5"
grid: "rgba(31,43,224,0.10)"
ink-faint: "rgba(31,43,224,0.18)"
typography:
# — reading ramp —
body: { fontFamily: "Hanken Grotesk", cqw: 0.83, weight: 400, lineHeight: 1.5 }
body-lede: { fontFamily: "Hanken Grotesk", cqw: 0.95, weight: 400, lineHeight: 1.5 }
micro: { fontFamily: "Hanken Grotesk", px: 13, weight: 600, tracking: "0.16em", upper: true }
micro-strong:{ fontFamily: "Hanken Grotesk", px: 16, weight: 600, tracking: "0.18em", upper: true }
mono-tag: { fontFamily: "DM Mono", cqw: 0.78, weight: 400, tracking: "0.05em" }
mono-chrome: { fontFamily: "DM Mono", px: 13, weight: 400, tracking: "0.06em" }
# — display / hero ramp (Newsreader 400, negative tracking) —
table-name: { fontFamily: "Newsreader", cqw: 1.5, weight: 400, lineHeight: 1.15 }
row-headline:{ fontFamily: "Newsreader", cqw: 2.1, weight: 400, lineHeight: 1.05 }
ed-callout: { fontFamily: "Newsreader", cqw: 2.6, weight: 400, lineHeight: 1.1, italic: true }
headline: { fontFamily: "Newsreader", cqw: 4.6, weight: 400, lineHeight: 0.95 }
headline-index:{ fontFamily: "Newsreader", cqw: 5.0, weight: 400, lineHeight: 0.95 }
display-quote:{ fontFamily: "Newsreader", cqw: 5.7, weight: 400, lineHeight: 1.05, tracking: "-0.005em" }
display-chapter:{ fontFamily: "Newsreader", cqw: 6.8, weight: 400, lineHeight: 1.0, tracking: "-0.005em" }
display-closing:{ fontFamily: "Newsreader", cqw: 9.4, weight: 400, lineHeight: 0.96, tracking: "-0.005em" }
display-hero:{ fontFamily: "Newsreader", cqw: 10.4, weight: 400, lineHeight: 0.9, tracking: "-0.008em" }
vbig-numeral:{ fontFamily: "Newsreader", cqw: 12.5, weight: 400, lineHeight: 0.9, tracking: "-0.015em" }
spacing:
edge: "4cqw" # standard frame edge inset (~80px@1920)
pad-top: "7cqw"
pad-bottom: "6cqw"
gap-md: "2cqw"
components:
graph-grid:
backgroundImage: "linear-gradient grid, ~2cqw cells, 10% {colors.ink} ({colors.grid})"
placement: "behind EVERY frame on the ground"
description: "Permanent graph-paper grid — never disabled; the canvas tone."
hairlines:
rule: "0.12cqw solid {colors.ink}"
placement: "≈3cqw from top + bottom, inset {spacing.edge}"
description: "Two persistent cobalt rules framing every composition."
page-chrome:
typography: "{typography.pagenum}"
color: "{colors.ink}"
placement: "page number bottom-right, nav/meta hint bottom-left, above the bottom hairline"
description: "The only persistent chrome."
pixel-glitch:
backgroundColor: "{colors.paper}"
fill: "repeating-linear-gradient(90deg, {colors.ink} 0 2px, transparent 2px 8px)"
size: "14–30cqw wide, full height, stair-stepped"
placement: "right on cover/data, left on chapter/colophon; z above grid, below headline"
description: "Stair-stepped scanline column. Decorative."
qr-block:
backgroundColor: "{colors.paper}"
cells: "8×8, {colors.ink} on / {colors.grid} off"
size: "3–9cqw square"
shadow: "0 0 0 0.12cqw {colors.paper} (anti-shadow for readability, not elevation)"
description: "QR mosaic patch — corner punctuation."
topbar-rule:
typography: "{typography.headline-index} + {typography.mono-tag}"
borderBottom: "0.12cqw solid {colors.ink}"
description: "Section header on index/data/table frames."
ledger-row:
layout: "grid: mono num · Newsreader name · Hanken desc · mono delta"
borderBottom: "0.06cqw solid {colors.ink-faint} (header 0.12cqw solid {colors.ink})"
typography: "{typography.mono-tag} + {typography.table-name} + {typography.body}"
description: "Dense matrix row with ↑/↓/— delta."
pixel-stack-bar:
cells: "column-reverse, {colors.grid} off / {colors.ink} on"
baseline: "0.12cqw solid {colors.ink} + {typography.mono-tick} ticks"
description: "Data as grid-unit cells; echoes the glitch."
vstack-label:
typography: "{typography.mono-tick}"
transform: "writing-mode: vertical-rl"
description: "Catalogue chrome along a frame edge."
---
# Cobalt Grid — Frame (video / frame layer)
## Overview
Cobalt Grid at frame scale is a **two-color risograph trend-report** — warm cream paper, electric
cobalt ink, and a **permanent graph-paper grid** behind every frame. Cobalt is the _only_ ink:
headlines, body, rules, the grid, the pixel-glitch decoration, the QR patches. There is no accent
color and no second surface.
The voice is a three-face conversation: **Newsreader** serif at weight 400 (size, not weight,
makes hierarchy) carries every display and headline; **Hanken Grotesk** carries body and
uppercase tracked labels; **DM Mono** carries all chrome — page numbers, tags, ticks, vertical
stacks. Every frame is framed by top + bottom cobalt hairlines; declarative frames carry the
**pixel-glitch column** and a **QR-block** patch.
**Key characteristics at frame scale:**
- **Strictly two-color** — cream paper + cobalt ink; no accent, no second surface.
- **Permanent ~2cqw graph-paper grid** (10% cobalt) behind every frame; cannot be disabled.
- **Top + bottom 1.5px cobalt hairlines** frame every composition, inset by `edge`.
- **Newsreader 400** for all display (negative tracking); **Hanken 600** uppercase 0.16em labels; **DM Mono** chrome.
- **Pixel-glitch column + QR-block** as the signature decorative patches on declarative frames.
- **Pixel-stack bars** render data as grid-unit cells (cobalt on / 10% off).
## The Frame
### Frame Craft Bar
Three eyeball tests gate every frame before any structural check:
- **Squint** — one Newsreader element dominates at **3–6× its nearest neighbor** (`display-hero`/`vbig-numeral`); hierarchy is size, never weight.
- **Silence** — declarative frames (cover, chapter, quote, colophon) read **45–60% empty** with the grid showing; the **index ledger and data frame are the one dense exception** (density via quiet repetition, not richness).
- **Restraint** — **two-color only** (cream + cobalt, never a second hue); one pixel-glitch column and at most one QR-block per declarative frame.
- **Reference** — aim at a **WIRED Japan / Shift two-color risograph monograph**; failure looks like a **colorful dashboard with a second accent hue**.
- **Primary:** 1920×1080 (16:9). Display authored in **`cqw`** (`px ÷ 1920 × 100 = cqw`).
- **Vertical:** 1080×1920 (9:16). **Square:** 1080×1080 (1:1).
- **Safe area:** `edge` (4cqw) inset; hairlines + page chrome live on the safe line.
**The container law (load-bearing).** Every frame ground sets `container-type: size` AND carries
the graph-paper `background-image`; ALL frame-relative units are `cqw`/`cqh` against it — never
`vw`. The grid `background-size` is `~2cqw 2cqw` so cell density holds at any render size.
## Colors
Tokens identical to the source. `{colors.paper}` is the ground; `{colors.ink}` cobalt is the only
ink (type, rules, grid lines, glitch, QR). `{colors.ink-soft}` is a secondary cobalt for editorial
subtitles only; `{colors.grid}` (10%) is the permanent grid + chart "off" cells; `{colors.ink-faint}`
(18%) is faint row dividers. **Never a second hue** — emphasis comes from size, from switching
Hanken→Newsreader, from a mono delta arrow, or from opacity, never from color.
## Typography
Two ramps. The **reading ramp** (Hanken body 0.83cqw, mono chrome in px) carries copy + labels;
the **display/hero ramp** (Newsreader `headline` 4.6cqw → `vbig-numeral` 12.5cqw) carries every
statement and figure.
- **Legibility floor:** any load-bearing line ≥ **1.4cqw**; mono chrome (px) is colophon only.
- **Fit-to-measure:** size the headline to its line length. Cap the block at **≤ 78cqw**; ≤3 words → `display-hero`/`vbig-numeral`; 4–6 → `display-chapter`; 7+ → `headline`.
- **Newsreader at 400 only**, in cobalt, with negative tracking (hero −0.008em, numeral −0.015em). **Hanken labels uppercase, 0.16–0.18em.** **DM Mono 0.04–0.08em.** No bold serif.
## Depth & Surface
Flat. Depth is structural only:
- **1.5px cobalt rules** — slide hairlines, topbar rule, chart baseline.
- **1px ink-faint dividers** — dense list / ledger rows.
- **The graph-paper grid** — measured-plane tone behind everything.
- **Pixel-glitch + QR** — texture and graphic punctuation, no z-axis.
**Ceiling:** no drop shadow (the QR's 1.5px paper outset is an anti-shadow for readability, not elevation), no gradient, no rounded corner, no second color.
## Shapes
- **0 radius everywhere** — frames, ledger rows, QR cells, glitch blocks, charts. Zero circular elements; this squareness is part of the identity.
## Components
- **graph-grid / hairlines / page-chrome** — the permanent frame furniture, inherited by every composition.
- **pixel-glitch** (edge column) + **qr-block** (corner patch) — the signature decoration on declarative frames.
- **topbar-rule** — the index/data/table section header. **ledger-row** — dense matrix row with delta arrows.
- **pixel-stack-bar** — data as grid-unit cells. **vstack-label** — vertical mono catalogue chrome.
## Frame Treatments
> Recipe: ground · container · composes · focal · chrome · accent · silence · Fixed/Free · density.
> The grid + hairlines are present on every frame. Choose density by type: declarative = sparse, index/data = dense.
### 1 · Hero Cover (identity · move: serif + glitch · left)
**Ground** paper + grid, hairlines. **Composes** pixel-glitch (right, ~26cqw), qr-block (top-right), display-hero, ed-callout. **Focal** a 1–2 line `display-hero` Newsreader headline in cobalt, left-anchored, with an italic `ed-callout` subtitle in `{colors.ink-soft}`. **Chrome** Hanken kicker; mono meta top + page number. **Accent** none (cobalt is the only ink). **Silence** ~45% paper. **Fixed** Newsreader 400, glitch + QR present, grid + hairlines. **Free** title, glitch step pattern, QR placement. **Density** sparse.
### 2 · Index Ledger (catalog · move: dense matrix · left — the dense frame)
**Ground** paper + grid, hairlines, `pad-top`/`pad-bottom`. **Composes** topbar-rule, ledger-rows. **Focal** a topbar (`headline-index` + mono lab-tag, 1.5px rule) over 4–6 ledger rows (mono num · Newsreader name · Hanken desc), ink-faint dividers. **Chrome** page number. **Accent** mono delta arrows. **Silence** tight — the density exception (the grid wants filling). **Fixed** 1.5px topbar rule, 1px ink-faint dividers, Newsreader names. **Free** rows, lab-tag, deltas. **Density** dense-exception.
### 3 · Chapter Opener (section · move: scale · sparse · left)
**Ground** paper + grid, hairlines. **Composes** pixel-glitch (left, ~14cqw, low opacity), mono index, display-chapter, body-lede. **Focal** a `display-chapter` Newsreader title, with a small mono `CHAPTER NN` index above and an optional Hanken lede ≤42cqw. **Accent** none. **Silence** ~60% — let the grid breathe. **Fixed** Newsreader 400, glitch low-opacity, grid showing. **Free** title, lede, glitch side. **Density** sparse.
### 4 · Data Frame (chart · move: pixel-stack · left)
**Ground** paper + grid, hairlines, `pad-top`. **Composes** topbar-rule, pixel-stack-bar row. **Focal** a row of 6–8 pixel-stack bars (cobalt on / 10% off) over a 1.5px cobalt baseline with mono ticks. **Chrome** topbar headline + mono fig-tag; page number. **Accent** none — data is cobalt cells. **Silence** moderate. **Fixed** grid-unit cells, cobalt baseline, mono ticks. **Free** bar values (from script), tick labels. **Density** standard/dense.
### 5 · Manifesto / Quote (quote · move: centered statement · sparse)
**Ground** paper + grid, hairlines. **Composes** display-quote (or display-manifesto), attribution rule, optional compact glitch. **Focal** a 2–3 line Newsreader pull in cobalt, with a Hanken kicker above and a 1px cobalt attribution rule + mono byline beneath. **Accent** none. **Silence** ~55% — deliberately open. **Fixed** Newsreader 400, attribution rule, grid showing. **Free** quote, byline. **Density** sparse.
### 6 · Colophon (closer · move: right-aligned close · sparse)
**Ground** paper + grid, hairlines. **Composes** pixel-glitch (left edge, mirroring the cover), display-closing, mono credit columns. **Focal** a right-aligned `display-closing` Newsreader title with a Hanken kicker above. **Chrome** 3–4 column mono credit grid at the foot; page number. **Accent** none. **Silence** ~50%. **Fixed** glitch on left, Newsreader 400. **Free** closing line, credits. **Density** sparse.
## Composition Rules
### Do
- Keep the **graph-paper grid + top/bottom hairlines** on every frame — they are the system.
- Set **Newsreader at 400 in cobalt**; make hierarchy with size, not weight; negative-track display.
- Track **Hanken labels uppercase 0.16em**; **DM Mono chrome 0.04–0.08em**.
- Render data as **pixel-stack cells** (cobalt on / 10% off); echo the glitch language.
- Use the **pixel-glitch column + QR patch** on declarative frames (cover, chapter, quote, colophon).
- Lean: declarative frames sparse and breathing; index/data dense. Vary anchor (left for index/chapter, centered for quote/closer).
### Don't
- Don't introduce a second ink color — cream + cobalt only.
- Don't bold Newsreader, round any corner, or add a drop shadow (QR outset is an anti-shadow only).
- Don't disable the grid or suppress the hairlines.
- Don't crowd chapter / quote / colophon — those let the grid show.
- Don't blow a serif headline edge-to-edge — fit to measure.
## Aspect-Ratio Behavior
| Treatment | 16:9 | 9:16 | 1:1 |
| ----------------- | --------------------------- | ------------------------------------ | ---------------------------- |
| Hero Cover | headline left, glitch right | headline top, glitch full-width band | headline upper, glitch lower |
| Index Ledger | topbar + rows | topbar + fewer rows | 2-col compresses to 1 |
| Chapter Opener | title left, glitch left | title top, glitch side | centered title |
| Data Frame | 6–8 bars | 4–5 bars taller | square chart |
| Manifesto / Quote | centered pull | centered, taller | centered |
| Colophon | right-aligned, glitch left | stacked, glitch top | centered close |
Grid + hairlines hold on the short edge for every ratio; re-step display so no load-bearing line
drops below 1.4cqw. Mono chrome stays Latin/digit-only.
## Approved Entities
No real customers, logos, or vendors are defined in the source — render any such mark as a
placeholder. Trend names, signals, and figures are content; the system supplies the grid and chrome.
## Numerals & Claims (hard rule)
Never invent figures, deltas, dates, or signal counts at frame scale. Render slots as `— figure —`,
`{metric}`, `↑ —`. Pixel-stack bar heights and ledger deltas especially carry placeholders until the
script supplies values. Mono catalogue ordinals (001, 002…) are decorative and may be sequential.
## Pre-Render Self-Audit
- **Squint** — one Newsreader element dominates at 3–5× its neighbor.
- **Silence** — declarative frames 45–60% empty; only index/data run dense.
- **Two-color** — cream + cobalt only; no second hue anywhere.
- **Furniture** — grid present, top/bottom hairlines present, page chrome above the hairline.
- **Type** — Newsreader 400 negative-tracked, fit-to-measure; Hanken labels 0.16em; ≥1.4cqw floor.
- **Depth** — 0 shadow (QR outset excepted), 0 rounded corner.
- **Anchor** — left on index/chapter/cover, centered on quote/closer; no 3 consecutive frames share an anchor.
- **Fabrication** — every numeral/delta traces to the script, else placeholder.
## Known Gaps
- **Motion intentionally out of scope.** frame.md specifies composition only; the 280ms cross-fade in the source is a deck mechanic.
- **Pixel-glitch is rendered in CSS here** (stacked scanline blocks) rather than the source's inline SVG, to keep the showcase SVG-free; fidelity is preserved.
- **Three Google Fonts** (Newsreader, Hanken Grotesk, DM Mono); CJK pairing (Noto Serif SC 700/400) carries over from the source.
- **9:16 / 1:1 are guidance**; verify the legibility floor and grid density per ratio.
- The QR mosaic and glitch step patterns are hand-authored; there is no generative layer.
@@ -1,237 +0,0 @@
<!--
Cobalt Grid — caption skin (preset-local source).
This is the preset's own lower-third karaoke caption look: a caption skin to read or
copy, not to mount as is. A copy used as compositions/captions.html fills its three
reserved holes and wraps the fragment in a <template>:
· the empty GROUPS array → the engine's scene-aware word groups
· the 0 duration + 0 width/height → real total + canvas size
· the empty data-brand-tokens style → :root tokens derived from the project's frame.md
Token contract — reference ONLY this fixed vocab (set it from the
project's frame.md; each var has a cobalt literal
fallback for standalone preview):
--cap-ink · --cap-canvas · --cap-accent · --cap-accent-2 · --font-display ·
--font-body · --cap-band-top · --cap-band-height
Hooks (do not rename): .caption-group / .caption-word + states .is-active / .is-spoken,
root data-composition-id="captions", timeline at window.__timelines["captions"].
State changes use gsap.set({className}) (applied on the engine's frame-by-frame seek);
never tl.call() callbacks (seek does not fire them → wrong state on render).
Visual: a cream-paper card carrying the permanent graph-paper grid behind the line,
framed by top + bottom 1.5px cobalt hairlines (cobalt's signature frame furniture) —
square corners, NO drop shadow (cobalt's depth is structural and flat, never cast).
Newsreader serif 400, sentence case, cobalt glyphs. Upcoming words sit in faint
cobalt; the current word is a hard COBALT FLAT BLOCK — a risograph knockout: a solid
cobalt fill (box-shadow spread = a square block, no blur, no layout shift) with the
cream paper reading on top. Spoken words settle to solid cobalt ink, the block cleared.
Strictly two-color throughout — cream + cobalt, never a second hue.
-->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<!-- the project's :root tokens go here -->
<style data-brand-tokens></style>
<style>
#captions-root {
position: absolute;
inset: 0;
pointer-events: none;
}
.caption-layer {
position: absolute;
inset: 0;
z-index: 20;
pointer-events: none;
}
/* the keep-out band, from captionBand(H) — frame content stays above it */
.caption-stage {
position: absolute;
left: 0;
right: 0;
top: var(--cap-band-top, 900px);
height: var(--cap-band-height, 180px);
display: flex;
align-items: center;
justify-content: center;
}
.caption-group {
/* every group is absolutely stacked over the SAME band — exactly one shown at a
time via opacity. Must be absolute: in-flow groups would lay out side-by-side. */
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
}
/* THE cream card — graph-paper grid behind the line + top/bottom 1.5px cobalt
hairlines (cobalt's frame furniture), square corners, NO drop shadow */
.caption-pill {
position: relative;
max-width: 78%;
padding: 20px 44px 24px;
background: var(--cap-canvas, #f0ebde);
background-image:
linear-gradient(
to right,
color-mix(in srgb, var(--cap-ink, #1f2be0) 10%, transparent) 1px,
transparent 1px
),
linear-gradient(
to bottom,
color-mix(in srgb, var(--cap-ink, #1f2be0) 10%, transparent) 1px,
transparent 1px
);
background-size: 16px 16px;
border-top: 1.5px solid var(--cap-ink, #1f2be0);
border-bottom: 1.5px solid var(--cap-ink, #1f2be0);
border-radius: 0;
}
.caption-line {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.1em 0.34em;
font-family: var(--font-display, "Newsreader"), Georgia, serif;
font-weight: 400;
font-size: clamp(40px, 4.4vw, 58px);
line-height: 1.16;
letter-spacing: -0.01em;
}
/* upcoming — faint cobalt, no block */
.caption-word {
display: inline-block;
padding: 0 0.06em;
color: color-mix(in srgb, var(--cap-ink, #1f2be0) 42%, var(--cap-canvas, #f0ebde));
}
/* current word — THE cobalt flat block (risograph knockout): a solid cobalt fill
via box-shadow spread = a hard square block w/o layout shift, no blur; the cream
paper reads on top */
.caption-word.is-active {
color: var(--cap-canvas, #f0ebde);
background: var(--cap-ink, #1f2be0);
box-shadow: 0 0 0 0.06em var(--cap-ink, #1f2be0);
}
/* already spoken — solid cobalt ink, block cleared */
.caption-word.is-spoken {
color: var(--cap-ink, #1f2be0);
background: transparent;
box-shadow: none;
}
/* optional per-word accents (dormant until a word-classer tags them) */
/* brand / key term — Newsreader italic, cobalt's only display inflection */
.caption-word.cap-brand {
font-style: italic;
}
/* numerals / data — the body sans voice, slightly recessed */
.caption-word.cap-num {
font-family: var(--font-body, "Hanken Grotesk"), ui-monospace, monospace;
color: color-mix(in srgb, var(--cap-ink, #1f2be0) 72%, var(--cap-canvas, #f0ebde));
}
/* emphasis — a cobalt hairline underline (no second hue; emphasis is the ink) */
.caption-word.cap-cta {
text-decoration: underline;
text-decoration-thickness: 2px;
text-decoration-color: var(--cap-accent, #5560e5);
text-underline-offset: 0.1em;
}
@media (max-aspect-ratio: 9/16) {
.caption-pill {
max-width: 92%;
}
.caption-line {
font-size: clamp(34px, 5.4vw, 48px);
}
}
</style>
<div
id="captions-root"
data-composition-id="captions"
data-timeline-locked
data-start="0"
data-duration="0"
data-fps="30"
data-width="0"
data-height="0"
>
<div class="caption-layer" aria-hidden="true">
<div id="caption-stage" class="caption-stage"></div>
</div>
</div>
<script>
// the scene-aware word groups + real duration go here.
var GROUPS = [];
var DURATION = 0;
(function () {
var stage = document.getElementById("caption-stage");
// build DOM: one .caption-group per group, .caption-word per word
GROUPS.forEach(function (group, g) {
var groupEl = document.createElement("div");
groupEl.className = "caption-group";
groupEl.id = "caption-group-" + g;
var pill = document.createElement("div");
pill.className = "caption-pill";
var line = document.createElement("div");
line.className = "caption-line";
(group.words || []).forEach(function (w, i) {
var span = document.createElement("span");
span.className = "caption-word";
span.id = "caption-word-" + g + "-" + i;
span.textContent = String(w.text);
line.appendChild(span);
});
pill.appendChild(line);
groupEl.appendChild(pill);
stage.appendChild(groupEl);
});
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
GROUPS.forEach(function (group, g) {
var groupEl = document.getElementById("caption-group-" + g);
var words = group.words || [];
var next = GROUPS[g + 1];
var isLast = g === GROUPS.length - 1;
var start = Math.max(0, Number(group.start));
var end = isLast ? DURATION : Math.min(Number(next.start), Number(group.end) + 0.3);
if (end <= start) end = start + 0.01;
// group on/off — exactly one group visible at a time
tl.set(groupEl, { opacity: 1 }, start);
tl.set(groupEl, { opacity: 0 }, end);
// 3-state karaoke via className SETS — seek-safe (gsap.set applies on the
// engine's frame-by-frame seek; .call callbacks would NOT fire).
words.forEach(function (w, i) {
var el = document.getElementById("caption-word-" + g + "-" + i);
var at = Math.max(start, Number(w.start));
tl.set(el, { className: "caption-word" }, start); // upcoming (also resets on reverse-seek)
tl.set(el, { className: "caption-word is-active" }, at); // current
tl.fromTo(el, { scale: 0.985 }, { scale: 1, duration: 0.1, ease: "power3.out" }, at);
if (i + 1 < words.length) {
var nextAt = Math.max(start, Number(words[i + 1].start));
tl.set(el, { className: "caption-word is-spoken" }, nextAt); // demote when next activates
}
});
if (words.length) {
var lastEl = document.getElementById("caption-word-" + g + "-" + (words.length - 1));
var lastSpoken = Math.min(end, Number(words[words.length - 1].end) + 0.1);
tl.set(lastEl, { className: "caption-word is-spoken" }, lastSpoken);
}
});
// full-span anchor so the sub-comp timeline spans the whole video
tl.to({}, { duration: DURATION }, 0);
window.__timelines["captions"] = tl;
})();
</script>
File diff suppressed because it is too large. Load diff
@@ -1,277 +0,0 @@
---
version: alpha
name: Code editorial — Frame (video / frame layer)
description: >
Video-first companion to Code editorial's design.md. The unit is the frame (1920×1080). Atoms are
identical and sacred — warm cream paper (never pure white, never cool), a single terracotta coral
as scarce "voltage", hairline ink elevation (no heavy shadow), EB Garamond for all
display + Inter body + JetBrains Mono for the index/code voice on a warm-navy code surface,
sentence-case display, and the ✱ coral spike mark. Composition + frame scale rewritten. Motion out
of scope.
unit: the frame — 1920×1080 primary; 9:16 and 1:1 documented
principle: atoms are sacred · composition is free · numbers come from the script
colors:
ink: "#141413"
cream: "#FAF9F5"
tile: "#EFE9DE"
tile-strong: "#ECE3D4"
coral: "#CC785C"
navy: "#181715"
navy-soft: "#1F1E1B"
navy-elev: "#252320"
borders: { hairline: "1px solid ink@12%", hairline-strong: "1px solid ink@20%", dark: "1px solid cream@14% (on navy)" }
shadows: { card: "0 1px 3px ink@8%, 0 4px 16px ink@4%", none: "none" }
typography:
# — reading + chrome ramp —
body: { fontFamily: "Inter", cqw: 1.5, weight: 400, lineHeight: 1.5 }
lead: { fontFamily: "Inter", cqw: 2.08, weight: 400, lineHeight: 1.5 }
card-title:{ fontFamily: "Inter", cqw: 2.3, weight: 500, lineHeight: 1.25, tracking: "-0.005em" }
button: { fontFamily: "Inter", cqw: 1.46, weight: 500, lineHeight: 1.0 }
tag-upper:{ fontFamily: "Inter", cqw: 1.35, weight: 500, tracking: "0.18em", upper: true }
kicker: { fontFamily: "JetBrains Mono", px: 28, cqw: 1.46, weight: 500, tracking: "0.16em", upper: true }
mono-label:{ fontFamily: "JetBrains Mono", px: 26, cqw: 1.35, weight: 500, tracking: "0.02em" }
code: { fontFamily: "JetBrains Mono", cqw: 1.67, weight: 400, lineHeight: 1.6 }
# — display ramp (EB Garamond 400, sentence case, negative tracking. Renderer embeds only 400/700 — author at 400; italic is the synthesized slant) —
headline:{ fontFamily: "EB Garamond", cqw: 4.6, weight: 400, lineHeight: 1.06, tracking: "-0.018em" }
quote-pull:{ fontFamily: "EB Garamond", cqw: 5.0, weight: 400, lineHeight: 1.12, tracking: "-0.012em", italic: true }
display-italic:{ fontFamily: "EB Garamond", cqw: 6.7, weight: 400, lineHeight: 1.05, tracking: "-0.012em", italic: true }
display:{ fontFamily: "EB Garamond", cqw: 7.3, weight: 400, lineHeight: 1.02, tracking: "-0.022em" }
number-hero:{ fontFamily: "EB Garamond", cqw: 9.4, weight: 400, lineHeight: 0.95, tracking: "-0.025em" }
display-cover:{ fontFamily: "EB Garamond", cqw: 9.9, weight: 400, lineHeight: 0.98, tracking: "-0.028em" }
number-unit:{ fontFamily: "JetBrains Mono", cqw: 2.08, weight: 500, lineHeight: 1.0 }
spacing:
slide-pad: "4.2cqw" # ~80px @1920
gap-md: "1.7cqw"
hairline: "1px"
radius-sm: "6px"
radius-md: "8px"
radius-lg: "12px"
radius-pill: "9999px"
components:
card-hairline:
backgroundColor: "{colors.cream} or {colors.tile}"
border: "1px solid {colors.ink}@12%"
rounded: "{spacing.radius-lg}"
shadow: "{shadows.card}"
typography: "{typography.card-title} + {typography.body}"
description: "The editorial content card. Elevation is the hairline + ONE soft warm shadow — never a heavy drop, glow, or gradient."
kicker-spike:
typography: "{typography.kicker}"
mark: "✱ coral spike prefix"
description: "The eyebrow — JetBrains Mono uppercase, indexical (2–5 words), prefixed with the coral ✱. Never plain text, never a sentence."
coral-callout:
backgroundColor: "{colors.coral} (full-bleed) or {colors.cream} with a coral edge"
textColor: "{colors.cream} on coral"
rounded: "{spacing.radius-md}"
typography: "{typography.button} / {typography.h2}"
description: "The ONE voltage moment per frame — the CTA, the single inline link, OR the full-bleed band. Never two corals in one frame."
number-lockup:
typography: "{typography.number-hero} figure + {typography.number-unit} unit"
description: "Hero stat — a EB Garamond figure paired with a JetBrains Mono unit (200K, +1,204, −318, 17 files). Figure is serif; the unit is ALWAYS mono, never the serif."
pull-quote:
typography: "{typography.quote-pull} (EB Garamond italic) + {typography.tag-upper} cite"
description: "A commit message, a reviewer line, or the thesis. EB Garamond italic, small Inter uppercase cite beneath."
section-rule:
rule: "1px solid {colors.ink}@12% (or {colors.cream}@14% on navy)"
description: "The only separator. A coral 1px rule may draw on to introduce a section. Never 2px+, never a heavy divider."
code-surface:
backgroundColor: "{colors.navy} body / {colors.navy-elev} title bar + status strip"
textColor: "{colors.cream} (JetBrains Mono); syntax in coral (keywords) / teal #5DB8A6 (strings) / amber #E8A55A (numbers)"
border: "1px solid {colors.cream}@14%"
rounded: "{spacing.radius-md}"
description: "The warm-navy code / terminal surface. The CODE ITSELF is rendered by the code-* registry blocks (code-diff / code-typing / code-snippet-*); this preset owns the surrounding surface, title bar, status strip, and mono chrome — not the code rendering."
spike-mark:
glyph: "✱ (U+2731), always {colors.coral}"
description: "The brand mark. Fades + scales 0.92→1 on a single emphasis beat; never spins."
---
# Code editorial — Frame (video / frame layer)
## Overview
Code editorial at frame scale is a **warm-editorial brand book come to life** — the register of a literary
imprint or a research note. The thesis is three colors: **cream is the ground, ink is the voice,
coral is the voltage** — and a fourth (warm navy) only where code shows itself. Every surface is
**warm cream** (never pure white, never cool gray); content gathers on a **tile** surface half a
step darker — the demarcation is a half-step, never a hard contrast. Elevation is a **1px hairline**
ink border at low alpha plus, rarely, one soft warm shadow. There are no heavy drops, no glows, no
gradients on content.
Three editorial voices, each in its own face: **EB Garamond** carries every display moment — covers,
headlines, pull-quotes, big stat numerals — at large display sizes with gentle negative tracking;
its **italic** is the expressive register. **Inter** carries body, leads, card titles, buttons, and
UI chrome. **JetBrains Mono** carries the indexical layer — kickers, technical labels, the code
window, status strips. Switching a voice's face collapses the register: a sans headline or a serif
label reads as a different brand.
**Key characteristics at frame scale:**
- **Cream / ink / coral trinity** + a warm-navy code surface; cream is the default ground, ink the voice, coral the scarce voltage.
- **EB Garamond** (sentence case, negative-tracked) for all display; **Inter** body/chrome; **JetBrains Mono** index + code.
- **Hairline elevation** — a 1px low-alpha ink border + at most one soft warm shadow. No heavy drop, glow, or gradient.
- **Coral is rationed** — at most ONE coral moment per frame (CTA, inline link, OR full-bleed band); coral never sets a headline or a body run.
- **Density is free** — fill the frame as the content wants; a frame may stand on a single focal or carry a dense, layered composition.
- **The ✱ coral spike** opens kickers; warm navy is reserved for the code/terminal surface.
## The Frame
### Frame Craft Bar
Eyeball tests gate every frame before any structural check:
- **Squint** — one EB Garamond display moment dominates at 3–6× its neighbor.
- **Trinity** — cream/tile ground, ink text, coral exactly **once**; warm navy only on the code surface; no cool gray / pure white / fourth hue.
- **Type** — EB Garamond sentence-case display (negative-tracked); Inter 400 body; JetBrains Mono kickers (uppercase 0.16em, coral ✱) + code.
- **Primary:** 1920×1080 (16:9). Display authored in **`cqw`** (`px ÷ 1920 × 100 = cqw`).
- **Vertical:** 1080×1920 (9:16). **Square:** 1080×1080 (1:1).
- **Safe area:** `slide-pad` ~4.2cqw; the kicker/mono chrome sit inside it.
**The container law (load-bearing).** Every frame ground sets `container-type: size`; ALL
frame-relative units are `cqw`/`cqh` against it — never `vw`. Hairlines stay 1px; card radii stay
6/8/12px; the warm-paper reading must survive every ratio.
## Colors
Tokens identical to the source. Default ground `{colors.cream}`; content gathers on
`{colors.tile}` / `{colors.tile-strong}` (half-step warm steps, never a hard contrast).
**Headlines & body:** `{colors.ink}` on cream/tile; `{colors.cream}` on navy. **Coral**
(`{colors.coral}`) is the scarce voltage — one moment per frame (CTA, inline link, or full-bleed
band), never body text, never a card fill. **Warm navy** (`{colors.navy}` / `navy-soft` /
`navy-elev`) is the code / terminal / dark-card surface — a structural anchor, not a fourth brand
hue. **No cool grays, no pure white, no pure black.**
**Fixed syntax colors (decoration, NOT remixable brand hues).** When a code line is hand-set rather
than rendered by a `code-*` block, keywords are coral, strings are **teal `#5DB8A6`**, numbers are
**amber `#E8A55A`**; status reads success `#5DB872` / warn `#C64545`. These track the code surface,
not the brand trinity — keep them out of the brand palette.
## Typography
Two ramps. The **reading/chrome ramp** (Inter `body` 1.5cqw / `lead` 2.08cqw weight 400; JetBrains
Mono `kicker`/`mono-label` in px) carries copy + chrome; the **display ramp** (EB Garamond `headline` 4.6cqw
→ `display-cover` 9.9cqw, weight 400, negative-tracked) carries every headline + stat.
- **Legibility floor:** any load-bearing line ≥ **1.4cqw**; mono px labels are chrome only.
- **Fit-to-measure:** size the headline to its length. Cap the block at **≤ 78cqw**; ≤3 words → `display-cover`; 4–6 → `display`; 7+ → `headline`. Reserve the 7.3–9.9cqw tier for cover / statement / stat.
- **EB Garamond display is sentence case** (NOT title case, NOT uppercase), weight 400, negative-tracked (−0.012..−0.028em); reach for **italic** when the line is a stance or a definition. **Inter body** sentence case weight 400. **JetBrains Mono** kickers UPPERCASE 0.16em with the coral ✱. No uppercase serif, no sans headline, no serif label.
## Depth & Surface
Hairline elevation:
- **1px hairline** ink border at ~12% alpha is the primary lift (cream@14% on navy).
- **One soft warm shadow** (`0 1px 3px ink@8%, 0 4px 16px ink@4%`) — used rarely, never heavy.
- **Half-step surface** — a `{colors.tile}` block on cream reads elevated by the warm step, not by a cast shadow.
**Ceiling:** no heavy drop shadow, no glow, no gradient on content, no tilt. The system has no light
to emit; it reads by warmth and hairline.
## Shapes
- **6px** small chrome, **8px** cards / code surface, **12px** large cards / quote frames, **9999px** true pills only. No square corners, no heavy rounding; the editorial register is gently rounded, never hard.
## Components
- **card-hairline** — the editorial content card (hairline + one soft shadow). **section-rule** — the only separator (1px, coral may draw on).
- **kicker-spike** — the ✱ coral eyebrow. **coral-callout** — the one voltage moment (CTA / inline link / full-bleed band).
- **number-lockup** — EB Garamond figure + mono unit (the PR `+N / −M`, `200K`, file counts). **pull-quote** — EB Garamond italic + cite (a commit message / reviewer line).
- **code-surface** — the warm-navy code / terminal surface; the code itself comes from the **`code-*` registry blocks**, this owns the surface + mono chrome.
- **spike-mark** — the ✱ brand glyph, always coral.
## Frame Treatments
> Recipe: ground · container · composes · focal · chrome · accent · Fixed/Free · density.
> One coral moment per frame; open with a kicker-spike.
### 1 · Cover (identity · move: oversized EB Garamond · cream)
**Ground** `{colors.cream}`, `slide-pad`. **Composes** kicker-spike, display-cover, lead, mono-label index. **Focal** a 2–3 line EB Garamond `display-cover` (sentence case, ink) under a coral ✱ kicker. **Chrome** mono index strip (repo · branch). **Accent** the single coral ✱. **Fixed** EB Garamond 400 sentence case, hairline, cream ground. **Free** title, the mono index, layout + how full the frame runs. **Density** free.
### 2 · Statement (statement · move: single EB Garamond line · cream or navy)
**Ground** `{colors.cream}` (or `{colors.navy}` for gravity). **Composes** kicker-spike, display, optional lead. **Focal** one 2-line EB Garamond `display` carrying the change in a sentence — reach for **italic** if it's a stance. **Chrome** mono kicker. **Accent** none — the serif carries it (or one coral word). **Fixed** sentence-case serif. **Free** the line, ground, layout + density. **Density** free.
### 3 · Code Surface (code · move: warm-navy code window · the PR-critical frame)
**Ground** `{colors.cream}` framing a `{colors.navy}` **code-surface** (8px, cream@14% hairline, `navy-elev` title bar + filename in mono). **Composes** mono-label filename, the **`code-*` block** (code-diff / code-typing / code-snippet-_), optional `section-rule`. **Focal** the code panel — the diff / before→after / typed-on snippet. **Chrome** mono filename + status strip. **Accent** syntax coral/teal/amber inside the panel; one coral marker outside (e.g. a `+`/`−` gutter cue). **Fixed** warm-navy surface, mono code, hairline. **Free** which code-_ block, the code (from the diff), how large the panel runs. **Density** dense.
### 4 · Number / Impact (data · move: oversized figure · cream)
**Ground** `{colors.cream}`, `slide-pad`. **Composes** kicker-spike, number-lockup, lead/caption, optional `section-rule`. **Focal** a EB Garamond `number-hero` figure with a mono unit over a 1px rule — the PR impact (`+1,204 / −318`, `17 files`, `2.1× faster`). **Chrome** mono tag. **Accent** the figure in ink; at most one coral unit. **Fixed** serif figure + mono unit, hairline rule. **Free** the figures (from the script), tag, layout + density. **Density** free.
### 5 · Pull-quote (quote · move: EB Garamond italic · cream)
**Ground** `{colors.cream}`. **Composes** kicker-spike, pull-quote, tag-upper cite. **Focal** a EB Garamond **italic** `quote-pull` — a commit message, a reviewer line, or the thesis — with a small Inter uppercase cite (author · role) beneath. **Chrome** mono kicker. **Accent** none, or one coral mark. **Fixed** EB Garamond italic quote + uppercase cite. **Free** quote, attribution, layout + density. **Density** free.
### 6 · Closing / CTA (closer · move: coral voltage · cream or navy)
**Ground** `{colors.cream}` (or `{colors.navy}`). **Composes** display sign-off, coral-callout, optional contributor row (`assets/<login>.png` avatars + mono names). **Focal** a short EB Garamond sign-off with the one **coral-callout** (the CTA or full-bleed band) and, for a "shipped-by" close, a row of hairline-ringed avatar chips. **Chrome** mono index. **Accent** the single coral voltage. **Fixed** one coral moment, hairline avatar rings, sentence-case serif. **Free** sign-off, who ships, layout + density. **Density** free.
## Composition Rules
### Do
- Stand every frame on the **warm cream floor**; gather content on a **half-step tile** surface.
- Set all display in **EB Garamond, sentence case**, negative-tracked; **Inter 400** body; **JetBrains Mono** kickers (uppercase, 0.16em, coral ✱) + code.
- Ration **coral to one moment per frame** — CTA, inline link, OR full-bleed band.
- Elevate with a **1px hairline** + at most one soft warm shadow; reserve **warm navy** for the code/terminal surface.
- Lead with **one clear focal**; open regions with a **kicker-spike**. Fill the frame as the content wants.
- Pair a EB Garamond figure with a **mono unit** for every stat; render code via the **`code-*` blocks** on the navy surface.
### Don't
- No pure white, no cool gray, no pure black; no fourth brand hue (navy is structural, syntax colors are decoration).
- No heavy drop shadow, glow, gradient on content, or tilt — hairline elevation only.
- No uppercase or title-case EB Garamond display; no sans headline; no serif label; no serif-set numeric unit.
- No two coral moments in one frame; coral never sets a headline or body run.
- Don't blow a headline past the measure — step the ramp down.
## Aspect-Ratio Behavior
| Treatment | 16:9 | 9:16 | 1:1 |
| ------------- | ----------------------- | --------------------------- | ---------------------- |
| Cover | display left, index top | display top, index below | display, index corner |
| Statement | line left/centered | line stacked | centered |
| Code Surface | panel framed in cream | panel taller, fewer lines | panel centered, square |
| Number/Impact | figure left | figure centered, taller | centered |
| Pull-quote | quote left | quote stacked | centered |
| Closing/CTA | sign-off + avatar row | sign-off top, avatars below | centered, avatars wrap |
`slide-pad` holds on the short edge; re-step display above the 1.4cqw floor. The code surface keeps
its hairline + mono chrome on every ratio; the avatar row wraps rather than shrinks below legibility.
## Approved Real Entities
No real customers, logos, or vendors are defined in the source — render any such mark as a
placeholder. Contributor avatars come from the project's `assets/<login>.png` (staged from the PR's
people graph); the ✱ spike, hairlines, and the code surface are CSS-only and need no external imagery.
## Numerals & Claims (hard rule)
Never invent figures, stats, diffs, or counts at frame scale. Render slots as `— figure —`,
`{metric}`, `+N / −M`. Number-lockups, code panels, and impact stats carry placeholders until the
script (from the PR ingest) supplies real values. Branch names, file counts, and `+/−` totals trace
to the diff; commit/issue numbers are chrome.
## Pre-Render Self-Audit
- **Squint** — one EB Garamond display moment dominates.
- **Trinity** — cream floor + tile step + ink voice; coral appears exactly once; warm navy only on the code surface; no cool gray / pure white / fourth hue.
- **Type** — EB Garamond sentence-case display (negative-tracked); Inter 400 body; JetBrains Mono kickers (uppercase 0.16em, coral ✱) + code; ≥1.4cqw floor.
- **Depth** — 1px hairline + at most one soft warm shadow; no heavy drop / glow / gradient / tilt; 6/8/12px radii.
- **Code** — code rendered by a `code-*` block on the warm-navy surface; syntax coral/teal/amber; figures paired with a mono unit.
- **Fabrication** — every numeral / diff traces to the PR, else placeholder.
## Known Gaps
- **Motion intentionally out of scope.** frame.md specifies composition only. Code editorial's motion register — short cross-dissolves, no overshoot/bounce/elastic, coral the only "draw-on", numbers count up, code types on line by line — lives in the skill's `references/motion.md`, not here.
- **EB Garamond + Inter + JetBrains Mono ship as licensed local WOFF2 assets with this preset.** Copy the six files in this preset's `fonts/` folder (weights 400 + 700) into the project's `assets/fonts/` and add one `@font-face` per file, so Studio, snapshots, and renders resolve them offline without a first-run Google Fonts fetch. Nothing stages them for you. Author display at **weight 400** (700 reads as a heavy bold, off-register), and treat italic as the browser-synthesized slant (acceptable for the pull-quote register; add a real italic face only if a project leans hard on it). EB Garamond is a warm old-style serif (low contrast, humanist); if it ever fails, fall to Georgia or another old-style serif — never to a sans. CJK: Noto Serif SC (display) / Noto Sans SC (body) / Noto Sans Mono CJK (code); the sentence-case warmth carries when the serif drops.
- **Syntax colors (teal `#5DB8A6` / amber `#E8A55A` / status) are fixed decoration**, declared in §Colors — they are NOT in the remixable `colors:` block, so a brand remix never repaints them.
- **The code itself is the `code-*` registry blocks**, not this preset — this preset owns only the surrounding warm-navy surface + mono chrome.
- **9:16 / 1:1 are guidance**; verify the legibility floor and that the cream/tile warmth + one-coral discipline hold per ratio.
@@ -1,223 +0,0 @@
<!--
Code editorial — caption skin (preset-local source).
This is the preset's own lower-third karaoke caption look: a caption skin to read or
copy, not to mount as is. A copy used as compositions/captions.html fills its three
reserved holes and wraps the fragment in a <template>:
· the empty GROUPS array → the engine's scene-aware word groups
· the 0 duration + 0 width/height → real total + canvas size
· the empty data-brand-tokens style → :root tokens derived from the project's frame.md
Token contract — reference ONLY this fixed vocab (set it from the
project's frame.md; each var has a code-editorial literal
fallback for standalone preview):
--cap-ink · --cap-canvas · --cap-accent · --cap-accent-2 · --font-display ·
--font-body · --cap-band-top · --cap-band-height
Hooks (do not rename): .caption-group / .caption-word + states .is-active / .is-spoken,
root data-composition-id="captions", timeline at window.__timelines["captions"].
State changes use gsap.set({className}) (applied on the engine's frame-by-frame seek);
never tl.call() callbacks (seek does not fire them → wrong state on render).
Visual: a warm cream card lifted off the page on a 1px hairline ink border + one soft
warm shadow — code-editorial's only elevation language, no tilt, editorial and precise.
EB Garamond sentence case, warm-ink glyphs. Upcoming words sit in faint ink; the current
word reads in full ink under a 3px coral "voltage" underline (the brand's inline-link
gesture, border-bottom = underline without layout shift); spoken words settle to solid
ink, underline cleared.
-->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<!-- the project's :root tokens go here -->
<style data-brand-tokens></style>
<style>
#captions-root {
position: absolute;
inset: 0;
pointer-events: none;
}
.caption-layer {
position: absolute;
inset: 0;
z-index: 20;
pointer-events: none;
}
/* the keep-out band, from captionBand(H) — frame content stays above it */
.caption-stage {
position: absolute;
left: 0;
right: 0;
top: var(--cap-band-top, 900px);
height: var(--cap-band-height, 180px);
display: flex;
align-items: center;
justify-content: center;
}
.caption-group {
/* every group is absolutely stacked over the SAME band — exactly one shown at a
time via opacity. Must be absolute: in-flow groups would lay out side-by-side. */
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
}
/* THE lifted cream card — warm cream fill, 1px hairline ink border, one soft warm
shadow (code-editorial's only elevation language), 12px radius, no tilt */
.caption-pill {
max-width: 78%;
padding: 22px 44px 26px;
background: var(--cap-canvas, #faf9f5);
border: 1px solid color-mix(in srgb, var(--cap-ink, #141413) 12%, transparent);
border-radius: 12px;
box-shadow:
0 1px 3px color-mix(in srgb, var(--cap-ink, #141413) 8%, transparent),
0 4px 16px color-mix(in srgb, var(--cap-ink, #141413) 4%, transparent);
}
.caption-line {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.12em 0.3em;
font-family: var(--font-display, "EB Garamond"), Georgia, serif;
font-weight: 500;
font-size: clamp(40px, 4.4vw, 56px);
line-height: 1.24;
letter-spacing: -0.02em;
}
/* upcoming — faint warm ink, no underline */
.caption-word {
display: inline-block;
padding: 0 0.02em 0.06em;
color: color-mix(in srgb, var(--cap-ink, #141413) 40%, var(--cap-canvas, #faf9f5));
border-bottom: 3px solid transparent;
}
/* current word — full ink under the coral voltage underline (the brand's signature
inline-link gesture; border-bottom = underline without layout shift) */
.caption-word.is-active {
color: var(--cap-ink, #141413);
border-bottom: 3px solid var(--cap-accent, #cc785c);
}
/* already spoken — solid warm ink, underline cleared */
.caption-word.is-spoken {
color: var(--cap-ink, #141413);
border-bottom: 3px solid transparent;
}
/* optional per-word accents (dormant until a word-classer tags them) */
/* brand / key term — the expressive EB Garamond italic */
.caption-word.cap-brand {
font-style: italic;
color: var(--cap-ink, #141413);
}
.caption-word.cap-brand.is-active {
border-bottom: 3px solid var(--cap-accent, #cc785c);
}
/* numerals / data — slightly recessed warm ink */
.caption-word.cap-num {
color: color-mix(in srgb, var(--cap-ink, #141413) 72%, var(--cap-canvas, #faf9f5));
}
/* emphasis — a persistent coral voltage underline */
.caption-word.cap-cta {
border-bottom: 3px solid var(--cap-accent-2, #b5654a);
color: var(--cap-ink, #141413);
}
@media (max-aspect-ratio: 9/16) {
.caption-pill {
max-width: 92%;
}
.caption-line {
font-size: clamp(34px, 5.4vw, 46px);
}
}
</style>
<div
id="captions-root"
data-composition-id="captions"
data-timeline-locked
data-start="0"
data-duration="0"
data-fps="30"
data-width="0"
data-height="0"
>
<div class="caption-layer" aria-hidden="true">
<div id="caption-stage" class="caption-stage"></div>
</div>
</div>
<script>
// the scene-aware word groups + real duration go here.
var GROUPS = [];
var DURATION = 0;
(function () {
var stage = document.getElementById("caption-stage");
// build DOM: one .caption-group per group, .caption-word per word
GROUPS.forEach(function (group, g) {
var groupEl = document.createElement("div");
groupEl.className = "caption-group";
groupEl.id = "caption-group-" + g;
var pill = document.createElement("div");
pill.className = "caption-pill";
var line = document.createElement("div");
line.className = "caption-line";
(group.words || []).forEach(function (w, i) {
var span = document.createElement("span");
span.className = "caption-word";
span.id = "caption-word-" + g + "-" + i;
span.textContent = String(w.text);
line.appendChild(span);
});
pill.appendChild(line);
groupEl.appendChild(pill);
stage.appendChild(groupEl);
});
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
GROUPS.forEach(function (group, g) {
var groupEl = document.getElementById("caption-group-" + g);
var words = group.words || [];
var next = GROUPS[g + 1];
var isLast = g === GROUPS.length - 1;
var start = Math.max(0, Number(group.start));
var end = isLast ? DURATION : Math.min(Number(next.start), Number(group.end) + 0.3);
if (end <= start) end = start + 0.01;
// group on/off — exactly one group visible at a time
tl.set(groupEl, { opacity: 1 }, start);
tl.set(groupEl, { opacity: 0 }, end);
// 3-state karaoke via className SETS — seek-safe (gsap.set applies on the
// engine's frame-by-frame seek; .call callbacks would NOT fire).
words.forEach(function (w, i) {
var el = document.getElementById("caption-word-" + g + "-" + i);
var at = Math.max(start, Number(w.start));
tl.set(el, { className: "caption-word" }, start); // upcoming (also resets on reverse-seek)
tl.set(el, { className: "caption-word is-active" }, at); // current
tl.fromTo(el, { scale: 0.98 }, { scale: 1, duration: 0.16, ease: "power1.out" }, at);
if (i + 1 < words.length) {
var nextAt = Math.max(start, Number(words[i + 1].start));
tl.set(el, { className: "caption-word is-spoken" }, nextAt); // demote when next activates
}
});
if (words.length) {
var lastEl = document.getElementById("caption-word-" + g + "-" + (words.length - 1));
var lastSpoken = Math.min(end, Number(words[words.length - 1].end) + 0.1);
tl.set(lastEl, { className: "caption-word is-spoken" }, lastSpoken);
}
});
// full-span anchor so the sub-comp timeline spans the whole video
tl.to({}, { duration: DURATION }, 0);
window.__timelines["captions"] = tl;
})();
</script>
@@ -1,93 +0,0 @@
Copyright 2017 The EB Garamond Project Authors (https://github.com/octaviopardo/EBGaramond12) EBGaramond-Italic[wght].ttf: Copyright 2017 The EB Garamond Project Authors (https://github.com/octaviopardo/EBGaramond12)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -1,93 +0,0 @@
Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -1,93 +0,0 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) JetBrainsMono-Italic[wght].ttf: Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
File diff suppressed because it is too large. Load diff
@@ -1,314 +0,0 @@
---
version: alpha
name: Coral — Frame (video / frame layer)
description: >
Video-first companion to Coral's design.md. The unit is the frame (1920×1080), not the
slide-in-a-deck. Atoms are identical and sacred — the three-surface system (coral fire /
ink black / warm cream), Bebas Neue uppercase tracked + Inter body, the 45° diagonal hatch,
decorative wallpaper numerals, hard color-region splits, zero shadow, zero radius (save
circles). Composition, frame scale, and aspect-ratio behavior are rewritten for the frame.
Motion is out of scope.
unit: the frame — 1920×1080 primary; 9:16 and 1:1 documented
principle: atoms are sacred · composition is free · numbers come from the script
colors:
coral: "#E85D5D"
coral-dark: "#D44A4A"
cream: "#F5F0E8"
cream-dark: "#E8E0D4"
black: "#1A1A1A"
gray: "#6B6B6B"
light-gray: "#B0B0B0"
white: "#FFFFFF"
typography:
# — reading ramp (Inter) —
body: { fontFamily: "Inter", cqw: 1.0, weight: 400, lineHeight: 1.7 }
body-light: { fontFamily: "Inter", cqw: 1.5, weight: 300, lineHeight: 1.5, note: "pull-quote voice" }
section-label: { fontFamily: "Inter", px: 12, weight: 700, tracking: "4px", upper: true }
item-label: { fontFamily: "Inter", px: 11, weight: 700, tracking: "3px", upper: true }
quote-attribution: { fontFamily: "Inter", px: 14, weight: 600, tracking: "3px", upper: true }
quote-role: { fontFamily: "Inter", px: 12, weight: 400, tracking: "1px" }
# — display / hero ramp (Bebas Neue, uppercase, tracked) —
card-title: { fontFamily: "Bebas Neue", cqw: 1.9, weight: 400, lineHeight: 1.1, tracking: "1px", upper: true }
sidebar-value: { fontFamily: "Bebas Neue", cqw: 2.3, weight: 400, lineHeight: 1.0, upper: true }
bar-title: { fontFamily: "Bebas Neue", cqw: 2.3, weight: 400, lineHeight: 1.0, tracking: "2px", upper: true }
card-stat: { fontFamily: "Bebas Neue", cqw: 2.5, weight: 400, lineHeight: 1.0, upper: true }
column-title: { fontFamily: "Bebas Neue", cqw: 3.7, weight: 400, lineHeight: 1.0, tracking: "2px", upper: true }
section-headline:{ fontFamily: "Bebas Neue", cqw: 4.2, weight: 400, lineHeight: 1.0, tracking: "2px", upper: true }
stat-numeral: { fontFamily: "Bebas Neue", cqw: 5.0, weight: 400, lineHeight: 1.0, upper: true }
hero-title: { fontFamily: "Bebas Neue", cqw: 6.5, weight: 400, lineHeight: 0.9, tracking: "4px", upper: true }
jumbo-feature: { fontFamily: "Bebas Neue", cqw: 9.0, weight: 400, lineHeight: 1.0, tracking: "12px", upper: true }
# — decorative —
background-numeral: { fontFamily: "Bebas Neue", cqw: 10.0, weight: 400, color: "rgba(0,0,0,0.12)", note: "wallpaper numeral inside a coral region" }
giant-mark: { fontFamily: "Bebas Neue", cqw: 14.0, weight: 400, color: "rgba(0,0,0,0.35)", note: "decorative quote mark inside a coral region" }
spacing:
pad-x: "5cqw" # standard horizontal frame padding
pad-y: "4cqw"
pad-col: "3cqw"
gap-grid: "1.7cqw"
card-pad: "2cqw"
components:
diagonal-hatch:
backgroundImage: "repeating-linear-gradient(45deg, transparent 0 20px, rgba(0,0,0,.06) 20px 40px)"
placement: "::before overlay on {colors.coral} regions"
description: "Signature 45° hatch (6% ink). Variants −45° 30/60px, 90° vertical 60/62px 10% ink. Texture, never depth."
region-split:
layout: "two/three solid surfaces meeting at a hard edge; ratios 38/62 rows, 40/60 cols, 50/50"
rounded: "0"
description: "The primary layout device — no gradient, no rounded junction; the boundary is the layout."
card:
backgroundColor: "{colors.white}"
borderTop: "0.26cqw solid {colors.coral}"
rounded: "0"
shadow: "none"
typography: "{typography.card-title} + {typography.body}"
description: "5px coral TOP border is the only chrome; holds a card-icon, title, body, coral card-stat."
sidebar-item:
backgroundColor: "{colors.white}"
borderLeft: "0.2cqw solid {colors.coral}"
rounded: "0"
typography: "{typography.sidebar-value} + {typography.section-label}"
description: "4px coral LEFT border is the only chrome."
card-icon:
backgroundColor: "{colors.coral}"
textColor: "{colors.white}"
size: "2.5cqw square"
rounded: "0"
description: "The card mark — one white Bebas glyph centered."
accent-line:
backgroundColor: "{colors.coral}"
size: "4cqw × 0.25cqw (60×4 closing variant)"
rounded: "0"
description: "Sub-headline accent rule."
background-numeral:
typography: "{typography.background-numeral}"
color: "rgba(0,0,0,0.12)"
placement: "behind a {colors.coral} region's title"
description: "The wallpaper-numeral signature (12% ink)."
giant-mark:
typography: "{typography.giant-mark}"
color: "rgba(0,0,0,0.35)"
placement: "inside a {colors.coral} region"
description: "Oversized quote mark / character, half-decorative."
timeline:
line: "0.2cqw solid {colors.black} (gradient-dashed ::after)"
node: "{colors.coral} circle, {colors.cream} halo, 50% radius"
description: "Ink line with coral nodes + cream halos."
info-bar:
backgroundColor: "{colors.cream-dark}"
typography: "{typography.bar-title} + uppercase {typography.section-label}"
rounded: "0"
shadow: "none"
description: "Footer band beneath a feature region — Bebas title left, Inter meta right."
---
# Coral — Frame (video / frame layer)
## Overview
Coral at frame scale is a **bold magazine poster** built from three solid surfaces — coral fire,
ink black, warm cream — that meet at **hard color edges.** The region boundary IS the layout: a
frame splits into a coral plane + a cream plane, or a coral panel + an ink panel, each holding a
self-contained composition. No gradient transitions, no rounded junctions, no drop shadows.
The voice is a two-face hierarchy: **Bebas Neue** — tall condensed caps, always uppercase, always
tracked (1–12px) — carries every headline, stat, title, and meta figure; **Inter** carries every
body line, label, and attribution across weights 300–700. Bebas declares; Inter explains. The
signature atmospherics are the **45° diagonal hatch** (6% ink) over coral regions and the
**oversized wallpaper numeral** (12% ink) behind a region's title.
**Key characteristics at frame scale:**
- **Three surfaces, hard edges** — `{colors.coral}` / `{colors.black}` / `{colors.cream}` as solid regions.
- **Bebas uppercase + tracking** on every display element; **Inter** on every body/label.
- **45° hatch** (6% ink) on coral regions; **wallpaper numerals** (12%) and **giant marks** (35%) behind content.
- **Ink-on-fire** — Bebas on coral is always ink, never white. Eyebrows coral on cream/ink, ink on coral.
- **Flat** — no shadow, no elevation; radius only on circles (nav dots, timeline nodes).
- **Coral as accent AND environment** — 4–5px coral borders, 48px coral icon squares, and full coral regions.
### Frame Craft Bar
Three eyeball tests gate every frame before any structural check:
- **Squint** — one element dominates at **3–6× its nearest neighbor**: the `hero-title`/`jumbo-feature` or a wallpaper numeral behind a region's title, never two rival headlines.
- **Silence** — coral/cream/ink regions read **40–55% empty**; the **three-column catalog is the one dense exception**. A coral region underfilled gets a wallpaper numeral, never more content.
- **Restraint** — coral fires as **either accent or one full region per frame** (not both at full strength); one giant-mark per quote; ink-on-fire (never white on coral).
- **Reference** — aim at a **sports-magazine cover / Saul Bass travel poster** (solid planes at hard edges, condensed caps as architecture); failure looks like a **soft drop-shadowed card deck**.
## The Frame
- **Primary:** 1920×1080 (16:9). Display sizes authored in **`cqw`** (`px ÷ 1920 × 100 = cqw`).
- **Vertical:** 1080×1920 (9:16). **Square:** 1080×1080 (1:1).
- **Safe area:** `5cqw` (pad-x) standard frame padding; region edges may bleed full-frame.
**The container law (load-bearing).** Every frame ground sets `container-type: size`; ALL
frame-relative units are `cqw`/`cqh` resolved against it — **never `vw`.** A `vw`-sized frame
inflates whenever it isn't full-screen; `cqw` resolves against the frame at any render size.
## Colors
Tokens identical to the source. At frame scale the three surfaces are intermixed by composition —
coral/cream, coral/ink, ink/cream, or single-surface. `{colors.coral}` is both accent (borders,
icon squares, timeline nodes, eyebrows on cream/ink) and environment (full regions). Headlines:
ink on cream/coral, cream on ink — **never gray, never white-on-coral.** Eyebrows: coral on
cream/ink, ink on coral — coral-on-coral does not exist. The only sanctioned gradient is the rare
135° coral-dark→coral feature region; everything else is flat.
## Typography
Two ramps. The **reading ramp** (Inter body 1.0cqw, body-light 1.5cqw, labels in px) carries copy
and eyebrows; the **display/hero ramp** (Bebas, `card-title` 1.9cqw → `jumbo-feature` 9.0cqw, plus
the decorative `background-numeral` 10cqw and `giant-mark` 14cqw) carries every headline and stat.
- **Legibility floor:** any load-bearing line ≥ **1.4cqw**; px labels are chrome only.
- **Fit-to-measure:** size the headline to its line length. Cap the block at **≤ 78cqw**; ≤3 words → `hero-title`/`jumbo-feature`; 4–6 → `section-headline`; 7+ → `column-title`.
- **Every Bebas element is uppercase with ≥1px tracking** (2px standard, 4px hero, 12px jumbo). **Every Inter label is uppercase, 1–4px tracked.** No italic, no underline, no sentence-case Bebas.
## Depth & Surface
Flat, with hard color edges. Depth signals only:
- **Hard region boundaries** — the primary structural device.
- **Accent borders** — 5px coral top (cards), 4px coral left (sidebar tiles), 4px ink (timeline).
- **45° hatch** — 6% ink texture on coral regions (no depth).
- **Wallpaper typography** — numerals at 12%, giant marks at 35%, layered behind content.
**Ceiling:** no box-shadow, no elevated card, no soft gradient (save the one 135° coral feature), no rounded rectangle.
## Shapes
- **0 radius** on every rectangle — regions, cards, sidebar tiles, icon squares, info bars, accent lines.
- **50%** on circles only — nav dots (10px), nav arrows (44px), timeline nodes (20px).
## Components
- **region-split** — the layout device; surfaces meet at a hard edge.
- **card** (5px coral top) / **sidebar-item** (4px coral left) / **card-icon** (48px coral square) — the only chrome on each is its single coral border.
- **diagonal-hatch** / **background-numeral** / **giant-mark** — the atmospheric + wallpaper signatures on coral regions.
- **accent-line** — coral sub-headline rule. **timeline** — ink line, coral nodes, cream halos. **info-bar** — cream-dark footer band.
## Frame Treatments
> Recipe per plate: ground · container · composes · focal · chrome · accent · silence · Fixed/Free · density.
> Lean centered where the move allows; vary anchor; one idea per region.
### 1 · Region-Split Cover (identity · move: hard region edge · left)
**Ground** 38/62 split — `{colors.coral}` top band (hatch + wallpaper numeral) over `{colors.cream}`.
**Container** grid rows; brand + meta in the coral band, hero title in the cream field. **Composes**
region-split, diagonal-hatch, background-numeral, hero-title. **Focal** a 2-line `hero-title` in
ink, second line in `{colors.coral}`, left-anchored in the cream field. **Chrome** Bebas brand left
- Bebas meta right in the coral band. **Accent** coral band + coral second line. **Silence** the
cream field ~45% empty. **Fixed** ink-on-fire in band, hatch on coral, hard edge. **Free** title
copy, which line is coral, meta. **Density** sparse.
### 2 · Feature Stat (anchor · move: scale · coral environment · left)
**Ground** full `{colors.coral}` with hatch. **Composes** diagonal-hatch, background-numeral,
section-label, stat headline, body-light. **Focal** a `stat-numeral`/`jumbo-feature` figure or
2-line headline in ink, with a `background-numeral` (12% ink) behind it as wallpaper. **Chrome** an
ink `section-label` eyebrow; an optional Inter-300 support line ≤44cqw. **Accent** the coral ground
IS the environment; ink type, no white. **Silence** ~40% of the coral field empty. **Fixed**
ink-on-coral, hatch present, wallpaper numeral behind. **Free** the figure, headline, support copy.
**Density** sparse.
### 3 · Quote Layout (quote · move: panel split · giant mark)
**Ground** 40/60 split — `{colors.coral}` left panel (hatch + giant-mark) + `{colors.black}` right
panel. **Composes** region-split, giant-mark, body-light, accent-line, quote-attribution.
**Focal** a 2–3 line pull quote in `{colors.cream}` Inter **weight 300** on the ink panel.
**Chrome** a `giant-mark` (35% ink) on the coral panel; a `60×4` coral accent-line above the
attribution. **Accent** coral panel + coral accent-line. **Silence** the coral panel is mostly the
mark. **Fixed** Inter-300 quote, ink panel, ink-on-coral mark. **Free** quote, attribution, mark
glyph. **Density** sparse.
### 4 · Closing Plate (closer · move: cream field + coral band · centered)
**Ground** `{colors.cream}` field with a bottom `{colors.coral}` band (hatch). **Composes**
section-label, section-headline/hero-title, accent-line, info-bar. **Focal** a 2-line sign-off in
ink, centered, with a coral `accent-line` beneath. **Chrome** an ink eyebrow above; a coral band
footer carrying a Bebas sign-off + year. **Accent** coral band + coral accent-line. **Silence**
~55% empty cream. **Fixed** centered, ink type, one coral band. **Free** sign-off copy, band
contents. **Density** sparse.
### 5 · Three-Column Catalog (catalog · move: density — the dense frame · centered head)
**Ground** `{colors.cream}` (or `{colors.black}`), `pad-x`. **Composes** section-headline, 3× card.
**Focal** a centered `section-headline` over three white `card`s (5px coral top, 48px icon square,
Bebas title, Inter body, coral stat). **Accent** the three coral top borders + icon squares.
**Silence** tight — the density exception. **Fixed** 5px coral top as sole chrome, no shadow/radius.
**Free** the three cards' content. **Density** dense-exception.
### 6 · Timeline (process · move: horizontal rail · left)
**Ground** `{colors.cream}`, `pad-x`. **Composes** section-headline, timeline. **Focal** the ink
timeline-line with 4–5 coral nodes (cream halos) and Bebas labels. **Accent** coral nodes.
**Silence** moderate. **Fixed** ink line, coral nodes, cream halos. **Free** node count, labels.
**Density** standard.
## Composition Rules
### Do
- Compose as **multi-surface region splits** — coral / ink / cream meeting at hard edges; the boundary is the layout.
- Set every Bebas element **uppercase + tracked** (2px standard, 4px hero, 12px jumbo); every Inter label uppercase, 1–4px.
- Render eyebrows **coral on cream/ink, ink on coral**; headlines ink on cream/coral, cream on ink.
- Apply the **45° hatch** (6% ink) on coral regions; fill underweight coral regions with a **12% wallpaper numeral**.
- Use the **5px coral top** (cards) / **4px coral left** (tiles) as the only chrome on those elements.
- Lean centered on cover sign-offs and catalog heads; left/panel-split on features and quotes.
### Don't
- Don't render Bebas in sentence case or untracked; don't pair it with a non-Inter body sans.
- Don't add a fourth surface, a drop shadow, an elevation, or a rounded rectangle.
- Don't put white headlines on coral (always ink) or gray headlines anywhere (gray is body/meta).
- Don't soften a region boundary with a gradient (except the rare 135° coral feature).
- Don't fill a coral region with sparse fragments — fully populate it or add a wallpaper numeral / giant mark.
- Don't blow a headline edge-to-edge — step the ramp down for long lines.
## Aspect-Ratio Behavior
| Treatment | 16:9 | 9:16 | 1:1 |
| -------------------- | -------------------------- | ------------------------------------ | -------------------- |
| Region-Split Cover | 38/62 rows, title left | taller coral band, title below | 40/60, title lower |
| Feature Stat | figure left, numeral right | figure top, numeral behind | centered figure |
| Quote Layout | 40/60 coral+ink | stacked: coral mark top, quote below | stacked |
| Closing Plate | cream + bottom coral band | cream + taller band | centered, band below |
| Three-Column Catalog | head over 3-up | head top, 3 stacked | head top, 2+1 |
| Timeline | horizontal rail | vertical rail | compact horizontal |
Safe area holds the `5cqw` padding on the short edge; re-step display per ratio so no load-bearing
line drops below the 1.4cqw floor. Bebas runs ~20% wider in CJK — adjust line breaks per ratio.
## Approved Entities
No real customers, logos, or vendors are defined in the source — render any such mark as a
placeholder. The system supplies surfaces and geometry, not brands.
## Numerals & Claims (hard rule)
Never invent figures, stats, dates, or counts at frame scale. Render slots as `— figure —`,
`{metric}`, `N×`. Real numerals appear only when the script supplies them — the feature stat,
catalog, and timeline especially carry placeholders, not fabricated values. Wallpaper numerals
(01, 02…) are decorative and may be ordinal.
## Pre-Render Self-Audit
- **Squint** — one focal element per region dominates at 3–5× its neighbor.
- **Silence** — sparse frames 40–55% empty; only the catalog runs dense.
- **Surfaces** — two or three of coral/ink/cream, meeting at hard edges; no fourth surface.
- **Type** — Bebas uppercase + tracked, fit-to-measure; ink-on-coral; eyebrow color correct for surface; ≥1.4cqw floor.
- **Depth** — 0 shadow, 0 rounded rectangle; hatch + wallpaper carry texture.
- **Anchor** — centered on sign-offs/heads, panel/left on features/quotes; no 3 consecutive frames share an anchor.
- **Fabrication** — every numeral traces to the script, else placeholder.
## Known Gaps
- **Motion intentionally out of scope.** frame.md specifies composition only; timing and transitions are a later stage. The 0.6s opacity fade in the source is a deck mechanic, not a frame spec.
- **Bebas Neue + Inter via Google Fonts.** CJK pairing (ZCOOL XiaoWei / Yozai) carries over from the source's CJK section; Bebas runs ~20% wider in CJK.
- **9:16 / 1:1 are guidance**, not pixel-locked; verify the legibility floor per ratio.
- The 45° hatch, wallpaper numerals, giant marks, and timeline dash are CSS-only; no external imagery is required.
@@ -1,225 +0,0 @@
<!--
Coral — caption skin (preset-local source).
This is the preset's own lower-third karaoke caption look: a caption skin to read or
copy, not to mount as is. A copy used as compositions/captions.html fills its three
reserved holes and wraps the fragment in a <template>:
· the empty GROUPS array → the engine's scene-aware word groups
· the 0 duration + 0 width/height → real total + canvas size
· the empty data-brand-tokens style → :root tokens derived from the project's frame.md
Token contract — reference ONLY this fixed vocab (set it from the
project's frame.md; each var has a coral literal
fallback for standalone preview):
--cap-ink · --cap-canvas · --cap-accent · --cap-accent-2 · --font-display ·
--font-body · --cap-band-top · --cap-band-height
Hooks (do not rename): .caption-group / .caption-word + states .is-active / .is-spoken,
root data-composition-id="captions", timeline at window.__timelines["captions"].
State changes use gsap.set({className}) (applied on the engine's frame-by-frame seek);
never tl.call() callbacks (seek does not fire them → wrong state on render).
Visual: a flat warm-cream block on a single ink hairline border — coral's hard-edge
geometry, square corners, ZERO radius, ZERO shadow (coral's depth is hard color edges,
never cast). Bebas Neue, uppercase + tracked, ink glyphs. Upcoming words sit in faint
ink; the current word gets a solid coral-fire flat block (the brand's signature
hard-edge fill — a square block, no blur, no radius) with ink reading on top; spoken
words settle to solid ink, fill cleared. Brand/key terms carry a coral hairline
underline (the counter inflection).
-->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<!-- the project's :root tokens go here -->
<style data-brand-tokens></style>
<style>
#captions-root {
position: absolute;
inset: 0;
pointer-events: none;
}
.caption-layer {
position: absolute;
inset: 0;
z-index: 20;
pointer-events: none;
}
/* the keep-out band, from captionBand(H) — frame content stays above it */
.caption-stage {
position: absolute;
left: 0;
right: 0;
top: var(--cap-band-top, 900px);
height: var(--cap-band-height, 180px);
display: flex;
align-items: center;
justify-content: center;
}
.caption-group {
/* every group is absolutely stacked over the SAME band — exactly one shown at a
time via opacity. Must be absolute: in-flow groups would lay out side-by-side. */
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
}
/* THE flat cream block — single ink hairline border, square corners (coral's hard-edge
geometry), ZERO radius, ZERO shadow */
.caption-pill {
max-width: 78%;
padding: 18px 44px 20px;
background: var(--cap-canvas, #f5f0e8);
border: 1px solid var(--cap-ink, #1a1a1a);
border-radius: 0;
}
.caption-line {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.1em 0.3em;
font-family: var(--font-display, "Bebas Neue"), sans-serif;
font-weight: 400;
text-transform: uppercase;
font-size: clamp(44px, 4.8vw, 64px);
line-height: 1.04;
letter-spacing: 0.06em;
}
/* upcoming — faint ink, no fill */
.caption-word {
display: inline-block;
padding: 0 0.12em;
color: color-mix(in srgb, var(--cap-ink, #1a1a1a) 40%, var(--cap-canvas, #f5f0e8));
}
/* current word — THE coral-fire flat block (box-shadow spread = a hard square block
w/o layout shift; no blur, no radius), ink reading on top */
.caption-word.is-active {
color: var(--cap-ink, #1a1a1a);
background: var(--cap-accent, #e85d5d);
box-shadow: 0 0 0 0.1em var(--cap-accent, #e85d5d);
}
/* already spoken — solid ink, fill cleared */
.caption-word.is-spoken {
color: var(--cap-ink, #1a1a1a);
background: transparent;
box-shadow: none;
}
/* optional per-word accents (dormant until a word-classer tags them) */
/* brand / key term — a coral hairline underline (coral's only display inflection) */
.caption-word.cap-brand {
text-decoration: underline;
text-decoration-thickness: 0.06em;
text-decoration-color: var(--cap-accent, #e85d5d);
text-underline-offset: 0.12em;
}
/* numerals / data — Inter body voice, slightly recessed */
.caption-word.cap-num {
font-family: var(--font-body, "Inter"), ui-sans-serif, sans-serif;
color: color-mix(in srgb, var(--cap-ink, #1a1a1a) 72%, var(--cap-canvas, #f5f0e8));
}
/* emphasis — a coral-dark hairline underline (the counter accent) */
.caption-word.cap-cta {
text-decoration: underline;
text-decoration-thickness: 2px;
text-decoration-color: var(--cap-accent-2, #d44a4a);
text-underline-offset: 0.1em;
}
@media (max-aspect-ratio: 9/16) {
.caption-pill {
max-width: 92%;
}
.caption-line {
font-size: clamp(38px, 5.8vw, 54px);
}
}
</style>
<div
id="captions-root"
data-composition-id="captions"
data-timeline-locked
data-start="0"
data-duration="0"
data-fps="30"
data-width="0"
data-height="0"
>
<div class="caption-layer" aria-hidden="true">
<div id="caption-stage" class="caption-stage"></div>
</div>
</div>
<script>
// the scene-aware word groups + real duration go here.
var GROUPS = [];
var DURATION = 0;
(function () {
var stage = document.getElementById("caption-stage");
// build DOM: one .caption-group per group, .caption-word per word
GROUPS.forEach(function (group, g) {
var groupEl = document.createElement("div");
groupEl.className = "caption-group";
groupEl.id = "caption-group-" + g;
var pill = document.createElement("div");
pill.className = "caption-pill";
var line = document.createElement("div");
line.className = "caption-line";
(group.words || []).forEach(function (w, i) {
var span = document.createElement("span");
span.className = "caption-word";
span.id = "caption-word-" + g + "-" + i;
span.textContent = String(w.text);
line.appendChild(span);
});
pill.appendChild(line);
groupEl.appendChild(pill);
stage.appendChild(groupEl);
});
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
GROUPS.forEach(function (group, g) {
var groupEl = document.getElementById("caption-group-" + g);
var words = group.words || [];
var next = GROUPS[g + 1];
var isLast = g === GROUPS.length - 1;
var start = Math.max(0, Number(group.start));
var end = isLast ? DURATION : Math.min(Number(next.start), Number(group.end) + 0.3);
if (end <= start) end = start + 0.01;
// group on/off — exactly one group visible at a time
tl.set(groupEl, { opacity: 1 }, start);
tl.set(groupEl, { opacity: 0 }, end);
// 3-state karaoke via className SETS — seek-safe (gsap.set applies on the
// engine's frame-by-frame seek; .call callbacks would NOT fire).
words.forEach(function (w, i) {
var el = document.getElementById("caption-word-" + g + "-" + i);
var at = Math.max(start, Number(w.start));
tl.set(el, { className: "caption-word" }, start); // upcoming (also resets on reverse-seek)
tl.set(el, { className: "caption-word is-active" }, at); // current
tl.fromTo(el, { scale: 0.97 }, { scale: 1, duration: 0.14, ease: "power2.out" }, at);
if (i + 1 < words.length) {
var nextAt = Math.max(start, Number(words[i + 1].start));
tl.set(el, { className: "caption-word is-spoken" }, nextAt); // demote when next activates
}
});
if (words.length) {
var lastEl = document.getElementById("caption-word-" + g + "-" + (words.length - 1));
var lastSpoken = Math.min(end, Number(words[words.length - 1].end) + 0.1);
tl.set(lastEl, { className: "caption-word is-spoken" }, lastSpoken);
}
});
// full-span anchor so the sub-comp timeline spans the whole video
tl.to({}, { duration: DURATION }, 0);
window.__timelines["captions"] = tl;
})();
</script>
File diff suppressed because it is too large. Load diff
@@ -1,333 +0,0 @@
---
version: alpha
name: Creative Mode — Frame (video / frame layer)
description: >
Video-first companion to Creative Mode's design.md. The unit is the frame (1920×1080),
not the slide-in-a-deck. Atoms are identical and sacred — warm cream canvas, 4px ink
borders, hard offset shadows (no blur), Archivo Black uppercase at 0.92 line-height,
JetBrains Mono taxonomy, Space Grotesk body, the four-accent palette. Composition,
frame scale, and aspect-ratio behavior are rewritten for the frame. Motion is out of scope.
unit: the frame — 1920×1080 primary; 9:16 and 1:1 documented
principle: atoms are sacred · composition is free · numbers come from the script
colors:
cream: "#EFE9D9"
cream-2: "#E4DCC4"
ink: "#0F0F0F"
ink-2: "#2A2A2A"
green: "#1F8A4C"
green-dark: "#136636"
pink: "#F06CA8"
pink-dark: "#D14E8B"
orange: "#E85A1F"
yellow: "#F5C518"
typography:
# — reading ramp (px @ 1920, with cqw) —
body-lg: { fontFamily: "Space Grotesk", px: 28, cqw: 1.46, weight: 400, lineHeight: 1.4 }
body-md: { fontFamily: "Space Grotesk", px: 24, cqw: 1.25, weight: 400, lineHeight: 1.3 }
mono-label: { fontFamily: "JetBrains Mono", px: 24, cqw: 1.25, weight: 400, tracking: "0.06em", upper: true }
mono-kicker:{ fontFamily: "JetBrains Mono", px: 24, cqw: 1.25, weight: 400, tracking: "0.14em", upper: true }
table-head: { fontFamily: "Archivo Black", px: 28, cqw: 1.46, weight: 400, upper: true }
# — display / hero ramp (frame-native, cqw-first) —
step-title: { fontFamily: "Archivo Black", px: 34, cqw: 1.77, weight: 400, lineHeight: 1.0, upper: true }
badge-label:{ fontFamily: "Archivo Black", px: 28, cqw: 1.46, weight: 400, upper: true }
marker: { fontFamily: "Archivo Black", cqw: 2.4, weight: 400, lineHeight: 1.0, upper: true }
stamp-num: { fontFamily: "Archivo Black", cqw: 2.2, weight: 400, lineHeight: 0.9 }
stat-num: { fontFamily: "Archivo Black", cqw: 6.4, weight: 400, lineHeight: 0.88 }
step-num: { fontFamily: "Archivo Black", cqw: 7.0, weight: 400, lineHeight: 0.85 }
display-head: { fontFamily: "Archivo Black", cqw: 4.2, weight: 400, lineHeight: 0.92, tracking: "-0.01em", upper: true }
display-lg: { fontFamily: "Archivo Black", cqw: 8.0, weight: 400, lineHeight: 0.9, tracking: "-0.01em", upper: true }
display-xl: { fontFamily: "Archivo Black", cqw: 11.0, weight: 400, lineHeight: 0.9, tracking: "-0.01em", upper: true }
display-hero:{ fontFamily: "Archivo Black", cqw: 15.5,weight: 400, lineHeight: 0.84, tracking: "-0.02em", upper: true }
spacing:
frame-pad: "3.3cqw" # 64px chrome gutter @1920
content-gutter: "5cqw" # 96px content gutter @1920
grid-gap: "1.5cqw" # 28px
cell-pad: "1.7cqw" # 32px
components:
frame-chrome:
typography: "{typography.mono-label}"
placement: "topbar 2.5cqw from top, meta 2.5cqw from bottom, both inset {spacing.frame-pad}"
rounded: "0"
shadow: "none"
description: "Mono topbar (section label left + 999px ink-stroked pill right) + meta footer (descriptor left + NN • NN counter right, 0.5cqw ink dot divider). Present on most frames."
stat-cell:
backgroundColor: "{colors.green} · {colors.pink} · {colors.orange} · {colors.cream}"
textColor: "{colors.cream} on accent · {colors.ink} on cream"
border: "0.4cqw solid {colors.ink}"
rounded: "0"
padding: "{spacing.cell-pad}"
typography: "{typography.stat-num} + {typography.mono-label}"
shadow: "none"
description: "Flat accent/cream stat tile, square corners, no shadow."
step-card:
backgroundColor: "{colors.cream} · {colors.pink} · {colors.yellow} · {colors.green}"
border: "0.4cqw solid {colors.ink}"
rounded: "0"
padding: "{spacing.cell-pad}"
typography: "{typography.step-num} + {typography.step-title} + {typography.body-md}"
shadow: "none"
description: "Ink-bordered card; giant step-num top. Sequence alternates cream with accents and ENDS on green."
marker-block:
backgroundColor: "{colors.pink}"
textColor: "{colors.ink}"
border: "0.4cqw solid {colors.ink}"
rounded: "0"
typography: "{typography.marker}"
shadow: "1.25cqw 1.25cqw 0 {colors.orange}, 1.25cqw 1.25cqw 0 0.2cqw {colors.ink}"
description: "The one hard-offset featured callout per frame."
kicker-block:
backgroundColor: "{colors.ink}"
textColor: "{colors.cream}"
typography: "{typography.mono-kicker}"
padding: "0.5cqw 1cqw"
rounded: "0"
description: "Inverted eyebrow chip."
badge-rotated:
backgroundColor: "{colors.yellow}"
border: "0.4cqw solid {colors.ink}"
typography: "{typography.badge-label}"
rounded: "0"
transform: "rotate(-4deg)"
description: "Deliberate-imperfection annotation."
pill-badge:
backgroundColor: "{colors.cream}"
border: "0.2cqw solid {colors.ink}"
rounded: "999px"
typography: "{typography.mono-label}"
description: "The ONLY rounded element; reads as a chip, not a card."
stamp:
backgroundColor: "{colors.pink}"
border: "0.4cqw solid {colors.cream}"
size: "~18cqw square"
rounded: "0"
transform: "rotate(-6deg)"
typography: "{typography.stamp-num}"
description: "Closing seal with a cream circular inner ring."
comparison-table:
backgroundColor: "{colors.cream-2}"
border: "0.4cqw solid {colors.ink}"
rule: "0.3cqw solid {colors.ink}"
rounded: "0"
typography: "{typography.table-head} + {typography.body-md}"
shadow: "none"
description: "Ink head row with cream Archivo labels; pink/green column-fill variants."
decorative-circle:
backgroundColor: "{colors.yellow}"
border: "0.4cqw solid {colors.ink}"
rounded: "50%"
description: "Decorative figure; pairs with a green panel for shape contrast."
---
# Creative Mode — Frame (video / frame layer)
## Overview
Creative Mode at frame scale is a **neo-brutalist editorial poster in motion's clothing** — warm
cream paper, near-black ink, and four accents that collide at full saturation. Every frame is one
flat color-blocked composition: no gradients, no blurred shadows, no rounded cards (save the one
pill chip). Depth is **hard offset shadow** (a solid same-direction duplicate) or **color-block
contrast**, never light.
The display voice is **Archivo Black in strict uppercase at 0.92 line-height** — letters overlap
their own cap height; that tightness is the brand. **JetBrains Mono** carries every label, kicker,
counter, and axis as a "technical artifact" register. **Space Grotesk** carries the few body lines.
The frame is loud by construction and calm by restraint: two or three accents per frame, the green
ground reserved for a single closing plate, the hard shadow spent on one featured element only.
**Key characteristics at frame scale:**
- **Cream ground** (`{colors.cream}`) on nearly every frame; `{colors.green}` reserved for the closing plate.
- **0.4cqw (4px @1920) ink borders** on every structural element; 0.3cqw internal rules.
- **Hard offset shadow** (≈1.25cqw, orange+ink) on one featured block per frame — never blurred.
- **Archivo Black uppercase**, 0.92 line-height, always; sentence-case Archivo Black does not exist.
- **Two or three accents per frame**, never all four; collisions are the design.
- **One pill chip** (999px) per frame as the sole rounded element.
### Frame Craft Bar
Three eyeball tests gate every frame before any structural check:
- **Squint** — exactly one element dominates, at **3–6× its nearest neighbor** (a chasm, not a ramp): the `display-hero`/`display-xl` claim or the `stat-num` figure, never two rival headlines.
- **Silence** — sparse frames (cover, claim, closer) read **45–60% empty**; the **stat grid and comparison ledger are the one dense exception**. Never fill a sparse frame to look complete.
- **Restraint** — the scarce gestures fire **once per frame**: at most one hard-offset shadow, two-to-three accents (never all four), the green ground reserved for the single closing plate.
- **Reference** — aim at a **Risograph editorial poster / punk-zine spread** (flat ink-bordered blocks, one big claim, vast cream); failure looks like a **rounded, soft-shadowed SaaS feature grid**.
## The Frame
- **Primary:** 1920×1080 (16:9). All display sizes authored in **`cqw`** (`px ÷ 1920 × 100 = cqw`).
- **Vertical:** 1080×1920 (9:16). **Square:** 1080×1080 (1:1).
- **Safe area:** chrome at `3.3cqw` (64px) inset; content at `5cqw` (96px) gutter. No load-bearing element crosses the `3.3cqw` line.
**The container law (load-bearing).** Every frame ground sets `container-type: size`. ALL
frame-relative units are `cqw`/`cqh` (1cqw = 1% of the frame's width), resolved against that
ground — **never `vw`.** `vw` measures the page viewport, so a frame inflates whenever it isn't
rendered full-screen; `cqw` resolves against the frame at any render size. This is why every size
in the display ramp is `cqw`.
## Colors
Tokens identical to the source. At frame scale: `{colors.cream}` is the **ground**, `{colors.ink}`
is borders + type, and the four accents (`green` / `pink` / `orange` / `yellow`) are **flat fills
rationed two-to-three per frame.** `{colors.orange}` is also the hard-shadow color. `{colors.green}`
doubles as the single closing-plate ground — its rarity is the impact. Never introduce a fifth
accent; never use pure white; never gradient.
## Typography
Two ramps. The **reading ramp** (body, mono labels, table heads) holds px+cqw for chrome and copy.
The **display/hero ramp** is frame-native and authored in `cqw` — from `display-head` (4.2cqw) up
to `display-hero` (15.5cqw) for a wordmark cover.
- **Legibility floor:** any load-bearing line ≥ **1.4cqw (≈27px@1920)**. Mono chrome at 1.15cqw is colophon only.
- **Fit-to-measure:** a headline's size tracks its line length. Cap the headline block at **≤ 78cqw** and never touch the safe margin. ≤3 words → `display-hero`/`display-xl`; 4–6 words → `display-lg`; 7+ words → `display-head`. Short lines go big; long lines step down.
- **Uppercase + 0.92 line-height on all Archivo Black**, always. Mono carries 0.06–0.14em tracking. Never letter-space Archivo Black beyond the encoded −0.01em; never set body centered.
## Depth & Surface
Zero blur. Two depth devices only:
- **Hard offset shadow** — a solid duplicate offset ≈1.25cqw in X and Y (`box-shadow: 1.25cqw 1.25cqw 0 {colors.orange}, 1.25cqw 1.25cqw 0 0.2cqw {colors.ink}`). **One featured block per frame** (marker, stamp). Diagram stacks may use a 0.95cqw ink-only offset.
- **Color-block contrast** — cream on cream-2, ink on cream, accent on cream. No shadow needed when contrast carries.
**Ceiling:** no blurred shadow, no gradient, no glow, anywhere.
## Shapes
- **0 radius** on every structural element — stat cells, step cards, table cells, markers, panels.
- **50%** on decorative circles, stamp inner ring, the meta dot.
- **999px** on the topbar pill chip only — the single rounded exception.
- **Rotation** only at the fixed brand angles: badge −4deg, stamp −6deg.
## Components
- **frame-chrome** — the mono topbar + meta footer. Present on most frames; dropped only on a pure full-bleed wordmark beat if it competes.
- **stat-cell / step-card** — ink-bordered flat-fill blocks; step sequences end on green.
- **marker-block** — the one hard-shadow featured callout per frame.
- **kicker-block** — inverted ink eyebrow chip; **badge-rotated** — −4deg yellow annotation; **pill-badge** — the lone rounded chip.
- **stamp** — the −6deg closing seal. **comparison-table** — cream-2 ledger with ink head row.
- **decorative-circle** — yellow disc for shape contrast against a green panel.
## Frame Treatments
> Recipe per plate: ground · container · composes · focal · chrome · accent · silence · Fixed/Free · density.
> Authored at 1920×1080. Lean centered; vary the anchor; one idea per frame.
### 1 · Wordmark Cover (identity · move: full-frame lockup · centered)
**Ground** `{colors.cream}`, `frame-pad`. **Container** grid, chrome top/bottom, focal centered.
**Composes** frame-chrome, the wordmark. **Focal** the two-line wordmark at `display-hero`
(15.5cqw), centered, second line in an accent (`{colors.pink}`/`orange`). **Chrome** mono topbar
(section label + pill) and meta footer (descriptor + 01•NN). **Accent** the second-line color only;
optionally one corner decorative-circle bleeding off an edge. **Silence** ~55% empty cream.
**Fixed** Archivo Black uppercase 0.84 lh, one accent on the wordmark, square corners. **Free** which
accent, line break, whether a circle bleeds a corner. **Density** sparse.
### 2 · Big Claim (oversized statement · move: scale · left)
**Ground** one full-bleed accent (`{colors.pink}`/`green`/`orange`), `content-gutter`. **Container**
flex, claim left-anchored and vertically centered. **Composes** kicker-block, the claim. **Focal**
a 2–3 line claim at `display-xl`/`display-lg`, ink on the accent (ink-on-fire — never white).
**Chrome** small ink kicker-block above the claim; mono meta footer. **Accent** the ground IS the
accent; no second accent competes. **Silence** ~45% of the colored field empty. **Fixed** ink type
on accent, fit-to-measure sizing, no shadow on type. **Free** the claim, which accent ground, which
word breaks. **Density** sparse — one idea.
### 3 · Stat Grid (catalog · move: density — the one dense frame · centered)
**Ground** `{colors.cream}`, `content-gutter`. **Container** grid: a centered `display-head` over a
3-up row of stat-cells. **Composes** frame-chrome, 3× stat-cell. **Focal** the row of three
ink-bordered cells (green / pink / orange), each a `stat-num` + mono label. **Chrome** mono topbar +
meta. **Accent** the three cell fills (the named density exception — three accents allowed here).
**Silence** ~25% — tight by design. **Fixed** 0.4cqw borders, square corners, no per-cell shadow,
mono labels. **Free** the three figures+labels, head copy, which three accents. **Density** dense-exception.
### 4 · Closing Plate (closer · move: ground-swap · centered)
**Ground** `{colors.green}` (the single green frame of the run), `frame-pad`. **Focal** a 2-line
sign-off at `display-lg` in `{colors.cream}`, centered. **Composes** frame-chrome (cream variant),
stamp. **Chrome** cream mono topbar + meta. **Accent** one `{colors.pink}` stamp rotated −6deg in
a corner, cream ring + stamp-num. **Silence** ~55% empty green. **Fixed** green ground reserved to
this beat, cream-on-green type, one stamp. **Free** sign-off copy, stamp text, stamp corner. **Density** sparse.
### 5 · Featured Marker (callout · move: hard-shadow focal · left/asymmetric)
**Ground** `{colors.cream}`. **Composes** frame-chrome, marker-block, optional body-md support line.
**Focal** the pink marker-block with the signature orange+ink hard offset shadow, set asymmetrically.
**Accent** pink block + orange shadow (two accents). **Silence** ~50%. **Fixed** exactly one hard
shadow on the frame, 0.4cqw borders. **Free** marker copy, block position, optional support line.
**Density** sparse.
### 6 · Comparison Ledger (data · move: matrix · left)
**Ground** `{colors.cream}`, `content-gutter`. **Composes** frame-chrome, comparison-table.
**Focal** the cream-2 table with ink head row; one column fill (pink or green) marks the winner.
**Accent** the single column fill. **Silence** tight — the second density exception. **Fixed**
cream-2 fill, 0.3cqw internal rules, ink head row with cream Archivo labels. **Free** rows, which
column fills, copy. **Density** dense-exception.
## Composition Rules
### Do
- Compose around **one idea per frame**, focal element **3–5× its neighbors** (squint test).
- **Lean centered** — cover, claim, stat grid, and closer all center their focal element; reserve left/asymmetric for the marker and ledger.
- Keep sparse frames **45–60% empty**; only the stat grid and ledger run dense.
- Use **two or three accents per frame**; reserve `{colors.green}` ground for the closing plate.
- Spend the hard offset shadow on **one featured block per frame**.
- Size headlines **fit-to-measure**; render Archivo Black uppercase at 0.92 lh.
### Don't
- Don't round corners (except the pill chip); don't gradient, blur, or glow.
- Don't set Archivo Black in sentence case or letter-space it beyond −0.01em.
- Don't use all four accents on one frame, a fifth accent, or pure white.
- Don't center body copy or set labels in anything but JetBrains Mono.
- Don't blow a headline edge-to-edge — step the ramp down for long lines.
- Don't put two hard shadows on one frame.
## Aspect-Ratio Behavior
| Treatment | 16:9 | 9:16 | 1:1 |
| ----------------- | ------------------------------- | ---------------------------- | ------------------------ |
| Wordmark Cover | two lines centered | stacked taller, circle top | centered, tighter |
| Big Claim | claim left, full accent | claim top, accent full | claim centered |
| Stat Grid | head over 3-up row | head top, 3 stacked | head top, 2×2 (4th cell) |
| Closing Plate | sign-off centered, stamp corner | stacked, stamp below | centered, stamp corner |
| Featured Marker | marker asymmetric | marker centered, shadow down | marker centered |
| Comparison Ledger | full-width table | table scrolls to fewer cols | 2-col table |
Safe area holds the `3.3cqw` chrome inset on the short edge for every ratio; re-step display per
ratio so no load-bearing line drops below the 1.4cqw floor.
## Approved Entities
No real customers, logos, or vendors are defined in the source — render any such mark as a
placeholder. Products/sections are content-agnostic; the system supplies geometry, not brands.
## Numerals & Claims (hard rule)
Never invent figures, percentages, counts, or dates at frame scale. Render data slots as
`— figure —`, `{metric}`, `N×`. Real numerals appear only when the script supplies them — the stat
grid and ledger especially carry placeholders, not fabricated values.
## Pre-Render Self-Audit
- **Squint** — one element dominates at 3–5× its neighbor, else rescale.
- **Silence** — sparse frames 45–60% empty; only stat grid / ledger run dense.
- **Accents** — two or three per frame, never all four; green ground only on the closer.
- **Depth** — 0 blur; at most one hard offset shadow; color-block contrast otherwise.
- **Geometry** — square corners except the pill; rotation only at −4/−6deg.
- **Type** — Archivo Black uppercase 0.92 lh, fit-to-measure, ≥1.4cqw on load-bearing lines.
- **Anchor** — centered default; left/asymmetric only on marker + ledger; no 3 consecutive frames share an anchor.
- **Fabrication** — every numeral traces to the script, else placeholder.
## Known Gaps
- **Motion intentionally out of scope.** frame.md specifies composition only; timing and transitions are a later stage. The closing green is described as a _plate_, not a transition.
- **Archivo Black requires Google Fonts**; fallback is `sans-serif`. CJK pairing (Noto Serif SC 900 / NSC 400) carries over from the source's CJK section.
- **9:16 / 1:1 are guidance**, not pixel-locked; verify the legibility floor per ratio.
- Decorative geometry (circle, stamp, stacked blocks) is CSS-only; no external imagery is required.
@@ -1,226 +0,0 @@
<!--
Creative Mode — caption skin (preset-local source).
This is the preset's own lower-third karaoke caption look: a caption skin to read or
copy, not to mount as is. A copy used as compositions/captions.html fills its three
reserved holes and wraps the fragment in a <template>:
· the empty GROUPS array → the engine's scene-aware word groups
· the 0 duration + 0 width/height → real total + canvas size
· the empty data-brand-tokens style → :root tokens derived from the project's frame.md
Token contract — reference ONLY this fixed vocab (set it from the
project's frame.md; each var has a creative-mode
literal fallback for standalone preview):
--cap-ink · --cap-canvas · --cap-accent · --cap-accent-2 · --font-display ·
--font-body · --cap-band-top · --cap-band-height
Hooks (do not rename): .caption-group / .caption-word + states .is-active / .is-spoken,
root data-composition-id="captions", timeline at window.__timelines["captions"].
State changes use gsap.set({className}) (applied on the engine's frame-by-frame seek);
never tl.call() callbacks (seek does not fire them → wrong state on render).
Visual: a warm-cream card on a thick 4px ink border with a HARD offset shadow (a solid
same-direction ink+orange duplicate, no blur — creative-mode's only depth device),
square corners. Archivo Black, UPPERCASE, 0.92 line-height, ink glyphs. Upcoming words
sit in faint ink; the current word becomes a solid yellow inked block (the brand's
signature hard-offset-shadow accent block, no blur) with ink reading on top; spoken
words settle to solid ink, the accent block cleared.
-->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<!-- the project's :root tokens go here -->
<style data-brand-tokens></style>
<style>
#captions-root {
position: absolute;
inset: 0;
pointer-events: none;
}
.caption-layer {
position: absolute;
inset: 0;
z-index: 20;
pointer-events: none;
}
/* the keep-out band, from captionBand(H) — frame content stays above it */
.caption-stage {
position: absolute;
left: 0;
right: 0;
top: var(--cap-band-top, 900px);
height: var(--cap-band-height, 180px);
display: flex;
align-items: center;
justify-content: center;
}
.caption-group {
/* every group is absolutely stacked over the SAME band — exactly one shown at a
time via opacity. Must be absolute: in-flow groups would lay out side-by-side. */
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
}
/* THE cream card — thick 4px ink border + the signature HARD offset shadow
(a solid same-direction orange+ink duplicate, no blur), square corners */
.caption-pill {
max-width: 80%;
padding: 18px 40px 22px;
background: var(--cap-canvas, #efe9d9);
border: 4px solid var(--cap-ink, #0f0f0f);
border-radius: 0;
box-shadow:
14px 14px 0 var(--cap-accent-2, #e85a1f),
14px 14px 0 4px var(--cap-ink, #0f0f0f);
}
.caption-line {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.16em 0.3em;
font-family: var(--font-display, "Archivo Black"), sans-serif;
font-weight: 400;
text-transform: uppercase;
font-size: clamp(34px, 3.8vw, 52px);
line-height: 0.92;
letter-spacing: -0.01em;
}
/* upcoming — faint ink, no accent block */
.caption-word {
display: inline-block;
padding: 0.02em 0.1em;
color: color-mix(in srgb, var(--cap-ink, #0f0f0f) 38%, var(--cap-canvas, #efe9d9));
}
/* current word — THE hard yellow inked block (box-shadow spread = a hard square
block w/o layout shift; no blur), ink reading on top */
.caption-word.is-active {
color: var(--cap-ink, #0f0f0f);
background: var(--cap-accent, #f5c518);
box-shadow: 0 0 0 0.08em var(--cap-accent, #f5c518);
}
/* already spoken — solid ink, accent block cleared */
.caption-word.is-spoken {
color: var(--cap-ink, #0f0f0f);
background: transparent;
box-shadow: none;
}
/* optional per-word accents (dormant until a word-classer tags them) */
/* brand / key term — an inverted ink chip (the kicker-block voice) */
.caption-word.cap-brand {
color: var(--cap-canvas, #efe9d9);
background: var(--cap-ink, #0f0f0f);
box-shadow: 0 0 0 0.08em var(--cap-ink, #0f0f0f);
}
/* numerals / data — the mono voice, slightly recessed */
.caption-word.cap-num {
font-family: var(--font-body, "Space Grotesk"), ui-monospace, monospace;
color: color-mix(in srgb, var(--cap-ink, #0f0f0f) 72%, var(--cap-canvas, #efe9d9));
}
/* emphasis — a thick orange underline (the counter accent) */
.caption-word.cap-cta {
text-decoration: underline;
text-decoration-thickness: 3px;
text-decoration-color: var(--cap-accent-2, #e85a1f);
text-underline-offset: 0.12em;
}
@media (max-aspect-ratio: 9/16) {
.caption-pill {
max-width: 92%;
}
.caption-line {
font-size: clamp(30px, 5vw, 44px);
}
}
</style>
<div
id="captions-root"
data-composition-id="captions"
data-timeline-locked
data-start="0"
data-duration="0"
data-fps="30"
data-width="0"
data-height="0"
>
<div class="caption-layer" aria-hidden="true">
<div id="caption-stage" class="caption-stage"></div>
</div>
</div>
<script>
// the scene-aware word groups + real duration go here.
var GROUPS = [];
var DURATION = 0;
(function () {
var stage = document.getElementById("caption-stage");
// build DOM: one .caption-group per group, .caption-word per word
GROUPS.forEach(function (group, g) {
var groupEl = document.createElement("div");
groupEl.className = "caption-group";
groupEl.id = "caption-group-" + g;
var pill = document.createElement("div");
pill.className = "caption-pill";
var line = document.createElement("div");
line.className = "caption-line";
(group.words || []).forEach(function (w, i) {
var span = document.createElement("span");
span.className = "caption-word";
span.id = "caption-word-" + g + "-" + i;
span.textContent = String(w.text);
line.appendChild(span);
});
pill.appendChild(line);
groupEl.appendChild(pill);
stage.appendChild(groupEl);
});
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
GROUPS.forEach(function (group, g) {
var groupEl = document.getElementById("caption-group-" + g);
var words = group.words || [];
var next = GROUPS[g + 1];
var isLast = g === GROUPS.length - 1;
var start = Math.max(0, Number(group.start));
var end = isLast ? DURATION : Math.min(Number(next.start), Number(group.end) + 0.3);
if (end <= start) end = start + 0.01;
// group on/off — exactly one group visible at a time
tl.set(groupEl, { opacity: 1 }, start);
tl.set(groupEl, { opacity: 0 }, end);
// 3-state karaoke via className SETS — seek-safe (gsap.set applies on the
// engine's frame-by-frame seek; .call callbacks would NOT fire).
words.forEach(function (w, i) {
var el = document.getElementById("caption-word-" + g + "-" + i);
var at = Math.max(start, Number(w.start));
tl.set(el, { className: "caption-word" }, start); // upcoming (also resets on reverse-seek)
tl.set(el, { className: "caption-word is-active" }, at); // current
tl.fromTo(el, { scale: 0.92 }, { scale: 1, duration: 0.16, ease: "back.out(2.2)" }, at);
if (i + 1 < words.length) {
var nextAt = Math.max(start, Number(words[i + 1].start));
tl.set(el, { className: "caption-word is-spoken" }, nextAt); // demote when next activates
}
});
if (words.length) {
var lastEl = document.getElementById("caption-word-" + g + "-" + (words.length - 1));
var lastSpoken = Math.min(end, Number(words[words.length - 1].end) + 0.1);
tl.set(lastEl, { className: "caption-word is-spoken" }, lastSpoken);
}
});
// full-span anchor so the sub-comp timeline spans the whole video
tl.to({}, { duration: DURATION }, 0);
window.__timelines["captions"] = tl;
})();
</script>
File diff suppressed because it is too large. Load diff
@@ -1,274 +0,0 @@
---
version: alpha
name: Daisy Days — Frame (video / frame layer)
description: >
Video-first companion to Daisy Days' design.md. The unit is the frame (1920×1080). Atoms are
identical and sacred — the sunny-garden pastel palette (cream + turquoise/pink/butter/mint/
lavender/peach/sky + coral accent), charcoal 3px outlines, hard offset shadows (6/4px, no blur),
the Fredoka + Quicksand pairing, generous radii, headline text-shadow on color, dot bullets, and
the hand-drawn SVG ornament layer. Composition + frame scale rewritten. Motion out of scope.
unit: the frame — 1920×1080 primary; 9:16 and 1:1 documented
principle: atoms are sacred · composition is free · numbers come from the script
colors:
cream: "#F5F0E6"
turquoise: "#7ECDC0"
soft-pink: "#F7C8D4"
butter: "#FDE68A"
mint: "#A8E6CF"
lavender: "#D4A5E8"
peach: "#FFCBA4"
sky: "#A8D8F0"
coral: "#F8635F"
text-dark: "#2D2D2D"
text-muted: "#6B6B6B"
white: "#FFFFFF"
borders: { primary: "3px solid text-dark", thin: "2px solid text-dark" }
shadows: { default: "6px 6px 0 text-dark", small: "4px 4px 0 text-dark", text-headline: "3px 3px 0 text-dark", text-headline-soft: "3px 3px 0 rgba(0,0,0,0.2)" }
typography:
# — reading ramp (Quicksand) —
body: { fontFamily: "Quicksand", cqw: 0.95, weight: 500, lineHeight: 1.6 }
body-strong:{ fontFamily: "Quicksand", cqw: 0.95, weight: 600, lineHeight: 1.5 }
meta: { fontFamily: "Quicksand", cqw: 0.78, weight: 600, lineHeight: 1.45 }
# — display ramp (Fredoka One / Fredoka 600 — single weight, never italic) —
badge: { fontFamily: "Fredoka One", cqw: 0.9, tracking: "0.02em" }
label-display:{ fontFamily: "Fredoka One", cqw: 1.3, lineHeight: 1.3, tracking: "0.02em" }
subtitle:{ fontFamily: "Fredoka One", cqw: 1.8, lineHeight: 1.2, tracking: "0.02em" }
quote: { fontFamily: "Fredoka One", cqw: 2.6, lineHeight: 1.35 }
title: { fontFamily: "Fredoka One", cqw: 3.0, lineHeight: 1.15, tracking: "0.02em" }
headline:{ fontFamily: "Fredoka One", cqw: 4.5, lineHeight: 1.1, tracking: "0.02em" }
display: { fontFamily: "Fredoka One", cqw: 6.5, lineHeight: 1.1, tracking: "0.02em" }
spacing:
pad-slide: "3cqw"
radius: "20px"
radius-lg: "28px"
radius-pill: "50px"
radius-round: "50%"
components:
card:
backgroundColor: "{colors.white}"
border: "3px solid {colors.text-dark}"
rounded: "{spacing.radius} (28px featured)"
shadow: "6px 6px 0 {colors.text-dark}"
description: "The universal container; white-on-pastel is standard."
framed-header:
backgroundColor: "pastel cap + {colors.white} body"
border: "3px solid {colors.text-dark} (one continuous)"
rounded: "{spacing.radius-lg}"
shadow: "6px 6px 0 {colors.text-dark}"
description: "Pastel header strip flush above a white body — one unit, one shadow."
badge-pill:
backgroundColor: "{colors.butter}"
border: "3px solid {colors.text-dark}"
rounded: "{spacing.radius-pill}"
typography: "{typography.badge}"
shadow: "4px 4px 0 {colors.text-dark}"
description: "Section tag. white-space:nowrap."
circle-marker:
backgroundColor: "any pastel"
textColor: "{colors.white} (dark on butter)"
border: "3px solid {colors.text-dark}"
rounded: "50%"
size: "bullet 20 / icon 44 / dot 48 / step 90"
typography: "Fredoka numeral"
description: "Steps carry a small shadow."
bullet-dot:
backgroundColor: "{colors.butter}"
border: "2px solid {colors.text-dark}"
rounded: "50%"
size: "20px (::before, 4px from line top)"
description: "Lists never use glyph bullets."
ornament:
type: "hand-drawn SVG (daisy, star, sun, cloud, rainbow)"
stroke: "2.1px {colors.text-dark}"
placement: "z-index:1 behind content (z-index:2), cropping past the frame edge"
description: "3–7 per frame, clustered at corners/edges."
quote-mark:
typography: "oversized Fredoka quote glyph"
color: "{colors.soft-pink} (charcoal stroke)"
description: "Above a quote body."
text-headline-shadow:
shadow: "3px 3px 0 {colors.text-dark} (soft 20% variant on pink/mint)"
appliesTo: "Fredoka headlines on saturated surfaces (cream headlines sit flat)"
description: "Makes the headline read 'outlined' like the shapes."
---
# Daisy Days — Frame (video / frame layer)
## Overview
Daisy Days at frame scale is a **cheerful, childlike system** — picture-book illustration meets
sticker-sheet kawaii. Every shape carries a **3px charcoal outline**, every elevated element a
**solid hard offset shadow** (no blur), every surface a sunny-garden pastel. The voice is one
pairing: **Fredoka One** (chunky rounded, single weight) for every headline, **Quicksand** for
every body and meta line. The signature is the **hand-drawn SVG ornament layer** — daisies, stars,
suns, clouds, rainbows clustering at corners and cropping past the edge.
The palette is **multi-pastel with one warm pop**: cream canvas, seven pastel surfaces, and
`{colors.coral}` reserved for small high-attention markers only. Headlines on a saturated surface
get a 3px charcoal text-shadow (so they read "outlined" like the shapes) and switch to white;
headlines on cream sit flat in charcoal. Depth is 2D and graphic — thick outline + hard offset =
sticker-on-paper.
**Key characteristics at frame scale:**
- **Cream default + rotating pastel surfaces**; `{colors.coral}` is a marker accent, never a surface.
- **Fredoka One** headlines + **Quicksand 500/600** body — strict by role, never crossed.
- **3px charcoal outline + hard offset shadow** (6/4px, zero blur) on every elevated shape.
- **Generous radii** — 20px cards, 28px featured, pill badges, full-circle markers; no square corners.
- **Headline text-shadow on saturated surfaces** (white text); flat charcoal on cream.
- **Dot bullets** (outlined butter discs, never glyphs) + a **3–7 ornament wreath** per frame.
## The Frame
### Frame Craft Bar
Three eyeball tests gate every frame before any structural check:
- **Squint** — one Fredoka headline or content card dominates at 3–6× its neighbor.
- **Silence** — **one content container per frame** surrounded by an ornament wreath; the **info-card grid is the one dense exception**. Empty corners read as broken.
- **Restraint** — cream or **one** pastel surface; coral is a small-marker accent (never a surface); charcoal borders + hard offset shadows only; no ninth color.
- **Reference** — aim at a **children's picture-book / sticker-sheet kawaii zine**; failure looks like a **flat, square-cornered, blurred-shadow corporate slide**.
- **Primary:** 1920×1080 (16:9). Display authored in **`cqw`** (`px ÷ 1920 × 100 = cqw`).
- **Vertical:** 1080×1920 (9:16). **Square:** 1080×1080 (1:1).
- **Safe area:** `pad-slide` ~3cqw; ornaments deliberately bleed past the edge.
**The container law (load-bearing).** Every frame ground sets `container-type: size`; ALL
frame-relative units are `cqw`/`cqh` against it — never `vw`. Borders stay 3px/2px; radii stay
20/28/50px; shadow offsets scale in `cqw` so the sticker offset holds proportionally.
## Colors
Tokens identical to the source. Default ground `{colors.cream}`; rotate saturated pastels
(turquoise / soft-pink / butter / mint / lavender / peach / sky) for tonal mood. **Cards are white**
on any surface. **Borders + shadows are always `{colors.text-dark}` charcoal** — never colored,
never blurred, never `rgba` (save the soft text-shadow variant). `{colors.coral}` is the lone
high-saturation accent — small markers (step circles, dots, headers) **only**, never a surface.
Body is charcoal/muted; pastels carry **no semantic meaning**. No ninth color.
## Typography
Two ramps. The **reading ramp** (Quicksand 500 body 0.95cqw, 600 emphasis, meta) carries copy; the
**display ramp** (Fredoka One `label-display` 1.3cqw → `display` 6.5cqw, single weight) carries
every headline, title, quote, and marker numeral.
- **Legibility floor:** any load-bearing line ≥ **1.4cqw**; meta is chrome only.
- **Fit-to-measure:** size the headline to its length. Cap the block at **≤ 78cqw**; ≤3 words → `display`; 4–6 → `headline`; 7+ → `title`.
- **Fredoka One for all display, Quicksand for all body — never crossed.** Fredoka is single-weight (no italic, no underline, no alt weights); Quicksand stays 500/600/700. Fredoka tracking 0.02em; Quicksand body never uppercase.
## Depth & Surface
2D graphic depth — hard offset shadow, solid charcoal, zero blur, bottom-right:
- **`shadows.default` 6px** cards, frames, badges, chart containers.
- **`shadows.small` 4px** small cards, step circles, avatars.
- **Text-headline shadow** — 3px charcoal on Fredoka headlines over saturated surfaces (20% soft variant on pink/mint); cream headlines flat.
- **Outline + offset** together are the sticker-on-paper signature.
**Ceiling:** no blurred shadow, no `rgba` (except the soft text-shadow), no gradient, no glow; an element either casts a hard charcoal offset or none.
## Shapes
- **20px** cards, **28px** featured, **50px** pill (badges, counter), **50%** all circles, **4px** legend swatch. Zero square corners — every region is rounded.
## Components
- **card / framed-header** — the white-fill bordered containers (one shadow). **badge-pill** — butter section tag.
- **circle-marker** family (bullet 20 / icon 44 / dot 48 / step 90) + **bullet-dot** — outlined pastel discs, Fredoka numerals.
- **ornament** — the hand-drawn SVG sticker layer (3–7 per frame). **quote-mark** — soft-pink Fredoka anchor. **text-headline-shadow** — the on-color headline treatment.
## Frame Treatments
> Recipe: ground · container · composes · focal · chrome · accent · silence · Fixed/Free · density.
> One content container per frame + a 3–7 ornament wreath; empty corners read as broken.
### 1 · Cover (identity · move: ornament wreath · saturated · centered)
**Ground** a saturated pastel (e.g. `{colors.turquoise}`). **Composes** badge-pill, display, body sub, 3–7 ornaments, counter. **Focal** a 1–2 line Fredoka `display` in **white with the 3px charcoal text-shadow**, centered, under a butter badge-pill. **Chrome** counter pill. **Accent** the ornament wreath (daisies + stars at corners, cropping past edges). **Silence** content centered; ornaments fill the edges. **Fixed** text-shadow on color, 3px outlines, charcoal shadows. **Free** surface color, ornament mix/positions, copy. **Density** full-but-not-crowded.
### 2 · Info Cards (catalog · move: 3-up white cards · cream · the dense frame)
**Ground** `{colors.cream}`, `pad-slide`. **Composes** headline (flat charcoal), 3× card (circle-icon + Fredoka title + Quicksand body), a couple ornaments. **Focal** three white 3px-bordered cards with 6px shadows. **Chrome** flat headline. **Accent** the pastel circle-icons (rotate turquoise/coral/lavender). **Silence** tight — the density exception (fewer ornaments here). **Fixed** white cards, 3px + 6px, 20px radius. **Free** card content, icon hues. **Density** dense-exception.
### 3 · Process Steps (sequence · move: rotating circle markers · peach · centered)
**Ground** `{colors.peach}` (or another pastel). **Composes** headline (white + text-shadow), 3–4 step circles + `→` arrows, ornaments. **Focal** a row of 90px outlined step circles, fills **rotating coral → mint → sky → lavender**, Fredoka white numerals, linked by Fredoka arrows. **Chrome** white headline with text-shadow. **Accent** the rotating circle fills. **Silence** moderate. **Fixed** 3px circles + small shadow, rotating fills, arrow glyphs. **Free** step count, labels. **Density** standard.
### 4 · Quote (quote · move: quote-mark anchor · soft-pink · centered)
**Ground** `{colors.soft-pink}`. **Composes** a white quote card (28px radius, 6px shadow), quote-mark, Fredoka quote, Quicksand attribution, ornaments. **Focal** a Fredoka `quote` in charcoal inside the white card, under an oversized soft-pink quote-mark. **Chrome** Quicksand 700 attribution. **Accent** the quote-mark + a star or two. **Silence** card centered, ornaments at corners. **Fixed** quote-mark anchor, white card. **Free** quote, attribution. **Density** moderate.
### 5 · Framed Section (feature · move: cap+body card · cream)
**Ground** `{colors.cream}`. **Composes** framed-header (pastel cap + white body), bullet-dot list, ornaments. **Focal** a framed-header — a pastel cap strip (Fredoka title, optional text-shadow) above a white body with a butter-dot bullet list. **Accent** the cap color + bullet dots. **Silence** moderate. **Fixed** one continuous 3px border + one shadow, butter dot bullets. **Free** cap color, list. **Density** standard.
### 6 · Closing (closer · move: ornament wreath · saturated · centered)
**Ground** a saturated pastel (e.g. `{colors.lavender}`). **Composes** badge-pill, display (white + text-shadow), 3–7 ornaments. **Focal** a Fredoka `display` sign-off in white with the charcoal text-shadow, centered. **Accent** the ornament wreath. **Silence** content centered. **Fixed** text-shadow on color, ornaments fill corners. **Free** sign-off, surface, ornaments. **Density** full.
## Composition Rules
### Do
- Pair **Fredoka One headlines + Quicksand body** strictly by role.
- Outline every shape **3px charcoal + a hard offset shadow** (6/4px, no blur); white card fills on any surface.
- Give Fredoka headlines on **saturated surfaces a 3px charcoal text-shadow + white text**; flat charcoal on cream.
- Use **outlined butter-disc bullets** (never glyphs); cluster **3–7 hand-drawn ornaments** per frame (corners, cropping past edges).
- Keep **one content container per frame**; rotate marker colors (coral → mint → sky → lavender → butter); reserve coral for small markers.
- Center most frames; lean cream for content-dense frames, saturated for cover/closer/quote.
### Don't
- No square corners; no blurred or `rgba` shadows (save the soft text-shadow).
- No colored borders (charcoal only); no coral surface; no ninth color.
- No third font; no Quicksand headline or Fredoka body; no italic/underline Fredoka; no uppercase Quicksand body.
- No glyph bullets; no empty corners (ornaments fill them); no two competing content panels.
- Don't blow a headline edge-to-edge — fit to measure.
## Aspect-Ratio Behavior
| Treatment | 16:9 | 9:16 | 1:1 |
| -------------- | -------------------------- | -------------------------------- | ---------- |
| Cover | centered, corner ornaments | centered, taller, more ornaments | centered |
| Info Cards | 3 across | stacked | 2+1 |
| Process Steps | horizontal + arrows | vertical, arrows rotate down | 2×2 |
| Quote | centered card | centered, taller | centered |
| Framed Section | cap + body | cap + body taller | cap + body |
| Closing | centered, wreath | centered, wreath | centered |
`pad-slide` holds on the short edge; re-step display above the 1.4cqw floor. On tighter ratios keep
the ornament count toward the upper end (5–7) so corners never read empty.
## Approved Real Entities
No real customers, logos, or vendors are defined in the source — render any such mark as a
placeholder. Ornaments and pastels are content-agnostic; counters/badges carry per-deck text.
## Numerals & Claims (hard rule)
Never invent figures, dates, or counts at frame scale. Render slots as `— figure —`, `{metric}`,
`N`. Step numbers and any chart values carry placeholders until the script supplies them; the slide
counter is decorative chrome.
## Pre-Render Self-Audit
- **Squint** — one Fredoka headline or content card dominates per frame.
- **Silence** — one container per frame surrounded by an ornament wreath; only the info-card grid runs dense.
- **Color** — cream or one pastel surface; coral markers only; charcoal borders/shadows; no ninth hue.
- **Type** — Fredoka headlines (text-shadow + white on saturated, flat charcoal on cream), Quicksand body; ≥1.4cqw floor.
- **Depth** — 3px outline + hard offset (no blur, no rgba save soft text-shadow); rounded corners only.
- **Ornaments** — 3–7 per frame, cropping past edges; no empty corners. **Bullets** — outlined discs, never glyphs.
- **Fabrication** — every numeral traces to the script, else placeholder.
## Known Gaps
- **Motion intentionally out of scope.** frame.md specifies composition only; the source uses scroll-snap nav, no transition spec.
- **Fonts:** the source names _Fredoka One_; Google now serves the **Fredoka** variable family — request `Fredoka:wght@500;600;700` and set display weight 600 (visually equal to Fredoka One), with `Fredoka One` kept first in the stack for environments that still serve it. Quicksand loads normally. CJK: ZCOOL XiaoWei (display) / Yozai (body).
- **9:16 / 1:1 are guidance**; keep ornament count high so corners stay filled per ratio.
- Ornaments (daisy/star/sun/cloud/rainbow), markers, and framed headers are CSS/SVG-only; recoloring SVG ornaments requires editing their stroke values.
- **Contrast:** keep `{colors.text-muted}` off pastel surfaces (cream/white cards only); small text on saturated grounds should be charcoal or white.
@@ -1,229 +0,0 @@
<!--
Daisy Days — caption skin (preset-local source).
This is the preset's own lower-third karaoke caption look: a caption skin to read or
copy, not to mount as is. A copy used as compositions/captions.html fills its three
reserved holes and wraps the fragment in a <template>:
· the empty GROUPS array → the engine's scene-aware word groups
· the 0 duration + 0 width/height → real total + canvas size
· the empty data-brand-tokens style → :root tokens derived from the project's frame.md
Token contract — reference ONLY this fixed vocab (set it from the
project's frame.md; each var has a daisy-days literal
fallback for standalone preview):
--cap-ink · --cap-canvas · --cap-accent · --cap-accent-2 · --font-display ·
--font-body · --cap-band-top · --cap-band-height
Hooks (do not rename): .caption-group / .caption-word + states .is-active / .is-spoken,
root data-composition-id="captions", timeline at window.__timelines["captions"].
State changes use gsap.set({className}) (applied on the engine's frame-by-frame seek);
never tl.call() callbacks (seek does not fire them → wrong state on render).
Visual: a cheerful picture-book card — a cream surface inside a 3px charcoal outline
with a hard 6px charcoal offset shadow (no blur) and a generous 24px radius, the
sticker-on-paper signature. Fredoka One glyphs, sentence case, charcoal ink. Upcoming
words sit in muted charcoal; the current word pops into a rounded sunny-pastel candy
block (coral fill, 3px charcoal outline, hard 3px offset shadow, 12px radius) with a
bouncy scale pop; spoken words settle to solid charcoal, the candy block cleared.
-->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<!-- the project's :root tokens go here -->
<style data-brand-tokens></style>
<style>
#captions-root {
position: absolute;
inset: 0;
pointer-events: none;
}
.caption-layer {
position: absolute;
inset: 0;
z-index: 20;
pointer-events: none;
}
/* the keep-out band, from captionBand(H) — frame content stays above it */
.caption-stage {
position: absolute;
left: 0;
right: 0;
top: var(--cap-band-top, 900px);
height: var(--cap-band-height, 180px);
display: flex;
align-items: center;
justify-content: center;
}
.caption-group {
/* every group is absolutely stacked over the SAME band — exactly one shown at a
time via opacity. Must be absolute: in-flow groups would lay out side-by-side. */
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
}
/* THE picture-book card — cream surface, 3px charcoal outline, generous 24px radius,
a hard 6px charcoal offset shadow (no blur): the sticker-on-paper signature */
.caption-pill {
max-width: 80%;
padding: 18px 40px 22px;
background: var(--cap-canvas, #f5f0e6);
border: 3px solid var(--cap-ink, #2d2d2d);
border-radius: 24px;
box-shadow: 6px 6px 0 var(--cap-ink, #2d2d2d);
}
.caption-line {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.18em 0.32em;
font-family: var(--font-display, "Fredoka One"), "Baloo 2", sans-serif;
font-weight: 600;
font-size: clamp(38px, 4.2vw, 56px);
line-height: 1.22;
letter-spacing: 0.02em;
}
/* upcoming — muted charcoal, no candy block */
.caption-word {
display: inline-block;
padding: 0.02em 0.16em;
border-radius: 12px;
color: color-mix(in srgb, var(--cap-ink, #2d2d2d) 52%, var(--cap-canvas, #f5f0e6));
}
/* current word — THE sunny-pastel candy block: a rounded coral chip with a 3px
charcoal outline + a hard 3px charcoal offset shadow (no blur), charcoal on top */
.caption-word.is-active {
color: var(--cap-ink, #2d2d2d);
background: var(--cap-accent, #f8635f);
border-radius: 12px;
box-shadow:
0 0 0 3px var(--cap-ink, #2d2d2d),
3px 5px 0 0 var(--cap-ink, #2d2d2d);
}
/* already spoken — solid charcoal, candy block cleared */
.caption-word.is-spoken {
color: var(--cap-ink, #2d2d2d);
background: transparent;
box-shadow: none;
}
/* optional per-word accents (dormant until a word-classer tags them) */
/* brand / key term — a butter candy chip, the cheerful second pastel */
.caption-word.cap-brand {
color: var(--cap-ink, #2d2d2d);
background: var(--cap-accent-2, #fde68a);
border-radius: 12px;
box-shadow: 0 0 0 3px var(--cap-ink, #2d2d2d);
}
/* numerals / data — Quicksand, slightly recessed */
.caption-word.cap-num {
font-family: var(--font-body, "Quicksand"), system-ui, sans-serif;
font-weight: 700;
color: color-mix(in srgb, var(--cap-ink, #2d2d2d) 78%, var(--cap-canvas, #f5f0e6));
}
/* emphasis — a butter underline rule (the second sunny pastel) */
.caption-word.cap-cta {
text-decoration: underline;
text-decoration-thickness: 3px;
text-decoration-color: var(--cap-accent-2, #fde68a);
text-underline-offset: 0.12em;
}
@media (max-aspect-ratio: 9/16) {
.caption-pill {
max-width: 92%;
}
.caption-line {
font-size: clamp(32px, 5.2vw, 46px);
}
}
</style>
<div
id="captions-root"
data-composition-id="captions"
data-timeline-locked
data-start="0"
data-duration="0"
data-fps="30"
data-width="0"
data-height="0"
>
<div class="caption-layer" aria-hidden="true">
<div id="caption-stage" class="caption-stage"></div>
</div>
</div>
<script>
// the scene-aware word groups + real duration go here.
var GROUPS = [];
var DURATION = 0;
(function () {
var stage = document.getElementById("caption-stage");
// build DOM: one .caption-group per group, .caption-word per word
GROUPS.forEach(function (group, g) {
var groupEl = document.createElement("div");
groupEl.className = "caption-group";
groupEl.id = "caption-group-" + g;
var pill = document.createElement("div");
pill.className = "caption-pill";
var line = document.createElement("div");
line.className = "caption-line";
(group.words || []).forEach(function (w, i) {
var span = document.createElement("span");
span.className = "caption-word";
span.id = "caption-word-" + g + "-" + i;
span.textContent = String(w.text);
line.appendChild(span);
});
pill.appendChild(line);
groupEl.appendChild(pill);
stage.appendChild(groupEl);
});
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
GROUPS.forEach(function (group, g) {
var groupEl = document.getElementById("caption-group-" + g);
var words = group.words || [];
var next = GROUPS[g + 1];
var isLast = g === GROUPS.length - 1;
var start = Math.max(0, Number(group.start));
var end = isLast ? DURATION : Math.min(Number(next.start), Number(group.end) + 0.3);
if (end <= start) end = start + 0.01;
// group on/off — exactly one group visible at a time
tl.set(groupEl, { opacity: 1 }, start);
tl.set(groupEl, { opacity: 0 }, end);
// 3-state karaoke via className SETS — seek-safe (gsap.set applies on the
// engine's frame-by-frame seek; .call callbacks would NOT fire).
words.forEach(function (w, i) {
var el = document.getElementById("caption-word-" + g + "-" + i);
var at = Math.max(start, Number(w.start));
tl.set(el, { className: "caption-word" }, start); // upcoming (also resets on reverse-seek)
tl.set(el, { className: "caption-word is-active" }, at); // current
tl.fromTo(el, { scale: 0.82 }, { scale: 1, duration: 0.2, ease: "back.out(2.6)" }, at);
if (i + 1 < words.length) {
var nextAt = Math.max(start, Number(words[i + 1].start));
tl.set(el, { className: "caption-word is-spoken" }, nextAt); // demote when next activates
}
});
if (words.length) {
var lastEl = document.getElementById("caption-word-" + g + "-" + (words.length - 1));
var lastSpoken = Math.min(end, Number(words[words.length - 1].end) + 0.1);
tl.set(lastEl, { className: "caption-word is-spoken" }, lastSpoken);
}
});
// full-span anchor so the sub-comp timeline spans the whole video
tl.to({}, { duration: DURATION }, 0);
window.__timelines["captions"] = tl;
})();
</script>
File diff suppressed because it is too large. Load diff
@@ -1,265 +0,0 @@
---
version: alpha
name: Editorial Forest — Frame (video / frame layer)
description: >
Video-first companion to Editorial Forest's design.md. The unit is the frame (1920×1080). Atoms
are identical and sacred — the green/pink/cream editorial triad, Source Serif 4 at weight 500
(optical-size axis) for all display + JetBrains Mono 500 uppercase chrome, flat paper depth (no
shadows), 2px hairline rules, 6/8px card radii, and the monogram circle stamp. Composition + frame
scale rewritten. Motion out of scope.
unit: the frame — 1920×1080 primary; 9:16 and 1:1 documented
principle: atoms are sacred · composition is free · numbers come from the script
colors:
green: "#2e4a2a"
green-deep: "#243a21"
green-lite: "#3a5a36"
pink: "#e89cb1"
pink-deep: "#d27e96"
cream: "#efe7d4"
cream-2: "#e6dcc4"
ink: "#1a1a17"
typography:
# — reading + chrome ramp —
body: { fontFamily: "Source Serif 4", cqw: 1.56, weight: 400, lineHeight: 1.38 }
body-card:{ fontFamily: "Source Serif 4", cqw: 1.35, weight: 400, lineHeight: 1.34 }
label: { fontFamily: "JetBrains Mono", px: 26, cqw: 1.35, weight: 500, tracking: "0.18em", upper: true }
caption-mono:{ fontFamily: "JetBrains Mono", px: 24, cqw: 1.25, weight: 500, tracking: "0.14em", upper: true }
# — display ramp (Source Serif 4 weight 500, opsz, negative tracking) —
title-card-sm:{ fontFamily: "Source Serif 4", cqw: 2.9, weight: 500, lineHeight: 0.98, tracking: "-0.01em" }
title-card:{ fontFamily: "Source Serif 4", cqw: 3.5, weight: 500, lineHeight: 0.96, tracking: "-0.01em" }
headline:{ fontFamily: "Source Serif 4", cqw: 4.4, weight: 500, lineHeight: 1.0, tracking: "-0.02em" }
headline-xl:{ fontFamily: "Source Serif 4", cqw: 5.0, weight: 500, lineHeight: 0.96, tracking: "-0.02em" }
display:{ fontFamily: "Source Serif 4", cqw: 7.3, weight: 500, lineHeight: 1.02, tracking: "-0.02em" }
display-hero:{ fontFamily: "Source Serif 4", cqw: 11.5, weight: 500, lineHeight: 0.92, tracking: "-0.02em" }
stat-figure:{ fontFamily: "Source Serif 4", cqw: 11.5, weight: 500, lineHeight: 0.92, tracking: "-0.03em" }
stat-figure-unit:{ fontFamily: "Source Serif 4", cqw: 5.7, weight: 500, lineHeight: 0.92 }
name: { fontFamily: "Source Serif 4", cqw: 2.3, weight: 600, lineHeight: 1.0 }
spacing:
slide-pad: "5cqw"
rule: "2px"
rule-card: "2.5px"
radius-card: "6px"
radius-step: "8px"
components:
topbar:
typography: "{typography.label} (JetBrains Mono) + monogram-circle or counter"
placement: "top edge, label left, mark right"
description: "On EVERY frame — the system's spine; a frame without it reads untreated."
footline:
typography: "{typography.caption-mono} ×2, space-between"
placement: "absolute bottom edge"
description: "On cover/data/summary frames."
monogram-circle:
border: "2px solid {colors.pink}"
rounded: "50%"
size: "130px"
typography: "mono monogram"
description: "The identity stamp. Cover/summary only."
topic-tile:
backgroundColor: "{colors.green} (pink text) / {colors.pink} (green-deep) / {colors.green-lite} (pink) / {colors.cream-2} + 2px {colors.green} border (green)"
rounded: "{spacing.radius-card}"
shadow: "none"
typography: "{typography.caption-mono} ordinal + {typography.title-card-sm} + mono foot"
description: "Fills rotate; never repeat one across a grid."
step-tile:
backgroundColor: "{colors.cream} + green border / {colors.green} / {colors.pink}"
border: "2.5px solid"
rounded: "{spacing.radius-step}"
typography: "mono ordinal + {typography.title-card} + {typography.body-card} + mono marker over a top rule"
description: "Framework/process card."
kpi-block:
typography: "{typography.caption-mono} tag → {typography.stat-figure} (+{typography.stat-figure-unit}) → {typography.body}"
rule: "2px accent rule above"
description: "Oversized serif figure."
meta-dl:
borderTop: "2px solid {colors.green}"
typography: "{typography.caption-mono} dt + {typography.meta-value} dd"
description: "3-column dt/dd grid."
bar:
backgroundColor: "{colors.pink} / {colors.cream} / {colors.green}"
rounded: "3px 3px 0 0"
size: "56px wide"
typography: "mono value above"
description: "Vertical chart bar."
rule-thin:
rule: "2px solid (green on cream / pink on green / green-deep on pink)"
description: "The only separator. Never 1px, never 3px+."
---
# Editorial Forest — Frame (video / frame layer)
## Overview
Editorial Forest at frame scale is a **serif-led literary-editorial system** — the register of a
Penguin classic or a quiet annual report. One confident voice, **Source Serif 4 at weight 500**
(optical-size axis engaged), carries every headline and stat up to ~12cqw; **JetBrains Mono** at
weight 500 uppercase is the editorial chrome (labels, captions, axis ticks, footlines). Three
surfaces — forest green, dusty rose, oat cream — and nothing else. Depth is **flat and paper-based**:
no shadows, no gradients; elevation is color-block contrast + 2px hairlines + border-vs-fill.
The unmistakable signature is **weight 500** (never 400 display, never 700) and the **mono/serif
role inversion** (mono for chrome, serif for body and display). Body text drops to serif weight
400 — that 500→400 step is the reading rhythm. Every frame carries a mono topbar; the monogram
circle is the identity stamp.
**Key characteristics at frame scale:**
- **Green / pink / cream triad** — two surface tones per frame is typical, three is loud.
- **Source Serif 4 weight 500** (opsz, negative tracking) for all display; **serif 400** body; **JetBrains Mono 500** uppercase chrome.
- **Flat — no shadows, no gradients**; elevation is color-block + 2px hairline + border-vs-fill.
- **2px hairline rules** are the only separator (2.5px on step tiles); 6/8px card radii; the monogram circle is the only full round.
- **Topbar on every frame** (mono label + monogram/counter); footline on data/cover frames.
- **Spacious & committed** — one subject per frame in deep negative space; fewer elements at larger sizes.
## The Frame
### Frame Craft Bar
Three eyeball tests gate every frame before any structural check:
- **Squint** — one Source Serif 4 moment dominates at 3–6× its neighbor; the surface block centers the eye.
- **Silence** — one subject per frame in deep negative space; the **topic-tile and step grids are the one dense exception**.
- **Restraint** — **two surface tones max** per frame (three is loud); serif **weight 500** display / **400** body; green ground reserved for gravity moments.
- **Reference** — aim at a **Penguin classic / quiet annual report / art-book spread**; failure looks like a **shadowed, multi-color web dashboard**.
- **Primary:** 1920×1080 (16:9). Display authored in **`cqw`** (`px ÷ 1920 × 100 = cqw`).
- **Vertical:** 1080×1920 (9:16). **Square:** 1080×1080 (1:1).
- **Safe area:** `slide-pad` 5cqw (the source's generous 96–140px); the topbar/footline sit inside it.
**The container law (load-bearing).** Every frame ground sets `container-type: size`; ALL
frame-relative units are `cqw`/`cqh` against it — never `vw` (the source is a fixed 1920 canvas, so
its px map cleanly: `px ÷ 1920 × 100`). Hairlines stay 2px; card radii stay 6/8px.
## Colors
Tokens identical to the source. Default ground `{colors.cream}` for content; `{colors.green}` for
cover/statement/summary gravity. **Headlines:** green on cream, cream (or pink at hero scale) on
green, green-deep on pink. **Body:** ink on cream, cream on green, green-deep on pink. Labels/rules
take the region's accent (pink on green, green on cream, green-deep on pink). Tile fills rotate
green / pink / green-lite / cream-2-with-green-border. **No fourth color family** — the triad +
near-duplicates is the whole palette; never `rgba` transparency on a surface.
## Typography
Two ramps. The **reading/chrome ramp** (Source Serif 4 **400** body 1.56cqw; JetBrains Mono labels
in px) carries copy + chrome; the **display ramp** (Source Serif 4 **500** `title-card-sm` 2.9cqw →
`display-hero`/`stat-figure` 11.5cqw, opsz, negative-tracked) carries every headline + figure.
- **Legibility floor:** any load-bearing line ≥ **1.4cqw**; mono px labels are chrome only.
- **Fit-to-measure:** size the headline to its length. Cap the block at **≤ 78cqw**; ≤3 words → `display-hero`; 4–6 → `headline-xl`; 7+ → `headline`. Reserve the 7.3–11.5cqw tier for statement/stat/cover.
- **Serif is weight 500 for display, 400 for body** (the rhythm), 600 only for an attribution name. Negative tracking (−0.01..−0.03em), tight line-height (0.92–1.02). **Mono uppercase, 0.08–0.18em.** No italic, no underline, no third face, no serif body at 500.
## Depth & Surface
Flat, paper-based. Elevation from:
- **Color-block contrast** — a green tile on cream reads elevated by ink-block separation.
- **2px hairline rules** — section separators in the region's accent.
- **Border vs fill** — a cream-2 tile + 2px green border reads a different elevation than a solid tile.
**Ceiling:** zero shadow (none — adding `box-shadow` shatters the paper feel), no gradient, no glow; surfaces are solid ink-on-paper.
## Shapes
- **6px** topic tiles, **8px** step tiles, **2px** legend swatch, **3px 3px 0 0** bar tops, **50%** monogram circle. No square corners, no heavy rounding.
## Components
- **topbar** (the spine) + **footline** + **monogram-circle** (identity stamp) — the chrome set.
- **topic-tile** (6px, rotating fills) / **step-tile** (8px, 2.5px border) — the catalog + framework patterns.
- **kpi-block** (oversized serif figure) / **meta-dl** (3-col, 2px rule) / **bar** / **rule-thin** — data + structure.
## Frame Treatments
> Recipe: ground · container · composes · focal · chrome · accent · silence · Fixed/Free · density.
> Topbar on every frame; one subject per frame in deep negative space.
### 1 · Cover (identity · move: oversized serif · green · left)
**Ground** `{colors.green}`, `slide-pad`. **Composes** topbar (mono label + monogram-circle), display-hero, body lede, footline. **Focal** a 2-line Source Serif 4 `display-hero` in pink (one word in cream), left. **Chrome** mono topbar; mono footline. **Accent** pink display + the monogram circle. **Silence** ~50%. **Fixed** serif 500, flat, monogram 2px pink. **Free** title, which word is cream. **Density** low.
### 2 · Topic Tiles (catalog · move: rotating-fill grid · cream · the dense frame)
**Ground** `{colors.cream}`, `slide-pad`. **Composes** topbar, headline-xl, 3–4× topic-tile. **Focal** a row of 6px tiles, fills rotating green / pink / cream-2-with-border, each a mono ordinal + serif title. **Chrome** mono topbar. **Accent** the tile fills (mix 3 of 4, never repeat one). **Silence** tight — the density exception. **Fixed** 6px radius, rotating fills, no shadow. **Free** tile titles, which fills. **Density** dense-exception.
### 3 · KPI Stat (data · move: oversized figure · green)
**Ground** `{colors.green}`, `slide-pad`. **Composes** topbar, kpi-block (mono tag + 220px stat-figure + serif description), footline. **Focal** a Source Serif 4 `stat-figure` (~11.5cqw, +unit) in pink over a 2px pink rule. **Chrome** mono topbar + footline. **Accent** the pink figure. **Silence** ~55%. **Fixed** serif 500 figure, 2px accent rule, flat. **Free** the figure (from script), tag, description. **Density** low.
### 4 · Statement (quote · move: display serif · cream · left)
**Ground** `{colors.cream}`, generous pad. **Composes** topbar, display, name (600), caption-mono role. **Focal** a 2-line Source Serif 4 `display` (~7.3cqw) in green; a 600-weight attribution name + mono role beneath. **Chrome** mono topbar. **Accent** none — the serif carries it. **Silence** ~55%. **Fixed** serif 500 display + 600 name only, no italic. **Free** quote, name, role. **Density** low.
### 5 · Step Framework (process · move: 8px step tiles · cream/green)
**Ground** `{colors.cream}` (or green), `slide-pad`. **Composes** topbar, headline, 3–4× step-tile. **Focal** a row of 8px step tiles (2.5px border) — mono ordinal + serif 68px title + body + mono marker over a top rule; fills rotate cream-with-green-border / green / pink. **Accent** the tile fills. **Silence** moderate. **Fixed** 8px radius, 2.5px border, mono markers. **Free** steps, fills. **Density** standard.
### 6 · Chart (data · move: bars + meta · green)
**Ground** `{colors.green}`, `slide-pad`. **Composes** topbar, headline, bars + 2px axis rules, meta-dl. **Focal** 56px pink/cream/green bars on inner-edge 2px rules with mono ticks; an optional 3-col meta-dl beneath. **Chrome** mono topbar. **Accent** the bar fills. **Silence** moderate. **Fixed** 56px bars, 3px tops, 2px axes, mono ticks. **Free** values (from script), labels. **Density** standard.
## Composition Rules
### Do
- Run every display in **Source Serif 4 weight 500**, opsz, negative-tracked, tight line-height; **body at 400**.
- Set every label/caption/axis/footline in **JetBrains Mono 500 uppercase, 0.08–0.18em**.
- Give every frame a **topbar** (mono label + monogram or counter); footline on data/cover.
- Pick **one dominant surface per frame** (max two tones); **rotate tile fills**, never repeat one across a grid.
- Separate sections with **2px hairlines** in the region's accent; reserve the monogram circle for cover/summary.
- Scale display aggressively (7.3–11.5cqw) for statement/stat/cover; lean left/editorial, one subject per frame.
### Don't
- No box-shadow, gradient, or glow — flat, paper-based depth only.
- No third typeface; no italic/underline; no serif body at 500; no mono at headline scale.
- No fourth color family; no `rgba` surfaces; no 1px or 4px+ rules (2px / 2.5px only).
- No two competing content blocks; don't omit the topbar.
- Don't blow a headline edge-to-edge — step the ramp down.
## Aspect-Ratio Behavior
| Treatment | 16:9 | 9:16 | 1:1 |
| -------------- | -------------------------------- | --------------------------- | ------------------------ |
| Cover | display left, monogram top-right | display top, monogram below | display, monogram corner |
| Topic Tiles | 3–4 across | stacked | 2×2 |
| KPI Stat | figure left | figure centered, taller | centered |
| Statement | quote left | quote stacked | centered |
| Step Framework | 3–4 across | stacked | 2×2 |
| Chart | bars + meta | bars taller, meta stacks | square chart |
`slide-pad` holds on the short edge; re-step display above the 1.4cqw floor. Topbar spans the top
on every ratio; the monogram may drop to a corner.
## Approved Real Entities
No real customers, logos, or vendors are defined in the source — render any such mark as a
placeholder. The monogram, footline strings, and tile counters carry per-deck identity; figures are content.
## Numerals & Claims (hard rule)
Never invent figures, KPIs, dates, or counts at frame scale. Render slots as `— figure —`,
`{metric}`, `— %`. KPI blocks, bars, and meta values especially carry placeholders until the script
supplies them. Issue numbers / counters are decorative chrome.
## Pre-Render Self-Audit
- **Squint** — one serif moment dominates; the surface block centers the eye.
- **Silence** — one subject per frame in deep negative space; only the topic-tile/step grids run dense.
- **Triad** — two surface tones max; headlines/body/labels use the correct per-surface color; no fourth family.
- **Type** — Source Serif 4 **500** display (opsz, negative-tracked) / **400** body; mono uppercase 0.08–0.18em; ≥1.4cqw floor.
- **Depth** — 0 shadow, 0 gradient; 2px hairlines; 6/8px radii; monogram the only full round.
- **Chrome** — topbar present on every frame; footline on data/cover.
- **Fabrication** — every numeral traces to the script, else placeholder.
## Known Gaps
- **Motion intentionally out of scope.** frame.md specifies composition only; the source relies on `deck-stage.js` for scaling/nav, no transition spec.
- **Source Serif 4 (opsz 8..60) + JetBrains Mono via Google Fonts** — the optical-size axis is critical; a non-opsz fallback flattens the size-aware letterforms. CJK: LXGW WenKai (display) / Noto Serif SC (body) / Noto Sans Mono CJK (chrome).
- **9:16 / 1:1 are guidance**; verify the floor and that two-tone discipline holds per ratio.
- Bars, hairlines, tiles, and the monogram circle are CSS-only; no external imagery is required.
- **Contrast:** green-on-pink / pink-on-green only at display scale (84px+); keep small text cream-on-green or ink-on-cream.
@@ -1,222 +0,0 @@
<!--
Editorial Forest — caption skin (preset-local source).
This is the preset's own lower-third karaoke caption look: a caption skin to read or
copy, not to mount as is. A copy used as compositions/captions.html fills its three
reserved holes and wraps the fragment in a <template>:
· the empty GROUPS array → the engine's scene-aware word groups
· the 0 duration + 0 width/height → real total + canvas size
· the empty data-brand-tokens style → :root tokens derived from the project's frame.md
Token contract — reference ONLY this fixed vocab (set it from the
project's frame.md; each var has an editorial-forest
literal fallback for standalone preview):
--cap-ink · --cap-canvas · --cap-accent · --cap-accent-2 · --font-display ·
--font-body · --cap-band-top · --cap-band-height
Hooks (do not rename): .caption-group / .caption-word + states .is-active / .is-spoken,
root data-composition-id="captions", timeline at window.__timelines["captions"].
State changes use gsap.set({className}) (applied on the engine's frame-by-frame seek);
never tl.call() callbacks (seek does not fire them → wrong state on render).
Visual: an oat-cream card on a single 2px green hairline border — the system's only
separator — with a 6px card radius and NO shadow (editorial-forest is flat, paper-based;
depth is color-block + hairline, never cast). Source Serif 4 weight 500, sentence case,
warm-ink glyphs. Upcoming words sit in faint ink; the current word gets a quiet
dusty-rose panel (a soft 6px-radius block, no blur — editorial restraint, not a loud
block) with ink reading on top; spoken words settle to solid ink, panel cleared. The
per-word emphasis accent is a 2px green hairline underline, never heavy.
-->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<!-- the project's :root tokens go here -->
<style data-brand-tokens></style>
<style>
#captions-root {
position: absolute;
inset: 0;
pointer-events: none;
}
.caption-layer {
position: absolute;
inset: 0;
z-index: 20;
pointer-events: none;
}
/* the keep-out band, from captionBand(H) — frame content stays above it */
.caption-stage {
position: absolute;
left: 0;
right: 0;
top: var(--cap-band-top, 900px);
height: var(--cap-band-height, 180px);
display: flex;
align-items: center;
justify-content: center;
}
.caption-group {
/* every group is absolutely stacked over the SAME band — exactly one shown at a
time via opacity. Must be absolute: in-flow groups would lay out side-by-side. */
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
}
/* THE oat-cream card — single 2px green hairline border (the system's only
separator), 6px card radius, NO shadow (flat, paper-based) */
.caption-pill {
max-width: 78%;
padding: 20px 44px 24px;
background: var(--cap-canvas, #efe7d4);
border: 2px solid var(--cap-accent, #d27e96);
border-radius: 6px;
}
.caption-line {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.1em 0.34em;
font-family: var(--font-display, "Source Serif 4"), Georgia, serif;
font-weight: 500;
font-size: clamp(40px, 4.4vw, 58px);
line-height: 1.16;
letter-spacing: -0.02em;
}
/* upcoming — faint warm ink, no panel */
.caption-word {
display: inline-block;
padding: 0 0.06em;
color: color-mix(in srgb, var(--cap-ink, #1a1a17) 42%, var(--cap-canvas, #efe7d4));
}
/* current word — a quiet dusty-rose panel (box-shadow spread = a soft 6px block w/o
layout shift; no blur — editorial restraint, not a loud block), warm ink on top */
.caption-word.is-active {
color: var(--cap-ink, #1a1a17);
background: var(--cap-accent, #d27e96);
box-shadow: 0 0 0 0.05em var(--cap-accent, #d27e96);
border-radius: 6px;
}
/* already spoken — solid warm ink, panel cleared */
.caption-word.is-spoken {
color: var(--cap-ink, #1a1a17);
background: transparent;
box-shadow: none;
}
/* optional per-word accents (dormant until a word-classer tags them) */
/* brand / key term — heavier serif weight, editorial-forest's only display inflection */
.caption-word.cap-brand {
font-weight: 600;
}
/* numerals / data — the mono chrome voice, slightly recessed */
.caption-word.cap-num {
font-family: var(--font-body, "JetBrains Mono"), ui-monospace, monospace;
color: color-mix(in srgb, var(--cap-ink, #1a1a17) 72%, var(--cap-canvas, #efe7d4));
}
/* emphasis — a 2px green hairline underline (the system's only separator, never heavy) */
.caption-word.cap-cta {
text-decoration: underline;
text-decoration-thickness: 2px;
text-decoration-color: var(--cap-accent-2, #e89cb1);
text-underline-offset: 0.1em;
}
@media (max-aspect-ratio: 9/16) {
.caption-pill {
max-width: 92%;
}
.caption-line {
font-size: clamp(34px, 5.4vw, 48px);
}
}
</style>
<div
id="captions-root"
data-composition-id="captions"
data-timeline-locked
data-start="0"
data-duration="0"
data-fps="30"
data-width="0"
data-height="0"
>
<div class="caption-layer" aria-hidden="true">
<div id="caption-stage" class="caption-stage"></div>
</div>
</div>
<script>
// the scene-aware word groups + real duration go here.
var GROUPS = [];
var DURATION = 0;
(function () {
var stage = document.getElementById("caption-stage");
// build DOM: one .caption-group per group, .caption-word per word
GROUPS.forEach(function (group, g) {
var groupEl = document.createElement("div");
groupEl.className = "caption-group";
groupEl.id = "caption-group-" + g;
var pill = document.createElement("div");
pill.className = "caption-pill";
var line = document.createElement("div");
line.className = "caption-line";
(group.words || []).forEach(function (w, i) {
var span = document.createElement("span");
span.className = "caption-word";
span.id = "caption-word-" + g + "-" + i;
span.textContent = String(w.text);
line.appendChild(span);
});
pill.appendChild(line);
groupEl.appendChild(pill);
stage.appendChild(groupEl);
});
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
GROUPS.forEach(function (group, g) {
var groupEl = document.getElementById("caption-group-" + g);
var words = group.words || [];
var next = GROUPS[g + 1];
var isLast = g === GROUPS.length - 1;
var start = Math.max(0, Number(group.start));
var end = isLast ? DURATION : Math.min(Number(next.start), Number(group.end) + 0.3);
if (end <= start) end = start + 0.01;
// group on/off — exactly one group visible at a time
tl.set(groupEl, { opacity: 1 }, start);
tl.set(groupEl, { opacity: 0 }, end);
// 3-state karaoke via className SETS — seek-safe (gsap.set applies on the
// engine's frame-by-frame seek; .call callbacks would NOT fire).
words.forEach(function (w, i) {
var el = document.getElementById("caption-word-" + g + "-" + i);
var at = Math.max(start, Number(w.start));
tl.set(el, { className: "caption-word" }, start); // upcoming (also resets on reverse-seek)
tl.set(el, { className: "caption-word is-active" }, at); // current
tl.fromTo(el, { scale: 0.985 }, { scale: 1, duration: 0.18, ease: "power1.out" }, at);
if (i + 1 < words.length) {
var nextAt = Math.max(start, Number(words[i + 1].start));
tl.set(el, { className: "caption-word is-spoken" }, nextAt); // demote when next activates
}
});
if (words.length) {
var lastEl = document.getElementById("caption-word-" + g + "-" + (words.length - 1));
var lastSpoken = Math.min(end, Number(words[words.length - 1].end) + 0.1);
tl.set(lastEl, { className: "caption-word is-spoken" }, lastSpoken);
}
});
// full-span anchor so the sub-comp timeline spans the whole video
tl.to({}, { duration: DURATION }, 0);
window.__timelines["captions"] = tl;
})();
</script>
@@ -1,46 +0,0 @@
# Captions
## Words and timings
- `npx hyperframes transcribe <file> --json` and a HeyGen voiceover (`/media-use`) give every word's start and end. Work from those as `[{ "id": "w0", "text": "Hello", "start": 0.0, "end": 0.5 }, …]`.
- Local transcription defaults to `small.en`, which **translates** non-English speech into English. Pass the model: `npx hyperframes transcribe <file> --model small.en` for English, `--model small --language <code>` otherwise, `--model medium.en` for speech over music. It writes `assets/transcript.json` (its JSON names the path) and overwrites it on the next run; `--to srt` or `--to vtt` writes a sidecar.
- For Chinese, Japanese and other unspaced scripts, local transcription returns a whole sentence as one "word", sometimes in the other script (Traditional for Simplified speech). Split each segment's text into short phrases and spread its time across them by character count; check the characters against what is said.
- Whisper invents "Thank you." over silence and repeats lines over a silent tail: drop words that start inside a final silence (`silencedetect=noise=-35dB:d=0.6`; real speech sits around −16 to −26 dB).
- Never put two words in one timed entry, and match transcript words to caption words by position, not text, or a repeated word takes the wrong time.
- Read the transcript before using it. More than 20% `♪` or `�` tokens, or nonsense words, means it failed: retry with a larger model, or take lyrics from the person. Drop non-word tokens before building groups.
## Structure
- One sub-composition host with `data-track-kind="captions"` holds every caption group, so Studio shows them on one captions track.
- Ready-made styles: `npx hyperframes catalog --tag caption-style --json`. An installed one is source you can restyle.
## Groups
- 2–3 words for high energy, 3–5 conversational, 4–6 calm. Break at a sentence end, a pause of 150 ms or more, or the word limit.
- One group visible at a time. A group opens at or before its first word's start and closes at or after its last word's end. A caption at exactly 0 s looks pre-existing: start 0.1–0.3 s in.
- Every group ends with a hard kill after its exit, or a stale group can stay on screen:
```js
tl.to(group, { opacity: 0, duration: 0.12, ease: "power2.in" }, end - 0.12);
tl.set(group, { opacity: 0, visibility: "hidden" }, end);
```
`tl.set` is an instant flip, so setting `visibility` on the group is fine; never tween it on a `.clip`.
## Fit and place
- Size each group to fit: `window.__hyperframes.fitTextFontSize(text, { fontFamily, fontWeight, maxWidth })` returns `{ fontSize }`. Defaults: `maxWidth` 1600 landscape, 900 portrait; base 78, minimum 42, step 2. When a word scales up for emphasis, divide `maxWidth` by that scale.
- A full-width absolute container, centred with text alignment. Never `left: 50%; transform: translateX(-50%)`, which clips at the frame edges.
- `overflow: visible`, not `hidden`, which cuts off scaled words and glows.
- Landscape: 80–120 px from the bottom. Portrait: about 600–700 px from the bottom. Never over the speaker's face; inside the 80% title-safe box.
- Position each group absolutely. In a flex column a group hidden with `visibility: hidden` still takes space, so the next one lands lower. In a stack that accumulates, groups in one block share one exit time, or the stack reflows.
- Fade the group or its words, not both: the opacities multiply and read as a pop. A gradient or `background-clip: text` fill goes on the inner text span, not the transformed container.
- Over footage, choose the blend by the brightness (0–255) behind the text: under 60 `screen`, 60–180 `overlay`, over 180 `normal` with opaque colour. Plain subtitles above brightness 60 get a scrim sized to the text (30–40%), never a full-width bar.
- Footage may already carry burned-in subtitles only mid-clip: scan a 1 fps contact sheet before placing captions.
## Text behind the speaker
- Stack the video, then the captions, then a cutout of the person from `npx hyperframes remove-background` (`/media-use`), ordered by `z-index`. Shake or punch in the video and the cutout together in one wrapper.
- `check` treats a `<video>` as opaque across its whole box, so a WebM cutout fails every caption behind it with `text_occluded`: put `data-layout-allow-occlusion` on the cutout and judge from `snapshot`.
- A big word behind the head reads best 30–55% covered: under about 15% it floats, over about 65% it is eaten. Re-lay out when a content word is 80% covered, or two adjacent words 50% each. Measure the rendered word boxes against the matte over each caption's own time window (heads move: one clip went from y=224 to y=96 in 8 s); estimates from character widths were wrong both ways.
- The matte model drops mic arms, lets large rigs near the body leak in, and flickers on held objects. Its first run downloads 168 MB, and it runs at about 2 fps at 1080p on CPU.
@@ -1,47 +0,0 @@
# Films from a source: measured facts
What is specific to HyperFrames, or was measured on shipped films, when one video is made from a website, a script, a pull request, a music track, a real place or a short motion brief. The story comes first, from `story.md`; the shape of each kind of film is in `types/`. Motion craft is in `motion.md`, look in `look.md`, sound in `sound.md`, captions in `captions.md`.
## Script and pacing
- TTS reads about 2.2 words a second. Keep a scene to about 19 words (9 s); at most two scenes may reach 26 words (12 s). 30–90 s is about 155 words or fewer; a whole film about 400 at most.
- A voiceover longer than its visuals makes the visuals pad with idle drift. Trim the script; never stretch the motion.
- Everything on screen in the first quarter of a scene, then frozen, reads as a slideshow. Reveal each piece when the voiceover names it, across the back half of the scene; write the voiceover as separate phrases so it has cue points.
- Up to about six short scenes, build them yourself in one context (about 9 min for five, against about 21 min split across sub-agents). Beyond that, give each worker two or three scenes and start them all at once; a second wave nearly doubles the time.
## From a website
- `npx hyperframes capture <url> --json` writes `screenshots/full-page.png`: a 1x plate of the whole page, taken after the scroll pass with sticky and fixed elements made static, pixel-exact for a 1920-wide viewport travelling down it. It is skipped above 16384 px tall (use `scroll-*.png`); pushing in past 1:1 needs its own 2x capture of that region.
- To show the site as it is, use its screenshots. Rebuilding the page in HTML drifts from the real layout; rebuild only the part that moves, or overlay captured assets at measured positions.
- `--capture-budget` (default 120000 ms) bounds work after navigation; `--timeout` bounds navigation only. Videos are not downloaded: `capture --video <dir> --list`, then `--index N`. `asset-descriptions.md` has vision captions only when `OPENROUTER_API_KEY`, `GEMINI_API_KEY` or `GOOGLE_API_KEY` is set.
- "very little text content" with an empty asset catalog means the capture is unusable even when it says `ok: true`. A `logo-*.svg` name is a guess from page structure, not proof.
- Brand tokens: link blue `#0000EE` and status reds and greens are never the accent; the accent is the colour that recurs across the most roles. Icon fonts (Font Awesome, icomoon, webflow-icons) are never the display or body face; a captured monospace goes to the mono role. Use only weights the brand font ships, or 600/700 render as fake bold. Every preset has a light ground: for a dark brand, swap ground and ink.
- Capture keeps each `@font-face` rule, `unicode-range` subsets included, in `extracted/page.html`; copying the font files alone loses which characters each covers. Name copied fonts by family, weight and style, or an upright and an italic "Regular" collide.
## From a pull request
- `gh pr view --json files` stops at about 100 files: page `gh api repos/{owner}/{repo}/pulls/N/files`.
- The PR author only opened it: credit commit authors, by commit count (`commits[].authors[]`); skip bots, lockfiles and `dist/`. Reviewers come as logins; names via `gh api users/<login> --jq .name`. TTS reads `@handles` aloud, so speak names.
- Never invent a version. Use the first non-draft release published at or after the merge; else the default branch's `package.json` version as "unreleased"; else only the PR URL.
- Length from size (additions + deletions), as a ceiling: ≲50 lines 20–40 s, 50–200 40–70 s, 200–600 70–110 s, ≳600 lines or 25+ files 110–180 s.
- Show 2–4 hunks of 4–12 lines; counts like +N/−M go on a stat card, not into the voiceover.
- The `code-*` registry blocks (`npx hyperframes catalog --query code --json`) are 1920×1080 scenes driven by `window.__TOKENS` (Shiki tokens, `github-dark`; `fontStyle` bit 1 italic, bit 2 bold) and `window.__BLOCK` (`{id, effect, seq, line?, duration}`). `code-diff` and `code-morph` need at least two states; `code-highlight` counts lines from 0 and `code-scroll` from 1; `code-typing` types at a fixed speed, so check characters × speed against the scene's duration. They reserve no caption band: inset the panel when captions are on.
## On music
- Library music, HeyGen's included, comes as 15–30 s loops: loop the bed to the film with a 0.4 s fade-in and a 1.5 s fade-out. A track whose opening is a quiet build drains a short film: start from a stronger later section.
- Trust beat times only on rhythmic stretches; on calm music the tracker invents a metronome, often at double tempo (`npx hyperframes beats` reports `bpmConfidence`). Cut there on phrase and energy changes; `npx hyperframes audio --only sections,onsets,moments --json` finds them.
- A move that must land on a hit starts 40–190 ms early; a reaction starts 0–45 ms after. The hit itself is a 0 ms `tl.set`: easing a hit kills it. Scenes tile the track with no gaps.
- A word meant to be read holds at least one beat. Any single treatment goes stale after about 6 s: lengthen a section with a hold or a new treatment, never by slowing tweens. On rhythmic sections give footage one clip per strong beat; on calm ones one slow push from 1.0 to 1.08.
## Maps and real places
- A real place (a city, a building, a route, a zoom from the globe to a street) is shown on real imagery. Vector outlines from Natural Earth or GeoJSON read as a diagram and lose the place; keep them for borders and labels drawn on top.
- Live tiles change between renders, so bake the camera move to video with `scripts/bake-basemap.mjs` (MapLibre over Esri satellite or Carto tiles; writes an all-intra MP4 and `<NAME>-coords.json`). From space down to a street: `PROJECTION=globe FROM="lng,lat" CENTER="lng,lat" ZSTART=1.85 ZEND=16` (Earth in a starfield turns from FROM to CENTER, then dives); `--help` lists the rest. It needs network for the tiles and takes well under a second a frame, so bake once, keep the MP4, and run one bake at a time. Draw borders, pins and labels as a live SVG from those coordinates, animating them only while the camera holds.
- Credit the imagery on screen ("Esri, Maxar, Earthstar Geographics" or "© CARTO, © OpenStreetMap") in small type on a soft dark backing, so it holds contrast over bright tiles; mark it `data-layout-ignore` so the type floor in `check` skips it.
## Short motion pieces
- Kinetic type takes 3–7 English words or 4–12 Chinese characters per scene. A count-up runs 1.2–1.6 s, then holds, landing on a downbeat or a hard stop.
- A highlighter that wraps across lines: tween a custom property on a `background-size` band with `box-decoration-break: clone`. `getTotalLength()` can return 0 before layout; give line draw-ons a fallback.
- A point in an image guessed directly misses by 16–24% of the frame. `scripts/locate.mjs` narrows it with a numbered 9×9 grid, then 6×6 on the crop, then a check render (about 2% error).
@@ -1,157 +0,0 @@
# Composition format
The detail behind the contract in `SKILL.md`. Full references: `npx hyperframes docs compositions`, `docs data-attributes`, `docs troubleshooting`.
## Shape of a project
```html
<!-- index.html: a normal document; declares variables, mounts scenes -->
<html
data-composition-variables='[{"id":"headline","type":"string","label":"Headline","default":"…"}]'
>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="12"
data-width="1920"
data-height="1080"
>
<div
id="intro"
data-composition-id="intro"
data-composition-src="compositions/intro.html"
data-start="0"
data-duration="4"
data-track-index="1"
data-width="1920"
data-height="1080"
></div>
</div>
<script>
window.__timelines["main"] = gsap.timeline({ paused: true });
</script>
</body>
</html>
```
```html
<!-- compositions/intro.html: everything inside <template> -->
<template>
<style>
#root {
position: absolute;
inset: 0;
}
</style>
<div id="root" data-composition-id="intro" data-width="1920" data-height="1080">
<h1 id="headline" class="clip" data-start="0.4" data-duration="3" data-var-text="headline">
…
</h1>
</div>
<script>
const tl = gsap.timeline({ paused: true });
tl.fromTo(
"#headline",
{ opacity: 0, filter: "blur(8px)" },
{ opacity: 1, filter: "blur(0px)", duration: 0.18, ease: "power4.out" },
0.4,
);
window.__timelines["intro"] = tl;
</script>
</template>
```
- Give the host, the scene root and the timeline key the same id. A host id that differs from its scene's makes each render wait 45 s per scene, then show static frames. Styles and scripts outside `<template>` are dropped.
- Size the scene root with `inset: 0`, never hard-coded pixels; the canvas is `data-width` × `data-height`.
- Keep ids unique across the assembled page (prefix them: `#intro-title`). A scene's timeline can't reach elements outside its file.
- Use `fromTo`, not `from`, inside a scene: the host re-seeks the scene each time it shows, and `from` records its start state once, so it drifts on seek-back.
- Never `add()` a scene's timeline to the root's; the runtime drives each scene itself, and nesting seeks it twice.
## Attributes you'll use most
| Attribute | Meaning |
| ---------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `data-start`, `data-duration` | Seconds, local to the file that declares the element |
| `data-track-index` | Studio lane only; paint order is CSS `z-index` |
| `data-track-kind` | `graphics` or `captions` on scene hosts (video and audio come from the tag) |
| `data-composition-src`, `data-variable-values` | Mount a scene file; per-mount variable values (a JSON object) |
| `data-media-start` | Offset into the source media (a trim) |
| `data-playback-rate` | Constant speed, 0.1–10 |
| `data-has-audio="true"` | A `<video>` keeps its own sound; a timed `<video>` with neither this nor `muted` fails lint |
| `data-volume`, `data-fade-in`, `data-fade-out` | Gain (1 = 0 dB, up to 3.98 = +12 dB) and edge fades; envelopes use `data-automation` (`/media-use`) |
| `data-link`, `data-sync-origin` | A video and audio edited as one (see Linked clips) |
| `data-hidden` | Hidden in preview and render; reversible |
| `data-no-timeline` | On the root of a still composition: no timeline to wait for |
## Variables
- Declare an array of `{id, type, label, default}`: the video's own on `index.html`'s `<html>`, a scene or block file's on that file's `<html>` (or root element), where they stay inside each mount. Types: `string` (`placeholder`, `maxLength`), `number` (`min`, `max`, `step`, `unit`), `color`, `boolean`, `enum` (`options: [{value, label}]`).
- Bind without script: `data-var-text="id"`, `data-var-src="id"` (keep a real fallback `src` on media with audio), `var(--id)` in CSS; for logic, `window.__hyperframes.getVariables()` once at init.
- Values are an object keyed by id. A host's `data-variable-values` sets one mount; Studio's Variables panel and batch renders set only the root's variables, so to vary a block, change its host's values. Variables can't change the root's `data-duration`.
## Fonts
- Bundled, so they resolve by name: Inter, Montserrat, Outfit, Nunito, Oswald, League Gothic, Archivo Black, Space Mono, IBM Plex Mono, JetBrains Mono, EB Garamond, Playfair Display, Source Code Pro, Noto Sans JP, Roboto, Open Sans, Lato, Poppins. Common system fonts (Helvetica, Arial, Times, Menlo and the like) are swapped for the nearest of these.
- Any other family needs `@font-face` in the file that uses it, or lint fails `font_family_without_font_face`: `src: url('assets/fonts/x.woff2')` for a file in the project (a capture's own fonts, or one you download there), or `local('Exact Name')` for a font installed on this machine.
## Timing and the timeline
- A clip shows over `[start, start + duration)`: an end state landing exactly on its end is never rendered.
- Only a composition's root timeline is paused; timelines nested inside it are not. Never `tl.play()`.
- A scene's clock starts at 0 at its host's `data-start`; media `data-start` is local to its composition. `<video>` / `<audio>` without `data-duration` play their full length; an `<img>` without one lasts 3 s.
- A host's `data-duration` is the scene's slot: a shorter scene timeline holds its last frame, and a slot shorter than the video goes blank when it ends.
- Root children with `data-start` are laid out full-frame by the runtime; an untimed full-bleed background needs `position: absolute; inset: 0` or it collapses to nothing.
- A fill on the composition root is dropped when the video has shader transitions or HDR media; put it on a full-bleed child.
- `data-start="intro + 0.5"` starts after `intro` ends (spaces required). A typo, a missing id or a cycle silently resolves to 0, so snapshot what you time this way.
- A clip nested inside another timed element counts its `data-start` from the composition, not from that element, and shows only while that element shows. `timeline` lists it under its parent and warns `never visible` when the windows miss (`lint`: `nested_clip_never_visible`).
- `npx hyperframes timeline --json` lists every clip by kind with `absStart`, `absEnd`, `file`, `src`, `playbackRate`, `nested`, `host` and `durationSource`: read it instead of opening every file. It reads one level of nesting (a deeper scene shows `children=unread`); `laneError` means a `data-automation` or `data-fx-chain` that doesn't parse.
## Adding media
Write what a Studio drop writes, so the clip behaves like one the person dropped:
- `id`, `class="clip"`, `data-start` (the playhead or the requested time, never a silent 0) and `data-track-index`. Video with sound: `playsinline data-has-audio="true"`; silent footage: `muted playsinline`. Audio: `data-volume="1"`.
- Images and video fill the frame: `position: absolute; left: 0; top: 0`, width and height equal to the canvas, `object-fit: contain`.
- `z-index` is the number of top-level clips already in that file plus one.
- Raise the root's `data-duration` to cover the new clip's end, or it never plays.
## Linked clips
Studio's Detach audio writes a muted `<video>` and an `<audio>` of the same file sharing `data-link` and `data-sync-origin`; Studio moves, trims, splits and deletes them as one.
- Prefer one `<video data-has-audio="true">`; link only when the sound needs its own track, volume or effects.
- Every member keeps the same `data-start`, `data-duration`, `data-media-start` and `data-playback-rate`; retime them all together.
- Unlink by removing `data-link` from every member. Leave `data-sync-origin` alone while they are still one source.
## Layout audit opt-outs
`check` measures boxes at sampled times; `overflow: hidden` does not silence a finding. Use the narrowest opt-out:
| Attribute | Silences |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `data-layout-allow-overflow` | Overflow on the element and every descendant, including text-clipping checks: keep it on the smallest decorative wrapper |
| `data-layout-bleed="true"` | One intentional crop of primary text |
| `data-layout-allow-overlap` | Deliberate text layering, on that block only (not inherited) |
| `data-layout-allow-occlusion` | An element meant to cover text |
| `data-layout-allow-caption-zone` | Intentional copy in the caption band |
| `data-layout-ignore` | A decorative element never audited |
## Lint errors you'll meet first
| Rule | Fix |
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ |
| `video_missing_muted` | A timed `<video>` gets `data-has-audio="true"` or `muted` |
| `gsap_css_transform_conflict` | Set the start inside the tween (`gsap.fromTo`) instead of a CSS `transform` |
| `gsap_animates_clip_element` | Animate a child of the `.clip`, or its `opacity` |
| `video_nested_in_timed_element` | Time the `<video>` or its wrapper, not both |
| `media_missing_id`, `media_crossorigin_breaks_preview` | Give every `<audio>` an `id`; drop `crossorigin` |
| `gsap_timeline_registered_before_async_build` | Register the timeline at the end of the async callback |
| `standalone_composition_wrapped_in_template` | Only scene files use `<template>` |
| `subcomposition_root_styled_by_class` | Style a scene's root as `#root` |
| `subcomposition_blanks_before_host` | Make the scene's slot span the time it should show |
| `clip_ends_past_root_duration` | Raise the root's `data-duration` |
| `linked_clips_out_of_sync`, `linked_clip_orphan` | Retime every linked member together; unlink from all of them |
| `sweep_static` | A 3 s+ video with no visible change: spread the motion, or mark a still composition `data-no-timeline` |
| `missing_data_no_timeline` | A root or host with no timeline: add `data-no-timeline`, or every render waits 45 s |
@@ -1,69 +0,0 @@
# The look
Left alone, the same brief comes out with the same look run after run, and banning that look only moves it to the next default. Outside input breaks it, in this order: the project's design spec, a real brand, a reference image or film the person gave you, the subject's own world. Settle it before the first scene, as numbers in the LOOK line (below).
## The project's design spec
Read the first of `frame.md`, `design.md`, `DESIGN.md` at the project root that exists; ignore the rest.
- **Frontmatter is normative**: `colors`, `typography`, `spacing`, `components`. Quote hex values, families and weights exactly; never round or invent them.
- **The prose is intent**: what the look is for, Do's and Don'ts. Follow it for judgment, not values.
- **Brand, not layout**: hex, fonts, weight relationships and Do's and Don'ts are strict; type sizes, spacing, border weight and decoration are yours to scale for video. A 1px border or a 6% shadow from a web page vanishes on screen.
## From a brand
- A website: `npx hyperframes capture <url> --json` writes screenshots, the copy (`extracted/visible-text.txt`), images, logos, `extracted/tokens.json` (colours, fonts) and `extracted/design-styles.json` under its output folder. Look at the screenshots before choosing anything. A capture that exits non-zero, prints `ok: false` or writes `BLOCKED.md` failed: take no brand data from its partial files.
- Figma: `npx hyperframes figma tokens <fileKey>` (`/figma`).
- Images the person attached: take palette and type from what you see.
Use the brand's own fonts when the capture shipped their files or they are bundled (`format.md`); otherwise download the family's `.woff2` into `assets/fonts/` with an `@font-face`, or pick the closest bundled family and say so.
## From a reference
A reference film or image gives its grammar, never its content: shot lengths, how titles enter and leave, the transitions, the palette roles, the type's scale and weight. Snapshot it next to yours (`snapshot --at <t,…> --against ref.mp4`) and write the grammar down, marking what you measured apart from what you guessed.
## Presets
The presets are slide-deck looks: use one when the person names it, or for a deck (`/slideshow`), never as the fallback for a film without a brand. Each folder in `../assets/frame-presets/` holds a `FRAME.md` spec, a `caption-skin.html` and a `frame-showcase.html` contact sheet. To use one, copy its `FRAME.md` to the project root as `frame.md`, then read it as above; its fonts that aren't bundled need `@font-face` files. The two HTML files are reference code: read them for treatments, never mount them.
| Preset | Look |
| ------------------- | ---------------------------------------------------------------------------------------------- |
| `biennale-yellow` | Museum catalogue: parchment, deep indigo ink, solar-yellow blooms, Instrument Serif, hairlines |
| `blockframe` | Loud neobrutalist: 4px black borders, hard shadows, candy pastels, Inter 900 caps |
| `blue-professional` | Consulting restraint: cream canvas, one cobalt accent, Space Grotesk + Inter, tinted cards |
| `bold-poster` | Vintage editorial poster: Shrikhand tilted display, Baskerville, tomato red, double borders |
| `broadside` | Protest poster: ink-black and fire-orange planes, giant lowercase Barlow 900, mono chrome |
| `capsule` | Friendly editorial: every container a pill, cream, nine candy accents, Bodoni Moda |
| `cartesian` | Quiet catalogue: warm-stone palette, Playfair + Inter, taupe hairline grid, compass rings |
| `cobalt-grid` | Two-colour risograph: cream paper, cobalt ink, graph-paper grid, Newsreader serif |
| `code-editorial` | Developer brand book: warm paper, scarce terracotta, EB Garamond, navy code surface |
| `coral` | Bold magazine: coral, ink and cream planes at hard edges, Bebas Neue, giant numerals |
| `creative-mode` | Sparse neobrutalist: cream, 4px ink borders, Archivo Black caps, rationed accents |
| `daisy-days` | Picture book: charcoal outlines, sunny pastels, Fredoka One, hand-drawn ornaments |
| `editorial-forest` | Literary: green, pink and cream, Source Serif display, mono chrome, flat paper |
## The LOOK line
With no design spec, the look is the LOOK line of `SPEC.md` (`spec.md`), taken from the brand, the reference or the subject itself: its era, its materials, where it would be shown. Every part is a number (hex, px, %, a named face and weight), the ground and the hero come from a medium rather than from effects, and the BANNED line keeps it off the defaults. Inter, Space Grotesk, Instrument Serif, Geist and JetBrains Mono are where generated design lands; use them when the brand does, not by default. Copy the palette and the faces into the root variables below.
## Video facts
- Full-screen linear gradients on dark grounds band under H.264; use a radial gradient, a solid fill or a solid plus a local glow.
- An accent at 5% opacity disappears in compression: 15–25% for atmosphere, full saturation for the focal element.
## Make it re-brandable
Declare palette and fonts as variables on the root `<html>` and use them everywhere, so the person can re-brand from Studio's Variables panel:
```html
<html
data-composition-variables='[
{"id":"color-ground","type":"color","label":"Background","default":"<ground from the plan>"},
{"id":"color-ink","type":"color","label":"Text","default":"<ink from the plan>"},
{"id":"color-accent","type":"color","label":"Accent","default":"<accent from the plan>"},
{"id":"font-display","type":"string","label":"Display font","default":"<display face from the plan>"}
]'
></html>
```
Reference them as `var(--color-accent)`; point a registry item's colour and font variables at the same values.
@@ -1,94 +0,0 @@
# Motion
## Motion craft
Measured frame by frame from the official HyperFrames launch films. These are defaults: a reference the person gave, or a Moment that does otherwise on purpose, wins for its own beat.
- **Moves**: a fast move takes 0.3–0.6 s and carries motion blur on its own ease, peaking at its fastest frame: 6–12 px on text-sized elements, 15–20 px full-frame, horizontal-only on a horizontal move. One move is one tween.
- **Arrive slow, leave clean**: long-tailed ease-outs on arrivals; landing takes longer than leaving. An exit is one smooth motion, never slide, stop, then leave. Entrances that are too snappy read as glitches; slowing the whole video by about a second often fixes it.
- **Ease by role**: an arrival lands on a long tail (`expo.out`, `power4.out`), a camera move or a colour flood eases both ways (`power2.inOut`), a zoom through or an exit accelerates (`power3.in`). The same `power2.out` at 0.5 s on everything is the flat default that makes a film feel generated. Linear only for rotation, progress and constant speed that is the point.
- **Springs** are named by damping ratio: 1.0 never bounces, 0.7-0.85 reads premium, 0.4-0.6 playful, under 0.3 a cartoon. Position and size may bounce; opacity and colour never do. GSAP's `elastic.out(1, 0.5)` and `back.out(1.4)` are the nearest built-ins; a closed-form spring of `t` keeps any frame renderable on its own.
- **Exits** are 40-50% shorter than their entrances. **Overlap**: the next element starts when the previous is 30-50% done; fully sequential looks robotic, all at once looks cheap. **Stagger**: letters a frame apart, words 60-120 ms, list items 40-80 ms.
- **Beat grid**: frames per beat = fps × 60 / BPM; compute each beat from its index and round it, never add a rounded beat repeatedly (at 128 BPM and 30 fps that drifts 2 frames in 32 beats). Cut on the beat, change the layout on the bar.
- **Cuts**: cut at the fastest frame of a move, with matching direction and speed on both sides. A click into a new screen is a match cut on the click's rebound; the new screen comes in slightly large and settles.
- **Titles**: a landed title is dead still; the motion moves to the next element, the camera or the world behind. Vary the entrance within a film, never a fade and slide-up for each:
- per word: `blur(6px)` → 0 and opacity 0 → 1 in 0.13 s, 0.067 s apart, no translate;
- per word from light grey to ink, 0.07–0.1 s apart, no position change;
- the whole line blurring in, 8 px → 0 in 0.13–0.2 s;
- per letter for a name, 0.1 s apart, opacity 0.3 → 1 with a slight blur;
- behind the subject: the title between the plate and a matte of the person;
- one word of the sentence rolling through options like a slot machine, about 0.1 s a roll.
- **Holds**: a line the viewer must read holds about 0.5 s plus 0.3 s a word, the film's last line at least 2 s; a one- or two-word chapter line may hold 0.4 s. Then exit with a blur-out and fade of 0.13–0.25 s, word by word, or with the scene's move.
- **Title size**: 4–9% of the frame height, one or two lines; quiet lines at 2.5–4% against hero words that fill 60–90% of the width. One highlighted word in the accent colour, which carries meaning.
- **Rhythm**: contrast speeds. Montages cut every 0.13–0.8 s; a demo holds one framing for 7–10 s while the UI acts; shots average 1.8–3 s with motion inside them. Hold 0.3–0.75 s before the payoff, which plays at normal speed.
- **Continuous motion that works**: the product's UI working, footage playing, a character acting, a 3D world turning, a slow orbit of a background stage (3–5° a second).
- **Defaults that look generated**: breathing, floating or pulsing loops standing in for motion on titles and UI; titles that keep sliding or scaling after they land; a slow creeping scale on a title or a slow push late in a scene; bounce on UI; click rings; every scene entering the same way; every element fading and sliding up. A character's idle and overshoot are acting, not these (`types/character.md`).
## Transitions with a cause
Use 3–5 kinds in a film, each caused by something on screen. Hard cuts belong inside a scene (punch-ins); at most a third of the seams between beats are hard cuts, and worlds never crossfade into each other. Measured on the official films:
| Transition | How it is built |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Zoom through a button | the camera tracks to the button with blur (0.6 s), then the button scales from ~8% to ~250% of the frame in 0.35 s, strong ease-in; its fill becomes the next frame |
| Object becomes the world | a pill or card grows past the frame in 0.65 s, ease-in-out, its radius shrinking; what was around it blurs ~15 px and fades in 0.3 s; the new content fades in while it still grows |
| Morph | one container widens into the next (icon card → prompt box, ~110 → ~880 px in 0.2 s `power3.out`) with a horizontal blur 12 → 0 |
| Carry across | the result shrinks 1 → 0.6 with blur peaking at 6 px mid-move and lands inside the next app as it builds around it |
| Colour flood | the hero object stays while the whole ground changes colour in ~0.4 s `power2.inOut` |
| Whip | a horizontal or vertical smear of 0.13–0.3 s; the arriving element lands blurred and sharpens in ~0.13 s |
| Iris from the click | `clip-path: circle()` opens from the clicked point onto the next world in 0.15 s |
| Dip cut | the outgoing frame darkens ~40% for 3 frames before a hard cut, so the cut reads as meant |
| Blur swap on one stage | out: opacity → 0 with ~8 px blur in 0.13–0.2 s; in: blur → 0 in 0.13 s; the ground never cuts |
Registry versions: `npx hyperframes catalog --tag transition --json`.
| The person says | Change |
| ---------------------- | ------------------------------------------------------------------------------ |
| "too fast" | Slow every zoom and pan to 0.7× of its speed, nothing else |
| "speed this up" | Speed the content and shorten the beat by the same amount; later beats move up |
| "the titles move" | Land them, then hold them dead still until they leave |
| "it feels like slides" | Enter the next scene through an object on screen; cut a move mid-flight |
## The right mechanism
| Request | Mechanism |
| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| Punch-in, zoom, pan, Ken Burns, reframe | Tween `scale` and `x`/`y` on a wrapper inside the clip, never on the timed media element; chain the states on one timeline |
| Crop or mask reveal | Tween `clip-path` or a mask on an inner wrapper; source time is untouched |
| Constant speed change | `data-playback-rate` on the media, 0.1–10 (picture and pitch-preserved sound) |
| Speed ramp | A `rate` lane in `data-automation` on the media (`t` in clip seconds, `v` 0.1–10); it overrides the constant rate |
| Hold or freeze | Hold a final frame or a finished scene; a freeze mid-source needs a still exported first and placed as its own clip |
Ready-made moves: `npx hyperframes catalog --tag camera --json`, or `catalog --query "<the move>" --tag motion-primitive --json`.
## Seeing motion
The whole-film pass (beat sheet, cuts, phone width, the craft warnings) is `review.md`. For one move:
- `npx hyperframes keyframes --json [--selector '#id'] [--runtime all]` lists keyframes and motion paths (GSAP, CSS, anime.js): property, time, value, ease.
- `npx hyperframes keyframes --shot motion.png --selector '#id'` draws the real element's samples over time with its path (9 samples; `--samples N`). `--layout strip` for motion in place, `--from`/`--to` for one stretch, `--angle iso|top|side|'yaw,pitch'` for 3D, `--ghost` for a `<canvas>`.
- `npx hyperframes snapshot --range 2,4 --frames 8` samples one stretch; `--composition <id>` samples one scene; `--at <t,…>` exact instants; `--zoom '#id'` a sharp crop; `--angle iso` depth and overlap.
- Against a reference video: `snapshot --at <t,…> --against ref.mp4` writes a side-by-side sheet.
- Assert intent instead of eyeballing it: a `<name>.motion.json` next to the composition makes `check` verify it on the seeked timeline, as errors (`motion_appears_late`, `motion_out_of_order`, `motion_off_frame`, `motion_frozen`):
```json
{
"assertions": [
{ "kind": "appearsBy", "selector": "#headline", "bySec": 0.5 },
{ "kind": "before", "a": "#headline", "b": "#cta" },
{ "kind": "staysInFrame", "selector": ".card" },
{ "kind": "keepsMoving", "withinSelector": ".scene" }
]
}
```
## Common faults
| Symptom | Usual cause |
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| Blank or frozen scene | Timeline registered before an async build finished, `visibility` tweened on a `.clip`, or a host id that differs from its scene's timeline key |
| Stutter or drift between renders | Layout measured in `onUpdate`, `requestAnimationFrame` or clock time; two timelines on one property |
| Jump at a cut | Outgoing and incoming states differ at the seam: snapshot just before and after it |
| Ease looks off at a handoff | Speeds don't match across the seam: mirror the ease (`power3.in` out, `power3.out` in) |
Studio's keyframe diamonds and motion-path handles rewrite the GSAP script in the composition file, so the person may have changed motion since you wrote it.
@@ -1,39 +0,0 @@
# Cards over talking-head footage
Designed cards, lower thirds and split layouts over a person speaking. Placement numbers are for 1920×1080 and 1080×1920; scale others.
## Where the video sits
| Layout | Landscape (x, y, w×h) | Portrait (x, y, w×h) |
| ---------------------------- | --------------------- | -------------------- |
| Split, video beside the card | 960, 0, 960×1080 | 0, 960, 1080×960 |
| Stack, video above | 14, 14, 1892×548 | 0, 0, 1080×844 |
| Overlay | full frame | full frame |
Picture-in-picture, 28 px from the edges (bottom-right: y = H − h − 28):
| Canvas | Source | Rectangle |
| --------- | ------ | ----------------- |
| Landscape | 16:9 | 1432, 28, 460×258 |
| Landscape | 9:16 | 1644, 28, 248×440 |
| Portrait | 16:9 | 690, 28, 360×203 |
| Portrait | 9:16 | 738, 28, 312×555 |
- Card zones: a lower third is the bottom 30%; a side panel the right 42% (landscape) or bottom 40% (portrait). Glass card slots: 48, 460, 660×540 landscape; 24, 1280, 1032×564 portrait.
- Portrait type is landscape × 1.3 (titles × 1.35, stats × 1.4); padding drops from 40–64 px to 24–36 px.
- Size a rectangle to its source's aspect: `object-fit: cover` crops a portrait source in a landscape box.
## Timing
- Cards fade in over 0.4 s (`power2.out`) and out over 0.35 s (`power2.in`). Re-frame the video (0.5–0.7 s) only in a gap between cards; move it with transforms on a wrapper, not `left`/`top`/`width`/`height`.
- A static card goes dead after about 8 s; past about 15 s it needs a staged reveal. As a starting density: one card per 6–8 s under a minute, 8–12 s up to 3 min, 12–20 s up to 10 min.
- A transcript's last word can end past the media: clamp card ends and the root `data-duration` to the file's ffprobe length, or the render ends in black.
## Pitfalls
- Lower-third, overlay and picture-in-picture scenes need a transparent root, or they hide the speaker.
- Glass over bright footage goes white on white: put a dark scrim behind it (about `rgba(0,0,0,.55)` fading out from the top, `.72` at the bottom) and a dark glass base such as `rgba(15,16,22,.78)` with `backdrop-filter: blur(22px)`.
- More than 3 timed elements on one track warns `timeline_track_too_dense`: give each chapter its own sub-composition.
- Footage with keyframes more than about 1 s apart freezes on seek: re-encode with `-g <fps>`.
Text behind the speaker (a caption between the footage and a cutout of the person): `captions.md`.
Loaded 100 of 375 files, more files were not shown because too many files have changed in this diff. Show more