# SkillPatch skill: react-vite-pdf-slides

Build high-resolution PDF slide decks from a React + Vite project. Use when the user wants to create a slide deck, presentation, sales deck, customer pitch deck, or investor deck and wants the final artifact to be a print-quality PDF. Sets up a fixed 1440x810 React canvas with a brand-token shell, captures every slide at 5x device scale via headless Chromium (Playwright), and combines the PNGs into a PDF using Pillow. Requires Node.js, Playwright, and Python with Pillow installed.

You (the agent) were given this URL and asked to use this skill. This is a **public** skill — no SkillPatch account, API key, or prior setup is required. Two ways to use it:

**1. Use it right now** — the skill's complete file tree (SKILL.md + all reference files) is inlined below; read `SKILL.md` first, then follow it, consulting the other files as it directs.

**2. Install the exact package onto disk** (recommended if you can run a shell — this reproduces the skill byte-for-byte, including any binary assets that can't be inlined):

```bash
mkdir -p .claude/skills/react-vite-pdf-slides
curl -sSL https://skillpatch.dev/install_skill/react-vite-pdf-slides | tar -xz -C .claude/skills/
```

(`.claude/skills/` is Claude Code's convention; use whatever directory your agent loads skills from.)


---

## Skill files (6)

- `SKILL.md`
- `references/export-workflow.md`
- `references/font-sizes.md`
- `references/layout-patterns.md`
- `scripts/make-pdf.py`
- `scripts/screenshot-slides.cjs`


### `SKILL.md`

````markdown
---
name: react-vite-pdf-slides
description: Build high-resolution PDF slide decks from a React + Vite project. Use when the user wants to create a slide deck, presentation, sales deck, customer pitch deck, or investor deck and wants the final artifact to be a print-quality PDF. Sets up a fixed 1440x810 React canvas with a brand-token shell, captures every slide at 5x device scale via headless Chromium (Playwright), and combines the PNGs into a PDF using Pillow. Requires Node.js, Playwright, and Python with Pillow installed.
license: Proprietary
compatibility: Requires Node.js (>=18) with npm, Python 3 with Pillow installed, and Playwright with Chromium. The one-time setup command `npx playwright install chromium` performs a network download (~150MB) from Microsoft's Playwright CDN. At export time the capture script spawns a local Vite dev server on port 5199 (localhost only) and drives a headless Chromium against it; the combine script reads PNGs and writes a PDF file inside the project directory. No external network calls are made during render or export.
---

# React + Vite PDF Slide Decks

Author a slide deck as a React + Vite app on a fixed 1440x810 canvas, then export every slide at 5x device scale (7200x4050) and combine them into a single PDF.

## When to use it

Trigger when the user asks to:

- Create a slide deck, presentation, sales deck, customer pitch deck, or investor deck.
- Build slides with full design control without PowerPoint or Keynote.
- Produce a high-resolution PDF (print-quality) as the final artifact.
- Author slides as React components (data-driven, version-controlled).

Do NOT use for short one-pagers, animated talks, or interactive web presentations — this skill optimizes for static PDF output.

## High-level workflow

1. Scaffold a `react-deck/` project with a fixed 1440x810 canvas.
2. Author one `.jsx` per slide under `src/slides/`, using a shared `SlideShell` component for brand tokens and frame.
3. Hide the dev nav bar behind a `?pdf` URL flag so it disappears in captures.
4. Run a two-script pipeline that captures each slide as a 7200x4050 PNG via Playwright, then stitches them into a PDF via Pillow.

## 1. Project structure

```
projects/<deck-name>/
├── README.md            # project intent, audience, decisions
├── slides.md            # markdown reference for slide content
├── <DeckName>.pdf       # output (created by export step)
└── react-deck/
    ├── src/
    │   ├── slides/      # one .jsx per slide
    │   ├── components/
    │   │   └── SlideShell.jsx   # brand tokens + shell component
    │   └── App.jsx
    ├── public/          # static assets (logos, screenshots)
    ├── screenshot-slides.cjs   # copy from scripts/
    └── make-pdf.py             # copy from scripts/
```

## 2. Setup

Inside `react-deck/`:

```bash
npm init -y
npm install react react-dom vite @vitejs/plugin-react
npm install --save-dev playwright
npx playwright install chromium    # one-time ~150MB Chromium download
pip install pillow                  # if not already installed
```

## 3. Canvas conventions

- Fixed 1440x810 viewport. Scale to viewport in `App.jsx` via `transform: scale(...)`.
- No CSS framework — inline styles throughout for predictability.
- `SlideShell` (in `src/components/SlideShell.jsx`) exports a brand-token object `C` and provides the standard frame (top bar with label + logo, padding, flex column layout).
- Set `body { background: #FFFFFF }` in `styles.css`. The slide canvas scales to ~96% of the viewport, leaving a thin gap around the edges. A dark body shows that gap as a dark frame around every white slide in screenshots.

## 4. Authoring slides

- One `.jsx` per slide under `src/slides/`.
- Pull brand tokens (colours, fonts, spacing) from `SlideShell.C`.
- Mind minimum font sizes for 5x capture — see `references/font-sizes.md`. (Rule of thumb: if it would be 12pt in PowerPoint, use at least 18px on the 1440 canvas.)
- For dark / full-bleed image slides, skip `SlideShell` and use raw layout — see `references/layout-patterns.md`.

## 5. Hide nav bar in PDF mode

The `App.jsx` nav bar (slide dots + arrows) is essential during development but must be hidden for PDF export, otherwise it appears at the bottom of every captured PNG.

```jsx
// In App.jsx
const isPdf = new URLSearchParams(window.location.search).has('pdf')

// ...
{!isPdf && (
  <div className="nav-bar">...</div>
)}
```

The capture script navigates to `http://localhost:5199/?pdf` so the nav bar is gone in captures but still present during `npm run dev`.

## 6. Export to PDF

Copy `scripts/screenshot-slides.cjs` and `scripts/make-pdf.py` into your `react-deck/` folder. Update `TOTAL_SLIDES` in `screenshot-slides.cjs` and `OUT_PATH` in `make-pdf.py`.

```bash
cd react-deck
node screenshot-slides.cjs && python make-pdf.py
```

This will:

1. Spawn a local Vite dev server on port 5199.
2. Open each slide in headless Chromium at viewport 1440x810 with `deviceScaleFactor: 5`.
3. Write PNGs to `screenshots/`.
4. Combine the PNGs in order into a single PDF (Pillow).
5. Shut down the dev server.

PDF resolution defaults to 7200x4050 per slide. For deeper details on the pipeline, common failure modes (such as the occasional broken `node_modules/.bin/vite` symlink), and tuning `deviceScaleFactor`, see `references/export-workflow.md`.

## References

- `references/font-sizes.md` — minimum font sizes for high-DPI PDF capture
- `references/layout-patterns.md` — flex/grid patterns, dark slides, full-bleed images
- `references/export-workflow.md` — Playwright + Pillow pipeline details, tuning, troubleshooting

## Scripts

- `scripts/screenshot-slides.cjs` — Playwright capture script (copy into your `react-deck/`)
- `scripts/make-pdf.py` — Pillow PDF combine script (copy into your `react-deck/`)

````


### `references/export-workflow.md`

````markdown
# Export workflow

## One-command export

```bash
cd react-deck
node screenshot-slides.cjs && python make-pdf.py
```

## Pipeline

1. `screenshot-slides.cjs` (Playwright / Node.js)
   - Spawns a Vite dev server on port 5199 (chosen to avoid colliding with other Vite instances on the common 5173).
   - Launches headless Chromium with viewport 1440x810 and `deviceScaleFactor: 5`.
   - For each slide N, opens `http://localhost:5199/?pdf&slide=N` and writes a PNG to `screenshots/`.
   - Kills the Vite process on exit.
2. `make-pdf.py` (Pillow / Python)
   - Loads all PNGs in name order.
   - Combines into a single PDF via `Image.save(..., save_all=True, append_images=...)`.
   - Writes to the configured `OUT_PATH`.

## Tuning resolution

| `deviceScaleFactor` | Output      | PDF size (12 slides) |
| ------------------- | ----------- | -------------------- |
| 3                   | 4320 x 2430 | ~5 MB                |
| 4                   | 5760 x 3240 | ~9 MB                |
| 5                   | 7200 x 4050 | ~12 MB               |

5x is the standard for print-quality output. Drop lower if file size is the constraint.

## Common failure: broken vite symlink

When copying a `react-deck/` folder and running `npm install`, occasionally `node_modules/.bin/vite` is created as a regular file instead of a symlink. The screenshot script then fails with:

```
Cannot find module '.../node_modules/dist/node/cli.js'
```

Fix:

```bash
rm node_modules/.bin/vite
ln -s ../vite/bin/vite.js node_modules/.bin/vite
```

Verify with `ls -la node_modules/.bin/vite` — it should show `-> ../vite/bin/vite.js`.

## Per-project tweaks

In `screenshot-slides.cjs`:

```js
const TOTAL_SLIDES = 12;       // number of slides in the deck
const deviceScaleFactor = 5;   // 5 = 7200x4050 per slide
const PORT = 5199;             // change if 5199 is in use
```

In `make-pdf.py`:

```python
OUT_PATH = Path(__file__).parent.parent / '<DeckName>.pdf'
```

## Verifying output

- Open the PDF at 100% zoom and check small text legibility (especially table cells and footnotes).
- If any text looks fuzzy or too small, return to `references/font-sizes.md` and bump sizes — re-export.
- Confirm no nav bar appears on any slide (capture script should be hitting `?pdf`).
- Confirm no dark frame appears around white slides (body background should be `#FFFFFF`).

````


### `references/font-sizes.md`

```markdown
# Font sizes for high-DPI PDF capture

At `deviceScaleFactor: 5`, the 1440x810 canvas renders 7200x4050px images. When these are placed in a PDF at standard page sizes, fonts that look fine in the browser appear very small in print. A 14px font on the 1440 canvas renders as roughly 8pt in the final PDF — barely readable.

## Minimum sizes (1440 canvas, 5x capture)

| Element              | Minimum | Recommended |
| -------------------- | ------- | ----------- |
| Slide title (h2)     | 34px    | 36px        |
| Subtitle / objective | 18px    | 19-20px     |
| Body text            | 17px    | 18-19px     |
| Table cells          | 16px    | 18px        |
| Section headers      | 14px    | 15px        |
| Small annotations    | 13px    | 14px        |
| Footnotes            | 12px    | 13px        |

## Rule of thumb

If it would be 12pt in PowerPoint, use at least 18px on the 1440 canvas at 5x capture. Design for the PDF, not the browser preview.

## What you see vs what prints

- Browser preview at 1440px shows text at screen resolution.
- The PDF shows it at ~655 DPI compressed to page size.

Always verify the exported PDF at 100% zoom, not the browser preview.

```


### `references/layout-patterns.md`

```markdown
# Layout patterns

## Flex + grid composition

- Grids inside flex containers need `gridTemplateRows: '1fr'` for rows to fill allocated height.
- Always add `minHeight: 0` to flex children that contain grids or overflow content (otherwise children clip silently at the canvas edge).
- Use `justifyContent: 'space-between'` on flex-column panels to distribute content and eliminate dead space.
- For grid-based logo/client slides where cards should fill the height, use `gridTemplateRows: '1fr 1fr'` (explicit row sizing) instead of `alignContent: 'center'` — the latter creates dead space at top and bottom.
- To stretch flex-row children to full height, add `alignItems: 'stretch'` to the row container. Default behaviour is fine most of the time but explicit is safer when cards have varying content.

## Full-bleed image panels

- For full-bleed image panels (e.g. the right half of a split slide), skip `SlideShell` entirely and use a raw `<div>` with `display: flex`.
- `SlideShell` accepts a `noPadRight` prop to remove right padding when an image needs to bleed to the edge.

## Dark / screenshot slides

- Use raw layout (skip `SlideShell`) for full-bleed dark slides.
- Overlay a gradient (`linear-gradient`) for top/bottom legibility.
- Pin the top bar (label pill + logo) absolutely over the image.
- Caption the environment clearly so viewers understand what they are seeing.

## Body background

Set `body { background: #FFFFFF }` in `styles.css` (not dark). The slide canvas scales to ~96% of the viewport, leaving a thin gap around the edges. If the body is dark, this gap appears as a dark frame around every white slide in the exported screenshots. White body = invisible gap on white slides; dark divider slides have their own background so they are unaffected.

```


### `scripts/make-pdf.py`

```
"""Combine slide PNGs from screenshots/ into a single PDF using Pillow.

Behaviour:
  - Reads every screenshots/slide-*.png in sorted order.
  - Writes one PDF at OUT_PATH (default: one directory above this script).
  - No network calls; pure local file IO.
"""

from pathlib import Path
from PIL import Image

SCRIPT_DIR = Path(__file__).parent
SCREENSHOTS_DIR = SCRIPT_DIR / "screenshots"
OUT_PATH = SCRIPT_DIR.parent / "Deck.pdf"   # adjust filename per project


def main() -> None:
    pngs = sorted(SCREENSHOTS_DIR.glob("slide-*.png"))
    if not pngs:
        raise SystemExit(f"No slide PNGs found in {SCREENSHOTS_DIR}")

    images = [Image.open(p).convert("RGB") for p in pngs]
    first, rest = images[0], images[1:]

    OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
    first.save(OUT_PATH, save_all=True, append_images=rest)
    print(f"wrote {OUT_PATH} ({len(images)} slides)")


if __name__ == "__main__":
    main()

```


### `scripts/screenshot-slides.cjs`

```
// Capture each slide of a React + Vite deck as a high-DPI PNG.
//
// Behaviour:
//   - Spawns a local Vite dev server on PORT (default 5199).
//   - Launches headless Chromium with viewport 1440x810 at deviceScaleFactor 5.
//   - For each slide N in [1..TOTAL_SLIDES], opens http://localhost:PORT/?pdf&slide=N
//     and writes screenshots/slide-NN.png.
//   - Kills the Vite process on exit.
//
// No external network calls; only localhost traffic.

const { chromium } = require('playwright');
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');

const TOTAL_SLIDES = 12;          // adjust per deck
const PORT = 5199;
const deviceScaleFactor = 5;      // 5 -> 7200x4050 per slide
const VIEWPORT = { width: 1440, height: 810 };
const OUT_DIR = path.join(__dirname, 'screenshots');

async function waitForServer(url, timeoutMs = 30000) {
  const start = Date.now();
  while (Date.now() - start < timeoutMs) {
    try {
      const res = await fetch(url);
      if (res.ok || res.status === 404) return;
    } catch (_) {
      // not up yet
    }
    await new Promise(r => setTimeout(r, 250));
  }
  throw new Error(`Vite server did not start at ${url} within ${timeoutMs}ms`);
}

(async () => {
  if (!fs.existsSync(OUT_DIR)) fs.mkdirSync(OUT_DIR, { recursive: true });

  const vite = spawn('npx', ['vite', '--port', String(PORT), '--strictPort'], {
    cwd: __dirname,
    stdio: 'inherit',
  });

  const cleanup = () => {
    try { vite.kill(); } catch (_) {}
  };
  process.on('SIGINT', () => { cleanup(); process.exit(130); });
  process.on('SIGTERM', () => { cleanup(); process.exit(143); });

  try {
    await waitForServer(`http://localhost:${PORT}`);

    const browser = await chromium.launch();
    const context = await browser.newContext({
      viewport: VIEWPORT,
      deviceScaleFactor,
    });
    const page = await context.newPage();

    for (let i = 1; i <= TOTAL_SLIDES; i++) {
      const url = `http://localhost:${PORT}/?pdf&slide=${i}`;
      await page.goto(url, { waitUntil: 'networkidle' });
      const file = path.join(OUT_DIR, `slide-${String(i).padStart(2, '0')}.png`);
      await page.screenshot({ path: file, fullPage: false });
      console.log(`captured ${file}`);
    }

    await browser.close();
  } finally {
    cleanup();
  }
})();

```
