30 KiB
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 incontent-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_TOKENfrom 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-coreandputer-inference.jsare 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):
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-libis 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.
// 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.
// 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
// 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
// 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:
useContentJob(companyId)for submit + SSE progressif (job.status === "done" && job.resultAssetId && !bundle)→ fetch asset URL →fetch()→JSON.parse()→ set bundle state- Display result component
- 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@importdisabled, or usewaitUntil: "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),
BrandKitBundleandPdfDocumentBundlemust be added toserver/src/services/renderers/types.tsbecause 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
// 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
// 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
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-03server/src/__tests__/brand-renderer.test.ts— covers BRAND-01..06- Install
archiver+@types/archiver - Add
PdfDocumentBundle+BrandKitBundletoserver/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-libfor report/invoice layout: still useful for form-filling and PDF merge, but not the right tool when you have Playwright availablewkhtmltopdf: system binary, poor Linux support, unmaintained
Open Questions
-
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
waitForSelectorcall in diagram-renderer. - Recommendation: Set Playwright
page.setContenttimeout 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.
-
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)