refactor: bundle the whole template suite under flat-artist/ + fix references

flat-artist is now the single container: all 16 template skills + the R&D
references/ moved inside flat-artist/. Cross-references updated — the orchestrator
points to bundled `<name>/SKILL.md`, sub-skills point to `../<name>/SKILL.md`,
and the R&D report path is relative. README catalog updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Soroush Asadi
2026-06-21 19:31:53 +03:30
parent bc778952ba
commit 4ffbcac9ee
19 changed files with 73 additions and 69 deletions
+95
View File
@@ -0,0 +1,95 @@
---
name: kinetic-typography
description: How to build animated-text systems for FlatRender Remotion templates — word/line/char reveals, mask wipes, typewriter, scale-pops, highlight sweeps, text-on-path, and number counters — Persian/RTL-aware and reusable. Use whenever a template's hero, caption, quote, title, price, or any text is the thing that moves. Persian is the priority; split by WORD, never by character.
---
# Kinetic typography (animated text systems)
Type is a first-class motion element here, not a label. A masterpiece text shot is ~5 layers: the right split, eased per-unit timing, a hold sized to a real read, legibility over the background, and a single hero word. Amateurs stop at "the text fades in."
## The one rule
Every value is a pure function of `useCurrentFrame()`. **Never** `useFrame`, `Math.random()`, `Date.now()`, `setState`, or `useEffect`-driven motion — the headless renderer samples frames out of order. For "random" jitter use `rand(seed)` from `lib/anim.ts`. Drive timing off `useVideoConfig().fps`; define `const sec = (s: number) => Math.round(s * fps)` — never hardcode `30`.
## Persian / RTL — get this right first (it's an Iran-facing product)
- **Split by WORD, not character.** Persian script is connected/cursive — splitting on chars shatters letterforms and joins. Latin char-reveals are fine; Persian is word- or line-only. A safe split is `text.split(/\s+/).filter(Boolean)` — this **preserves ZWNJ** (نیم‌فاصله, ``) inside words like «می‌شود» because ZWNJ is not whitespace. Never `.split("")` or `.replace(//g, …)` on Persian.
- Every text node: `fontFamily: FONT` (Vazirmatn, from `lib/fonts.ts`), `direction: "rtl"`, align right or center. The existing `KineticQuote.tsx` hardcodes Georgia/serif + pixel sizes + no RTL — **do not copy that**; it's a Latin-only relic.
- Persian needs weight (headings 700900) and `lineHeight: 1.41.6`. Numerals: pick Persian (۱۲۳ via `toLocaleString('fa-IR')`) or Latin and stay consistent; prices/years are usually Persian digits. See `../persian-fonts/SKILL.md`.
- For RTL word reveals, the wrapping container does the ordering — keep `flexWrap: "wrap"` + `direction: "rtl"` and let words flow; don't manually reverse the array.
## Size & position from layout tokens, never pixels
Read `useLayout()` from `lib/aspect.ts`: `vmin(n)`, `unit`, `isWide/isSquare/isTall`. Hero type ≈ `vmin(80110)`, body ≈ `vmin(2840)`. Tune timing/scale per aspect — wider reads faster (tighter stagger), tall reads slower (looser). Add this `pick` helper to `Layout` (per R&D Tier-0) and use it:
```ts
const pick = <T,>(w: T, s: T, t: T) => (L.isWide ? w : L.isSquare ? s : t);
const stagger = pick(2, 3, 4); // frames between units
```
## Animation patterns (all driven by `frame - start`)
| Pattern | Recipe | Persian-safe? |
|---|---|---|
| **Word reveal** (default) | split words; per word `start = i*stagger`; `spring({frame: frame-start, fps})``translateY(vmin)` + `opacity` | ✅ word-split |
| **Line reveal** | wrap by line in `<Sequence>`s; each line springs up behind a `clip-path` edge | ✅ |
| **Char reveal / scatter** | split chars, per-char delay; rotate/scale in | ❌ Latin only |
| **Mask wipe** | `clipPath: inset(0 ${100-p}% 0 0)` (RTL: wipe from right → `0 0 0 ${100-p}%`); `p = interpolate(frame,[a,b],[0,100],{extrapolateRight:"clamp", easing: Easing.out(Easing.cubic)})` | ✅ |
| **Typewriter** | `text.slice(0, Math.floor(interpolate(frame,[a,b],[0, words.length])))` joined — **slice by WORD for Persian**, by char only for Latin; add a blinking caret `frame % sec(0.8) < sec(0.4)` | ✅ word-slice |
| **Scale-pop ("ta-da")** | `scale = spring({config:{damping:12,mass:0.6,stiffness:180}})` or `Easing.bezier(0.34,1.56,0.64,1)` overshoot→settle | ✅ |
| **Highlight sweep** | gradient bar/`background-clip:text` shifting `background-position` per frame, or an accent rect growing under a key word | ✅ |
| **Text-on-path** | SVG `<textPath href="#p">`; animate `startOffset` by frame — Latin/numeric only (RTL on a path is unreliable) | ❌ |
| **Number counter** | `Math.round(interpolate(frame,[a,b],[0, target],{extrapolateRight:"clamp", easing: Easing.out(Easing.cubic)}))` then `toLocaleString('fa-IR')` | ✅ (format fa) |
| **Variable-weight pulse** | Vazirmatn ships a variable axis: `fontVariationSettings: \`'wght' ${interpolate(frame,[a,b],[300,900])}\`` (needs the variable woff2 registered in `fonts.ts`) | ✅ |
### Reusable word-reveal component (the workhorse — Persian-correct, aspect-aware)
```tsx
const RevealText: React.FC<{ text: string; start: number; color: string }> = ({ text, start, color }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const L = useLayout();
const pick = <T,>(w: T, s: T, t: T) => (L.isWide ? w : L.isSquare ? s : t);
const stagger = pick(2, 3, 4);
const words = text.split(/\s+/).filter(Boolean); // keeps ZWNJ
return (
<div style={{ display: "flex", flexWrap: "wrap", justifyContent: "center",
direction: "rtl", fontFamily: FONT, fontWeight: 800, fontSize: L.vmin(96),
lineHeight: 1.4, color, gap: `0 ${L.vmin(18)}px`, maxWidth: "86%",
textShadow: `0 ${L.vmin(2)}px ${L.vmin(20)}px rgba(0,0,0,.6)` }}>
{words.map((w, i) => {
const s = spring({ frame: frame - start - i * stagger, fps,
config: { damping: 16, mass: 0.7, stiffness: 120 } });
return (
<span key={i} style={{ display: "inline-block", opacity: s,
transform: `translateY(${interpolate(s, [0, 1], [L.vmin(28), 0])}px)` }}>
{w}
</span>
);
})}
</div>
);
};
```
Follow-through upgrade: give a trailing accent word a *looser* spring (`damping: 6`) so it settles last.
## Easing & spring (linear is the sound of an amateur)
- Entrances → **ease-out** default (`Easing.out(Easing.cubic)`); hero titles → `Easing.bezier(0.16,1,0.3,1)`. Exits → **ease-in, sharper than the entrance**. Snappy pop → back bezier `(0.34,1.56,0.64,1)`.
- `interpolate` for exact marks — **always `extrapolateLeft/Right: "clamp"`** (forgetting it is the #1 drift bug). `spring` for organic feel. Combine: `interpolate(spring(...), [0,1], [vmin(28), 0])`.
- Spring cheats: clean reveal `{damping:200,mass:0.5,stiffness:200}` · default pop `{damping:12,mass:0.6,stiffness:180}` · bouncy `{damping:8,mass:1,stiffness:120}` · trailing wobble `{damping:6,mass:1,stiffness:80}`.
## Timing budgets (@ whatever `fps` is)
Micro pop 814f · word stagger 24f · standard reveal 1828f · hero entrance 2840f · **hold = a comfortable read** (≥ `sec(0.7)` per text element before the next competes). Cut frames before adding them — over-animating reads as amateur. Anticipation: dip below start before launch (`interpolate(frame,[0,6,30],[0,-0.12,1])`).
## Legibility over busy / 3D / video backgrounds
- Scrim or `textShadow: 0 0 vmin(20) rgba(0,0,0,.7)`, or a semi-transparent panel behind text.
- Gradient text: `WebkitBackgroundClip: "text"`, transparent fill, plus a `drop-shadow` for edge separation.
- Colors come from `colorSchema` props (`accentColor/secondaryColor/backgroundColor/textColor` via `lib/branding.ts`) — pass user hex through `mixHex`/`hexToRgba` so a garish value doesn't break the look. Never hardcode `#fff`.
- Captions (TikTok/Reels/Shorts) = high-contrast white/yellow + black outline, lower-middle third, inside the tightest safe zone. See `../remotion-aspect-ratios/SKILL.md`.
## Checklist
- [ ] Persian text split by WORD; ZWNJ preserved; `direction:"rtl"` + `fontFamily: FONT`.
- [ ] All sizes via `vmin`/`unit`; timing/stagger via `pick(...)` per aspect — verified in 16:9, 1:1, 9:16.
- [ ] No linear easing; ≥1 overshoot-and-settle; staggered, not all on frame 0.
- [ ] Every `interpolate` clamps both ends; no `useFrame`/`random`/`Date.now`; `fps` not `30`.
- [ ] Numbers formatted (`fa-IR`) and consistent; counter eases out.
- [ ] Legible over the background (scrim/shadow); colors from props.
- [ ] A real hold sized to reading; longest Persian string doesn't overflow, shortest doesn't look empty.
- [ ] Re-render twice → identical pixels (deterministic).
Related: `../remotion-template-composition/SKILL.md`, `../persian-fonts/SKILL.md`, `../remotion-aspect-ratios/SKILL.md`, `../remotion-design-styles/SKILL.md`, `../remotion-svg-colors/SKILL.md`, `../remotion-sound-effects/SKILL.md`, `../remotion-music-picker/SKILL.md`, `../flatrender-template-seo/SKILL.md`.