pro-deck-builder · v1.0.0 · 2026-03-11 · sha256 d8b89f6a5ab8522c

pro-deck-builder v1.0.0A

Immutable. This exact content is served forever at /api/v1/blob/d8b89f6a5ab8522c.

---
name: pro-deck-builder
description: Create polished HTML slide decks and PDF-ready documents for consulting deliverables. Uses the RRBC design system with warm light mode, dark mode cover pages, Lora/Inter/Roboto Mono typography, and data visualization palette. Trigger on 'deck', 'slides', 'presentation', 'pitch deck', 'keynote', 'report', or 'PDF'.
license: MIT
origin: custom
author: Rebecca Rae Barton
author_url: https://github.com/thatrebeccarae
metadata:
  version: 1.0.0
  category: dtc-marketing
  domain: presentations
  updated: 2026-02-23
---

# Presentation Designer

VC-backed SaaS design quality for PowerPoint. Think Linear, Attio, Vercel, Raycast — not corporate clip art.

## Quick Reference

| Task | Approach |
|------|----------|
| Create from scratch | Read [REFERENCE.md](REFERENCE.md), use PptxGenJS |
| Edit existing / use template | Read [editing.md](editing.md) |
| Read/extract content | `python -m markitdown presentation.pptx` |
| Visual overview | `python scripts/thumbnail.py presentation.pptx` |

---

## Before You Start (Required Steps)

### Step 1: Ask the User

Before writing any code, ask:

1. **Light or dark mode?** Dark = premium/keynote feel. Light = reports/data-heavy decks.
2. **Palette preference?** Show the palette table below, or ask if they have brand colors.
3. **Logo?** Ask if they have a logo file to include (PNG/SVG path).

### Step 2: Discover Fonts

Check what's installed — premium fonts make a massive difference:

```bash
# macOS — list all installed font families
fc-list : family | sort -u

# Search for premium fonts first
fc-list : family | grep -iE "inter|dm.sans|poppins|avenir|futura|geist|sf.pro|sf.mono|jetbrains|fira.code|montserrat|playfair|source.sans"

# Check macOS Font Book directories directly
ls ~/Library/Fonts/ /Library/Fonts/ /System/Library/Fonts/Supplemental/ 2>/dev/null
```

**Priority order for font selection:**
1. **Inter** — the gold standard for SaaS UI (if installed, use it for everything)
2. **DM Sans** — geometric, startup-friendly
3. **Poppins** — rounded, approachable
4. **Avenir / Avenir Next** — ships with macOS, Apple-adjacent elegance
5. **Calibri** — safe fallback, always available

**For data/mono:** JetBrains Mono > Fira Code > SF Mono > Consolas

---

## Design Language: VC-Backed SaaS

### Aesthetic Principles

1. **Depth through layering** — Surfaces float above backgrounds. Cards and panels have subtle elevation via shadows and fine borders, not flat colored boxes.
2. **Bold typography, minimal decoration** — Let type hierarchy do the work. No accent lines, no decorative shapes, no clip art.
3. **Restrained color** — One accent color used sparingly (10%). Mostly neutrals. Color = signal, not decoration.
4. **Generous whitespace** — Empty space signals confidence. If a slide feels sparse, make the content bigger, don't add more.
5. **Every element earns its place** — Before adding anything: what does it communicate? If removing it changes nothing, remove it.

### Light vs Dark Mode

The user picks one mode for the entire deck. Never mix modes within a deck.

---

## Color System

### Dark Mode (Default — Premium/Keynote)

| Token | Value | Use |
|-------|-------|-----|
| `bg-primary` | `0A0A0C` | Slide backgrounds (deep space black) |
| `bg-surface` | `17181A` | Cards, panels (deep slate) |
| `bg-elevated` | `1E1F23` | Elevated surfaces, hover states |
| `bg-glass` | `FFFFFF` at 4% opacity | Translucent overlays |
| `text-primary` | `FFFFFF` | Headlines, key content |
| `text-secondary` | `A0A0A0` | Body text, descriptions |
| `text-muted` | `5A5A5E` | Captions, metadata, labels |
| `border-subtle` | `FFFFFF` at 8% opacity | Card borders |

### Light Mode (Reports/Data-Heavy)

| Token | Value | Use |
|-------|-------|-----|
| `bg-primary` | `FFFFFF` | Slide backgrounds |
| `bg-surface` | `F8F9FA` | Cards, panels |
| `bg-elevated` | `FFFFFF` | Elevated surfaces (use shadow) |
| `text-primary` | `111111` | Headlines, key content |
| `text-secondary` | `555555` | Body text, descriptions |
| `text-muted` | `999999` | Captions, metadata, labels |
| `border-subtle` | `E8E8E8` | Card borders |

---

## Palette System

Each palette defines one accent color (used at 10%) plus semantic status colors. The accent is the personality — everything else is neutral.

### Built-In Palettes

| Name | Accent | Hex | Best For |
|------|--------|-----|----------|
| **Electric Blue** | Intense azure | `0055FF` | SaaS, tech, product |
| **Neon Lime** | Acid green | `39FF14` | Dev tools, performance |
| **Hot Coral** | Warm energy | `FF6B6B` | Marketing, creative |
| **Indigo** | Deep purple | `6366F1` | Startups, AI/ML |
| **Teal Trust** | Cool confidence | `0D9488` | Healthcare, finance |
| **Amber Signal** | Warm authority | `F59E0B` | Consulting, strategy |
| **Rose** | Refined pink | `F43F5E` | Design, brand, luxury |
| **Emerald** | Growth green | `10B981` | Sustainability, success |
| **Cyan Edge** | Sharp blue-green | `06B6D4` | Data, analytics |
| **Violet** | Creative depth | `8B5CF6` | Education, innovation |
| **Slate** | No accent | `64748B` | Minimal, understated |
| **Gold Standard** | Financial premium | `D4AF37` | Investor, board decks |

### Custom Palette (User Brand Colors)

If the user provides brand colors, build a custom palette:

```javascript
const palette = {
  accent: "0055FF",      // primary brand color
  accentMuted: "0055FF" + " at 15% opacity", // for tinted backgrounds
  positive: "10B981",    // good, up, success
  warning: "F59E0B",     // attention, caution
  critical: "EF4444",    // bad, down, error
};
```

### Color Application: 85/10/5 Rule

- **85%** — Backgrounds + neutral text (bg + text tokens from mode)
- **10%** — Accent (key numbers, chart primary, CTAs, active states)
- **5%** — Semantic status (positive/warning/critical in KPIs and data)

---

## Typography

### Font Priority

Run font discovery (Step 2 above), then pick:

| Priority | Header | Body | Data/Mono | Vibe |
|----------|--------|------|-----------|------|
| 1st | Inter 700 | Inter 400 | JetBrains Mono | Linear/Vercel |
| 2nd | DM Sans 700 | DM Sans 400 | Fira Code | Geometric startup |
| 3rd | Avenir Next Bold | Avenir 400 | SF Mono | Apple-adjacent |
| 4th | Poppins 700 | Poppins 400 | JetBrains Mono | Friendly modern |
| Safe | Calibri Bold | Calibri | Consolas | Always available |

For editorial/financial decks:

| Header | Body | Vibe |
|--------|------|------|
| DM Serif Display | DM Sans | Sophisticated editorial |
| Playfair Display | Source Sans Pro | Luxury, financial |
| Georgia | Calibri | Classic corporate |

### Size Scale

| Role | Size | Weight | Use |
|------|------|--------|-----|
| Hero title | 54-72pt | Bold (700) | Title slide only |
| Slide title | 32-40pt | Bold (700) | Every content slide |
| Section header | 20-24pt | Semibold (600) | Within-slide sections |
| Body text | 14-16pt | Regular (400) | Paragraphs, bullets |
| Captions/meta | 10-12pt | Regular (400) | Footnotes, sources |
| Big KPI number | 48-72pt | Bold (700) | Dashboard callouts |
| KPI label | 9-10pt | Semibold, ALL CAPS, +tracking | Below KPI numbers |
| Data/numbers | 11-14pt | Mono regular | Tables, data labels |

### Typography Rules
- **Left-align** body text and lists — center ONLY titles and KPI values
- **Max 2 font families** per deck (+1 mono for data)
- **ALL CAPS + charSpacing** only for small labels (KPI labels, category tags)
- **Line height**: 1.3x for body, 1.1x for titles
- **Never underline** for emphasis — use bold or accent color

### Text Fitting (Prevents Overflow)

Big numbers and titles WILL overflow if you use a fixed font size without checking content length. Use these rules:

**Big KPI Numbers — scale font to character count:**

| Characters | Example | Max Font Size (card) | Max Font Size (standalone) |
|------------|---------|---------------------|--------------------------|
| 1–4 | `$4.2M` | 48pt | 72pt |
| 5–7 | `$5,307K` | 36pt | 54pt |
| 8–10 | `$5,307,540` | 28pt | 44pt |
| 11+ | `$12,345,678` | 22pt | 36pt |

**Rule: prefer abbreviated numbers.** `$5.3M` > `$5,307,540`. Only use full numbers in tables or footnotes where precision matters.

**Slide Titles — max characters before wrapping:**

| Font Size | Max Chars (~9" wide) | If Longer |
|-----------|---------------------|-----------|
| 54pt | ~30 | Reduce to 44pt or rewrite shorter |
| 44pt | ~38 | Reduce to 36pt or rewrite shorter |
| 36pt | ~46 | Reduce to 32pt or rewrite shorter |
| 32pt | ~52 | Use subtitle for overflow |

**Rule: count characters before setting fontSize.** Never let content slide titles wrap to 2 lines. Section dividers can wrap.

**Body Text in Narrow Containers (Insight Panels, Sidebars):**

Text overflow in narrow panels (< 4" wide) is a common problem. PptxGenJS does not auto-shrink text. Apply these rules:

| Container Width | Max fontSize | Max chars per line (~) | Max lines at 1.25x spacing |
|----------------|-------------|----------------------|---------------------------|
| 4.0"+ | 14pt | ~55 | Height ÷ 0.22" |
| 3.0-3.9" | 11pt | ~45 | Height ÷ 0.18" |
| 2.0-2.9" | 10pt | ~35 | Height ÷ 0.16" |
| < 2.0" | 9pt | ~25 | Height ÷ 0.15" |

**Rule: estimate line count before adding text.** Count characters in your text, divide by max chars per line to get line count, then verify it fits in the container height. If it doesn't fit: (1) reduce fontSize, (2) shorten the text, or (3) increase the container. Never let text silently overflow — it gets clipped or overlaps adjacent elements.

```javascript
// ✅ CORRECT: 2.8" panel → max 10pt, ~35 chars/line
// "Welcome series dominates at 62%..." = 90 chars ≈ 3 lines
// Container h: 1.6" → fits ~10 lines at 10pt. Safe.
s.addText("Short, edited copy.", {
  x: 6.55, y: 1.45, w: 2.7, h: 1.6,
  fontSize: 10, fontFace: theme.font.body,
});

// ❌ WRONG: 2.8" panel with 14pt font and 200+ char paragraph
s.addText("Very long paragraph that will overflow...", {
  x: 6.55, y: 1.45, w: 2.8, h: 1.6,
  fontSize: 14, fontFace: theme.font.body,  // too large for narrow panel
});
```

### Chart Number Formatting

Always format chart numbers for readability — raw numbers like "1000000" are never acceptable on axes OR data labels.

**CRITICAL: `valAxisLabelFormatCode` only formats the AXIS. Data labels from `showValue: true` are formatted separately via `dataLabelFormatCode`.** If you set `valAxisLabelFormatCode` but not `dataLabelFormatCode`, data labels will show raw unformatted numbers like "2888000". `numFmt` is NOT a valid PptxGenJS property — always use `dataLabelFormatCode`.

**Preferred approach — OOXML conditional format with raw values:**
Use raw (unscaled) values and let OOXML conditional number formatting handle the display. This is the most reliable approach and handles mixed-magnitude data (e.g., a chart with both $2.9M and $3.6K values).

```javascript
// ✅ BEST: Raw values + OOXML conditional format
// Under 1K: whole number ($500)
// 1K-999K: X.XXK ($540.18K, $57.72K)
// 1M+: X.XM ($2.9M)
const currFmt = '[>=1000000]"$"#,##0.0,,"M";[>=1000]"$"#,##0.00,"K";"$"#,##0';

values: [2888000, 717734, 425467, 3655],  // raw dollar values
dataLabelFormatCode: currFmt,              // conditional brackets OK here
valAxisLabelFormatCode: '"$"#,##0.0,,"M"', // simple format only (no brackets)
```

```javascript
// ✅ ALSO OK: Pre-scale when all values are same magnitude
values: [2888, 718, 425],  // raw values divided by 1000
dataLabelFormatCode: '"$"#,##0"K"',
valAxisLabelFormatCode: '"$"#,##0"K"',
```

```javascript
// ❌ WRONG: Raw values with axis-only format
values: [2888000, 718000, 425000],
valAxisLabelFormatCode: "$#,##0,K",  // axis may format but data labels show 2888000
// ❌ WRONG: numFmt (not a valid PptxGenJS property)
```

**OOXML conditional format reference (use with RAW values):**

| Data Range | Format Code | Renders As |
|------------|-------------|------------|
| Currency (auto-scale) | `'[>=1000000]"$"#,##0.0,,"M";[>=1000]"$"#,##0.00,"K";"$"#,##0'` | $2.9M, $540.18K, $500 |
| Plain number (auto-scale) | `'[>=1000000]#,##0.0,,"M";[>=1000]#,##0.00,"K";#,##0'` | 2.9M, 540.18K, 500 |
| Percentage | `'0%'` | 50%, 85% |

**Format code syntax:** OOXML supports up to 2 conditions in brackets + a default: `[condition1]format1;[condition2]format2;defaultFormat`. Each trailing `,` divides by 1,000 (so `,,` divides by 1M).

**IMPORTANT: `valAxisLabelFormatCode` does NOT support OOXML conditional brackets `[>=1000]`.** Use a simple format for the axis based on the data range:

| Axis Range | `valAxisLabelFormatCode` | Renders As |
|------------|----------------|------------|
| Up to ~999K | `'"$"#,##0,"K"'` | $0K, $100K, $500K |
| Up to ~10M+ | `'"$"#,##0.0,,"M"'` | $0.0M, $0.5M, $1.0M |
| Up to ~999 | `'"$"#,##0'` | $0, $100, $500 |

Use the conditional `currFmt` for `dataLabelFormatCode` (which supports brackets), but a simple format for `valAxisLabelFormatCode`. Always set `showValue: true` with `dataLabelPosition: "outEnd"` on bar charts so readers can see exact values without tracing to the axis.

---

## Spacing

| Token | Value | Use |
|-------|-------|-----|
| `xs` | 0.125" | Within grouped elements |
| `sm` | 0.25" | Label to value, icon to text |
| `md` | 0.375" | Between sections within a card |
| `lg` | 0.5" | Between cards, major sections |
| `xl` | 0.75" | Between content zones |

### Layout Grid (10" × 5.625")

```
Margins:       0.5" from all edges
Content area:  9.0" wide × ~4.5" tall
Title zone:    y = 0.3"
Content zone:  y = 1.0" to y = 5.0"
Footer zone:   y = 5.2"
Card gap:      0.25" - 0.3"
```

---

## Logo Placement

### Finding Logos

```bash
find ~/Desktop ~/Downloads ~/Documents -maxdepth 3 -iname "*logo*" \( -name "*.png" -o -name "*.svg" -o -name "*.jpg" \) 2>/dev/null
```

### Placement Patterns

| Slide Type | Position | Size | Notes |
|------------|----------|------|-------|
| Title slide (top-left) | `x:0.5, y:0.4` | `w:1.5, h:0.5` | Subtle brand mark |
| Title slide (centered) | `x:4.0, y:0.8` | `w:2.0, h:0.7` | Above title text |
| Content slides | `x:8.5, y:0.2` | `w:1.0, h:0.35` | Top-right corner, every slide |
| Closing slide | `x:3.5, y:1.5` | `w:3.0, h:1.0` | Centered, hero size |

### Logo Rules
- Always use `sizing: { type: "contain", w, h }` to preserve aspect ratio
- **Content slides**: max 1" wide, don't compete with content
- **Title/closing**: up to 3" wide for brand moment
- **Dark mode**: use white/light logo variant if available (ask user)
- **Slide master**: if logo goes on every slide, add it to a master definition

---

## Icons

### Pipeline: react-icons → sharp → PNG → PptxGenJS

```bash
npm install -g react-icons react react-dom sharp
```

**Prefer Lucide (`react-icons/lu`) or Feather (`react-icons/fi`)** — they match the Linear/Vercel icon language.

| Package | Import | Style |
|---------|--------|-------|
| `react-icons/lu` | Lucide | Linear-style, clean |
| `react-icons/fi` | Feather | Thin, modern |
| `react-icons/hi2` | Heroicons v2 | Apple-ish |
| `react-icons/tb` | Tabler | Consistent strokes |

### Icon Sizes

| Context | Display Size | Color |
|---------|-------------|-------|
| KPI card icon | 0.35" × 0.35" | Accent on dark, muted on light |
| Feature row icon | 0.4" × 0.4" | White inside accent circle |
| Section divider | 0.5" × 0.5" | Accent color |
| Inline bullet | 0.25" × 0.25" | text-secondary |

---

## Reusable Components

### Bottom Callout Box (Standardized)

Many slides end with a highlighted callout box (recommendation, key insight, total impact). **These must be visually identical across all slides:**

| Property | Value | Notes |
|----------|-------|-------|
| Box height | 0.6" | Always 0.6" — never 0.5", 0.7", 0.9" |
| Text height | 0.5" | Fits inside 0.6" box with padding |
| Font size | 11pt | Always 11pt bold — never 12pt or 13pt |
| Font | `theme.font.body` | Bold, left-aligned, valign: "middle" |
| Box x/w | `x: 0.5, w: 9.0` | Full content width |
| Text x/w | `x: 0.7, w: 8.6` | Inset 0.2" from box edges |
| Text y offset | box y + 0.05" | Centers text vertically in box |
| Corner radius | 0.06" | Consistent across all callouts |

```javascript
// Standard bottom callout — copy this pattern exactly
s.addShape(pres.shapes.RECTANGLE, {
  x: 0.5, y: calloutY, w: 9.0, h: 0.6,
  fill: { color: "DCFCE7" },  // or theme.accentMuted, "FEF3C7", etc.
  line: { color: theme.status.positive, width: 0.75 },
  rectRadius: 0.06,
});

s.addText("Callout text here.", {
  x: 0.7, y: calloutY + 0.05, w: 8.6, h: 0.5,
  fontSize: 11, fontFace: theme.font.body, bold: true,
  color: "166534", align: "left", valign: "middle",
});
```

### Table + Callout Layout (Prevents Overlap)

**Always calculate the table bottom position before placing a callout below it.** PptxGenJS tables don't auto-report their height, so you must compute it:

```
tableBottom = tableY + (rowCount × rowH)
calloutY = tableBottom + 0.15"  // minimum 0.15" gap
```

**Verify calloutY + 0.6" (callout height) < 5.2" (footer zone).** If it doesn't fit, reduce `rowH` or table font sizes — never let the callout overlap the footer.

| Rows (incl header) | rowH: 0.32" | rowH: 0.36" | rowH: 0.42" | rowH: 0.48" | rowH: 0.55" |
|---------------------|-------------|-------------|-------------|-------------|-------------|
| 6 rows | 1.92" | 2.16" | 2.52" | 2.88" | 3.30" |
| 7 rows | 2.24" | 2.52" | 2.94" | 3.36" | 3.85" |
| 8 rows | 2.56" | 2.88" | 3.36" | 3.84" | 4.40" |
| 11 rows | 3.52" | 3.96" | 4.62" | — | — |

With table starting at y: 1.0" and callout needing 0.75" (0.15" gap + 0.6" box), the table height limit is **3.45"** to stay above footer. Choose `rowH` accordingly.

### Cross-Slide Consistency Rules

Repeated elements MUST use identical dimensions across all slides:

1. **Bottom callout boxes** — same h, fontSize, padding on every slide (see standard above)
2. **Chart font sizes** — if multiple slides have bar/line charts, use identical `dataLabelFontSize`, `catAxisLabelFontSize`, and `valAxisLabelFontSize` across all of them
3. **Table styling** — header row style (`hdr()`) and cell style (`cell()`) should be defined once as helpers and reused
4. **Card dimensions** — if using stat cards on multiple slides, keep card width/height/corner radius consistent
5. **Section badge positions** — impact badges (e.g., "$400K-$800K") should use the same x, y, w, h, fontSize

**Rule: define reusable constants at the top of the script** for any dimension that appears on more than one slide.

---

## Slide Compositions

### Vary Layouts — The #1 Rule

Never repeat the same layout more than twice consecutively. Rotate through:

| Type | When | Elements |
|------|------|----------|
| **Title Slide** | Opening/closing | Hero text, subtitle, logo, date |
| **KPI Dashboard** | Executive summary | 3-5 cards, big numbers |
| **Chart + Insight** | Data with context | 60/40 split |
| **Full-Width Chart** | Chart IS the story | Full span, callout overlay |
| **Icon Feature Grid** | Features, pillars | 3-4 icon+text rows |
| **Data Table** | Financials, comparisons | Styled rows, mono numbers |
| **Section Divider** | Between sections | Bold title on dark/accent bg |
| **Quote** | Testimonials | Large italic, attribution |
| **Big Number** | Single key stat | Huge centered metric |
| **Comparison** | Before/after, vs. | Side-by-side columns |
| **Image + Text** | Products, case studies | Half-bleed image |
| **Closing** | End | Logo, CTA, contact |

---

## Anti-Patterns (Never Do These)

1. **NEVER add horizontal accent lines/bars under titles, subtitles, or headings** — This is the #1 AI tell. No thin rectangles (h: 0.03-0.05) placed under text as decorative separators. Not on title slides, not on section dividers, not on closing slides, not anywhere. If you feel a slide needs visual separation, use whitespace or a tinted panel instead.
2. **No gradient fills on shapes** — use solid colors + subtle borders
3. **No heavy shadows** — max opacity 0.1, blur 6
4. **Don't repeat layouts** on consecutive slides
5. **Don't center body text** — left-align everything except titles/KPIs
6. **No more than 2 fonts** (+1 mono)
7. **No walls of text** — max 6 lines body per slide
8. **No decorative shapes** — no random circles, triangles, swooshes
9. **No clip art** — use Lucide/Feather icons
10. **No colored card backgrounds** — cards = `bg-surface` + subtle border
11. **No unicode bullets** — use `bullet: true`
12. **No mixing light/dark** within a deck
13. **No unformatted chart data labels** — if you use `showValue: true`, you MUST also set `dataLabelFormatCode`. `valAxisLabelFormatCode` alone does NOT format data labels. Use OOXML conditional formats with raw values for best results.
14. **No long text in narrow panels without size checks** — before adding body text to any container < 4" wide, estimate line count (chars ÷ chars-per-line) and verify it fits the container height. Reduce font size or shorten text if it won't fit.
15. **No inconsistent callout boxes** — all full-width bottom callout boxes must use identical height (0.6"), text fontSize (11pt), and padding across every slide. Define the callout pattern once and copy it exactly.
16. **No callout-over-table overlap** — always calculate `tableBottom = tableY + (rows × rowH)` and place the callout at `tableBottom + 0.15"` minimum. Never hardcode callout y-positions without verifying they clear the table.
17. **No inconsistent chart font sizes** — if multiple slides contain charts, use identical `dataLabelFontSize`, `catAxisLabelFontSize`, and `valAxisLabelFontSize` values. Define these as constants and reuse them.

---

## QA (Required)

**Assume there are problems. First render is never correct.**

### Content Check
```bash
python -m markitdown output.pptx
python -m markitdown output.pptx | grep -iE "xxxx|lorem|ipsum|placeholder|click to"
```

### Visual QA (Use Subagents)
```bash
python scripts/office/soffice.py --headless --convert-to pdf output.pptx
pdftoppm -jpeg -r 150 output.pdf slide
```

Subagent prompt:
```
Inspect these slides. Assume issues exist — find them.
Look for:
- TEXT OVERFLOW: Numbers or titles that wrap to a second line inside their container. Also check body text in narrow panels (<4" wide) — long paragraphs at font sizes >11pt will overflow.
- ACCENT LINES: Any thin horizontal bars/lines under titles or headings (must be removed)
- CHART DATA LABELS: Raw unformatted numbers on data labels (e.g., "2888000" instead of "$2,889K"). This is the #1 chart bug — valAxisLabelFormatCode does NOT format data labels.
- CHART AXES: Raw unformatted numbers on axes (should be $500K not 500000)
- Overlapping elements, cramped spacing (<0.3"), insufficient margins (<0.5")
- Low contrast, text-only slides, repeated layouts, placeholder text
- Mode inconsistency (light elements on dark or vice versa)
- Big numbers that should be abbreviated ($5.3M not $5,307,540)
- CALLOUT INCONSISTENCY: Bottom callout boxes with different heights, font sizes, or padding across slides (all must be h:0.6", fontSize:11)
- TABLE-CALLOUT OVERLAP: Callout boxes overlapping tables — calculate tableY + (rows × rowH) and verify callout clears it
- CHART FONT INCONSISTENCY: Different dataLabelFontSize, catAxisLabelFontSize, or valAxisLabelFontSize between charts on different slides
```

Fix → re-render → inspect → repeat until clean. **Complete at least one fix cycle.**

---

## Dependencies

```bash
npm install -g pptxgenjs                          # Creation
npm install -g react-icons react react-dom sharp  # Icons
pip install "markitdown[pptx]" Pillow             # Reading + QA
# Also: LibreOffice (soffice) + Poppler (pdftoppm) for visual QA
```

## Implementation

Read [REFERENCE.md](REFERENCE.md) for PptxGenJS API patterns and complete slide composition code for both light and dark modes.