# Carousel Newsletter

> Use when user wants to promote a beehiiv newsletter as a 10-slide illustrated carousel to Buffer for Instagram, LinkedIn, Facebook, Threads — "carousel the newsletter", "swipe post", "newsletter carousel", "10-slide post". Generates on-brand EVC slides with AI-generated illustrations (Gemini 2.5 Flash Image / Nano Banana) and schedules them with a "comment 'newsletter'" CTA.

- Skill: `michaellady/carousel-newsletter` (Agent Skill, multi-file: 22 files)
- Install (CLI): `npx skillmds@latest add michaellady/carousel-newsletter`
- Raw SKILL.md: https://api.skillmd.com/api/skills/michaellady/carousel-newsletter/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Docs & Writing
- Author: michaellady (https://skillmd.com/u/michaellady)
- Updated: 2026-09-21
- Page: https://skillmd.com/skills/michaellady/carousel-newsletter

---


# carousel-newsletter

Promote a beehiiv newsletter post as a **10-slide illustrated carousel** on every Buffer-connected channel (Instagram, LinkedIn, Facebook, Threads). The carousel summarizes the newsletter using **direct quotes** and drives a `comment "newsletter"` CTA that routes into the Comment-to-DM funnel.

The visual system: **two-zone slides** — a cream text zone + a dedicated illustration zone filled by a Gemini-generated image. The Enterprise Vibe Code (EVC) banner is attached as a style reference on every generation, locking palette / character design / illustration language.

## When to run

User says things like:
- "carousel my latest newsletter"
- "make a swipe post for the EVC article"
- "10-slide post of this newsletter"

If they want a **single-image snippet**, use `promote-newsletter`. If they want to **republish the full article**, use `crosspost-newsletter`.

**Recommended sequencing for major article launches:** run `/crosspost-newsletter` FIRST (publishes the LinkedIn pulse article), THEN `/carousel-newsletter` so the carousel re-engages an audience that already has the long-form context. Confirmed 2026-04-27: the LinkedIn pulse accompanying post for "Tokens From Our Past" was the #1-impressions LinkedIn post within hours of publish; following it with a carousel hits a primed audience rather than a cold one. See also `feedback_full_stack_newsletter_launch.md` for the broader 4-skill pipeline.

---

## 🟢 Happy Path (read first; everything below is edge-case detail)

For a beehiiv newsletter carousel when nothing goes wrong. ~10-15 min wall-clock (Gemini rate limits dominate). Two user gates: copy approval before illustration spend, image approval before Buffer.

**Phase 1 — Fetch (1 min).** WebFetch the beehiiv RSS feed (or the URL the user provides). Extract title, subtitle, H2 headings, body paragraphs, blockquotes, stat-shaped phrases. Save to `/tmp/carousel-<slug>/source.json`. Run `_shared/voice-corpus/voice-corpus` and hold the JSON output for voice-grounding the original-copy slides.

**Phase 2 — Draft 10-slide script (2-3 min). USER REVIEW GATE (COPY).** Plain-text outline only — no rendering. Fixed structure: 1 hook, 2/6/9 sections, 3/5/7 verbatim quotes (≤260 chars each), 4/8 stats, 10 fixed CTA with accent word `newsletter`. Voice-ground slides 1/2/4/6/8/9 against the corpus (quotes are verbatim — do NOT rewrite). Surface the outline and stop for user approval before spending on illustrations.

**Phase 3 — Generate illustrations (3-5 min).** For each slide write a scene prompt that fills the illustration zone edge-to-edge and reinforces the slide's meaning. Aspect ratios: hook `4:3`, section/stat/CTA `9:16`, quote `16:9`. Call `python3 templates/gen_illustration.py "<scene>" /tmp/carousel-<slug>/illustrations/slide-NN.png --aspect <ratio>`. Sleep ~15s between calls for Nano Banana rate limit. ~$0.40 total.

**Phase 4 — Render HTML → PNG (1 min).** For each slide, copy the template, substitute `{{ILLUS_IMG}}` and copy placeholders, then `templates/render.sh <filled.html> <output.png>`. Always produces exactly 1080×1350.

**Phase 5 — Adversarial review (REQUIRED).** Run `_shared/adversarial-review/adversarial-review` over all 10 slides at once (copy + scene prompts). SOURCE_CONTENT must have `Publication: Enterprise Vibe Code` prepended above the article title so the attribution line on quote slides isn't flagged as fabrication. Must return `all_pass`. Copy fails → fix + re-render (cheap). Illustration fails → regen single image (~$0.04) + re-render.

**Phase 6 — User image review gate.** Open `/tmp/carousel-<slug>/preview.html` as a 10-slide grid. Single-slide regen is cheap — encourage iteration.

**Phase 7 — Host publicly (30 sec).** `mkdir -p generated/<YYYY-MM-DD>-<slug>/`, copy PNGs in, commit + push. Raw URLs: `https://raw.githubusercontent.com/<owner>/<repo>/main/generated/<YYYY-MM-DD>-<slug>/slide-NN.png`.

**Phase 8 — Post to Buffer (1 min). 🔒 HARD GATE: adversarial review must be `all_pass` before this phase.** `list_channels` filtered to `isDisconnected=false AND isLocked=false`. Per-channel `mcp__buffer__create_post` with all 10 raw PNG URLs in `assets.images`, caption = `"<strongest quote>"` + `_shared/cta.sh "<Article Title>"` output (no trailing punctuation), `mode: "addToQueue"`, `schedulingType: "automatic"`, `tagIds: [<carousel Tag ID from _shared/buffer-post-prep/tag-ids.local.json>]`. Instagram metadata: `{ instagram: { type: "post", shouldShareToFeed: true } }` — Instagram auto-renders ≥2 images as a carousel. Facebook + Threads use their respective `type: "post"` metadata. LinkedIn needs no metadata.

**Phase 9 — Summary.** Table: Platform | Channel | Status | Buffer queue URL. Confirm slide 10 accent word is literally `newsletter`.

---

## Prerequisites

**Auth (one-time):** Google Cloud Application Default Credentials.
```bash
brew install --cask gcloud-cli
gcloud auth application-default login
gcloud config set project gen-lang-client-0527845499
```

**Python deps (one-time):** `pip3 install --user --break-system-packages google-genai`

**Runtime requirements:**
- Google Chrome at `/Applications/Google Chrome.app` (headless render).
- `sips` on PATH (macOS built-in).
- `python3` ≥ 3.10.
- EVC banner reference image at `~/Pictures/evc_banner2.png` (used as style reference for every image-gen call).
- Public hosting for the rendered PNGs — default flow commits them to this repo under `generated/<YYYY-MM-DD>-<slug>/` and serves via GitHub raw URLs. Buffer needs publicly reachable URLs.

## Key files

- `templates/shared.css` — brand tokens + two-zone layout classes (`.split-hook`, `.split-section`, `.split-quote`, `.split-stat`, `.split-cta`). Each `.split-*` class sets the absolute positioning of the `.zone-text` and `.zone-illus` boxes for that slide type.
- `templates/01-hook.html` — cover slide (text 40% top, illus 60% bottom, 4:3 scene).
- `templates/02-section.html` — kicker + headline + body (text left 65%, illus right 35%, 9:16 narrow scene).
- `templates/03-quote.html` — verbatim quote (text top 80%, illus bottom 20% wide strip, 16:9 scene).
- `templates/04-stat.html` — big number + label (text left 60%, illus right 40%, 9:16 scene).
- `templates/05-cta.html` — "Comment 'newsletter'" CTA (text left 60%, illus right 40%, 9:16 scene).
- `templates/gen_illustration.py` — image-gen helper. Calls Gemini 2.5 Flash Image via Vertex AI with the EVC banner as style reference + master brand prompt + per-slide scene prompt. Usage: `gen_illustration.py "<scene>" <output.png> --aspect <ratio>`.
- `templates/gen_illustration_openai.py` — **drop-in alternative engine** (added 2026-06-27). Same CLI + the SAME imported `BRAND_PROMPT`, but renders via OpenAI **gpt-image-1** through `images.edit` (the only gpt-image-1 path that accepts a reference image — `images.generate` has none) with the EVC banner as the reference. Use for an engine bake-off or a deliberate library restyle. **Keep ONE engine per deck** — mixing Gemini + gpt-image-1 drifts slide-to-slide. gpt-image-1 reads bolder/heavier-ink vs Gemini's lighter painterly style; ~5× the cost (~$0.20/slide at `high`) and ~6× slower. Needs `OPENAI_API_KEY` (a real platform key with image access — a codex/ChatGPT login token does NOT authorize the Images API) **and** a verified OpenAI org (the script reports that specific failure). gpt-image-1 only emits 1024×1024 / 1536×1024 / 1024×1536, so the script maps each `--aspect` to the nearest by orientation; the two-zone HTML render crops to the zone, so the size mismatch is lossless.
- `templates/render.sh` — HTML → 1080×1350 PNG. Headless Chrome with viewport-chrome compensation + `sips` crop. Usage: `render.sh <filled.html> <output.png>`.
- `templates/illustrations.svg` — **legacy** SVG sprite from the v1 hand-drawn approach. Retained for backward compat (render.sh still inlines it if `<!--SVG_SPRITE-->` marker present) but no longer used by the current templates.
- `examples/sample-deck/` — reference 10-slide rendered deck (pre-image-gen era; visually outdated but shows the structural layout).

## Related skills to read first

- `../promote-newsletter/SKILL.md` — beehiiv RSS fetch, Buffer channel filter (connected + unlocked), CTA copy, rate-limit/remaining-posts pattern.
- `../crosspost-newsletter/SKILL.md` — richer beehiiv DOM extraction if you need quote candidates beyond what RSS exposes.

## Workflow

### Phase 1 — Fetch the newsletter + Voice Corpus

Same pattern as `promote-newsletter`. WebFetch the beehiiv RSS feed (or the URL the user provides). Extract: title, subtitle, H2 section headings, body paragraphs, blockquotes, stat-shaped phrases, hero image URL (for reference, not used in the deck). Save to `/tmp/carousel-<slug>/source.json`.

**Also fetch the voice corpus** (recent newsletters, used as voice reference in Phase 2 for the original-copy slides):

```bash
_shared/voice-corpus/voice-corpus  # auto-refreshes if cache > 7 days old
```

Output is JSON with `posts: [{title, url, published_at, source_type, body_text}]` (`source_type` = `newsletter` or `youtube_live`). Hold onto this output for Phase 2. See [PATTERNS.md#pattern-voice-grounding-for-original-copy-generation](../PATTERNS.md#pattern-voice-grounding-for-original-copy-generation) for the rationale.

**Use `body_text` as-is in Phase 2 prompts — do NOT add a second truncation** (no `body_text[:N]` inline). The binary already caps each post per its config. See [`_shared/voice-corpus/README.md` § Consumers](../_shared/voice-corpus/README.md#consumers-of-this-binary--do-not-add-a-second-truncation).

### Phase 2 — Draft the 10-slide script  ← USER REVIEW GATE (COPY)

**VOICE GROUNDING applies to slides 1, 2, 4, 6, 8, 9 ONLY.**

The 10-slide deck mixes verbatim quotes and original copy. The voice grounding rule applies only to the original-copy slides:

- **Slides 1 (hook), 2 / 6 / 9 (sections), 4 / 8 (stats)** — original copy, MUST sound like the author. Prepend the Phase 1 voice-corpus output as inline excerpts and match: sentence rhythm, vocabulary preferences ("vibe coding", "agentic", etc.), first-person stance, slight irreverence + grounded practicality. Mismatched voice on these slides is a fail signal — same weight as a fabrication. See [PATTERNS.md#pattern-voice-grounding-for-original-copy-generation](../PATTERNS.md#pattern-voice-grounding-for-original-copy-generation).
- **Slides 3 / 5 / 7 (quotes)** — VERBATIM from the source article. Voice grounding does NOT apply; do not rewrite the quote in the author's voice (the quote IS the author's voice already).
- **Slide 10 (CTA)** — fixed template. Not subject to voice rewriting.

Plain text outline, no rendering yet. Fixed structure:

| # | Template | Purpose |
|---|---|---|
| 1 | `01-hook.html` | Title + 1-line tease. `LINE_1` + `LINE_2` ≤ ~26 chars combined for clean 2-line wrap. `TEASE` ≤ 120 chars. |
| 2 | `02-section.html` | Stage-setter. Body ≤ 220 chars. |
| 3 | `03-quote.html` | Strongest verbatim quote (≤260 chars). Attribution = `<Article Title> — Enterprise Vibe Code`. |
| 4 | `04-stat.html` | Stat or big number from the article. |
| 5 | `03-quote.html` | Second verbatim quote. |
| 6 | `02-section.html` | Key insight / "the shift". |
| 7 | `03-quote.html` | Third verbatim quote. |
| 8 | `04-stat.html` OR `02-section.html` | Supporting point. |
| 9 | `02-section.html` | Payoff / what to do. |
| 10 | `05-cta.html` | Fixed CTA, accent word = `newsletter` (verbatim; must match Comment-to-DM trigger). |

Surface this outline and **stop for user approval** before generating illustrations (which cost ~$0.04 each and hit rate limits).

### Phase 3 — Generate per-slide illustrations

For each slide, compose a **scene prompt** that describes what fills the illustration zone. Key rules:

1. **Describe a scene that FILLS the entire frame edge-to-edge.** Don't say "leave empty" — Nano Banana ignores region constraints. Composition is guaranteed by the two-zone HTML layout, not by the prompt.
2. **Tie the scene to the slide's meaning.** The robot, bricks, and tracks should *do something relevant to the slide's text* — a robot meditating on a stat slide about patience, a robot running on tracks for a "velocity" slide, a robot carefully stacking bricks for a "build slowly" slide. Generic scenes feel boring after a few decks.
3. **Match the aspect to the zone:**
   - Hook → `--aspect 4:3`
   - Section → `--aspect 9:16`
   - Quote → `--aspect 16:9`
   - Stat → `--aspect 9:16`
   - CTA → `--aspect 9:16`

**Example scene prompts (contextual):**

| Slide concept | Scene |
|---|---|
| Hook: "3 Lessons from Black Belt" | Horizontal scene: robot on rail tracks with wrench, minecart of bricks, 3-brick tower, large gear, small gear accent. |
| Section: "Train so you can train tomorrow" | Narrow portrait: robot patiently placing one brick on a 2-brick base — showing careful incremental work. |
| Quote about sustainability | Wide strip: robot seated on a rail tie next to a water-jug-shaped brick, relaxed posture. |
| Stat: "14 yrs" | Narrow portrait: tall 4-tier brick tower with tiny gear at top, robot looking up at it. |
| Section: "Democratized knowing and doing" | Narrow portrait: robot handing a wrench to a second smaller robot — teaching/enabling. |
| CTA | Narrow portrait: cheerful robot waving, celebratory brick tower, confetti-like gears floating. |

**Call pattern:**
```bash
python3 templates/gen_illustration.py "<scene>" /tmp/carousel-<slug>/illustrations/slide-NN.png --aspect <ratio>
```

**Rate limiting:** Nano Banana defaults to ~5 req/min on a new project. Sleep ~15s between calls, OR wrap each call in a retry loop with 30s backoff on 429. A full 10-slide deck takes ~3–5 min wall-clock including backoff.

**Cost:** ~$0.04/image = ~$0.40 per 10-slide deck in steady state. Billed to the `gen-lang-client-0527845499` project.

**Auth model:** `gen_illustration.py` uses `GOOGLE_GENAI_USE_VERTEXAI=true` + ADC. If you see `google.auth.exceptions.DefaultCredentialsError`, the user needs to re-run `gcloud auth application-default login`.

### Phase 4 — Render HTML → PNG

For each slide:
1. Copy the template, substitute placeholders:
   ```python
   html = html.replace('{{ILLUS_IMG}}', f'file://{illus_path}')
   for k, v in copy.items():
       html = html.replace('{{' + k + '}}', v)
   ```
2. `templates/render.sh <filled.html> <output.png>` — always produces exactly 1080×1350.

The render script handles Chrome's 87px window-chrome offset and crops via `sips`. Don't reinvent it.

### Adversarial review (REQUIRED before user review)

Apply the **[Adversarial Review pattern](../PATTERNS.md#pattern-adversarial-review)** with these per-skill specifics:

- **SOURCE_LABEL:** "SOURCE ARTICLE"
- **SOURCE_CONTENT:** the full beehiiv article body, verbatim. **Prepend a `Publication: Enterprise Vibe Code` line** above the title so reviewers know the publication name is in scope when validating attribution lines (otherwise stricter reviewers like `agy` flag `<Title> — Enterprise Vibe Code` as a fabrication because "Enterprise Vibe Code" doesn't appear in the article body). Caught 2026-05-03 (then with the gemini reviewer; agy now fills that strict-reviewer slot).
- **SKILL_NAME:** `carousel-newsletter`
- **ARTIFACT_NAME:** "slide" (run reviewer over all 10 slides — copy + illustration scene prompt — at once)
- **RULES_LIST:**
  - Quote slides (template 03-quote): the QUOTE field MUST be verbatim from the source. No paraphrasing.
  - Section slides (template 02-section): KICKER + HEADLINE + BODY must be grounded in the source. BODY can paraphrase but must not invent claims.
  - Stat slides (template 04-stat): the NUMBER + LABEL must come from the source verbatim or be derivable from a fact in the source.
  - CTA slide (template 05-cta): ACCENT_WORD MUST be literally `newsletter` (case-insensitive). Comment-to-DM trigger depends on this exact string — see [CTA pattern](../PATTERNS.md#pattern-comment-newsletter-cta--dm-trigger).
  - Illustrations: each scene prompt should reinforce the slide's meaning (the illustration content should be relatable to the slide copy).
  - BANNED: invented stats, fabricated quotes, claims the source doesn't support.
- **ISSUE_GUIDANCE:** "For quote slides cite the source line; for invented claims cite both the slide and what the source says."

**Apply verdicts (carousel-specific):**
- All PASS → proceed to Phase 5 image preview.
- Any FAIL on copy → fix the copy + re-render the slide PNG (cheap, no Gemini cost) → re-run reviewer.
- Any FAIL on illustration → regenerate that single illustration (~$0.04) with a revised scene prompt → re-render → re-run reviewer.

**Verify reviewer FAILs against the source before editing — quote slides especially (learned 2026-06-27).** Two persistent panel-noise patterns on this skill:
- **Apostrophe hallucination on quote slides.** `codex`/`agy`/`grok-build` intermittently claim a curly-vs-straight apostrophe mismatch on a verbatim quote — *in both directions* (claimed straight U+0027 when the source was curly U+2019, and vice versa), and even contradicting their own prior-round PASS on the same slide. The "I turned 35" source genuinely **mixes** curly and straight apostrophes by region. When a quote slide is flagged for a character mismatch, dump codepoints (`ord()`) of the quote vs the source substring — if the quote is a byte-identical substring of `body_text`, it IS verbatim and the claim is false. Do NOT "fix" a correct quote to appease the reviewer.
- **`grok-build` "not in source" false claims.** It flagged source-grounded phrases as absent when `grep` proved otherwise (e.g. "running my own race" is a literal section title; "build" appears in the body). Always `grep` a "not in source" claim before editing. Per the [round cap](../PATTERNS.md#round-cap-5-iterations-max), surface verified-false claims as deadlocks with the proof rather than degrading copy to chase an all_pass — 4-of-5 reviewers passing + two disprovable objections is a deadlock, not a fail.

### Phase 5 — User review gate (IMAGES)

Open a preview grid:
```bash
python3 -c "..." > /tmp/carousel-<slug>/preview.html && open /tmp/carousel-<slug>/preview.html
```
Let the user approve or request regenerations. **Single-slide regen is cheap** (~$0.04), so encourage iteration on slides that missed.

Common failure modes to watch for:
- Illustration went off-palette (teal/orange snuck in) → regenerate with stronger palette emphasis in the scene prompt.
- Text from the article accidentally appeared in the illustration → regenerate (model sometimes draws words).
- Zone cropping cut off a key element → shift the scene prompt to position the subject where the zone crops it well (center, left, right).

### Phase 6 — Host publicly

Buffer requires public URLs. Default flow:
```bash
mkdir -p generated/<YYYY-MM-DD>-<slug>/
cp /tmp/carousel-<slug>/slide-*.png generated/<YYYY-MM-DD>-<slug>/
git add generated/<YYYY-MM-DD>-<slug>/ && git commit -m "Add carousel assets: <title>" && git push
```
Raw URL: `https://raw.githubusercontent.com/<owner>/<repo>/main/generated/<YYYY-MM-DD>-<slug>/slide-NN.png`

### Phase 7 — Post to Buffer

**🔒 HARD GATE — adversarial review must have returned `summary == "all_pass"` for ALL slides before this phase.**

If the prior `_shared/adversarial-review/adversarial-review` run returned `some_fail` for any slide draft, you MUST NOT call `mcp__buffer__create_post`. Iterate fixes per the [round cap](../PATTERNS.md#round-cap-5-iterations-max) (5 rounds max), then surface deadlocks to the user. Both copy AND illustration scene prompts go through review; both must PASS before the carousel ships.

Reuse patterns from `../promote-newsletter/SKILL.md`. Filter `list_channels` to `isDisconnected=false AND isLocked=false`. Per-channel:

All captions use the canonical CTA — generate via `_shared/cta.sh "<Article Title>"` and concatenate. Do NOT add a trailing period or other punctuation; PATTERNS.md warns that ad-hoc edits to the CTA risk breaking the Manychat trigger silently.

| Platform | `metadata` | `assets.images` | Caption |
|---|---|---|---|
| Instagram | `{ instagram: { type: "post", shouldShareToFeed: true } }` | All 10 PNG URLs (multi-image post auto-creates carousel when ≥2 images attached) | `"<strongest quote>"\n\n` + canonical CTA from `_shared/cta.sh` |
| LinkedIn | (no metadata required) | All 10 PNG URLs | Same as IG. |
| Facebook | `{ facebook: { type: "post" } }` | All 10 PNG URLs | Same as IG. |
| Threads | `{ threads: { type: "post" } }` | All 10 PNG URLs (max 20) | Same as IG. |

`mode: "addToQueue"`, `schedulingType: "automatic"`, **`tagIds: [<format:carousel Tag ID>]`** (required for closed-loop measurement — `buffer-stats` uses this tag to compute per-format engagement). Buffer's `CreatePostInput` schema has `tagIds: [TagId!]` (24-char hex MongoDB ObjectIds) — NOT a `tags` field. Tag *name* strings are silently dropped if you send them as `tags`. Look up the carousel Tag ID from `_shared/buffer-post-prep/tag-ids.local.json` (one-time setup — see [`_shared/buffer-post-prep/README.md`](../_shared/buffer-post-prep/README.md)) and pass it as `tagIds: [<id>]`. If the file is missing or the key isn't there, ship the post untagged and warn the user — `audit-buffer-queue` will catch it next week.

**Important — `instagram.type` does NOT accept `carousel` as of 2026-04-26.** The Buffer MCP API rejects it with `"Invalid option: expected one of 'story'|'reel'|'post'"`. Use `instagram.type: "post"` and attach all 10 image URLs in `assets.images` — Instagram automatically renders multi-image posts as a carousel. Earlier skill claim about `PostType.carousel` was inaccurate (that's a top-level `PostType` enum but not valid for `InstagramPostMetadataInput.type`).

If a channel rejects the carousel payload, log and continue — do not abort the whole run. Channels not targeted: X/Twitter (not connected), TikTok/YouTube (need video).

### Phase 8 — Summary

Print a table: Platform | Channel | Status | Buffer queue URL. Report path to local PNGs and the GitHub raw URL prefix. Confirm the CTA accent word is literally `newsletter`.

## The master brand prompt

Locked in `gen_illustration.py` as the `BRAND_PROMPT` constant. It covers: illustration style, strict palette (navy / green / yellow / blue / gray / brown / white / cream), robot character design, lego brick rules, gears, rails, minecart, composition rules (no text in images, small navy triangle watermark bottom-right). Every image-gen call prepends this prompt before the per-slide scene description. Do not tweak it per-post — consistency across decks is the point.

## Gotchas

- **ADC expiry:** the creds file at `~/.config/gcloud/application_default_credentials.json` is long-lived but tied to the user's Google account. Revoking Google access or `gcloud auth application-default revoke` breaks the skill.
- **Banner size:** the full `evc_banner2.png` is ~4.9 MB; Vertex silently drops attachments that large. `gen_illustration.py` caches a downscaled 1024px version at `/tmp/evc-banner-1024.png`.
- **Slide 10 accent word:** MUST be `newsletter` verbatim — the Comment-to-DM automation listens for this exact trigger.
- **Inter font fallback:** templates assume Inter with `Helvetica Neue` fallback. If Inter isn't installed, the display weight reads a little lighter but still on-brand.
- **Chrome version drift:** the 87-px window-chrome reservation in `render.sh` is based on Chrome 147. If elements clip at the bottom, measure the actual viewport and bump the `--window-size` value.

## Verification checklist before shipping

- [ ] All 10 PNGs exist at exactly 1080×1350.
- [ ] Slide 10 accent word is literally `newsletter`.
- [ ] Every quote slide uses a verbatim quote (diff-check against source).
- [ ] Every illustration is on-palette (no teal/orange/red).
- [ ] No stray text in any illustration (model occasionally draws words).
- [ ] Scene per slide is *related to the slide's copy*, not a generic EVC tableau.
- [ ] Images committed + pushed; spot-check slide-01 raw URL returns HTTP 200.
- [ ] Buffer `create_post` returned success (not `InvalidInputError`) for every scheduled channel.

