Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VU6qqzefQ2TxgZetJ7beKU
141 lines
6.3 KiB
Markdown
141 lines
6.3 KiB
Markdown
# Batch & Typography
|
||
|
||
How to feed many reviews into one card design, plus the quote-typography craft that separates a credible testimonial from a sloppy one.
|
||
|
||
## Review prop schema
|
||
|
||
One object per testimonial. Keep it flat so a CSV row maps to it directly.
|
||
|
||
```ts
|
||
type Review = {
|
||
quote: string; // verbatim — never paraphrased or trimmed mid-sentence
|
||
highlight?: string; // EXACT substring of `quote`; one phrase only
|
||
rating: number; // 0–max, halves allowed (4.5)
|
||
max?: number; // default 5
|
||
name: string; // required — a quote with no name is not social proof
|
||
role?: string;
|
||
company?: string;
|
||
avatar?: string; // real photo URL, or omit (never a stock face)
|
||
};
|
||
```
|
||
|
||
### Validation (fail loud, before rendering 50 videos)
|
||
|
||
```ts
|
||
function validate(r: Review): string[] {
|
||
const errs: string[] = [];
|
||
if (!r.quote?.trim()) errs.push("quote is empty");
|
||
if (!r.name?.trim()) errs.push("name is required (no anonymous testimonials)");
|
||
const max = r.max ?? 5;
|
||
if (r.rating < 0 || r.rating > max) errs.push(`rating ${r.rating} out of 0–${max}`);
|
||
if (r.highlight && !r.quote.includes(r.highlight))
|
||
errs.push(`highlight "${r.highlight}" is not a substring of the quote`);
|
||
return errs;
|
||
}
|
||
```
|
||
|
||
The `highlight ⊂ quote` check is the important one: it guarantees you only ever emphasize words the customer actually wrote. If the highlight isn't an exact substring, the emphasis silently disappears in the card — so block the render instead.
|
||
|
||
## CSV → props
|
||
|
||
Reviews usually arrive as a spreadsheet export. Map columns to the schema and coerce the rating.
|
||
|
||
```js
|
||
import { parse } from "csv-parse/sync";
|
||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||
|
||
// CSV columns: quote,highlight,rating,name,role,company,avatar
|
||
const rows = parse(readFileSync("reviews.csv"), { columns: true, trim: true });
|
||
mkdirSync("reviews", { recursive: true });
|
||
|
||
rows.forEach((row, i) => {
|
||
const r = {
|
||
quote: row.quote,
|
||
highlight: row.highlight || undefined,
|
||
rating: Number(row.rating),
|
||
name: row.name,
|
||
role: row.role || undefined,
|
||
company: row.company || undefined,
|
||
avatar: row.avatar || undefined,
|
||
};
|
||
const errs = validate(r);
|
||
if (errs.length) { console.error(`Row ${i + 1} (${r.name}):`, errs); process.exit(1); }
|
||
writeFileSync(`reviews/${String(i).padStart(3, "0")}-${slug(r.name)}.json`,
|
||
JSON.stringify(r, null, 2));
|
||
});
|
||
|
||
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||
```
|
||
|
||
## Batch render — one video per review
|
||
|
||
```bash
|
||
mkdir -p out
|
||
for f in reviews/*.json; do
|
||
name=$(basename "$f" .json)
|
||
npx remotion render Testimonial "out/$name.mp4" --props="$f"
|
||
done
|
||
```
|
||
|
||
Everything visual lives in the single `theme` object in the composition (colors, fonts, quote-mark glyph, spacing). Because only the props change per render, 50 testimonials stay pixel-for-pixel brand-locked while the words and score vary. To re-theme the whole set, edit one object and re-run the loop.
|
||
|
||
For multi-aspect (9:16 feed + 1:1 post + 16:9 site), register the composition at each size with a shared center-safe layout and loop over both `reviews/*.json` and the aspect list.
|
||
|
||
## Quote typography craft
|
||
|
||
Motion is the easy part; legible, well-set type is what makes the quote trustworthy.
|
||
|
||
### Smart quotes, always
|
||
|
||
Straight quotes (`"`) read as un-proofed. Convert to typographic quotes before rendering.
|
||
|
||
```js
|
||
const smarten = (s) => s
|
||
.replace(/(^|[\s([{])"/g, "$1“").replace(/"/g, "”") // “ ”
|
||
.replace(/(^|[\s([{])'/g, "$1‘").replace(/'/g, "’") // ‘ ’
|
||
.replace(/--/g, "—"); // em dash
|
||
```
|
||
|
||
### Hanging quotation marks
|
||
|
||
A leading quote glyph indents the first line and breaks the flush-left edge. Hang it into the margin so the text block stays optically aligned — the gold standard for set quotes.
|
||
|
||
```css
|
||
blockquote { hanging-punctuation: first; } /* Safari/WebKit */
|
||
/* Cross-engine fallback: pull the opening mark left by its own width */
|
||
blockquote::before { content: "\201C"; margin-left: -0.55em; color: var(--muted); }
|
||
```
|
||
|
||
When the card uses a large decorative quote mark (as in `quote-card.md`), drop the inline opening glyph so the mark isn't doubled.
|
||
|
||
### Splitting into lines / clauses
|
||
|
||
Reveal in reading order means splitting on meaning, not on a fixed character count. Prefer clause boundaries (`. ! ? , ; —`), then pack to a comfortable measure (~32–40 chars at card size). The `toLines` helper in `quote-card.md` does exactly this. Never split a short quote into one-word-per-line — that reads as a ransom note, not a testimonial.
|
||
|
||
### Orphans & widows
|
||
|
||
A single dangling word on the last line looks broken. Bind the last two words with a non-breaking space so they wrap together.
|
||
|
||
```js
|
||
const noOrphan = (s) => s.replace(/ ([^ ]+)\s*$/, " $1");
|
||
```
|
||
|
||
### Legibility in motion
|
||
|
||
- Use a font that stays readable while moving — avoid thin weights and tight tracking; a serif or a humanist sans at 500–600 weight holds up best on a rising line.
|
||
- Animate **opacity + a small translate** only. Blur, rotation, and scale on body text hurt readability mid-reveal.
|
||
- Contrast: body text ≥ 4.5:1 against the card; the highlight accent should pass against both the card and the ink it sits behind.
|
||
- One emphasized span per quote. A second highlight halves the weight of the first.
|
||
|
||
## Star-rating accessibility & correctness
|
||
|
||
- The visual stars are decorative; expose the value to assistive tech with `role="img"` and `aria-label="4.5 out of 5 stars"` on the container (already in `quote-card.md`).
|
||
- For fractional scores, the width-clip method (`rating / max * 100%`) is exact for any decimal — no rounding to the nearest half-star, so 4.3 renders as 4.3.
|
||
- Never animate the stars before the quote is read, and never show more filled stars than the data supports. A faked rating is the fastest way to lose the trust the testimonial was meant to build.
|
||
- If a review has no rating, omit the star row entirely rather than defaulting to 5 — an invented score is worse than none.
|
||
|
||
## Pacing notes
|
||
|
||
- Hold the finished card still for the back half of the clip (≥3s) so it's screenshot-able and re-shareable — shares are the whole point of social proof.
|
||
- For a sequence of testimonials in one video, give each card a full read (~3–4s) and hard-cut between them; cross-dissolving quotes makes both unreadable.
|