feat(skills): add typography and motion principles, fix validate $& bug (#228)

Add two new skill reference files that address measured LLM composition failures:

- fonts.md: Typography principles — banned fonts, guardrails for violations
  (pairing two sans-serifs, defaulting to 400/700 weight), and guidance the LLM
  genuinely doesn't apply without being told (register switching, tension as
  meaning, easing direction as emotion). Includes Google Fonts API discovery
  script with 7-category multi-strategy query.

- motion-principles.md: Motion design principles — guardrails for same-ease and
  same-speed defaults, y-axis entrance monotony, and guidance for build/breathe/
  resolve scene structure, hard cuts as intentional transitions, visual
  composition rules for video-not-web density.

Both files validated against baseline evals: 3 compositions created without
guidance confirmed the LLM reaches for banned fonts (Inter, Cormorant Garamond,
Playfair Display, Roboto Condensed), uses power2.out on 45-72% of tweens,
enters 80%+ of elements from y-axis, and pairs multiple sans-serifs.

Also:
- Fix validate.ts $& replacement bug (runtime source containing $& caused
  String.prototype.replace to re-insert the matched <script src=""> tag)
- Clean up font loading guidance across skills (compiler embeds automatically)
- Update house-style.md to reference fonts.md

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-04-08 20:49:46 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent fc973ee2e8
commit 4c5b8e38a1
8 changed files with 214 additions and 10 deletions
+1 -1
View File
@@ -129,4 +129,4 @@ tl.seek(0);
- Sync to transcript timestamps.
- One group visible at a time.
- Every group must have a hard `tl.set` kill at `group.end`.
- Check project root for font files before defaulting to Google Fonts.
- The compiler embeds supported fonts automatically — just declare `font-family` in CSS.
+1 -1
View File
@@ -49,7 +49,7 @@
style="
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
font-family: 'Inter', sans-serif; font-size: 72px; color: #fff;
font-family: 'Bricolage Grotesque', sans-serif; font-size: 72px; color: #fff;
background: #111;
"
>
+134
View File
@@ -0,0 +1,134 @@
# Typography
The compiler embeds supported fonts — just write `font-family` in CSS.
## Banned
Inter, Roboto, Open Sans, Noto Sans, Arimo, Lato, Source Sans, PT Sans, Nunito, Poppins, Outfit, Sora, Playfair Display, Cormorant Garamond, Bodoni Moda, EB Garamond, Cinzel, Prata
## Guardrails
You know these rules but you violate them. Stop.
- **Don't pair two sans-serifs.** You do this constantly — one for headlines, one for body. Cross the boundary: serif + sans, or sans + mono.
- **One expressive font per scene.** You pick two interesting fonts trying to make it "better." One performs, one recedes.
- **Weight contrast must be extreme.** You default to 400 vs 700. Video needs 300 vs 900. The difference must be visible in motion at a glance.
- **Video sizes, not web sizes.** Body: 20px minimum. Headlines: 60px+. Data labels: 16px. You will try to use 14px. Don't.
## What You Don't Do Without Being Told
- **Tension should mean something.** Don't pattern-match pairings. Ask WHY these two fonts disagree. The pairing should embody the content's contradiction — mechanical vs human, public vs private, institutional vs personal. If you can't articulate the tension, it's arbitrary.
- **Register switching.** Assign different fonts to different communicative modes — one voice for statements, another for data, another for attribution. Not hierarchy on a page. Voices in a conversation.
- **Tension can live inside a single font.** A font that looks familiar but is secretly strange creates tension with the viewer's expectations, not with another font.
- **One variable changed = dramatic contrast.** Same letterforms, monospaced vs proportional. Same family at different optical sizes. Changing only rhythm while everything else stays constant.
- **Double personality works.** Two expressive fonts can coexist if they share an attitude (both irreverent, both precise) even when their forms are completely different.
- **Time is hierarchy.** The first element to appear is the most important. In video, sequence replaces position.
- **Motion is typography.** How a word enters carries as much meaning as the font. A 0.1s slam vs a 2s fade — same font, completely different message.
- **Fixed reading time.** 3 seconds on screen = must be readable in 2. Fewer words, larger type.
- **Tracking tighter than web.** -0.03em to -0.05em on display sizes. Video encoding compresses letter detail.
## Finding Fonts
Don't default to what you know. If the content is luxury, a grotesque sans might create more tension than the expected Didone serif. Decide the register first, then search.
Save this script to `/tmp/fontquery.py` and run with `curl -s 'https://fonts.google.com/metadata/fonts' > /tmp/gfonts.json && python3 /tmp/fontquery.py /tmp/gfonts.json`:
```python
import json, sys
from collections import OrderedDict
with open(sys.argv[1]) as f:
data = json.load(f)
fonts = data.get("familyMetadataList", [])
ban = {"Inter","Roboto","Open Sans","Noto Sans","Lato","Poppins","Source Sans 3",
"PT Sans","Nunito","Outfit","Sora","Playfair Display","Cormorant Garamond",
"Bodoni Moda","EB Garamond","Cinzel","Prata","Arimo","Source Sans Pro"}
skip_pfx = ("Roboto","Noto ","Google Sans","Bpmf","Playwrite","Anek","BIZ ",
"Nanum","Shippori","Sawarabi","Zen ","Kaisei","Kiwi ","Yuji ","Radio ")
def ok(f):
if f["family"] in ban: return False
if any(f["family"].startswith(b) for b in skip_pfx): return False
if "latin" not in (f.get("subsets") or []): return False
return True
seen = set()
R = OrderedDict()
# Trending Sans — recent (2022+), popular (<300)
R["Trending Sans"] = []
for f in fonts:
if not ok(f) or f["family"] in seen: continue
if f.get("category") in ("Sans Serif","Display") and f.get("dateAdded","") >= "2022-01-01" and f.get("popularity",9999) < 300:
R["Trending Sans"].append(f); seen.add(f["family"])
# Trending Serif — recent (2018+), popular (<600)
R["Trending Serif"] = []
for f in fonts:
if not ok(f) or f["family"] in seen: continue
if f.get("category") == "Serif" and f.get("dateAdded","") >= "2018-01-01" and f.get("popularity",9999) < 600:
R["Trending Serif"].append(f); seen.add(f["family"])
# Monospace — recent (2018+), popular (<600)
R["Monospace"] = []
for f in fonts:
if not ok(f) or f["family"] in seen: continue
if f.get("category") == "Monospace" and f.get("dateAdded","") >= "2018-01-01" and f.get("popularity",9999) < 600:
R["Monospace"].append(f); seen.add(f["family"])
# Impact & Condensed — curated names + heavy display fonts
R["Impact & Condensed"] = []
impact = {"Bebas Neue","Archivo Black","Big Shoulders Display","Teko","League Gothic",
"Barlow Condensed","Staatliches","Anton","Oswald","Saira","Syne",
"Titillium Web","Alumni Sans","Advent Pro"}
for f in fonts:
if not ok(f) or f["family"] in seen: continue
is_impact = f["family"] in impact
is_heavy_display = ("Display" in (f.get("classifications") or [])
and any(k in list(f.get("fonts",{}).keys()) for k in ("800","900"))
and f.get("popularity",9999) < 400
and f.get("category") in ("Sans Serif","Display"))
if is_impact or is_heavy_display:
R["Impact & Condensed"].append(f); seen.add(f["family"])
# Bold Geometric Display — curated
R["Bold Geometric Display"] = []
for f in fonts:
if not ok(f) or f["family"] in seen: continue
if f["family"] in {"DM Serif Display","Abril Fatface","Righteous","Orbitron","Black Ops One"}:
R["Bold Geometric Display"].append(f); seen.add(f["family"])
# Script & Handwriting — popular (<300)
R["Script & Handwriting"] = []
for f in fonts:
if not ok(f) or f["family"] in seen: continue
if f.get("category") == "Handwriting" and f.get("popularity",9999) < 300:
R["Script & Handwriting"].append(f); seen.add(f["family"])
# Established Classics — good older fonts
R["Established Classics"] = []
classics = {"Josefin Sans","Raleway","Montserrat","Abel","Exo","Red Hat Display",
"Rubik","Alegreya","Arvo","Besley","Crimson Text","Fraunces",
"Lora","Merriweather","Vollkorn"}
for f in fonts:
if f["family"] in classics and f["family"] not in seen:
R["Established Classics"].append(f); seen.add(f["family"])
# Print
for cat in R:
R[cat].sort(key=lambda x: x.get("popularity",9999))
limits = {"Trending Sans":15,"Trending Serif":12,"Monospace":8,
"Impact & Condensed":12,"Bold Geometric Display":8,
"Script & Handwriting":10,"Established Classics":20}
for cat in R:
items = R[cat][:limits.get(cat,10)]
if not items: continue
print(f"--- {cat} ({len(items)}) ---")
for ff in items:
var = "VAR" if ff.get("axes") else " "
print(f' {ff.get("popularity"):4d} | {var} | {ff["family"]}')
print()
```
Seven categories: trending sans, trending serif, monospace, impact/condensed, bold geometric, script/handwriting, and established classics. Cross classification boundaries when pairing.
@@ -0,0 +1,69 @@
# Motion Principles
## Guardrails
You know these rules but you violate them. Stop.
- **Don't use the same ease on every tween.** You default to `power2.out` on everything. Vary eases like you vary font weights — no more than 2 independent tweens with the same ease in a scene.
- **Don't use the same speed on everything.** You default to 0.4-0.5s for everything. The slowest scene should be 3× slower than the fastest. Vary duration deliberately.
- **Don't enter everything from the same direction.** You default to `y: 30, opacity: 0` on every element. Vary: from left, from right, from scale, opacity-only, letter-spacing.
- **Don't use the same stagger on every scene.** Each scene needs its own rhythm.
- **Don't use ambient zoom on every scene.** Pick different ambient motion per scene: slow pan, subtle rotation, scale push, color shift, or nothing. Stillness after motion is powerful.
- **Don't start at t=0.** Offset the first animation 0.1-0.3s. Zero-delay feels like a jump cut.
## What You Don't Do Without Being Told
### Easing is emotion, not technique
The transition is the verb. The easing is the adverb. A slide-in with `expo.out` = confident. With `sine.inOut` = dreamy. With `elastic.out` = playful. Same motion, different meaning. Choose the adverb deliberately.
**Direction rules — these are not optional:**
- `.out` for elements entering. Starts fast, decelerates. Feels responsive. This is your default.
- `.in` for elements leaving. Starts slow, accelerates away. Throws them off.
- `.inOut` for elements moving between positions.
You get this backwards constantly. Ease-in for entrances feels sluggish. Ease-out for exits feels reluctant.
### Speed communicates weight
- Fast (0.15-0.3s) — energy, urgency, confidence
- Medium (0.3-0.5s) — professional, most content
- Slow (0.5-0.8s) — gravity, luxury, contemplation
- Very slow (0.8-2.0s) — cinematic, emotional, atmospheric
### Scene structure: build / breathe / resolve
Every scene has three phases. You dump everything in the build and leave nothing for breathe or resolve.
- **Build (0-30%)** — elements enter, staggered. Don't dump everything at once.
- **Breathe (30-70%)** — content visible, alive with ONE ambient motion.
- **Resolve (70-100%)** — exit or decisive end. Exits are faster than entrances.
### Transitions are meaning
- **Crossfade** = "this continues"
- **Hard cut** = "wake up" / disruption
- **Slow dissolve** = "drift with me"
You crossfade everything. Use hard cuts for disruption and register shifts.
### Choreography is hierarchy
The element that moves first is perceived as most important. Stagger in order of importance, not DOM order. Don't wait for completion — overlap entries. Total stagger sequence under 500ms regardless of item count.
### Asymmetry
Entrances need longer than exits. A card takes 0.4s to appear but 0.25s to disappear.
## Visual Composition
You build for the web. Video frames are not pages.
- **Two focal points minimum per scene.** The eye needs somewhere to travel. Never a single text block floating in empty space.
- **Fill the frame.** Hero text: 60-80% of width. You will try to use web-sized elements. Don't.
- **Three layers minimum per scene.** Background treatment (glow, oversized faded type, color panel). Foreground content. Accent elements (dividers, labels, data bars).
- **Background is not empty.** Radial glows, oversized faded type bleeding off-frame, subtle border panels, hairline rules. Pure solid #000 reads as "nothing loaded."
- **Anchor to edges.** Pin content to left/top or right/bottom. Centered-and-floating is a web pattern.
- **Split frames.** Data panel on the left, content on the right. Top bar with metadata, full-width below. Zone-based layouts, not centered stacks.
- **Use structural elements.** Rules, dividers, border panels. They create paths for the eye and animate well (scaleX from 0).
@@ -8,7 +8,7 @@ These cause real bugs if violated.
**Scene visibility:** Scene 1 visible by default (no `opacity: 0`). Scenes 2+ have `opacity: 0` on the CONTAINER div. GSAP reveals them. No visibility shim (`timedEls`).
**Iframe compatibility:** No external font links (`<link>` to Google Fonts, `@import`). They block sandboxed iframes. Use system fonts.
**Fonts:** Just write the `font-family` you want — the compiler embeds supported fonts automatically via `@font-face` with inline data URIs. No need for `<link>` tags or `@import`. Works in all contexts including sandboxed iframes.
**Element structure:** No `class="clip"` on scene divs in standalone compositions. Only the root div gets `data-composition-id`/`data-start`/`data-duration`.
@@ -57,7 +57,7 @@ Read [shader-setup.md](./shader-setup.md) for the full setup code these rules ap
height: 1080px;
overflow: hidden;
background: #000;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
font-family: "YOUR FONT", sans-serif; /* compiler embeds supported fonts automatically */
}
.scene {
position: absolute;