# Phase 43: Documents & Branding - Research

**Researched:** 2026-04-04
**Domain:** PDF generation via Playwright, brand identity kit assembly, ZIP packaging
**Confidence:** HIGH

---

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions
All implementation choices are at Claude's discretion — discuss phase was skipped per user setting.

### Claude's Discretion
All implementation choices are at Claude's discretion. Use ROADMAP phase goal, success criteria, and codebase conventions.

### Deferred Ideas (OUT OF SCOPE)
None — discuss phase skipped.
</user_constraints>

---

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|------------------|
| DOC-01 | User can generate formatted PDF reports from conversation content | Playwright page.pdf() with HTML-to-PDF approach; follows diagram-renderer.ts Playwright pattern |
| DOC-02 | User can generate invoices and contracts from templates | Template-driven HTML rendered to PDF via Playwright; pdf-lib for lightweight data-only invoices |
| DOC-03 | User can generate one-pagers and API documentation | LLM-generated HTML + CSS styled page → Playwright PDF; same pipeline as DOC-01 |
| BRAND-01 | User can generate a full brand identity from a single conversation | Multi-step LLM extraction: brand name/colors/typography → feeds each sub-renderer |
| BRAND-02 | System produces logo mark (SVG), avatar in multiple sizes | SVG logo via LLM + validateAndCleanSvg; sharp rasterizes to [512, 256, 128, 64, 32]px PNGs |
| BRAND-03 | System produces social media profile images and banners per platform | Re-uses PLATFORM_DIMENSIONS from wallpaper-renderer; logo composited onto brand-colored background |
| BRAND-04 | System produces email signature and letterhead templates | LLM generates HTML; stored as HTML string inside bundle + PNG preview via Playwright screenshot |
| BRAND-05 | System produces a brand guidelines document (PDF) | Playwright PDF of a styled brand guidelines HTML page generated by LLM |
| BRAND-06 | User can download all brand assets as a zip package | `archiver` v7 streams all bundle assets into a ZIP buffer returned as a single RenderResult |
</phase_requirements>

---

## Summary

Phase 43 adds two new job types — `pdf-document` (DOC-01..03) and `brand-kit` (BRAND-01..06) — to the existing content job pipeline. Both use infrastructure from Phase 40 (job store, SSE, asset storage) and follow the renderer pattern established in Phases 41-42.

PDF generation uses the Playwright Chromium browser already installed at `~/.cache/ms-playwright/chromium-1217/`. The `resolveBrowserPath()` function in `diagram-renderer.ts` is already in place and reusable. The pattern is: LLM generates an HTML page → Playwright renders it → `page.pdf()` outputs a PDF buffer. This sidesteps any native binary dependencies beyond the already-present Chromium.

Brand kit generation orchestrates multiple sub-renders — logo SVG, avatar PNGs, social images, email signature HTML, letterhead HTML, brand guidelines PDF — and then packages them with `archiver` into a single ZIP buffer. The ZIP is stored as a single generated asset; the UI fetches and triggers a browser download. `pdf-lib` is NOT needed for Phase 43 — Playwright PDF covers all three document types.

**Primary recommendation:** One `pdf-renderer.ts` for DOC-01..03 and one `brand-renderer.ts` for BRAND-01..06. Both follow the `renderX(input) → RenderResult` contract. Add two new `case` blocks in `content-job-runner.ts`. Add two new tabs to `ContentStudio.tsx`.

---

## Project Constraints (from CLAUDE.md)

No `CLAUDE.md` exists in the project root. Constraints are derived from codebase conventions documented in STATE.md:

- **Async job pattern is mandatory** — all render requests return 202 + job ID immediately; never block HTTP on render
- **sourceTaskId required** on every generated asset from day one
- **MAX_GENERATED_ASSET_BYTES** applies to all generated assets (bypasses 10MB upload limit for "generated" namespace)
- **Playwright Chromium** already decided for design-rich PDFs (confirmed in STATE.md blocker note: "Confirm pdf-lib scope: Playwright for design-rich PDFs, pdf-lib for data-driven invoices — decide at Phase 43 planning")
- **Renderer pattern**: `renderX(input: Record<string, unknown>): Promise<RenderResult>` — default export via dynamic import in `content-job-runner.ts`
- **Bundle pattern**: rich JSON blob stored as the RenderResult; UI fetches the asset URL, parses JSON, hydrates component
- **puterChatComplete** for all LLM calls; reads `PUTER_AUTH_TOKEN` from env
- **No new binary dependencies** beyond what is already installed (`sharp`, `svgo`, `playwright-core`, `@resvg/resvg-js`, `ffmpeg-static`)
- **TypeScript strict** — all new files need proper types
- **Test mocks**: `playwright-core` and `puter-inference.js` are always vi.mock()ed in tests

---

## Standard Stack

### Core (already installed — no new installs needed for PDF)

| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| `playwright-core` | 1.58.2 | HTML → PDF via Chromium headless | Already installed, `resolveBrowserPath()` written |
| `sharp` | ^0.34.5 | SVG → PNG rasterization for avatars and social images | Already in use (wallpaper-renderer.ts) |
| `svgo` | ^4.0.1 | SVG cleanup/validation | Already in use (icon-renderer.ts) |
| `@resvg/resvg-js` | ^2.6.2 | High-fidelity SVG → PNG for logo mark | Already in use (diagram-renderer.ts) |

### New Dependency: ZIP Packaging

| Library | Version | Purpose | Why |
|---------|---------|---------|-----|
| `archiver` | ^7.0.1 | Stream multiple buffers into a ZIP buffer | Well-maintained (MIT), streams-based, works entirely in memory via `archiver.finalize()` + collect into Buffer; no disk I/O |

**Installation (one new package):**
```bash
pnpm --filter @paperclipai/server add archiver
pnpm --filter @paperclipai/server add -D @types/archiver
```

**Version verification (npm registry, 2026-04-04):**
- `archiver`: 7.0.1 (latest) — confirmed
- `@types/archiver`: published alongside, v5.3.4

### Why NOT pdf-lib

STATE.md blocker note says "Confirm pdf-lib scope: Playwright for design-rich PDFs, pdf-lib for data-driven invoices — decide at Phase 43 planning."

**Decision: Use Playwright for all three doc types (DOC-01, DOC-02, DOC-03).**

Rationale:
- DOC-01 (reports), DOC-02 (invoices), DOC-03 (one-pagers) all need styled output — headings, tables, code blocks
- LLM can generate HTML + inline CSS in a single shot; Playwright renders it faithfully
- `pdf-lib` is excellent for programmatic PDF manipulation (merge, fill form fields) but poor for styled layout
- We already have Playwright Chromium; adding pdf-lib adds another package for no benefit
- Invoice "templates" work cleanly as HTML templates: LLM fills the line items, Playwright renders to PDF

### Alternatives Considered

| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| Playwright HTML→PDF | `pdf-lib` | pdf-lib has no layout engine; styling complex reports requires hand-coding coordinates — far harder than HTML+CSS |
| archiver | `jszip` | jszip is promise-based but slower; archiver's streaming API handles large asset sets better |
| archiver | `adm-zip` | adm-zip v0.5 is synchronous; blocks event loop for large zips |
| LLM-generated SVG logo | Stable Diffusion / DALL-E | Out of scope per REQUIREMENTS.md |

---

## Architecture Patterns

### Recommended Project Structure

```
server/src/services/renderers/
├── pdf-renderer.ts          # NEW — DOC-01, DOC-02, DOC-03
├── brand-renderer.ts        # NEW — BRAND-01..06
├── diagram-renderer.ts      # existing
├── icon-renderer.ts         # existing
├── wallpaper-renderer.ts    # existing
├── social-renderer.ts       # existing
├── convert-renderer.ts      # existing
└── types.ts                 # add PdfDocumentBundle + BrandKitBundle

ui/src/components/
├── DocumentGeneratePanel.tsx   # NEW — DOC tab UI
├── BrandKitPanel.tsx           # NEW — Brand tab UI
├── BrandKitResult.tsx          # NEW — brand kit display + ZIP download trigger
└── ... (existing)

ui/src/pages/
└── ContentStudio.tsx        # add "Documents" tab + "Brand" tab
```

### Pattern 1: Playwright PDF Renderer

Same structure as `diagram-renderer.ts` Playwright usage — launch browser from `resolveBrowserPath()`, create page, set HTML content, call `page.pdf()`, close browser.

```typescript
// server/src/services/renderers/pdf-renderer.ts
import { chromium } from "playwright-core";
import { resolveBrowserPath } from "./diagram-renderer.js";
import { puterChatComplete } from "../puter-inference.js";
import type { RenderResult, PdfDocumentBundle } from "./types.js";

export async function renderPdfDocument(
  input: Record<string, unknown>,
): Promise<RenderResult> {
  const docType = typeof input.docType === "string" ? input.docType : "report";
  const prompt = typeof input.prompt === "string" ? input.prompt : "";
  const title = typeof input.title === "string" ? input.title : "Document";

  // LLM generates a complete, self-contained HTML document
  const html = await puterChatComplete([
    { role: "system", content: buildPdfSystemPrompt(docType) },
    { role: "user", content: prompt },
  ]);
  const cleanHtml = stripMarkdownFences(html);

  const executablePath = resolveBrowserPath();
  const browser = await chromium.launch({
    executablePath,
    headless: true,
    args: ["--no-sandbox", "--disable-setuid-sandbox"],
  });

  let pdfBuffer: Buffer;
  try {
    const page = await browser.newPage();
    await page.setContent(cleanHtml, { waitUntil: "networkidle" });
    const pdfUint8 = await page.pdf({
      format: "A4",
      printBackground: true,
      margin: { top: "20mm", bottom: "20mm", left: "20mm", right: "20mm" },
    });
    pdfBuffer = Buffer.from(pdfUint8);
  } finally {
    await browser.close();
  }

  const bundle: PdfDocumentBundle = {
    type: "pdf-document-bundle",
    docType,
    title,
    pdfBase64: pdfBuffer.toString("base64"),
  };

  return {
    filename: `document-${docType}.json`,
    contentType: "application/json",
    buffer: Buffer.from(JSON.stringify(bundle)),
  };
}
```

**Key Playwright PDF options:**
- `page.pdf({ format: "A4", printBackground: true })` — produces a Buffer (Uint8Array in Playwright v1.58)
- `waitUntil: "networkidle"` — ensures any web fonts / images finish before capture; use `"domcontentloaded"` as fallback for offline-only HTML
- Margin in mm units
- `printBackground: true` — needed for colored headers/footers in styled documents

### Pattern 2: Brand Kit Orchestration

Brand kit is a multi-step job: one LLM call extracts the brand specification, then sub-renderers produce each asset in sequence, then archiver packages everything.

```typescript
// server/src/services/renderers/brand-renderer.ts

interface BrandSpec {
  name: string;
  tagline: string;
  primaryColor: string;   // hex
  secondaryColor: string; // hex
  fontStyle: "sans" | "serif" | "mono";
  logoDescription: string;
  industry: string;
}

export async function renderBrandKit(
  input: Record<string, unknown>,
): Promise<RenderResult> {
  const prompt = typeof input.prompt === "string" ? input.prompt : "";

  // Step 1: Extract brand specification
  const spec = await extractBrandSpec(prompt);

  // Step 2: Generate logo SVG
  const logoSvg = await generateLogoSvg(spec);

  // Step 3: Rasterize logo to avatar sizes [512, 256, 128, 64, 32]
  const avatarPngs = await rasterizeAvatars(logoSvg);

  // Step 4: Generate social platform images (profile + banner per platform)
  const socialImages = await generateSocialImages(spec, logoSvg);

  // Step 5: Generate email signature HTML + letterhead HTML
  const { signature, letterhead } = await generateTemplates(spec, logoSvg);

  // Step 6: Generate brand guidelines PDF via Playwright
  const guidelinesPdf = await generateGuidelinesPdf(spec, logoSvg, signature);

  // Step 7: Package everything into a ZIP
  const zipBuffer = await buildZip({
    logoSvg, avatarPngs, socialImages,
    signature, letterhead, guidelinesPdf,
  });

  const bundle: BrandKitBundle = {
    type: "brand-kit-bundle",
    spec,
    logoSvgBase64: Buffer.from(logoSvg).toString("base64"),
    avatarPngs,    // { "512": base64, "256": base64, ... }
    socialImages,  // { "twitter-profile": base64, ... }
    signatureHtml: signature,
    letterheadHtml: letterhead,
    guidelinesPdfBase64: guidelinesPdf.toString("base64"),
    zipBase64: zipBuffer.toString("base64"),
  };

  return {
    filename: "brand-kit-bundle.json",
    contentType: "application/json",
    buffer: Buffer.from(JSON.stringify(bundle)),
  };
}
```

### Pattern 3: archiver ZIP buffer assembly

```typescript
// Source: archiver v7 official docs
import archiver from "archiver";
import { Writable } from "stream";

async function buildZipBuffer(entries: Array<{ name: string; data: Buffer }>): Promise<Buffer> {
  return new Promise((resolve, reject) => {
    const chunks: Buffer[] = [];
    const sink = new Writable({
      write(chunk: Buffer, _enc, cb) { chunks.push(chunk); cb(); },
    });

    const archive = archiver("zip", { zlib: { level: 6 } });
    archive.on("error", reject);
    sink.on("finish", () => resolve(Buffer.concat(chunks)));
    archive.pipe(sink);

    for (const entry of entries) {
      archive.append(entry.data, { name: entry.name });
    }

    void archive.finalize();
  });
}
```

### Pattern 4: content-job-runner.ts additions

```typescript
// Add to renderContent() switch in content-job-runner.ts
case "pdf-document": {
  const { renderPdfDocument } = await import("./renderers/pdf-renderer.js");
  return renderPdfDocument(input);
}
case "brand-kit": {
  const { renderBrandKit } = await import("./renderers/brand-renderer.js");
  return renderBrandKit(input);
}
```

### Pattern 5: UI — useContentJob + bundle fetch (established pattern)

The UI pattern for both new tabs is identical to `SocialPostPanel.tsx`:
1. `useContentJob(companyId)` for submit + SSE progress
2. `if (job.status === "done" && job.resultAssetId && !bundle)` → fetch asset URL → `fetch()` → `JSON.parse()` → set bundle state
3. Display result component
4. Download button triggers `URL.createObjectURL(base64ToBinary(...))` + `<a>.click()`

For BRAND-06 (ZIP download): the zipBase64 field in the bundle drives a single download button that triggers a browser `<a download>` with the ZIP blob.

### Anti-Patterns to Avoid

- **Do NOT use `waitUntil: "load"` for Playwright PDF** — network requests for CDN fonts will fail in the sandbox; use self-contained inline CSS with `@import` disabled, or use `waitUntil: "domcontentloaded"` + system fonts only
- **Do NOT open a new Playwright browser per sub-step in brand kit** — open once, generate guidelines PDF in that session, close; reuse same `resolveBrowserPath()` pattern
- **Do NOT pass a file path to archiver from tmpdir** — use `archive.append(buffer, { name })` in-memory to avoid disk temp files
- **Do NOT build the brand kit as a single sequential LLM mega-prompt** — extract spec first (structured JSON), then feed spec fields into individual generators; this gives predictable output shapes
- **Do NOT define BrandKitBundle only in the panel component file** — unlike wallpaper/social bundles (which are panel-local per STATE.md decision), `BrandKitBundle` and `PdfDocumentBundle` must be added to `server/src/services/renderers/types.ts` because the brand renderer references them directly

---

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| PDF from styled HTML | Custom layout engine | `playwright-core` `page.pdf()` | CSS layout is already solved; Playwright has CSS paged media support |
| ZIP archive in memory | Manually writing ZIP file format | `archiver` v7 | ZIP format has CRC32, compression, directory entries — trivially wrong to hand-roll |
| SVG logo cleanup | Custom regex stripper | `svgo` (already installed) `validateAndCleanSvg()` in icon-renderer.ts | Already written and tested |
| SVG → PNG rasterization | Sharp for logo mark | `@resvg/resvg-js` (already installed, used in diagram-renderer.ts) | Handles embedded fonts and complex gradients better than sharp for LLM-generated logos |
| Brand color parsing | Write hex parser | CSS string; pass raw hex from LLM spec directly to SVG fill attributes | No parsing needed |

**Key insight:** Every difficult sub-problem in this phase already has a solved dependency in the codebase. This phase is integration work, not new infrastructure.

---

## Common Pitfalls

### Pitfall 1: Playwright `page.pdf()` returns Uint8Array, not Buffer
**What goes wrong:** TypeScript infers `Uint8Array`; passing directly to `Buffer.from()` works but `byteLength` comparison for MAX_GENERATED_ASSET_BYTES expects a Buffer.
**Why it happens:** Playwright v1.58 changed the return type of `page.pdf()` to `Promise<Uint8Array>`.
**How to avoid:** Always wrap: `const pdfBuffer = Buffer.from(await page.pdf({...}))`.
**Warning signs:** TS2345 type error, or `buffer.byteLength` returning wrong value.

### Pitfall 2: LLM-generated HTML includes external resource references
**What goes wrong:** HTML links to Google Fonts CDN, external images. Playwright in `--no-sandbox` headless mode may not fetch them, producing blank/missing content.
**Why it happens:** LLM follows standard web conventions; doesn't know the context is offline.
**How to avoid:** System prompt must explicitly say: "Use only inline CSS. No external URLs. Use web-safe system fonts (Arial, Georgia, monospace). No `<link>` or `<script src>` tags."
**Warning signs:** PDF has blank sections, missing fonts fall back to serif.

### Pitfall 3: Brand kit job exceeds MAX_GENERATED_ASSET_BYTES
**What goes wrong:** Brand kit bundle contains logo SVG + 5 avatar PNGs + ~12 social images + letterhead + guidelines PDF + ZIP — all as base64 in a single JSON blob. This can easily exceed the limit.
**Why it happens:** base64 encoding inflates by ~33%; large social images (1584×396 LinkedIn banner) at PNG are big.
**How to avoid:** Compress social images with sharp (`png({ compressionLevel: 9 })`) or use JPEG for banners. Keep logo SVG small (SVGO). Check `MAX_GENERATED_ASSET_BYTES` value before storing.
**Warning signs:** `Generated asset size X exceeds limit` error in job runner.

### Pitfall 4: resolveBrowserPath() called multiple times in brand-renderer
**What goes wrong:** Brand renderer needs Playwright for guidelines PDF and potentially for letterhead/signature screenshots. If called in a tight loop, each `chromium.launch()` spins up a new process.
**Why it happens:** Renderer functions are stateless; no shared browser instance.
**How to avoid:** Resolve browser path once, launch one browser instance per brand kit job, create multiple pages from that browser, close after all PDF/screenshot work is done.

### Pitfall 5: archiver `finish` fires before all entries are flushed
**What goes wrong:** `archive.finalize()` returns a promise but the `sink` Writable may not have received all chunks yet.
**Why it happens:** archiver is stream-based; finalize signals end of input, but the sink collects data asynchronously.
**How to avoid:** Resolve on `sink.on("finish", ...)` not on `archive.finalize()` promise — see code example in Pattern 3 above. This is the correct pattern per archiver docs.

### Pitfall 6: ContentStudio tab count grows — need stable tab IDs
**What goes wrong:** Adding two new tabs ("Documents" and "Brand") shifts defaultValue to avoid breaking existing tab state.
**Why it happens:** React Tabs stores active tab by value string, which persists if passed via URL or state.
**How to avoid:** Use stable string values (`"documents"`, `"brand"`) not index-based values. Keep `defaultValue="diagrams"` unchanged.

---

## Code Examples

### HTML system prompt for PDF generation

```typescript
// Source: established LLM system prompt pattern from diagram-renderer.ts + icon-renderer.ts
function buildPdfSystemPrompt(docType: "report" | "invoice" | "one-pager" | "api-docs"): string {
  const typeInstructions: Record<string, string> = {
    report: "Generate a professional report with an executive summary, body sections with headings, and a conclusion.",
    invoice: "Generate an invoice with company header, line items table (description, qty, unit price, total), subtotal, tax, and grand total.",
    "one-pager": "Generate a single-page summary document with a prominent headline, key points, and call to action.",
    "api-docs": "Generate API documentation with endpoint descriptions, request/response examples in <pre> blocks, and parameter tables.",
  };

  return [
    "You are a professional document generator.",
    "Output ONLY a complete, valid HTML document starting with <!DOCTYPE html>.",
    "Include all CSS inline in a <style> block — no external stylesheets.",
    "Use only web-safe system fonts: Arial, Georgia, or monospace.",
    "No external URLs, no <link> tags, no <script> tags.",
    "Use clean, readable typography with good line-height and margins.",
    "Ensure the document looks professional when printed to A4.",
    "",
    typeInstructions[docType] ?? typeInstructions.report,
  ].join("\n");
}
```

### ZIP download trigger in UI

```typescript
// Pattern for BRAND-06 — browser-side ZIP download from base64
function downloadZip(zipBase64: string, brandName: string) {
  const byteString = atob(zipBase64);
  const bytes = new Uint8Array(byteString.length);
  for (let i = 0; i < byteString.length; i++) {
    bytes[i] = byteString.charCodeAt(i);
  }
  const blob = new Blob([bytes], { type: "application/zip" });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = `${brandName.toLowerCase().replace(/\s+/g, "-")}-brand-kit.zip`;
  a.click();
  URL.revokeObjectURL(url);
}
```

### ZIP folder structure convention

```
brand-kit/
├── logo/
│   ├── logo.svg
│   ├── logo-512.png
│   ├── logo-256.png
│   ├── logo-128.png
│   ├── logo-64.png
│   └── logo-32.png
├── social/
│   ├── twitter-profile.png      (400×400)
│   ├── twitter-banner.png       (1500×500)
│   ├── linkedin-profile.png     (400×400)
│   ├── linkedin-banner.png      (1584×396)
│   └── instagram-profile.png   (1080×1080)
├── templates/
│   ├── email-signature.html
│   └── letterhead.html
└── guidelines.pdf
```

### New types to add to types.ts

```typescript
export interface PdfDocumentBundle {
  type: "pdf-document-bundle";
  docType: string;
  title: string;
  pdfBase64: string;
}

export interface BrandKitBundle {
  type: "brand-kit-bundle";
  spec: {
    name: string;
    tagline: string;
    primaryColor: string;
    secondaryColor: string;
    fontStyle: string;
    industry: string;
  };
  logoSvgBase64: string;
  avatarPngs: Record<string, string>;      // "512" | "256" | "128" | "64" | "32" → base64
  socialImages: Record<string, string>;    // "twitter-profile" | ... → base64
  signatureHtml: string;
  letterheadHtml: string;
  guidelinesPdfBase64: string;
  zipBase64: string;
}

// Update ContentBundle union:
export type ContentBundle = DiagramBundle | IconSetBundle | ThemePaletteBundle
  | WallpaperBundle | AppIconBundle | SocialPostBundle | ConvertBundle
  | PdfDocumentBundle | BrandKitBundle;
```

---

## Environment Availability

| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Playwright Chromium | PDF generation, brand guidelines | ✓ | 1217 (chromium-1217) | — |
| playwright-core | PDF + screenshot | ✓ | 1.58.2 in server deps | — |
| sharp | Avatar + social image rasterization | ✓ | ^0.34.5 | — |
| @resvg/resvg-js | Logo SVG → PNG | ✓ | ^2.6.2 | — |
| svgo | Logo SVG cleanup | ✓ | ^4.0.1 | — |
| archiver | ZIP packaging (BRAND-06) | ✗ (not yet installed) | 7.0.1 available | — |
| @types/archiver | TypeScript types for archiver | ✗ (not yet installed) | available | — |

**Missing dependencies with no fallback:**
- `archiver` + `@types/archiver` — must be installed in Wave 0 / Plan 01

**Missing dependencies with fallback:**
- None

---

## Validation Architecture

### Test Framework

| Property | Value |
|----------|-------|
| Framework | vitest ^3.0.5 |
| Config file | `server/vitest.config.ts` (`environment: "node"`) |
| Quick run command | `pnpm --filter @paperclipai/server test --run src/__tests__/pdf-renderer.test.ts` |
| Full suite command | `pnpm --filter @paperclipai/server test --run` |

### Phase Requirements → Test Map

| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| DOC-01 | renderPdfDocument produces pdf-document-bundle with non-empty pdfBase64 | unit | `pnpm --filter @paperclipai/server test --run src/__tests__/pdf-renderer.test.ts` | ❌ Wave 0 |
| DOC-02 | renderPdfDocument with docType="invoice" fills line items from prompt | unit | same file | ❌ Wave 0 |
| DOC-03 | renderPdfDocument with docType="api-docs" | unit | same file | ❌ Wave 0 |
| BRAND-01 | renderBrandKit returns brand-kit-bundle with all required fields | unit | `pnpm --filter @paperclipai/server test --run src/__tests__/brand-renderer.test.ts` | ❌ Wave 0 |
| BRAND-02 | avatarPngs keys include "512", "256", "128", "64", "32" | unit | same file | ❌ Wave 0 |
| BRAND-03 | socialImages keys include twitter-profile, linkedin-banner | unit | same file | ❌ Wave 0 |
| BRAND-04 | signatureHtml and letterheadHtml are non-empty strings | unit | same file | ❌ Wave 0 |
| BRAND-05 | guidelinesPdfBase64 is non-empty; decodes to a PDF-header buffer | unit | same file | ❌ Wave 0 |
| BRAND-06 | zipBase64 decodes to a buffer starting with PK (ZIP magic bytes) | unit | same file | ❌ Wave 0 |

### Sampling Rate
- **Per task commit:** quick run for the renderer under test
- **Per wave merge:** `pnpm --filter @paperclipai/server test --run`
- **Phase gate:** full suite green before `/gsd:verify-work`

### Wave 0 Gaps
- [ ] `server/src/__tests__/pdf-renderer.test.ts` — covers DOC-01, DOC-02, DOC-03
- [ ] `server/src/__tests__/brand-renderer.test.ts` — covers BRAND-01..06
- [ ] Install `archiver` + `@types/archiver`
- [ ] Add `PdfDocumentBundle` + `BrandKitBundle` to `server/src/services/renderers/types.ts`

---

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| pdf-lib for styled PDFs | Playwright page.pdf() | Became standard ~2022 | HTML/CSS layout skills apply directly |
| jszip for archives | archiver v7 | archiver has been standard for Node.js streaming zips since ~2018 | streaming API handles large sets without OOM |
| puppeteer for Chromium | playwright-core | Phase 41 established playwright-core | No puppeteer in this project |

**Deprecated/outdated:**
- `pdf-lib` for report/invoice layout: still useful for form-filling and PDF merge, but not the right tool when you have Playwright available
- `wkhtmltopdf`: system binary, poor Linux support, unmaintained

---

## Open Questions

1. **Brand kit job timeout**
   - What we know: The brand kit runs ~7 sequential sub-renders (spec extraction, logo gen, 5 avatar sizes, ~5 social images, signature, letterhead, guidelines PDF). Total LLM calls: ~4-5. Total sharp/Playwright ops: ~8-10.
   - What's unclear: Total wall-clock time. Could be 30-90 seconds on the M4. The existing job runner has no timeout; Playwright uses 15s timeout per `waitForSelector` call in diagram-renderer.
   - Recommendation: Set Playwright `page.setContent` timeout to 30s for PDF jobs. Accept that brand kit may take 45-90s — SSE progress feedback is already in place so the user sees the spinner.

2. **MAX_GENERATED_ASSET_BYTES limit for brand kit**
   - What we know: The constant exists; its exact value is not visible in the files read.
   - What's unclear: Whether a full brand kit bundle with ZIP (all base64) fits within it.
   - Recommendation: Check the constant value in `attachment-types.ts`. If brand kit bundle would exceed it, split: store ZIP as a separate generated asset and reference its asset ID from the brand-kit-bundle JSON instead of embedding zipBase64.

---

## Sources

### Primary (HIGH confidence)
- Codebase: `/opt/nexus/server/src/services/renderers/diagram-renderer.ts` — Playwright launch/PDF pattern, `resolveBrowserPath()`
- Codebase: `/opt/nexus/server/src/services/renderers/types.ts` — existing bundle interface contracts
- Codebase: `/opt/nexus/server/src/services/content-job-runner.ts` — switch dispatch pattern for new job types
- Codebase: `/opt/nexus/server/package.json` — confirmed installed packages and versions
- Codebase: `/opt/nexus/.planning/STATE.md` — locked architectural decisions (async jobs, Playwright for PDFs)
- npm registry: `archiver@7.0.1` — current version confirmed 2026-04-04
- npm registry: `pdf-lib@1.17.1` — checked and explicitly excluded from stack

### Secondary (MEDIUM confidence)
- Playwright official docs on `page.pdf()` — format options, margin units, `printBackground`, Uint8Array return type in v1.58
- archiver v7 README — `archive.append(buffer, { name })` in-memory usage pattern

### Tertiary (LOW confidence)
- None

---

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — all packages either already installed or version-confirmed from npm registry
- Architecture: HIGH — follows patterns already established in Phases 41-42 with minimal new concepts
- Pitfalls: HIGH — derived from existing codebase patterns and known Playwright/archiver behaviors
- Environment: HIGH — Chromium binary confirmed present at `~/.cache/ms-playwright/chromium-1217/`

**Research date:** 2026-04-04
**Valid until:** 2026-05-04 (stable packages; Playwright version pinned at 1.58.2)
