Files
cycle-atlas/.agents/skills/testimonial-video/references/batch-and-typography.md
T

6.3 KiB
Raw Blame History

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.

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)

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.

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

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.

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.

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.

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.