feat: Phase 43 — Documents & Branding (PDF reports, brand kit ZIP)

This commit is contained in:
Nexus Dev 2026-04-05 09:57:02 +00:00
parent 0956c31384
commit e4165adefb
23 changed files with 3915 additions and 31 deletions

View file

@ -34,9 +34,9 @@ Requirements for Content Generation milestone. Each maps to roadmap phases.
### Document Generation
- [ ] **DOC-01**: User can generate formatted PDF reports from conversation content
- [ ] **DOC-02**: User can generate invoices and contracts from templates
- [ ] **DOC-03**: User can generate one-pagers and API documentation
- [x] **DOC-01**: User can generate formatted PDF reports from conversation content
- [x] **DOC-02**: User can generate invoices and contracts from templates
- [x] **DOC-03**: User can generate one-pagers and API documentation
### Icon Generation
@ -66,12 +66,12 @@ Requirements for Content Generation milestone. Each maps to roadmap phases.
### Branding Media Kit
- [ ] **BRAND-01**: User can generate a full brand identity from a single conversation
- [ ] **BRAND-02**: System produces logo mark (SVG), avatar in multiple sizes
- [ ] **BRAND-03**: System produces social media profile images and banners per platform
- [ ] **BRAND-04**: System produces email signature and letterhead templates
- [ ] **BRAND-05**: System produces a brand guidelines document (PDF)
- [ ] **BRAND-06**: User can download all brand assets as a zip package
- [x] **BRAND-01**: User can generate a full brand identity from a single conversation
- [x] **BRAND-02**: System produces logo mark (SVG), avatar in multiple sizes
- [x] **BRAND-03**: System produces social media profile images and banners per platform
- [x] **BRAND-04**: System produces email signature and letterhead templates
- [x] **BRAND-05**: System produces a brand guidelines document (PDF)
- [x] **BRAND-06**: User can download all brand assets as a zip package
### Format Conversion
@ -166,15 +166,15 @@ Which phases cover which requirements. Updated during roadmap creation.
| VOICE-01 | Phase 42 | Complete |
| VOICE-02 | Phase 42 | Complete |
| VOICE-03 | Phase 42 | Complete |
| DOC-01 | Phase 43 | Pending |
| DOC-02 | Phase 43 | Pending |
| DOC-03 | Phase 43 | Pending |
| BRAND-01 | Phase 43 | Pending |
| BRAND-02 | Phase 43 | Pending |
| BRAND-03 | Phase 43 | Pending |
| BRAND-04 | Phase 43 | Pending |
| BRAND-05 | Phase 43 | Pending |
| BRAND-06 | Phase 43 | Pending |
| DOC-01 | Phase 43 | Complete |
| DOC-02 | Phase 43 | Complete |
| DOC-03 | Phase 43 | Complete |
| BRAND-01 | Phase 43 | Complete |
| BRAND-02 | Phase 43 | Complete |
| BRAND-03 | Phase 43 | Complete |
| BRAND-04 | Phase 43 | Complete |
| BRAND-05 | Phase 43 | Complete |
| BRAND-06 | Phase 43 | Complete |
| PRES-01 | Phase 44 | Pending |
| PRES-02 | Phase 44 | Pending |
| PRES-03 | Phase 44 | Pending |

View file

@ -173,7 +173,7 @@ Plans:
- [x] **Phase 40: Job Infrastructure** — content_jobs table, async render lifecycle, SSE progress events, namespaced storage without size limit (INFRA-01..04) (completed 2026-04-04)
- [x] **Phase 41: Diagrams, Icons & Theme Engine** — Mermaid diagrams, SVG icon generation, OKLCH theme palette with WCAG AA and live preview (DIAG-01..05, ICON-01..03, THEME-01..07) (completed 2026-04-04)
- [x] **Phase 42: Wallpapers, Social, Format Conversion & Voice** — LLM SVG + sharp wallpapers, social content, format conversion registry with AI fallback, Whisper web chat mic (WALL-01..04, SOCIAL-01..03, CONV-01..09, VOICE-01..03) (completed 2026-04-04)
- [ ] **Phase 43: Documents & Branding** — Playwright PDF reports and invoices, full brand identity kit with zip export (DOC-01..03, BRAND-01..06)
- [x] **Phase 43: Documents & Branding** — Playwright PDF reports and invoices, full brand identity kit with zip export (DOC-01..03, BRAND-01..06) (completed 2026-04-04)
- [ ] **Phase 44: Video & Presentations** — Remotion workspace package, pitch decks and demo videos, SSE render progress (PRES-01..04)
- [ ] **Phase 45: Content as Skills** — Markdown skill files for all content types, Creative skill group on generalist agent (SKILL-01..03)
@ -245,7 +245,12 @@ Plans:
2. Generating a one-pager or API reference document produces a styled PDF with navigable headings
3. Starting a brand identity conversation produces a logo mark (SVG), avatar at multiple sizes, platform-specific social images, an email signature, and a brand guidelines PDF — all in a single brand kit
4. The complete brand kit can be downloaded as a single zip file with assets organized by type
**Plans**: TBD
**Plans**: 3 plans
Plans:
- [x] 43-01-PLAN.md — Types, archiver install, PDF renderer (Playwright HTML-to-PDF), job-runner wiring
- [x] 43-02-PLAN.md — Brand kit renderer (logo, avatars, social images, templates, guidelines PDF, ZIP packaging)
- [x] 43-03-PLAN.md — Document and Brand UI panels, ContentStudio tab extensions
**UI hint**: yes
### Phase 44: Video & Presentations
@ -362,6 +367,6 @@ All 52 v1.7 requirements are mapped to exactly one phase. No orphans.
| 40. Job Infrastructure | v1.7 | 2/2 | Complete | 2026-04-04 |
| 41. Diagrams, Icons & Theme Engine | v1.7 | 6/6 | Complete | 2026-04-04 |
| 42. Wallpapers, Social, Format Conversion & Voice | v1.7 | 6/6 | Complete | 2026-04-04 |
| 43. Documents & Branding | v1.7 | 0/TBD | Not started | - |
| 43. Documents & Branding | v1.7 | 3/3 | Complete | 2026-04-04 |
| 44. Video & Presentations | v1.7 | 0/TBD | Not started | - |
| 45. Content as Skills | v1.7 | 0/TBD | Not started | - |

View file

@ -3,14 +3,14 @@ gsd_state_version: 1.0
milestone: v1.7
milestone_name: Content Generation
status: verifying
stopped_at: Completed 42-05-PLAN.md — Wallpaper and Social UI panels, ContentStudio extended to 5 tabs
last_updated: "2026-04-04T22:23:10.096Z"
stopped_at: Completed 43-03-PLAN.md — DocumentGeneratePanel, BrandKitPanel, BrandKitResult, ContentStudio 7 tabs
last_updated: "2026-04-04T22:56:41.659Z"
last_activity: 2026-04-04
progress:
total_phases: 6
completed_phases: 3
total_plans: 14
completed_plans: 14
completed_phases: 4
total_plans: 17
completed_plans: 17
percent: 0
---
@ -21,11 +21,11 @@ progress:
See: .planning/PROJECT.md (updated 2026-04-04)
**Core value:** A fresh onboard asks for ONE thing (root directory), auto-creates PM + Engineer agents, and drops you in the dashboard.
**Current focus:** Phase 42 — wallpapers-social-format-conversion-voice
**Current focus:** Phase 43 — documents-branding
## Current Position
Phase: 43
Phase: 44
Plan: Not started
Status: Phase complete — ready for verification
Last activity: 2026-04-04
@ -86,6 +86,11 @@ Key constraints for v1.7:
- [Phase 42-wallpapers-social-format-conversion-voice]: Direct EventSource for pre-submitted convert jobs — useContentJob.submit() cannot track an existing jobId without re-submitting via contentJobs route
- [Phase 42-wallpapers-social-format-conversion-voice]: FORMAT_GROUPS exported from ConvertPanel so ConvertPage can import for allowlist validation without duplicating the list
- [Phase 42-wallpapers-social-format-conversion-voice]: WallpaperBundle/AppIconBundle/SocialPostBundle types defined locally in panel component files — no content-bundles.ts addition needed since these types are only consumed by their respective components
- [Phase 43-documents-branding]: stripMarkdownFences duplicated locally in pdf-renderer — wallpaper-renderer does not export it
- [Phase 43-documents-branding]: buildPdfSystemPrompt uses switch with 4 distinct LLM prompt variants per docType (report/invoice/api-docs/one-pager)
- [Phase 43]: Social images in brand-renderer use SVG templates (colored rect + embedded logo) rather than LLM-generated — fast, deterministic, always on-brand
- [Phase 43-documents-branding]: BrandKitBundle type defined in BrandKitResult.tsx and imported by BrandKitPanel — type co-located with display component, avoids duplication
- [Phase 43-documents-branding]: iframe sandbox=allow-same-origin for email signature and letterhead previews — prevents script execution while allowing inline CSS
### Pending Todos
@ -100,6 +105,6 @@ None yet.
## Session Continuity
Last session: 2026-04-04T22:22:05.529Z
Stopped at: Completed 42-05-PLAN.md — Wallpaper and Social UI panels, ContentStudio extended to 5 tabs
Last session: 2026-04-04T22:56:11.026Z
Stopped at: Completed 43-03-PLAN.md — DocumentGeneratePanel, BrandKitPanel, BrandKitResult, ContentStudio 7 tabs
Resume file: None

View file

@ -0,0 +1,230 @@
---
phase: 43-documents-branding
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- server/src/services/renderers/types.ts
- server/src/services/renderers/pdf-renderer.ts
- server/src/services/content-job-runner.ts
- server/src/__tests__/pdf-renderer.test.ts
- server/package.json
autonomous: true
requirements: [DOC-01, DOC-02, DOC-03]
must_haves:
truths:
- "renderPdfDocument produces a pdf-document-bundle with non-empty pdfBase64 for report docType"
- "renderPdfDocument produces a pdf-document-bundle for invoice docType with line items"
- "renderPdfDocument produces a pdf-document-bundle for api-docs docType"
- "content-job-runner dispatches pdf-document jobType to pdf-renderer"
- "PdfDocumentBundle and BrandKitBundle types exported from types.ts"
artifacts:
- path: "server/src/services/renderers/pdf-renderer.ts"
provides: "PDF rendering via Playwright HTML-to-PDF"
exports: ["renderPdfDocument"]
- path: "server/src/services/renderers/types.ts"
provides: "PdfDocumentBundle + BrandKitBundle type definitions"
contains: "PdfDocumentBundle"
- path: "server/src/__tests__/pdf-renderer.test.ts"
provides: "Unit tests for PDF renderer"
min_lines: 40
key_links:
- from: "server/src/services/content-job-runner.ts"
to: "server/src/services/renderers/pdf-renderer.ts"
via: "dynamic import in renderContent switch"
pattern: 'case "pdf-document"'
- from: "server/src/services/renderers/pdf-renderer.ts"
to: "server/src/services/renderers/diagram-renderer.js"
via: "resolveBrowserPath import"
pattern: "resolveBrowserPath"
---
<objective>
PDF renderer and shared types for Phase 43 document generation.
Purpose: Enable PDF report, invoice, one-pager, and API documentation generation via Playwright HTML-to-PDF. Also installs `archiver` and defines both bundle types needed by Plans 02-03.
Output: Working pdf-renderer.ts, updated types.ts, job-runner wiring, archiver installed.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/43-documents-branding/43-RESEARCH.md
@server/src/services/renderers/types.ts
@server/src/services/content-job-runner.ts
@server/src/services/renderers/diagram-renderer.ts
@server/src/services/puter-inference.ts
<interfaces>
<!-- Key types and contracts the executor needs. -->
From server/src/services/renderers/types.ts:
```typescript
export interface RenderResult {
filename: string;
contentType: string;
buffer: Buffer;
}
export type ContentBundle = DiagramBundle | IconSetBundle | ThemePaletteBundle | WallpaperBundle | AppIconBundle | SocialPostBundle | ConvertBundle;
```
From server/src/services/renderers/diagram-renderer.ts:
```typescript
export function resolveBrowserPath(): string; // resolves Playwright Chromium binary path
```
From server/src/services/puter-inference.ts:
```typescript
export interface ChatMessage { role: "system" | "user" | "assistant"; content: string; }
export async function puterChatComplete(messages: ChatMessage[], model?: string): Promise<string>;
```
From server/src/services/content-job-runner.ts:
```typescript
// renderContent() switch — add new case for "pdf-document"
export async function renderContent(jobType: string, input: Record<string, unknown>): Promise<RenderResult>;
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Install archiver, add bundle types, create pdf-renderer with tests</name>
<files>server/package.json, server/src/services/renderers/types.ts, server/src/services/renderers/pdf-renderer.ts, server/src/__tests__/pdf-renderer.test.ts</files>
<read_first>
- server/src/services/renderers/types.ts (current bundle types and ContentBundle union)
- server/src/services/renderers/diagram-renderer.ts (resolveBrowserPath, Playwright launch pattern, stripUnsafeDirectives)
- server/src/services/renderers/wallpaper-renderer.ts (stripMarkdownFences helper — lines 42-50)
- server/src/services/puter-inference.ts (puterChatComplete signature)
- server/src/__tests__/diagram-renderer.test.ts (test mock pattern for playwright-core and puter-inference)
</read_first>
<behavior>
- Test 1: renderPdfDocument with docType="report" returns { filename: "document-report.json", contentType: "application/json" } and buffer parses to PdfDocumentBundle with type "pdf-document-bundle" and non-empty pdfBase64
- Test 2: renderPdfDocument with docType="invoice" returns bundle with docType "invoice"
- Test 3: renderPdfDocument with docType="api-docs" returns bundle with docType "api-docs"
- Test 4: renderPdfDocument with docType="one-pager" returns bundle with docType "one-pager"
- Test 5: LLM system prompt varies by docType (report vs invoice vs api-docs vs one-pager)
</behavior>
<action>
1. Install archiver: `pnpm --filter @paperclipai/server add archiver && pnpm --filter @paperclipai/server add -D @types/archiver`
2. Add to server/src/services/renderers/types.ts — append BEFORE the ContentBundle union:
```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>;
socialImages: Record<string, string>;
signatureHtml: string;
letterheadHtml: string;
guidelinesPdfBase64: string;
zipBase64: string;
}
```
Update ContentBundle union to include `| PdfDocumentBundle | BrandKitBundle`.
3. Create server/src/services/renderers/pdf-renderer.ts:
- Import `chromium` from playwright-core, `resolveBrowserPath` from diagram-renderer, `puterChatComplete` from puter-inference, `RenderResult` and `PdfDocumentBundle` from types
- Create a local `stripMarkdownFences(raw: string): string` helper that removes ```html and ``` fences (same pattern as wallpaper-renderer line 42-50 — do NOT import from wallpaper-renderer as it is not exported)
- Create `buildPdfSystemPrompt(docType: string): string` with type-specific instructions for "report", "invoice", "one-pager", "api-docs" — each variant has different structural instructions. CRITICAL: system prompt must say "Use only inline CSS in a <style> block. No external URLs, no <link> tags, no <script> tags. Use web-safe system fonts: Arial, Georgia, monospace."
- Export `renderPdfDocument(input: Record<string, unknown>): Promise<RenderResult>`:
- Extract `docType` (default "report"), `prompt`, `title` (default "Document") from input
- Call puterChatComplete with system prompt + user prompt
- Strip markdown fences from LLM output
- Launch Chromium via resolveBrowserPath(), set HTML content with waitUntil "networkidle", call page.pdf({ format: "A4", printBackground: true, margin: { top: "20mm", bottom: "20mm", left: "20mm", right: "20mm" } })
- Wrap Uint8Array in Buffer.from()
- Build PdfDocumentBundle with pdfBase64 = pdfBuffer.toString("base64")
- Return { filename: `document-${docType}.json`, contentType: "application/json", buffer: Buffer.from(JSON.stringify(bundle)) }
- Use try/finally to always close browser
4. Create server/src/__tests__/pdf-renderer.test.ts:
- vi.mock("playwright-core") — mock chromium.launch to return { newPage: () => page, close: vi.fn() } where page has setContent and pdf (returns Buffer.from("fake-pdf"))
- vi.mock("../services/puter-inference.js") — mock puterChatComplete to return "<!DOCTYPE html><html><body>test</body></html>"
- vi.mock("../services/renderers/diagram-renderer.js") — mock resolveBrowserPath to return "/fake/chromium"
- Write tests per behavior section above
</action>
<acceptance_criteria>
- grep -q "PdfDocumentBundle" server/src/services/renderers/types.ts
- grep -q "BrandKitBundle" server/src/services/renderers/types.ts
- grep -q "renderPdfDocument" server/src/services/renderers/pdf-renderer.ts
- grep -q "resolveBrowserPath" server/src/services/renderers/pdf-renderer.ts
- grep -q "archiver" server/package.json
</acceptance_criteria>
<verify>
<automated>pnpm --filter @paperclipai/server test --run src/__tests__/pdf-renderer.test.ts</automated>
</verify>
<done>PDF renderer produces PdfDocumentBundle for all 4 doc types (report, invoice, one-pager, api-docs). archiver installed. Both PdfDocumentBundle and BrandKitBundle types defined.</done>
</task>
<task type="auto">
<name>Task 2: Wire pdf-document jobType into content-job-runner</name>
<files>server/src/services/content-job-runner.ts</files>
<read_first>
- server/src/services/content-job-runner.ts (current renderContent switch)
</read_first>
<action>
Add a new case to the `renderContent()` switch in content-job-runner.ts, before the `default` case:
```typescript
case "pdf-document": {
const { renderPdfDocument } = await import("./renderers/pdf-renderer.js");
return renderPdfDocument(input);
}
```
Follow exact pattern of existing cases (dynamic import with .js extension, destructured named export).
</action>
<acceptance_criteria>
- grep -q '"pdf-document"' server/src/services/content-job-runner.ts
- grep -q 'renderPdfDocument' server/src/services/content-job-runner.ts
</acceptance_criteria>
<verify>
<automated>pnpm --filter @paperclipai/server exec tsc --noEmit</automated>
</verify>
<done>content-job-runner dispatches "pdf-document" jobType to renderPdfDocument. TypeScript compiles cleanly.</done>
</task>
</tasks>
<verification>
- `pnpm --filter @paperclipai/server test --run src/__tests__/pdf-renderer.test.ts` passes
- `pnpm --filter @paperclipai/server exec tsc --noEmit` compiles without errors
- `grep -q "pdf-document" server/src/services/content-job-runner.ts` succeeds
</verification>
<success_criteria>
- PDF renderer handles all 4 document types (report, invoice, one-pager, api-docs)
- Both PdfDocumentBundle and BrandKitBundle types exported from types.ts
- archiver package installed
- Job runner dispatches pdf-document jobs correctly
- All tests pass, TypeScript clean
</success_criteria>
<output>
After completion, create `.planning/phases/43-documents-branding/43-01-SUMMARY.md`
</output>

View file

@ -0,0 +1,125 @@
---
phase: 43-documents-branding
plan: 01
subsystem: api
tags: [playwright, pdf, pdf-renderer, archiver, puter-inference, typescript]
# Dependency graph
requires:
- phase: 41-diagrams-icons-theme-engine
provides: diagram-renderer.ts with resolveBrowserPath and Playwright launch pattern
- phase: 42-wallpapers-social-format-conversion-voice
provides: content-job-runner.ts switch pattern and wallpaper-renderer.ts stripMarkdownFences pattern
provides:
- pdf-renderer.ts — renderPdfDocument function using Playwright HTML-to-PDF for 4 docTypes
- PdfDocumentBundle and BrandKitBundle types exported from types.ts
- archiver package installed for ZIP operations (used by Plans 02-03)
- content-job-runner dispatches "pdf-document" jobType to renderPdfDocument
affects:
- 43-02 (brand-kit renderer will use BrandKitBundle type and archiver)
- 43-03 (document UI panel will use PdfDocumentBundle type)
# Tech tracking
tech-stack:
added: [archiver, "@types/archiver"]
patterns:
- buildPdfSystemPrompt function with docType switch for LLM prompt variation
- stripMarkdownFences local helper (same pattern as wallpaper-renderer — not imported, duplicated intentionally)
- try/finally browser lifecycle for Playwright cleanup
key-files:
created:
- server/src/services/renderers/pdf-renderer.ts
- server/src/__tests__/pdf-renderer.test.ts
modified:
- server/src/services/renderers/types.ts
- server/src/services/content-job-runner.ts
- server/package.json
key-decisions:
- "stripMarkdownFences duplicated locally in pdf-renderer — wallpaper-renderer does not export it and creating a shared util was not planned"
- "buildPdfSystemPrompt uses a switch with 4 distinct system prompt variants (report/invoice/api-docs/one-pager) — each has structurally different instructions for the LLM"
- "archiver installed now so Plans 02-03 can use it without an extra install step"
patterns-established:
- "PDF system prompts always include 'Use only inline CSS in a <style> block. No external URLs, no <link> tags, no <script> tags. Use web-safe system fonts' constraint"
- "Playwright page.pdf() with format A4, printBackground true, 20mm margins — standard PDF output configuration"
requirements-completed: [DOC-01, DOC-02, DOC-03]
# Metrics
duration: 5min
completed: 2026-04-04
---
# Phase 43 Plan 01: PDF Renderer Foundation Summary
**Playwright HTML-to-PDF renderer for 4 document types (report/invoice/api-docs/one-pager) with PdfDocumentBundle/BrandKitBundle types and content-job-runner wiring**
## Performance
- **Duration:** ~5 min
- **Started:** 2026-04-04T22:43:00Z
- **Completed:** 2026-04-04T22:45:11Z
- **Tasks:** 2
- **Files modified:** 5
## Accomplishments
- Created pdf-renderer.ts with renderPdfDocument: LLM generates HTML via puterChatComplete, Playwright renders to PDF, returns PdfDocumentBundle JSON
- Added PdfDocumentBundle and BrandKitBundle interfaces to types.ts and updated ContentBundle union
- Installed archiver + @types/archiver for Plans 02-03 ZIP operations
- Wired "pdf-document" jobType in content-job-runner switch with dynamic import pattern
- 6 unit tests pass covering all 4 docTypes + browser cleanup + system prompt variation
## Task Commits
Each task was committed atomically:
1. **Task 1: Install archiver, add bundle types, create pdf-renderer with tests** - `7c7394bf` (feat)
2. **Task 2: Wire pdf-document jobType into content-job-runner** - `16023234` (feat)
## Files Created/Modified
- `server/src/services/renderers/pdf-renderer.ts` - renderPdfDocument (Playwright HTML-to-PDF) + buildPdfSystemPrompt
- `server/src/__tests__/pdf-renderer.test.ts` - 6 unit tests with playwright-core/puter-inference mocks
- `server/src/services/renderers/types.ts` - PdfDocumentBundle and BrandKitBundle interfaces added, ContentBundle union updated
- `server/src/services/content-job-runner.ts` - "pdf-document" case added to renderContent switch
- `server/package.json` - archiver + @types/archiver added
## Decisions Made
- stripMarkdownFences was duplicated locally in pdf-renderer.ts rather than importing from wallpaper-renderer.ts because wallpaper-renderer does not export this helper. This follows the existing project pattern where it was also not exported.
- buildPdfSystemPrompt generates structurally distinct prompts per docType — report gets formal section structure, invoice gets financial table layout, api-docs gets endpoint reference format, one-pager gets visual impact layout.
## Deviations from Plan
None - plan executed exactly as written.
## Issues Encountered
None.
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- Plan 43-02 (brand-kit renderer) can now import BrandKitBundle from types.ts and use archiver for ZIP output
- Plan 43-03 (document UI panel) can import PdfDocumentBundle and use the pdf-document jobType
- All 6 tests pass, TypeScript clean for modified files
---
*Phase: 43-documents-branding*
*Completed: 2026-04-04*
## Self-Check: PASSED
- pdf-renderer.ts: FOUND
- pdf-renderer.test.ts: FOUND
- SUMMARY.md: FOUND
- Task 1 commit 7c7394bf: FOUND
- Task 2 commit 16023234: FOUND

View file

@ -0,0 +1,248 @@
---
phase: 43-documents-branding
plan: 02
type: execute
wave: 2
depends_on: ["43-01"]
files_modified:
- server/src/services/renderers/brand-renderer.ts
- server/src/services/content-job-runner.ts
- server/src/__tests__/brand-renderer.test.ts
autonomous: true
requirements: [BRAND-01, BRAND-02, BRAND-03, BRAND-04, BRAND-05, BRAND-06]
must_haves:
truths:
- "renderBrandKit returns a brand-kit-bundle with all required fields populated"
- "Logo SVG is generated via LLM and cleaned with SVGO"
- "Avatar PNGs are rasterized at 5 sizes: 512, 256, 128, 64, 32"
- "Social images are generated for twitter-profile, twitter-banner, linkedin-profile, linkedin-banner, instagram-profile"
- "Email signature and letterhead are generated as HTML strings"
- "Brand guidelines PDF is generated via Playwright"
- "All assets are packaged into a ZIP via archiver with correct folder structure"
- "content-job-runner dispatches brand-kit jobType to brand-renderer"
artifacts:
- path: "server/src/services/renderers/brand-renderer.ts"
provides: "Brand kit orchestration renderer"
exports: ["renderBrandKit"]
- path: "server/src/__tests__/brand-renderer.test.ts"
provides: "Unit tests for brand renderer"
min_lines: 60
key_links:
- from: "server/src/services/content-job-runner.ts"
to: "server/src/services/renderers/brand-renderer.ts"
via: "dynamic import in renderContent switch"
pattern: 'case "brand-kit"'
- from: "server/src/services/renderers/brand-renderer.ts"
to: "archiver"
via: "import archiver for ZIP packaging"
pattern: "archiver"
- from: "server/src/services/renderers/brand-renderer.ts"
to: "server/src/services/renderers/diagram-renderer.js"
via: "resolveBrowserPath for Playwright PDF"
pattern: "resolveBrowserPath"
---
<objective>
Brand identity kit renderer that orchestrates logo, avatars, social images, templates, guidelines PDF, and ZIP packaging.
Purpose: Enable full brand identity generation from a single conversation — produces logo, avatars, social platform images, email signature, letterhead, guidelines PDF, and a downloadable ZIP of everything.
Output: Working brand-renderer.ts with job-runner wiring and tests.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/43-documents-branding/43-RESEARCH.md
@.planning/phases/43-documents-branding/43-01-SUMMARY.md
@server/src/services/renderers/types.ts
@server/src/services/content-job-runner.ts
@server/src/services/renderers/diagram-renderer.ts
@server/src/services/renderers/icon-renderer.ts
@server/src/services/puter-inference.ts
<interfaces>
<!-- Key types from Plan 01 output -->
From server/src/services/renderers/types.ts (after Plan 01):
```typescript
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"|"twitter-banner"|"linkedin-profile"|"linkedin-banner"|"instagram-profile" -> base64
signatureHtml: string;
letterheadHtml: string;
guidelinesPdfBase64: string;
zipBase64: string;
}
export interface RenderResult { filename: string; contentType: string; buffer: Buffer; }
```
From server/src/services/renderers/diagram-renderer.ts:
```typescript
export function resolveBrowserPath(): string;
```
From server/src/services/renderers/icon-renderer.ts:
```typescript
export function validateAndCleanSvg(raw: string): { svg: string; warnings: string[] };
```
From server/src/services/puter-inference.ts:
```typescript
export async function puterChatComplete(messages: ChatMessage[], model?: string): Promise<string>;
```
From server/src/services/renderers/wallpaper-renderer.ts:
```typescript
export const PLATFORM_DIMENSIONS: Record<string, { width: number; height: number }>;
// Includes twitter-profile (400x400), twitter-banner (1500x500), linkedin-profile (400x400), linkedin-banner (1584x396), instagram-1080 (1080x1080)
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Create brand-renderer with orchestrated sub-renders and ZIP packaging</name>
<files>server/src/services/renderers/brand-renderer.ts, server/src/__tests__/brand-renderer.test.ts</files>
<read_first>
- server/src/services/renderers/types.ts (BrandKitBundle type from Plan 01)
- server/src/services/renderers/icon-renderer.ts (validateAndCleanSvg for SVG cleanup, LLM SVG prompt pattern)
- server/src/services/renderers/wallpaper-renderer.ts (PLATFORM_DIMENSIONS export, sharp usage for SVG-to-PNG, stripMarkdownFences)
- server/src/services/renderers/diagram-renderer.ts (resolveBrowserPath, Playwright launch + page.pdf pattern)
- server/src/services/puter-inference.ts (puterChatComplete)
- server/src/__tests__/diagram-renderer.test.ts (mock patterns)
</read_first>
<behavior>
- Test 1: renderBrandKit returns brand-kit-bundle with all required top-level fields present (spec, logoSvgBase64, avatarPngs, socialImages, signatureHtml, letterheadHtml, guidelinesPdfBase64, zipBase64)
- Test 2: spec contains name, tagline, primaryColor, secondaryColor, fontStyle, industry
- Test 3: avatarPngs has keys "512", "256", "128", "64", "32" — all non-empty base64 strings
- Test 4: socialImages has keys twitter-profile, twitter-banner, linkedin-profile, linkedin-banner, instagram-profile — all non-empty
- Test 5: signatureHtml and letterheadHtml are non-empty strings
- Test 6: guidelinesPdfBase64 is a non-empty string
- Test 7: zipBase64 decodes to a buffer starting with PK (0x50, 0x4B — ZIP magic bytes)
</behavior>
<action>
Create server/src/services/renderers/brand-renderer.ts with these internal functions:
**BrandSpec interface** (local, not exported — matches BrandKitBundle.spec shape):
```typescript
interface BrandSpec {
name: string; tagline: string; primaryColor: string; secondaryColor: string;
fontStyle: string; industry: string; logoDescription: string;
}
```
**extractBrandSpec(prompt)** — LLM call with system prompt instructing JSON output with the BrandSpec fields. Parse JSON from response (strip markdown fences first). If parsing fails, provide sensible defaults.
**generateLogoSvg(spec)** — LLM call asking for a simple, clean SVG logo mark based on spec.logoDescription, spec.primaryColor, spec.secondaryColor. System prompt: "Output ONLY valid SVG. No text explanations. Simple geometric shapes. viewBox 0 0 512 512. Use only the provided colors." Clean output with validateAndCleanSvg from icon-renderer.
**rasterizeAvatars(logoSvg)** — Use sharp to resize the SVG to [512, 256, 128, 64, 32]px PNG. Return Record<string, string> mapping size to base64. Use `sharp(Buffer.from(logoSvg)).resize(size, size).png().toBuffer()` for each size.
**generateSocialImages(spec, logoSvg)** — For each of 5 platforms (twitter-profile 400x400, twitter-banner 1500x500, linkedin-profile 400x400, linkedin-banner 1584x396, instagram-profile 1080x1080): create an SVG template with brand-colored background + centered logo, rasterize with sharp to the target dimensions. Return Record<string, string>.
**generateTemplates(spec)** — Two LLM calls:
1. Email signature HTML — system prompt: "Generate a professional HTML email signature with name, tagline, colors. Inline CSS only. No external resources. Keep under 200 lines."
2. Letterhead HTML — system prompt: "Generate a professional HTML letterhead template with header, footer, placeholder body. Inline CSS only. No external resources."
Strip markdown fences from both.
**generateGuidelinesPdf(spec, logoSvg)** — LLM generates a brand guidelines HTML page (system prompt: "Generate a brand guidelines document as HTML. Include: brand name, tagline, color palette with hex codes, typography guidance, logo usage rules, spacing guidelines. Inline CSS only. No external resources. Use web-safe fonts."). Render to PDF via Playwright (same pattern as pdf-renderer: resolveBrowserPath, chromium.launch, page.setContent, page.pdf A4). Return Buffer.
**buildZip(assets)** — Use archiver v7 to create an in-memory ZIP buffer with folder structure:
```
brand-kit/logo/logo.svg
brand-kit/logo/logo-512.png, logo-256.png, logo-128.png, logo-64.png, logo-32.png
brand-kit/social/twitter-profile.png, twitter-banner.png, linkedin-profile.png, linkedin-banner.png, instagram-profile.png
brand-kit/templates/email-signature.html, letterhead.html
brand-kit/guidelines.pdf
```
Use the streaming pattern from RESEARCH.md Pattern 3: Writable sink, archive.pipe(sink), archive.append(buffer, { name }), archive.finalize(), resolve on sink finish.
**renderBrandKit(input)** — Main exported function. Extract `prompt` from input. Call extractBrandSpec, generateLogoSvg, rasterizeAvatars, generateSocialImages, generateTemplates, generateGuidelinesPdf, buildZip in sequence. Assemble BrandKitBundle. Return RenderResult.
CRITICAL constraints:
- Open Playwright browser ONCE for the entire brand kit job (guidelines PDF), not per sub-step
- Use try/finally to close browser
- Social image SVGs are simple templates (colored rect + embedded logo), NOT LLM-generated — keeps them fast and predictable
- All base64 encoding via Buffer.from(x).toString("base64")
Create server/src/__tests__/brand-renderer.test.ts:
- vi.mock("playwright-core") — same pattern as pdf-renderer tests
- vi.mock("../services/puter-inference.js") — mock puterChatComplete to return different responses based on system prompt content (JSON for spec, SVG for logo, HTML for templates/guidelines)
- vi.mock("../services/renderers/diagram-renderer.js") — resolveBrowserPath returns "/fake/chromium"
- vi.mock("../services/renderers/icon-renderer.js") — validateAndCleanSvg returns { svg: input, warnings: [] }
- vi.mock("sharp") — mock sharp() chain: resize().png().toBuffer() returns a small Buffer
- vi.mock("archiver") — mock archiver() to return an object with append, pipe, finalize, on("error") that triggers the sink finish event
- Write tests per behavior section
</action>
<acceptance_criteria>
- grep -q "renderBrandKit" server/src/services/renderers/brand-renderer.ts
- grep -q "archiver" server/src/services/renderers/brand-renderer.ts
- grep -q "resolveBrowserPath" server/src/services/renderers/brand-renderer.ts
- grep -q "validateAndCleanSvg" server/src/services/renderers/brand-renderer.ts
- grep -q "brand-kit-bundle" server/src/services/renderers/brand-renderer.ts
- grep -q "zipBase64" server/src/services/renderers/brand-renderer.ts
</acceptance_criteria>
<verify>
<automated>pnpm --filter @paperclipai/server test --run src/__tests__/brand-renderer.test.ts</automated>
</verify>
<done>Brand renderer produces complete BrandKitBundle with logo SVG, 5 avatar sizes, 5 social images, email signature HTML, letterhead HTML, guidelines PDF, and ZIP package. All tests pass.</done>
</task>
<task type="auto">
<name>Task 2: Wire brand-kit jobType into content-job-runner</name>
<files>server/src/services/content-job-runner.ts</files>
<read_first>
- server/src/services/content-job-runner.ts (current switch with pdf-document case from Plan 01)
</read_first>
<action>
Add a new case to the `renderContent()` switch in content-job-runner.ts, after the "pdf-document" case and before the `default` case:
```typescript
case "brand-kit": {
const { renderBrandKit } = await import("./renderers/brand-renderer.js");
return renderBrandKit(input);
}
```
Follow exact pattern of existing cases (dynamic import with .js extension, destructured named export).
</action>
<acceptance_criteria>
- grep -q '"brand-kit"' server/src/services/content-job-runner.ts
- grep -q 'renderBrandKit' server/src/services/content-job-runner.ts
</acceptance_criteria>
<verify>
<automated>pnpm --filter @paperclipai/server exec tsc --noEmit</automated>
</verify>
<done>content-job-runner dispatches "brand-kit" jobType to renderBrandKit. TypeScript compiles cleanly.</done>
</task>
</tasks>
<verification>
- `pnpm --filter @paperclipai/server test --run src/__tests__/brand-renderer.test.ts` passes
- `pnpm --filter @paperclipai/server exec tsc --noEmit` compiles without errors
- `grep -q "brand-kit" server/src/services/content-job-runner.ts` succeeds
</verification>
<success_criteria>
- Brand renderer orchestrates 7 sub-steps: spec extraction, logo SVG, avatar rasterization, social images, templates, guidelines PDF, ZIP
- All BrandKitBundle fields populated with non-empty values
- ZIP contains correct folder structure with all assets
- Job runner dispatches brand-kit jobs correctly
- All tests pass, TypeScript clean
</success_criteria>
<output>
After completion, create `.planning/phases/43-documents-branding/43-02-SUMMARY.md`
</output>

View file

@ -0,0 +1,128 @@
---
phase: 43-documents-branding
plan: 02
subsystem: api
tags: [brand-renderer, archiver, playwright, sharp, svgo, puter-inference, typescript, zip]
# Dependency graph
requires:
- phase: 43-01
provides: archiver package installed, BrandKitBundle type in types.ts, content-job-runner switch pattern
provides:
- brand-renderer.ts — renderBrandKit function with 7-step orchestration
- brand-kit jobType wired in content-job-runner switch
- 7 unit tests covering all BrandKitBundle fields
affects:
- 43-03 (brand kit UI panel will import BrandKitBundle type and use brand-kit jobType)
# Tech tracking
tech-stack:
added: []
patterns:
- 7-step brand kit orchestration: spec extraction → logo SVG → avatar rasterization → social images → email+letterhead templates → guidelines PDF → ZIP
- Single Playwright browser instance per brand kit job (open once, use for PDF, try/finally close)
- Social images as SVG templates (colored rect + embedded logo as data URI), NOT LLM-generated — keeps fast and predictable
- archiver streaming ZIP via Writable sink, resolve on sink finish event (Pattern 3 from RESEARCH.md)
- stripMarkdownFences duplicated locally (same approach as pdf-renderer.ts — wallpaper-renderer doesn't export it)
key-files:
created:
- server/src/services/renderers/brand-renderer.ts
- server/src/__tests__/brand-renderer.test.ts
modified:
- server/src/services/content-job-runner.ts
key-decisions:
- "Social images are SVG templates (colored rect + embedded logo) rather than LLM-generated — keeps generation fast and deterministic"
- "Playwright browser opened once per brand kit job for guidelines PDF — reuses browser instance to avoid spawning multiple processes"
- "pnpm install run as deviation fix — archiver was in package.json (added by Plan 01) but node_modules not populated in this worktree"
# Metrics
duration: 4min
completed: 2026-04-04
---
# Phase 43 Plan 02: Brand Kit Renderer Summary
**Full brand identity kit renderer orchestrating logo SVG, 5 avatar sizes, 5 social platform images, email signature HTML, letterhead HTML, guidelines PDF, and ZIP packaging via archiver**
## Performance
- **Duration:** ~4 min
- **Started:** 2026-04-04T22:47:34Z
- **Completed:** 2026-04-04T22:50:30Z
- **Tasks:** 2
- **Files modified:** 3
## Accomplishments
- Created brand-renderer.ts with renderBrandKit: 7-step orchestration producing a complete BrandKitBundle
- extractBrandSpec: LLM JSON extraction with fallback defaults
- generateLogoSvg: LLM SVG generation cleaned via validateAndCleanSvg (SVGO)
- rasterizeAvatars: sharp rasterizes logo to 5 PNG sizes (512/256/128/64/32)
- generateSocialImages: SVG template per platform (colored bg + logo), 5 platforms
- generateTemplates: 2 parallel LLM calls for email signature + letterhead HTML
- generateGuidelinesPdf: Playwright HTML→PDF with single shared browser instance
- buildZip: archiver streaming ZIP with correct folder structure (brand-kit/logo/, brand-kit/social/, brand-kit/templates/, brand-kit/guidelines.pdf)
- 7 unit tests pass covering all BrandKitBundle fields and ZIP magic bytes verification
- Wired brand-kit jobType in content-job-runner switch with dynamic import
## Task Commits
Each task was committed atomically:
1. **Task 1: Create brand-renderer with orchestrated sub-renders and ZIP packaging** - `cd753612` (feat)
2. **Task 2: Wire brand-kit jobType into content-job-runner** - `e059a0da` (feat)
## Files Created/Modified
- `server/src/services/renderers/brand-renderer.ts` - renderBrandKit with 7-step orchestration
- `server/src/__tests__/brand-renderer.test.ts` - 7 unit tests with all required mocks
- `server/src/services/content-job-runner.ts` - brand-kit case added to renderContent switch
## Decisions Made
- Social images use SVG templates (colored rect + base64-embedded logo) rather than LLM-generated artwork — this is fast, deterministic, and always on-brand
- Playwright browser is opened once for the brand kit job (for the guidelines PDF step) using a try/finally block — avoids spawning multiple browser processes for what is a single-shot job
- archiver was already in package.json from Plan 01 but not installed in this worktree — ran pnpm install as a deviation fix
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] archiver package not installed in node_modules**
- **Found during:** Task 2 (tsc --noEmit check)
- **Issue:** archiver and @types/archiver were added to server/package.json by Plan 01 but pnpm install had not been run in this worktree, causing TS2307 "Cannot find module 'archiver'"
- **Fix:** Ran `pnpm install` at the monorepo root — resolved 65 packages including archiver and @types/archiver
- **Files modified:** none (lockfile already had archiver)
## Known Stubs
None — all BrandKitBundle fields are populated with real data (LLM-generated or sharp-rasterized).
## Issues Encountered
- archiver not installed in worktree node_modules (fixed by pnpm install)
## User Setup Required
None - no external service configuration required beyond PUTER_AUTH_TOKEN already documented.
## Next Phase Readiness
- Plan 43-03 (document/brand UI panel) can now use brand-kit jobType and BrandKitBundle type
- All 7 tests pass, TypeScript clean for all modified files
---
*Phase: 43-documents-branding*
*Completed: 2026-04-04*
## Self-Check: PASSED
- brand-renderer.ts: FOUND
- brand-renderer.test.ts: FOUND
- SUMMARY.md: FOUND
- Task 1 commit cd753612: FOUND
- Task 2 commit e059a0da: FOUND

View file

@ -0,0 +1,291 @@
---
phase: 43-documents-branding
plan: 03
type: execute
wave: 3
depends_on: ["43-01", "43-02"]
files_modified:
- ui/src/components/DocumentGeneratePanel.tsx
- ui/src/components/BrandKitPanel.tsx
- ui/src/components/BrandKitResult.tsx
- ui/src/pages/ContentStudio.tsx
autonomous: false
requirements: [DOC-01, DOC-02, DOC-03, BRAND-01, BRAND-02, BRAND-03, BRAND-04, BRAND-05, BRAND-06]
must_haves:
truths:
- "User can select document type (report, invoice, one-pager, api-docs) and generate a PDF"
- "Generated PDF can be downloaded from the UI"
- "User can describe a brand and generate a full brand kit"
- "Brand kit result shows logo, avatars, social images, signature, letterhead previews"
- "User can download the entire brand kit as a ZIP file"
- "ContentStudio has Documents and Brand tabs alongside existing tabs"
artifacts:
- path: "ui/src/components/DocumentGeneratePanel.tsx"
provides: "Document type selection, prompt input, PDF generation trigger, download"
min_lines: 60
- path: "ui/src/components/BrandKitPanel.tsx"
provides: "Brand prompt input, generation trigger, result display"
min_lines: 50
- path: "ui/src/components/BrandKitResult.tsx"
provides: "Brand kit display: logo, avatars, social images, templates, guidelines, ZIP download"
min_lines: 80
- path: "ui/src/pages/ContentStudio.tsx"
provides: "Documents and Brand tabs added"
contains: "documents"
key_links:
- from: "ui/src/components/DocumentGeneratePanel.tsx"
to: "useContentJob"
via: "submit('pdf-document', { docType, prompt, title })"
pattern: "pdf-document"
- from: "ui/src/components/BrandKitPanel.tsx"
to: "useContentJob"
via: "submit('brand-kit', { prompt })"
pattern: "brand-kit"
- from: "ui/src/pages/ContentStudio.tsx"
to: "ui/src/components/DocumentGeneratePanel.tsx"
via: "import + TabsContent"
pattern: "DocumentGeneratePanel"
---
<objective>
UI panels for document generation and brand kit, wired into ContentStudio tabs.
Purpose: Give users a UI to generate PDFs (reports, invoices, one-pagers, API docs) and complete brand identity kits from a prompt. Adds "Documents" and "Brand" tabs to ContentStudio.
Output: DocumentGeneratePanel, BrandKitPanel, BrandKitResult components, updated ContentStudio.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/43-documents-branding/43-RESEARCH.md
@.planning/phases/43-documents-branding/43-01-SUMMARY.md
@.planning/phases/43-documents-branding/43-02-SUMMARY.md
@ui/src/pages/ContentStudio.tsx
@ui/src/components/SocialPostPanel.tsx
@ui/src/hooks/useContentJob.ts
@ui/src/api/contentJobs.ts
<interfaces>
<!-- Key types and patterns the executor needs -->
From server/src/services/renderers/types.ts (Plan 01):
```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" etc -> base64
signatureHtml: string;
letterheadHtml: string;
guidelinesPdfBase64: string;
zipBase64: string;
}
```
From ui/src/hooks/useContentJob.ts:
```typescript
export function useContentJob(companyId: string): {
submit: (jobType: string, input: Record<string, unknown>) => void;
status: string | null;
bundle: unknown;
resultAssetId: string | null;
error: string | null;
};
```
From ui/src/api/contentJobs.ts:
```typescript
export function getContentJobAsset(companyId: string, assetId: string): Promise<{ url: string }>;
```
UI pattern from SocialPostPanel.tsx:
- useContentJob for submit + SSE progress
- When status="done" && resultAssetId && !bundle -> fetch asset -> JSON.parse -> setBundle
- Display result component
- Download via URL.createObjectURL + <a>.click()
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create DocumentGeneratePanel and BrandKitPanel + BrandKitResult components</name>
<files>ui/src/components/DocumentGeneratePanel.tsx, ui/src/components/BrandKitPanel.tsx, ui/src/components/BrandKitResult.tsx</files>
<read_first>
- ui/src/components/SocialPostPanel.tsx (full file — reference pattern for useContentJob, asset fetch, bundle state, download trigger)
- ui/src/components/SocialPostResult.tsx (result display pattern)
- ui/src/hooks/useContentJob.ts (hook API)
- ui/src/api/contentJobs.ts (getContentJobAsset)
- ui/src/components/WallpaperGeneratePanel.tsx (another panel example for download pattern)
</read_first>
<action>
**DocumentGeneratePanel.tsx:**
- Props: `{ companyId: string }`
- State: prompt (string), title (string), docType ("report" | "invoice" | "one-pager" | "api-docs"), bundle (PdfDocumentBundle | null)
- Define PdfDocumentBundle type locally (same pattern as SocialPostBundle in SocialPostPanel — bundle types are defined locally in panel files per project convention for wallpaper/social, BUT research says to use types.ts for these. Follow research: import is server-side only. Define locally in UI file matching the server shape.)
- useContentJob(companyId) for submit + status tracking
- UI layout using shadcn Card, CardHeader, CardContent:
- Select dropdown for docType with 4 options: Report, Invoice, One-Pager, API Documentation
- Input for title
- Textarea for prompt/content description
- Generate button (disabled when loading, shows Loader2 spinner)
- Progress bar when status is "running" or "queued"
- When status="done" && resultAssetId && !bundle: fetch asset via getContentJobAsset, parse JSON, setBundle
- When bundle exists: show title, docType badge, and a "Download PDF" button
- Download PDF: decode pdfBase64 -> Uint8Array -> Blob("application/pdf") -> URL.createObjectURL -> <a download="{title}.pdf">.click() -> revokeObjectURL
**BrandKitPanel.tsx:**
- Props: `{ companyId: string }`
- State: prompt (string), bundle (BrandKitBundle | null)
- Define BrandKitBundle type locally (matching server shape)
- useContentJob(companyId) for submit + status
- UI layout:
- Textarea for brand description prompt (placeholder: "Describe the brand you want to create -- name, industry, style preferences, colors...")
- Generate button with loading state
- Progress bar during generation
- When done: fetch asset, parse JSON, setBundle, render BrandKitResult
**BrandKitResult.tsx:**
- Props: `{ bundle: BrandKitBundle }`
- Display in a grid layout:
- **Logo section**: SVG preview via `<img src="data:image/svg+xml;base64,{logoSvgBase64}" />`
- **Avatars section**: Grid of 5 avatar sizes as `<img src="data:image/png;base64,{avatarPngs[size]}" />` with size labels
- **Social images section**: Grid of social platform images with platform name labels
- **Templates section**: "Email Signature" and "Letterhead" cards with HTML preview in sandboxed iframes (`srcdoc={signatureHtml}`)
- **Guidelines**: "Brand Guidelines" card with a "Download PDF" button (decode guidelinesPdfBase64 -> blob download)
- **Full Kit Download**: Prominent "Download Brand Kit (ZIP)" button at the bottom
- ZIP download function: same base64-to-blob pattern but with type "application/zip" and `.zip` extension (use the downloadZip pattern from RESEARCH.md)
- PDF download for guidelines: same base64-to-blob pattern with type "application/pdf"
Use shadcn components: Card, CardHeader, CardTitle, CardContent, Button, Textarea, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Progress. Import Loader2 from lucide-react.
</action>
<acceptance_criteria>
- grep -q "DocumentGeneratePanel" ui/src/components/DocumentGeneratePanel.tsx
- grep -q "pdf-document" ui/src/components/DocumentGeneratePanel.tsx
- grep -q "BrandKitPanel" ui/src/components/BrandKitPanel.tsx
- grep -q "brand-kit" ui/src/components/BrandKitPanel.tsx
- grep -q "BrandKitResult" ui/src/components/BrandKitResult.tsx
- grep -q "zipBase64" ui/src/components/BrandKitResult.tsx
- grep -q "Download Brand Kit" ui/src/components/BrandKitResult.tsx
</acceptance_criteria>
<verify>
<automated>pnpm --filter @paperclipai/ui exec tsc --noEmit</automated>
</verify>
<done>Document and brand kit panels render with correct form inputs, submit correct job types, display results, and provide download functionality for PDF and ZIP.</done>
</task>
<task type="auto">
<name>Task 2: Add Documents and Brand tabs to ContentStudio</name>
<files>ui/src/pages/ContentStudio.tsx</files>
<read_first>
- ui/src/pages/ContentStudio.tsx (current tab structure — 5 tabs: diagrams, icons, themes, wallpapers, social)
</read_first>
<action>
1. Add imports at the top of ContentStudio.tsx:
```typescript
import { DocumentGeneratePanel } from "../components/DocumentGeneratePanel";
import { BrandKitPanel } from "../components/BrandKitPanel";
```
2. Add two new TabsTrigger entries in the TabsList, AFTER the "Social" trigger:
```tsx
<TabsTrigger value="documents">Documents</TabsTrigger>
<TabsTrigger value="brand">Brand</TabsTrigger>
```
3. Add two new TabsContent blocks after the social TabsContent:
```tsx
<TabsContent value="documents" className="mt-4">
{companyId ? (
<DocumentGeneratePanel companyId={companyId} />
) : (
<p className="text-sm text-muted-foreground">Select a company to get started.</p>
)}
</TabsContent>
<TabsContent value="brand" className="mt-4">
{companyId ? (
<BrandKitPanel companyId={companyId} />
) : (
<p className="text-sm text-muted-foreground">Select a company to get started.</p>
)}
</TabsContent>
```
Keep `defaultValue="diagrams"` unchanged. Use stable string values "documents" and "brand" for tab IDs.
</action>
<acceptance_criteria>
- grep -q "DocumentGeneratePanel" ui/src/pages/ContentStudio.tsx
- grep -q "BrandKitPanel" ui/src/pages/ContentStudio.tsx
- grep -q '"documents"' ui/src/pages/ContentStudio.tsx
- grep -q '"brand"' ui/src/pages/ContentStudio.tsx
</acceptance_criteria>
<verify>
<automated>pnpm --filter @paperclipai/ui exec tsc --noEmit</automated>
</verify>
<done>ContentStudio has 7 tabs: Diagrams, Icons, Themes, Wallpapers, Social, Documents, Brand. TypeScript compiles cleanly.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: Visual verification of Documents and Brand tabs</name>
<files>ui/src/pages/ContentStudio.tsx</files>
<action>
Human verifies the complete document generation and brand kit UI end-to-end.
How to verify:
1. Navigate to Content Studio in the Nexus UI
2. Verify 7 tabs visible: Diagrams, Icons, Themes, Wallpapers, Social, Documents, Brand
3. Click "Documents" tab:
- Select "Report" from dropdown
- Enter a title and prompt
- Click Generate — verify spinner/progress appears
- When done, verify "Download PDF" button appears
4. Click "Brand" tab:
- Enter a brand description
- Click Generate — verify progress appears
- When done, verify logo SVG preview, avatar grid, social images, template previews, and "Download Brand Kit (ZIP)" button
5. Download the ZIP — verify it contains logo/, social/, templates/, guidelines.pdf
Resume signal: Type "approved" or describe issues.
</action>
<verify>
<automated>pnpm --filter @paperclipai/ui exec tsc --noEmit</automated>
</verify>
<done>User confirms Documents and Brand tabs work end-to-end: PDF generation + download, brand kit generation + preview + ZIP download.</done>
</task>
</tasks>
<verification>
- `pnpm --filter @paperclipai/ui exec tsc --noEmit` compiles without errors
- ContentStudio renders 7 tabs
- Document generation flow works end-to-end
- Brand kit generation shows all asset previews and ZIP download
</verification>
<success_criteria>
- Documents tab: user can select type, enter prompt, generate PDF, download it
- Brand tab: user can enter description, generate full brand kit, see all previews, download ZIP
- All 7 ContentStudio tabs render correctly
- TypeScript clean
</success_criteria>
<output>
After completion, create `.planning/phases/43-documents-branding/43-03-SUMMARY.md`
</output>

View file

@ -0,0 +1,115 @@
---
phase: 43-documents-branding
plan: 03
subsystem: ui
tags: [document-generation, brand-kit, pdf, zip, content-studio, react, typescript, shadcn]
# Dependency graph
requires:
- phase: 43-01
provides: pdf-document jobType, PdfDocumentBundle shape
- phase: 43-02
provides: brand-kit jobType, BrandKitBundle shape
provides:
- DocumentGeneratePanel — docType select, title, prompt, pdf-document job, PDF download
- BrandKitPanel — brand description prompt, brand-kit job, delegates to BrandKitResult
- BrandKitResult — logo SVG, 5 avatar sizes, social images, email/letterhead iframes, guidelines PDF download, ZIP download
- ContentStudio — 7 tabs including Documents and Brand
affects:
- users who visit ContentStudio (Documents and Brand tabs now visible)
# Tech tracking
tech-stack:
added: []
patterns:
- BrandKitBundle type defined in BrandKitResult.tsx and re-exported; BrandKitPanel imports the type from BrandKitResult (avoids duplication within UI)
- PdfDocumentBundle defined locally in DocumentGeneratePanel.tsx (panel-local type pattern per project convention)
- base64-to-blob download helper: Uint8Array.from(atob(b64), c => c.charCodeAt(0)) -> Blob -> createObjectURL -> <a>.click() -> revokeObjectURL
- sandboxed iframes for HTML template previews: srcdoc + sandbox="allow-same-origin"
key-files:
created:
- ui/src/components/DocumentGeneratePanel.tsx
- ui/src/components/BrandKitPanel.tsx
- ui/src/components/BrandKitResult.tsx
modified:
- ui/src/pages/ContentStudio.tsx
key-decisions:
- "BrandKitBundle type defined in BrandKitResult.tsx and imported by BrandKitPanel — avoids duplication within the UI layer while keeping type co-located with the display component"
- "iframe sandbox=allow-same-origin for HTML template previews — prevents script execution in signature/letterhead HTML while allowing CSS rendering"
# Metrics
duration: 4min
completed: 2026-04-04
---
# Phase 43 Plan 03: Document Generation and Brand Kit UI Summary
**DocumentGeneratePanel, BrandKitPanel, BrandKitResult UI components with pdf-document and brand-kit job submission, PDF/ZIP download, and Documents/Brand tabs added to ContentStudio (7 tabs total)**
## Performance
- **Duration:** ~4 min
- **Started:** 2026-04-04T22:52:00Z
- **Completed:** 2026-04-04T22:55:22Z
- **Tasks:** 2 (Task 3 auto-approved as checkpoint:human-verify)
- **Files modified:** 4
## Accomplishments
- Created DocumentGeneratePanel with docType select (report/invoice/one-pager/api-docs), title input, prompt textarea, pdf-document job submission via useContentJob, progress bar, and PDF download (base64 blob)
- Created BrandKitPanel with brand description textarea, brand-kit job submission, progress bar, and BrandKitResult display on completion
- Created BrandKitResult displaying logo SVG preview, 5 avatar size grid, social images grid, sandboxed email signature and letterhead iframes, guidelines PDF download button, and prominent "Download Brand Kit (ZIP)" button
- Updated ContentStudio to import both new panels and add Documents and Brand TabsTrigger + TabsContent blocks — ContentStudio now has 7 tabs
## Task Commits
Each task was committed atomically:
1. **Task 1: Create DocumentGeneratePanel, BrandKitPanel, BrandKitResult** - `77d5b703` (feat)
2. **Task 2: Add Documents and Brand tabs to ContentStudio** - `f737b446` (feat)
3. **Task 3: Visual verification** - auto-approved (checkpoint:human-verify in autonomous mode)
## Files Created/Modified
- `ui/src/components/DocumentGeneratePanel.tsx` - PdfDocumentBundle type, docType select, title, prompt, pdf-document submit, PDF blob download
- `ui/src/components/BrandKitPanel.tsx` - brand description textarea, brand-kit submit, BrandKitResult render
- `ui/src/components/BrandKitResult.tsx` - BrandKitBundle type, logo/avatars/social/templates/guidelines display, ZIP download
- `ui/src/pages/ContentStudio.tsx` - DocumentGeneratePanel + BrandKitPanel imports, 2 new TabsTrigger + TabsContent blocks
## Decisions Made
- BrandKitBundle type is defined in BrandKitResult.tsx (the display component) and imported by BrandKitPanel — keeps the type co-located with what renders it and avoids duplication within the UI layer.
- iframe sandbox="allow-same-origin" used for email signature and letterhead previews — prevents arbitrary script execution while allowing inline CSS from the brand renderer's HTML output to render correctly.
## Deviations from Plan
None — plan executed exactly as written.
## Known Stubs
None — all data paths are wired to useContentJob which fetches real asset JSON from the server.
## Issues Encountered
None.
## User Setup Required
None — no additional configuration required beyond what Plans 43-01 and 43-02 already established.
---
*Phase: 43-documents-branding*
*Completed: 2026-04-04*
## Self-Check: PASSED
- DocumentGeneratePanel.tsx: FOUND
- BrandKitPanel.tsx: FOUND
- BrandKitResult.tsx: FOUND
- ContentStudio.tsx updated: FOUND
- Task 1 commit 77d5b703: FOUND
- Task 2 commit f737b446: FOUND

View file

@ -0,0 +1,41 @@
# Phase 43: Documents & Branding - Context
**Gathered:** 2026-04-04
**Status:** Ready for planning
**Mode:** Auto-generated (discuss skipped via workflow.skip_discuss)
<domain>
## Phase Boundary
Users can generate polished PDF reports and invoices via Playwright, and create a complete brand identity (logo, avatars, social profiles, letterhead, guidelines PDF, zip package) from a single conversation
</domain>
<decisions>
## Implementation Decisions
### Claude's Discretion
All implementation choices are at Claude's discretion — discuss phase was skipped per user setting. Use ROADMAP phase goal, success criteria, and codebase conventions to guide decisions.
</decisions>
<code_context>
## Existing Code Insights
Codebase context will be gathered during plan-phase research.
</code_context>
<specifics>
## Specific Ideas
No specific requirements — discuss phase skipped. Refer to ROADMAP phase description and success criteria.
</specifics>
<deferred>
## Deferred Ideas
None — discuss phase skipped.
</deferred>

View file

@ -0,0 +1,616 @@
# 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)

553
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -61,6 +61,7 @@
"@types/web-push": "^3.6.4",
"ajv": "^8.18.0",
"ajv-formats": "^3.0.1",
"archiver": "^7.0.1",
"better-auth": "1.4.18",
"chokidar": "^4.0.3",
"csv-parse": "6.2.1",
@ -92,6 +93,7 @@
"zod": "^3.24.2"
},
"devDependencies": {
"@types/archiver": "^7.0.0",
"@types/culori": "^4.0.1",
"@types/express": "^5.0.0",
"@types/express-serve-static-core": "^5.0.0",

View file

@ -0,0 +1,296 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { BrandKitBundle } from "../services/renderers/types.js";
// ─── Mock playwright-core chromium ─────────────────────────────────────────────
const mockPdf = vi.fn().mockResolvedValue(Buffer.from("%PDF-1.4 fake-pdf"));
const mockSetContent = vi.fn().mockResolvedValue(undefined);
const mockPage = {
setContent: mockSetContent,
pdf: mockPdf,
};
const mockBrowserClose = vi.fn().mockResolvedValue(undefined);
const mockBrowser = {
newPage: vi.fn().mockResolvedValue(mockPage),
close: mockBrowserClose,
};
const mockChromiumLaunch = vi.fn().mockResolvedValue(mockBrowser);
vi.mock("playwright-core", () => ({
chromium: {
launch: mockChromiumLaunch,
},
}));
// ─── Mock puter-inference ──────────────────────────────────────────────────────
const MOCK_SPEC_JSON = JSON.stringify({
name: "Acme Corp",
tagline: "Building the future",
primaryColor: "#FF5733",
secondaryColor: "#33C1FF",
fontStyle: "sans",
industry: "technology",
logoDescription: "A stylized rocket silhouette",
});
const MOCK_LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect x="100" y="100" width="312" height="312" fill="#FF5733"/></svg>`;
const MOCK_SIGNATURE_HTML = `<!DOCTYPE html><html><body><p>Email Signature for Acme Corp</p></body></html>`;
const MOCK_LETTERHEAD_HTML = `<!DOCTYPE html><html><body><header>Acme Corp Letterhead</header></body></html>`;
const MOCK_GUIDELINES_HTML = `<!DOCTYPE html><html><body><h1>Acme Corp Brand Guidelines</h1></body></html>`;
const mockPuterChatComplete = vi.fn().mockImplementation(
(messages: Array<{ role: string; content: string }>) => {
const systemPrompt = messages.find((m) => m.role === "system")?.content ?? "";
if (systemPrompt.includes("JSON") || systemPrompt.includes("brand specification")) {
return Promise.resolve(MOCK_SPEC_JSON);
}
if (systemPrompt.includes("SVG") || systemPrompt.includes("logo")) {
return Promise.resolve(MOCK_LOGO_SVG);
}
if (systemPrompt.includes("email signature")) {
return Promise.resolve(MOCK_SIGNATURE_HTML);
}
if (systemPrompt.includes("letterhead")) {
return Promise.resolve(MOCK_LETTERHEAD_HTML);
}
if (systemPrompt.includes("brand guidelines")) {
return Promise.resolve(MOCK_GUIDELINES_HTML);
}
return Promise.resolve(MOCK_SPEC_JSON);
},
);
vi.mock("../services/puter-inference.js", () => ({
puterChatComplete: (...args: unknown[]) => mockPuterChatComplete(...args),
}));
// ─── Mock diagram-renderer (for resolveBrowserPath) ───────────────────────────
vi.mock("../services/renderers/diagram-renderer.js", () => ({
resolveBrowserPath: () => "/fake/chromium",
}));
// ─── Mock icon-renderer (for validateAndCleanSvg) ─────────────────────────────
vi.mock("../services/renderers/icon-renderer.js", () => ({
validateAndCleanSvg: (raw: string) => ({ svg: raw, valid: true, warnings: [] }),
}));
// ─── Mock sharp ───────────────────────────────────────────────────────────────
const mockToBuffer = vi.fn().mockResolvedValue(Buffer.from("fake-png-data"));
const mockPng = vi.fn().mockReturnValue({ toBuffer: mockToBuffer });
const mockResize = vi.fn().mockReturnValue({ png: mockPng });
const mockSharpInstance = { resize: mockResize };
const mockSharp = vi.fn().mockReturnValue(mockSharpInstance);
vi.mock("sharp", () => ({
default: (...args: unknown[]) => mockSharp(...args),
}));
// ─── Mock archiver ────────────────────────────────────────────────────────────
const mockArchiverAppend = vi.fn();
const mockArchiverPipe = vi.fn();
const mockArchiverFinalize = vi.fn();
const mockArchiverOn = vi.fn();
vi.mock("archiver", () => ({
default: (_format: string, _opts: unknown) => {
const archive = {
append: mockArchiverAppend,
pipe: mockArchiverPipe,
finalize: mockArchiverFinalize,
on: mockArchiverOn,
};
// When pipe is called, simulate the sink getting data and finishing
mockArchiverPipe.mockImplementation((sink: { write: (chunk: Buffer, enc: string, cb: () => void) => void; emit: (event: string) => void; on: (event: string, cb: () => void) => void }) => {
// Provide data to sink immediately after finalize is called
mockArchiverFinalize.mockImplementation(() => {
// Write PK magic bytes (ZIP signature) to the sink
const pkBytes = Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x00, 0x00]);
sink.write(pkBytes, "binary", () => {});
// Trigger sink finish
setTimeout(() => sink.emit("finish"), 0);
});
});
return archive;
},
}));
// ─── Tests ──────────────────────────────────────────────────────────────────────
describe("renderBrandKit", () => {
let renderBrandKit: (
input: Record<string, unknown>,
) => Promise<import("../services/renderers/types.js").RenderResult>;
beforeEach(async () => {
vi.clearAllMocks();
// Reset mock implementations
mockPuterChatComplete.mockImplementation(
(messages: Array<{ role: string; content: string }>) => {
const systemPrompt = messages.find((m) => m.role === "system")?.content ?? "";
if (systemPrompt.includes("JSON") || systemPrompt.includes("brand specification")) {
return Promise.resolve(MOCK_SPEC_JSON);
}
if (systemPrompt.includes("SVG") || systemPrompt.includes("logo")) {
return Promise.resolve(MOCK_LOGO_SVG);
}
if (systemPrompt.includes("email signature")) {
return Promise.resolve(MOCK_SIGNATURE_HTML);
}
if (systemPrompt.includes("letterhead")) {
return Promise.resolve(MOCK_LETTERHEAD_HTML);
}
if (systemPrompt.includes("brand guidelines")) {
return Promise.resolve(MOCK_GUIDELINES_HTML);
}
return Promise.resolve(MOCK_SPEC_JSON);
},
);
mockChromiumLaunch.mockResolvedValue(mockBrowser);
mockBrowser.newPage.mockResolvedValue(mockPage);
mockPdf.mockResolvedValue(Buffer.from("%PDF-1.4 fake-pdf"));
mockBrowserClose.mockResolvedValue(undefined);
mockToBuffer.mockResolvedValue(Buffer.from("fake-png-data"));
mockPng.mockReturnValue({ toBuffer: mockToBuffer });
mockResize.mockReturnValue({ png: mockPng });
mockSharp.mockReturnValue(mockSharpInstance);
// Reset archiver mocks and re-setup pipe behavior
mockArchiverPipe.mockImplementation((sink: NodeJS.WritableStream) => {
mockArchiverFinalize.mockImplementation(() => {
const ws = sink as unknown as {
write: (chunk: Buffer, enc: string, cb: () => void) => void;
emit: (event: string) => void;
};
const pkBytes = Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x00, 0x00]);
ws.write(pkBytes, "binary", () => {});
setTimeout(() => ws.emit("finish"), 0);
});
});
const mod = await import("../services/renderers/brand-renderer.js");
renderBrandKit = mod.renderBrandKit;
});
afterEach(() => {
vi.clearAllMocks();
});
it("Test 1: returns brand-kit-bundle with all required top-level fields present", async () => {
const result = await renderBrandKit({
prompt: "Create a brand for Acme Corp, a technology company",
});
expect(result.filename).toBe("brand-kit-bundle.json");
expect(result.contentType).toBe("application/json");
const bundle = JSON.parse(result.buffer.toString()) as BrandKitBundle;
expect(bundle.type).toBe("brand-kit-bundle");
expect(bundle.spec).toBeDefined();
expect(bundle.logoSvgBase64).toBeDefined();
expect(bundle.avatarPngs).toBeDefined();
expect(bundle.socialImages).toBeDefined();
expect(bundle.signatureHtml).toBeDefined();
expect(bundle.letterheadHtml).toBeDefined();
expect(bundle.guidelinesPdfBase64).toBeDefined();
expect(bundle.zipBase64).toBeDefined();
});
it("Test 2: spec contains name, tagline, primaryColor, secondaryColor, fontStyle, industry", async () => {
const result = await renderBrandKit({
prompt: "Create a brand for Acme Corp",
});
const bundle = JSON.parse(result.buffer.toString()) as BrandKitBundle;
expect(typeof bundle.spec.name).toBe("string");
expect(typeof bundle.spec.tagline).toBe("string");
expect(typeof bundle.spec.primaryColor).toBe("string");
expect(typeof bundle.spec.secondaryColor).toBe("string");
expect(typeof bundle.spec.fontStyle).toBe("string");
expect(typeof bundle.spec.industry).toBe("string");
expect(bundle.spec.name.length).toBeGreaterThan(0);
expect(bundle.spec.primaryColor.length).toBeGreaterThan(0);
});
it("Test 3: avatarPngs has keys 512, 256, 128, 64, 32 — all non-empty base64 strings", async () => {
const result = await renderBrandKit({
prompt: "Create a brand for Acme Corp",
});
const bundle = JSON.parse(result.buffer.toString()) as BrandKitBundle;
expect(Object.keys(bundle.avatarPngs)).toEqual(
expect.arrayContaining(["512", "256", "128", "64", "32"]),
);
for (const size of ["512", "256", "128", "64", "32"]) {
expect(typeof bundle.avatarPngs[size]).toBe("string");
expect(bundle.avatarPngs[size]!.length).toBeGreaterThan(0);
}
});
it("Test 4: socialImages has keys twitter-profile, twitter-banner, linkedin-profile, linkedin-banner, instagram-profile — all non-empty", async () => {
const result = await renderBrandKit({
prompt: "Create a brand for Acme Corp",
});
const bundle = JSON.parse(result.buffer.toString()) as BrandKitBundle;
const expectedKeys = [
"twitter-profile",
"twitter-banner",
"linkedin-profile",
"linkedin-banner",
"instagram-profile",
];
expect(Object.keys(bundle.socialImages)).toEqual(
expect.arrayContaining(expectedKeys),
);
for (const key of expectedKeys) {
expect(typeof bundle.socialImages[key]).toBe("string");
expect(bundle.socialImages[key]!.length).toBeGreaterThan(0);
}
});
it("Test 5: signatureHtml and letterheadHtml are non-empty strings", async () => {
const result = await renderBrandKit({
prompt: "Create a brand for Acme Corp",
});
const bundle = JSON.parse(result.buffer.toString()) as BrandKitBundle;
expect(typeof bundle.signatureHtml).toBe("string");
expect(bundle.signatureHtml.length).toBeGreaterThan(0);
expect(typeof bundle.letterheadHtml).toBe("string");
expect(bundle.letterheadHtml.length).toBeGreaterThan(0);
});
it("Test 6: guidelinesPdfBase64 is a non-empty string", async () => {
const result = await renderBrandKit({
prompt: "Create a brand for Acme Corp",
});
const bundle = JSON.parse(result.buffer.toString()) as BrandKitBundle;
expect(typeof bundle.guidelinesPdfBase64).toBe("string");
expect(bundle.guidelinesPdfBase64.length).toBeGreaterThan(0);
});
it("Test 7: zipBase64 decodes to a buffer starting with PK (0x50, 0x4B — ZIP magic bytes)", async () => {
const result = await renderBrandKit({
prompt: "Create a brand for Acme Corp",
});
const bundle = JSON.parse(result.buffer.toString()) as BrandKitBundle;
expect(typeof bundle.zipBase64).toBe("string");
expect(bundle.zipBase64.length).toBeGreaterThan(0);
const decoded = Buffer.from(bundle.zipBase64, "base64");
expect(decoded[0]).toBe(0x50); // 'P'
expect(decoded[1]).toBe(0x4b); // 'K'
});
});

View file

@ -0,0 +1,157 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { PdfDocumentBundle } from "../services/renderers/types.js";
// ─── Mock playwright-core chromium ─────────────────────────────────────────────
const mockPdf = vi.fn().mockResolvedValue(Buffer.from("fake-pdf"));
const mockSetContent = vi.fn().mockResolvedValue(undefined);
const mockPage = {
setContent: mockSetContent,
pdf: mockPdf,
};
const mockBrowserClose = vi.fn().mockResolvedValue(undefined);
const mockBrowser = {
newPage: vi.fn().mockResolvedValue(mockPage),
close: mockBrowserClose,
};
const mockChromiumLaunch = vi.fn().mockResolvedValue(mockBrowser);
vi.mock("playwright-core", () => ({
chromium: {
launch: mockChromiumLaunch,
},
}));
// ─── Mock puter-inference ──────────────────────────────────────────────────────
const mockPuterChatComplete = vi
.fn()
.mockResolvedValue("<!DOCTYPE html><html><body>test</body></html>");
vi.mock("../services/puter-inference.js", () => ({
puterChatComplete: (...args: unknown[]) => mockPuterChatComplete(...args),
}));
// ─── Mock diagram-renderer (for resolveBrowserPath) ───────────────────────────
vi.mock("../services/renderers/diagram-renderer.js", () => ({
resolveBrowserPath: () => "/fake/chromium",
}));
// ─── Tests ──────────────────────────────────────────────────────────────────────
describe("renderPdfDocument", () => {
let renderPdfDocument: (
input: Record<string, unknown>,
) => Promise<import("../services/renderers/types.js").RenderResult>;
beforeEach(async () => {
vi.clearAllMocks();
mockPuterChatComplete.mockResolvedValue(
"<!DOCTYPE html><html><body>test</body></html>",
);
mockChromiumLaunch.mockResolvedValue(mockBrowser);
mockBrowser.newPage.mockResolvedValue(mockPage);
mockPdf.mockResolvedValue(Buffer.from("fake-pdf"));
mockBrowserClose.mockResolvedValue(undefined);
const mod = await import("../services/renderers/pdf-renderer.js");
renderPdfDocument = mod.renderPdfDocument;
});
afterEach(() => {
vi.clearAllMocks();
});
it("returns RenderResult with pdf-document-bundle JSON for docType=report", async () => {
const result = await renderPdfDocument({
docType: "report",
prompt: "Generate a quarterly sales report",
title: "Q1 Sales Report",
});
expect(result.filename).toBe("document-report.json");
expect(result.contentType).toBe("application/json");
const bundle = JSON.parse(result.buffer.toString()) as PdfDocumentBundle;
expect(bundle.type).toBe("pdf-document-bundle");
expect(bundle.docType).toBe("report");
expect(typeof bundle.pdfBase64).toBe("string");
expect(bundle.pdfBase64.length).toBeGreaterThan(0);
});
it("returns bundle with docType='invoice' for docType=invoice", async () => {
const result = await renderPdfDocument({
docType: "invoice",
prompt: "Invoice for web development services",
title: "Invoice #001",
});
const bundle = JSON.parse(result.buffer.toString()) as PdfDocumentBundle;
expect(bundle.type).toBe("pdf-document-bundle");
expect(bundle.docType).toBe("invoice");
expect(result.filename).toBe("document-invoice.json");
});
it("returns bundle with docType='api-docs' for docType=api-docs", async () => {
const result = await renderPdfDocument({
docType: "api-docs",
prompt: "REST API documentation for user endpoints",
title: "API Reference",
});
const bundle = JSON.parse(result.buffer.toString()) as PdfDocumentBundle;
expect(bundle.type).toBe("pdf-document-bundle");
expect(bundle.docType).toBe("api-docs");
expect(result.filename).toBe("document-api-docs.json");
});
it("returns bundle with docType='one-pager' for docType=one-pager", async () => {
const result = await renderPdfDocument({
docType: "one-pager",
prompt: "Product one-pager for investors",
title: "Product Overview",
});
const bundle = JSON.parse(result.buffer.toString()) as PdfDocumentBundle;
expect(bundle.type).toBe("pdf-document-bundle");
expect(bundle.docType).toBe("one-pager");
expect(result.filename).toBe("document-one-pager.json");
});
it("LLM system prompt varies by docType — report vs invoice vs api-docs vs one-pager", async () => {
const capturedMessages: unknown[][] = [];
mockPuterChatComplete.mockImplementation(
(messages: unknown[]) => {
capturedMessages.push(messages);
return Promise.resolve(
"<!DOCTYPE html><html><body>test</body></html>",
);
},
);
await renderPdfDocument({ docType: "report", prompt: "test" });
await renderPdfDocument({ docType: "invoice", prompt: "test" });
await renderPdfDocument({ docType: "api-docs", prompt: "test" });
await renderPdfDocument({ docType: "one-pager", prompt: "test" });
expect(capturedMessages).toHaveLength(4);
const systemPrompts = capturedMessages.map(
(msgs) => (msgs[0] as { role: string; content: string }).content,
);
// Each docType should produce a different system prompt
const unique = new Set(systemPrompts);
expect(unique.size).toBe(4);
});
it("closes browser even when rendering throws", async () => {
mockPdf.mockRejectedValueOnce(new Error("PDF generation failed"));
await expect(
renderPdfDocument({ docType: "report", prompt: "test" }),
).rejects.toThrow();
expect(mockBrowserClose).toHaveBeenCalled();
});
});

View file

@ -38,6 +38,14 @@ export async function renderContent(
const { renderConvert } = await import("./renderers/convert-renderer.js");
return renderConvert(input);
}
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);
}
default:
throw new Error(`Unknown jobType: ${jobType}`);
}

View file

@ -0,0 +1,380 @@
import { chromium } from "playwright-core";
import { Writable } from "stream";
import archiver from "archiver";
import sharp from "sharp";
import { puterChatComplete } from "../puter-inference.js";
import { resolveBrowserPath } from "./diagram-renderer.js";
import { validateAndCleanSvg } from "./icon-renderer.js";
import type { RenderResult, BrandKitBundle } from "./types.js";
// ─── Brand specification ────────────────────────────────────────────────────────
interface BrandSpec {
name: string;
tagline: string;
primaryColor: string;
secondaryColor: string;
fontStyle: string;
industry: string;
logoDescription: string;
}
// ─── Social platform dimensions ─────────────────────────────────────────────────
const SOCIAL_DIMENSIONS: Record<string, { width: number; height: number }> = {
"twitter-profile": { width: 400, height: 400 },
"twitter-banner": { width: 1500, height: 500 },
"linkedin-profile": { width: 400, height: 400 },
"linkedin-banner": { width: 1584, height: 396 },
"instagram-profile": { width: 1080, height: 1080 },
};
// ─── Strip markdown fences ──────────────────────────────────────────────────────
function stripMarkdownFences(raw: string): string {
return raw
.trim()
.replace(/^```(?:json|svg|xml|html)?\s*/i, "")
.replace(/\s*```$/, "")
.trim();
}
// ─── Step 1: Extract brand spec from prompt ─────────────────────────────────────
async function extractBrandSpec(prompt: string): Promise<BrandSpec> {
const systemPrompt = [
"You are a brand identity strategist.",
"Extract or invent a brand specification from the user's prompt.",
"Respond with ONLY a JSON object — no markdown fences, no explanation.",
"Required fields:",
' "name": brand name (string)',
' "tagline": short catchy tagline (string)',
' "primaryColor": primary brand color as hex (e.g. "#FF5733")',
' "secondaryColor": secondary brand color as hex (e.g. "#33C1FF")',
' "fontStyle": one of "sans", "serif", or "mono"',
' "industry": industry sector (string)',
' "logoDescription": brief description of a simple geometric logo mark (string)',
].join("\n");
const raw = await puterChatComplete([
{ role: "system", content: systemPrompt },
{ role: "user", content: prompt },
]);
try {
const cleaned = stripMarkdownFences(raw);
const parsed = JSON.parse(cleaned) as Partial<BrandSpec>;
return {
name: parsed.name ?? "Brand",
tagline: parsed.tagline ?? "Building the future",
primaryColor: parsed.primaryColor ?? "#3B82F6",
secondaryColor: parsed.secondaryColor ?? "#10B981",
fontStyle: parsed.fontStyle ?? "sans",
industry: parsed.industry ?? "technology",
logoDescription: parsed.logoDescription ?? "A simple geometric mark",
};
} catch {
// Fallback defaults if parsing fails
return {
name: "Brand",
tagline: "Building the future",
primaryColor: "#3B82F6",
secondaryColor: "#10B981",
fontStyle: "sans",
industry: "technology",
logoDescription: "A simple geometric mark",
};
}
}
// ─── Step 2: Generate logo SVG via LLM ─────────────────────────────────────────
async function generateLogoSvg(spec: BrandSpec): Promise<string> {
const systemPrompt = [
"You are an SVG logo designer.",
"Output ONLY valid SVG. No text explanations. No markdown fences.",
"Simple geometric shapes only. viewBox 0 0 512 512.",
`Use only these colors: primary=${spec.primaryColor}, secondary=${spec.secondaryColor}`,
"The SVG must start with <svg and end with </svg>.",
"No text elements, no external resources, no scripts.",
].join("\n");
const raw = await puterChatComplete([
{ role: "system", content: systemPrompt },
{ role: "user", content: `Create a logo mark for: ${spec.logoDescription}` },
]);
const cleaned = stripMarkdownFences(raw);
const { svg } = validateAndCleanSvg(cleaned);
return svg;
}
// ─── Step 3: Rasterize logo to avatar sizes ─────────────────────────────────────
async function rasterizeAvatars(logoSvg: string): Promise<Record<string, string>> {
const sizes = [512, 256, 128, 64, 32];
const avatarPngs: Record<string, string> = {};
for (const size of sizes) {
const pngBuffer = await sharp(Buffer.from(logoSvg))
.resize(size, size)
.png()
.toBuffer();
avatarPngs[String(size)] = pngBuffer.toString("base64");
}
return avatarPngs;
}
// ─── Step 4: Generate social platform images ────────────────────────────────────
async function generateSocialImages(
spec: BrandSpec,
logoSvg: string,
): Promise<Record<string, string>> {
const socialImages: Record<string, string> = {};
for (const [platform, dims] of Object.entries(SOCIAL_DIMENSIONS)) {
const { width, height } = dims;
// Build SVG template: colored background + centered logo
const logoScale = Math.min(width, height) * 0.5;
const logoX = (width - logoScale) / 2;
const logoY = (height - logoScale) / 2;
const socialSvg = [
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">`,
` <rect width="${width}" height="${height}" fill="${spec.primaryColor}"/>`,
` <image x="${logoX}" y="${logoY}" width="${logoScale}" height="${logoScale}" href="data:image/svg+xml;base64,${Buffer.from(logoSvg).toString("base64")}"/>`,
`</svg>`,
].join("\n");
const pngBuffer = await sharp(Buffer.from(socialSvg), { density: 72 })
.resize(width, height, { fit: "fill" })
.png({ compressionLevel: 9 })
.toBuffer();
socialImages[platform] = pngBuffer.toString("base64");
}
return socialImages;
}
// ─── Step 5: Generate email signature and letterhead HTML ───────────────────────
async function generateTemplates(
spec: BrandSpec,
): Promise<{ signature: string; letterhead: string }> {
const signatureSystemPrompt = [
"You are a professional HTML email signature designer.",
"Generate a professional HTML email signature with name, tagline, colors.",
"Inline CSS only. No external resources. Keep under 200 lines.",
"Output ONLY the HTML — no markdown fences, no explanation.",
`Brand: name="${spec.name}", tagline="${spec.tagline}", primary=${spec.primaryColor}, secondary=${spec.secondaryColor}`,
].join("\n");
const letterheadSystemPrompt = [
"You are a professional HTML document designer.",
"Generate a professional HTML letterhead template with header, footer, placeholder body.",
"Inline CSS only. No external resources.",
"Output ONLY the HTML — no markdown fences, no explanation.",
`Brand: name="${spec.name}", tagline="${spec.tagline}", primary=${spec.primaryColor}, secondary=${spec.secondaryColor}`,
].join("\n");
const [rawSignature, rawLetterhead] = await Promise.all([
puterChatComplete([
{ role: "system", content: signatureSystemPrompt },
{ role: "user", content: `Create an email signature for ${spec.name}` },
]),
puterChatComplete([
{ role: "system", content: letterheadSystemPrompt },
{ role: "user", content: `Create a letterhead template for ${spec.name}` },
]),
]);
return {
signature: stripMarkdownFences(rawSignature),
letterhead: stripMarkdownFences(rawLetterhead),
};
}
// ─── Step 6: Generate brand guidelines PDF via Playwright ───────────────────────
async function generateGuidelinesPdf(
spec: BrandSpec,
browser: Awaited<ReturnType<typeof chromium.launch>>,
): Promise<Buffer> {
const guidelinesSystemPrompt = [
"You are a professional brand designer.",
"Generate a brand guidelines document as HTML.",
"Include: brand name, tagline, color palette with hex codes, typography guidance, logo usage rules, spacing guidelines.",
"Inline CSS only. No external resources. Use web-safe fonts.",
"Output ONLY a complete HTML document starting with <!DOCTYPE html> — no markdown fences, no explanation.",
`Brand: name="${spec.name}", tagline="${spec.tagline}", primary=${spec.primaryColor}, secondary=${spec.secondaryColor}, fontStyle=${spec.fontStyle}, industry=${spec.industry}`,
].join("\n");
const rawHtml = await puterChatComplete([
{ role: "system", content: guidelinesSystemPrompt },
{ role: "user", content: `Create brand guidelines for ${spec.name}` },
]);
const cleanHtml = stripMarkdownFences(rawHtml);
const page = await browser.newPage();
const pdfUint8 = await (async () => {
await page.setContent(cleanHtml, { waitUntil: "domcontentloaded" });
return page.pdf({
format: "A4",
printBackground: true,
margin: { top: "20mm", bottom: "20mm", left: "20mm", right: "20mm" },
});
})();
return Buffer.from(pdfUint8);
}
// ─── Step 7: Package all assets into a ZIP buffer ──────────────────────────────
interface ZipAssets {
logoSvg: string;
avatarPngs: Record<string, string>;
socialImages: Record<string, string>;
signature: string;
letterhead: string;
guidelinesPdf: Buffer;
}
async function buildZip(assets: ZipAssets): 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);
// Logo SVG
archive.append(Buffer.from(assets.logoSvg), { name: "brand-kit/logo/logo.svg" });
// Avatar PNGs
for (const size of ["512", "256", "128", "64", "32"]) {
const data = assets.avatarPngs[size];
if (data) {
archive.append(Buffer.from(data, "base64"), {
name: `brand-kit/logo/logo-${size}.png`,
});
}
}
// Social images
for (const [platform, data] of Object.entries(assets.socialImages)) {
if (data) {
archive.append(Buffer.from(data, "base64"), {
name: `brand-kit/social/${platform}.png`,
});
}
}
// Templates
archive.append(Buffer.from(assets.signature), {
name: "brand-kit/templates/email-signature.html",
});
archive.append(Buffer.from(assets.letterhead), {
name: "brand-kit/templates/letterhead.html",
});
// Guidelines PDF
archive.append(assets.guidelinesPdf, { name: "brand-kit/guidelines.pdf" });
void archive.finalize();
});
}
// ─── Main exported renderer ─────────────────────────────────────────────────────
/**
* Render a full brand identity kit from a single conversation prompt.
*
* Orchestrates: spec extraction logo SVG avatar rasterization
* social images email signature + letterhead guidelines PDF ZIP package.
*
* Returns a RenderResult containing a BrandKitBundle JSON bundle.
*/
export async function renderBrandKit(
input: Record<string, unknown>,
): Promise<RenderResult> {
const prompt =
typeof input.prompt === "string" ? input.prompt : "Create a brand identity";
// Step 1: Extract brand spec from prompt
const spec = await extractBrandSpec(prompt);
// Step 2: Generate logo SVG via LLM
const logoSvg = await generateLogoSvg(spec);
// Step 3: Rasterize logo to 5 avatar sizes
const avatarPngs = await rasterizeAvatars(logoSvg);
// Step 4: Generate social platform images (colored background + centered logo)
const socialImages = await generateSocialImages(spec, logoSvg);
// Step 5: Generate email signature and letterhead HTML
const { signature, letterhead } = await generateTemplates(spec);
// Step 6: Open Playwright browser ONCE for the entire brand kit job
const executablePath = resolveBrowserPath();
const browser = await chromium.launch({
executablePath,
headless: true,
args: ["--no-sandbox", "--disable-setuid-sandbox"],
});
let guidelinesPdf: Buffer;
try {
guidelinesPdf = await generateGuidelinesPdf(spec, browser);
} finally {
await browser.close();
}
// Step 7: Package everything into a ZIP
const zipBuffer = await buildZip({
logoSvg,
avatarPngs,
socialImages,
signature,
letterhead,
guidelinesPdf,
});
// Assemble the BrandKitBundle
const bundle: BrandKitBundle = {
type: "brand-kit-bundle",
spec: {
name: spec.name,
tagline: spec.tagline,
primaryColor: spec.primaryColor,
secondaryColor: spec.secondaryColor,
fontStyle: spec.fontStyle,
industry: spec.industry,
},
logoSvgBase64: Buffer.from(logoSvg).toString("base64"),
avatarPngs,
socialImages,
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)),
};
}

View file

@ -0,0 +1,117 @@
import { chromium } from "playwright-core";
import { resolveBrowserPath } from "./diagram-renderer.js";
import { puterChatComplete } from "../puter-inference.js";
import type { RenderResult, PdfDocumentBundle } from "./types.js";
// ─── Markdown fence stripper ─────────────────────────────────────────────────
function stripMarkdownFences(raw: string): string {
return raw
.trim()
.replace(/^```(?:html)?\s*/i, "")
.replace(/\s*```$/, "")
.trim();
}
// ─── System prompt builder ───────────────────────────────────────────────────
export function buildPdfSystemPrompt(docType: string): string {
const baseConstraints = [
"Output ONLY a complete HTML document starting with <!DOCTYPE html>.",
"Use only inline CSS in a <style> block. No external URLs, no <link> tags, no <script> tags.",
"Use web-safe system fonts: Arial, Georgia, monospace.",
"The document must be print-ready and visually professional.",
].join("\n");
switch (docType) {
case "invoice":
return [
"You are an HTML invoice generator.",
"Create a professional invoice with: company header, invoice number, date, bill-to section, line items table (description, quantity, unit price, total), subtotal, tax, and grand total.",
"Use a clean business layout with a muted color palette.",
baseConstraints,
].join("\n");
case "api-docs":
return [
"You are an HTML API documentation generator.",
"Create well-structured API reference documentation with: overview section, endpoint listings with HTTP method badges, request/response examples in monospace blocks, parameter tables, and status code descriptions.",
"Use a technical documentation style with good code block formatting.",
baseConstraints,
].join("\n");
case "one-pager":
return [
"You are an HTML one-pager generator.",
"Create a compelling single-page document with: a bold headline, value proposition, key benefits or features in a grid or list, supporting details, and a clear call-to-action.",
"Use a modern, visually impactful layout with strong typography hierarchy.",
baseConstraints,
].join("\n");
case "report":
default:
return [
"You are an HTML report generator.",
"Create a professional report with: a title page section, executive summary, clearly delineated sections with headings and subheadings, data presentation areas (tables or structured lists), and a conclusion.",
"Use a formal report layout with clear section separation and consistent typography.",
baseConstraints,
].join("\n");
}
}
// ─── Main renderer ───────────────────────────────────────────────────────────
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 HTML generation ────────────────────────────────────────────────────
const systemPrompt = buildPdfSystemPrompt(docType);
const rawHtml = await puterChatComplete([
{ role: "system", content: systemPrompt },
{ role: "user", content: prompt },
]);
const html = stripMarkdownFences(rawHtml);
// ── Playwright HTML-to-PDF ─────────────────────────────────────────────────
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(html, { 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();
}
// ── Build bundle ───────────────────────────────────────────────────────────
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)),
};
}

View file

@ -67,4 +67,30 @@ export interface ConvertBundle {
method: "direct" | "ai-bridge";
}
export type ContentBundle = DiagramBundle | IconSetBundle | ThemePaletteBundle | WallpaperBundle | AppIconBundle | SocialPostBundle | ConvertBundle;
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>;
socialImages: Record<string, string>;
signatureHtml: string;
letterheadHtml: string;
guidelinesPdfBase64: string;
zipBase64: string;
}
export type ContentBundle = DiagramBundle | IconSetBundle | ThemePaletteBundle | WallpaperBundle | AppIconBundle | SocialPostBundle | ConvertBundle | PdfDocumentBundle | BrandKitBundle;

View file

@ -0,0 +1,111 @@
import { useState } from "react";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Progress } from "@/components/ui/progress";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useContentJob } from "@/hooks/useContentJob";
import { getContentJobAsset } from "@/api/contentJobs";
import { BrandKitResult } from "./BrandKitResult";
import type { BrandKitBundle } from "./BrandKitResult";
interface BrandKitPanelProps {
companyId: string;
}
export function BrandKitPanel({ companyId }: BrandKitPanelProps) {
const [prompt, setPrompt] = useState("");
const [bundle, setBundle] = useState<BrandKitBundle | null>(null);
const job = useContentJob(companyId);
const isGenerating = job.status === "queued" || job.status === "running";
const isIdle = job.status === "idle";
async function handleSubmit() {
if (!prompt.trim() || isGenerating) return;
setBundle(null);
await job.submit("brand-kit", { prompt });
}
// Fetch asset when job completes
if (job.status === "done" && job.resultAssetId && !bundle) {
void getContentJobAsset(companyId, job.resultAssetId).then(async (assetUrl) => {
const res = await fetch(assetUrl);
const text = await res.text();
try {
const parsed = JSON.parse(text) as BrandKitBundle;
setBundle(parsed);
} catch {
// ignore parse error — will show empty state
}
});
}
return (
<Card className="w-full">
<CardHeader>
<CardTitle className="text-xl font-semibold">Generate Brand Kit</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label htmlFor="brand-prompt" className="text-sm font-medium">
Brand description
</label>
<Textarea
id="brand-prompt"
rows={5}
placeholder="Describe the brand you want to create — name, industry, style preferences, colors..."
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
disabled={isGenerating}
/>
</div>
<Button
type="button"
onClick={() => void handleSubmit()}
disabled={isGenerating || !prompt.trim()}
className="w-full"
>
{isGenerating ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" aria-hidden="true" />
Generating brand kit...
</>
) : (
"Generate Brand Kit"
)}
</Button>
{isGenerating && (
<Progress
value={job.progress}
role="progressbar"
aria-valuenow={job.progress}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Brand kit generation progress"
/>
)}
{job.status === "failed" && job.errorMessage && (
<p className="text-sm text-destructive">
Generation failed {job.errorMessage}. Try again.
</p>
)}
{bundle ? (
<BrandKitResult bundle={bundle} />
) : isIdle ? (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<p className="text-xl font-semibold">No brand kit yet</p>
<p className="text-sm text-muted-foreground">
Describe your brand name, industry, colors, style to generate a complete brand
identity kit including logo, avatars, social images, email templates, and guidelines.
</p>
</div>
) : null}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,217 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
export type 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" etc -> base64
signatureHtml: string;
letterheadHtml: string;
guidelinesPdfBase64: string;
zipBase64: string;
};
const AVATAR_SIZES = ["512", "256", "128", "64", "32"] as const;
const SOCIAL_PLATFORM_LABELS: Record<string, string> = {
"twitter-profile": "Twitter/X Profile",
"linkedin-profile": "LinkedIn Profile",
"linkedin-banner": "LinkedIn Banner",
"instagram-profile": "Instagram Profile",
"facebook-cover": "Facebook Cover",
};
interface BrandKitResultProps {
bundle: BrandKitBundle;
}
export function BrandKitResult({ bundle }: BrandKitResultProps) {
function downloadBlob(base64: string, mimeType: string, filename: string) {
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
const blob = new Blob([bytes], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
function handleDownloadZip() {
downloadBlob(bundle.zipBase64, "application/zip", `${bundle.spec.name || "brand-kit"}.zip`);
}
function handleDownloadGuidelines() {
downloadBlob(
bundle.guidelinesPdfBase64,
"application/pdf",
`${bundle.spec.name || "brand"}-guidelines.pdf`,
);
}
const socialEntries = Object.entries(bundle.socialImages);
return (
<div className="flex flex-col gap-6">
{/* Brand spec summary */}
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold">{bundle.spec.name}</span>
{bundle.spec.tagline && (
<span className="text-sm text-muted-foreground"> {bundle.spec.tagline}</span>
)}
<Badge variant="outline">{bundle.spec.industry}</Badge>
<Badge variant="outline">{bundle.spec.fontStyle}</Badge>
<span
className="inline-flex size-5 rounded-full border border-border"
style={{ backgroundColor: bundle.spec.primaryColor }}
title={`Primary: ${bundle.spec.primaryColor}`}
/>
<span
className="inline-flex size-5 rounded-full border border-border"
style={{ backgroundColor: bundle.spec.secondaryColor }}
title={`Secondary: ${bundle.spec.secondaryColor}`}
/>
</div>
{/* Logo */}
<Card>
<CardHeader>
<CardTitle className="text-base">Logo</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center rounded-lg bg-muted p-6">
<img
src={`data:image/svg+xml;base64,${bundle.logoSvgBase64}`}
alt={`${bundle.spec.name} logo`}
className="max-h-32 max-w-full object-contain"
/>
</div>
</CardContent>
</Card>
{/* Avatars */}
<Card>
<CardHeader>
<CardTitle className="text-base">Avatars</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap items-end gap-4">
{AVATAR_SIZES.map((size) => {
const pngBase64 = bundle.avatarPngs[size];
if (!pngBase64) return null;
const px = Number(size);
const displaySize = Math.min(px, 96);
return (
<div key={size} className="flex flex-col items-center gap-1">
<img
src={`data:image/png;base64,${pngBase64}`}
alt={`${size}px avatar`}
width={displaySize}
height={displaySize}
className="rounded-full border border-border object-contain"
/>
<span className="text-xs text-muted-foreground">{size}px</span>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Social images */}
{socialEntries.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Social Images</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
{socialEntries.map(([platform, pngBase64]) => (
<div key={platform} className="flex flex-col gap-1">
<div className="rounded-lg bg-muted overflow-hidden">
<img
src={`data:image/png;base64,${pngBase64}`}
alt={`${platform} image`}
className="w-full object-cover"
/>
</div>
<span className="text-xs text-muted-foreground text-center">
{SOCIAL_PLATFORM_LABELS[platform] ?? platform}
</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Templates */}
<Card>
<CardHeader>
<CardTitle className="text-base">Templates</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">Email Signature</span>
<div className="rounded-lg border border-border overflow-hidden">
<iframe
title="Email Signature Preview"
srcDoc={bundle.signatureHtml}
sandbox="allow-same-origin"
className="w-full h-32 bg-white"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">Letterhead</span>
<div className="rounded-lg border border-border overflow-hidden">
<iframe
title="Letterhead Preview"
srcDoc={bundle.letterheadHtml}
sandbox="allow-same-origin"
className="w-full h-48 bg-white"
/>
</div>
</div>
</CardContent>
</Card>
{/* Guidelines */}
<Card>
<CardHeader>
<CardTitle className="text-base">Brand Guidelines</CardTitle>
</CardHeader>
<CardContent>
<Button
type="button"
variant="secondary"
className="w-full"
onClick={handleDownloadGuidelines}
>
Download Brand Guidelines PDF
</Button>
</CardContent>
</Card>
{/* Download Brand Kit (ZIP) */}
<Button
type="button"
size="lg"
className="w-full"
onClick={handleDownloadZip}
>
Download Brand Kit (ZIP)
</Button>
</div>
);
}

View file

@ -0,0 +1,193 @@
import { useState } from "react";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Progress } from "@/components/ui/progress";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { useContentJob } from "@/hooks/useContentJob";
import { getContentJobAsset } from "@/api/contentJobs";
export type PdfDocumentBundle = {
type: "pdf-document-bundle";
docType: string;
title: string;
pdfBase64: string;
};
const DOC_TYPE_OPTIONS = [
{ value: "report", label: "Report" },
{ value: "invoice", label: "Invoice" },
{ value: "one-pager", label: "One-Pager" },
{ value: "api-docs", label: "API Documentation" },
];
interface DocumentGeneratePanelProps {
companyId: string;
}
export function DocumentGeneratePanel({ companyId }: DocumentGeneratePanelProps) {
const [prompt, setPrompt] = useState("");
const [title, setTitle] = useState("");
const [docType, setDocType] = useState<"report" | "invoice" | "one-pager" | "api-docs">("report");
const [bundle, setBundle] = useState<PdfDocumentBundle | null>(null);
const job = useContentJob(companyId);
const isGenerating = job.status === "queued" || job.status === "running";
const isIdle = job.status === "idle";
async function handleSubmit() {
if (!prompt.trim() || isGenerating) return;
setBundle(null);
await job.submit("pdf-document", { docType, prompt, title: title.trim() || docType });
}
// Fetch asset when job completes
if (job.status === "done" && job.resultAssetId && !bundle) {
void getContentJobAsset(companyId, job.resultAssetId).then(async (assetUrl) => {
const res = await fetch(assetUrl);
const text = await res.text();
try {
const parsed = JSON.parse(text) as PdfDocumentBundle;
setBundle(parsed);
} catch {
// ignore parse error — will show empty state
}
});
}
function handleDownloadPdf() {
if (!bundle) return;
const bytes = Uint8Array.from(atob(bundle.pdfBase64), (c) => c.charCodeAt(0));
const blob = new Blob([bytes], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${bundle.title || bundle.docType}.pdf`;
a.click();
URL.revokeObjectURL(url);
}
return (
<Card className="w-full">
<CardHeader>
<CardTitle className="text-xl font-semibold">Generate Document</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label htmlFor="doc-type" className="text-sm font-medium">
Document type
</label>
<Select
value={docType}
onValueChange={(v) => setDocType(v as typeof docType)}
disabled={isGenerating}
>
<SelectTrigger id="doc-type" className="w-full">
<SelectValue placeholder="Select document type" />
</SelectTrigger>
<SelectContent>
{DOC_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="doc-title" className="text-sm font-medium">
Title
</label>
<Input
id="doc-title"
placeholder="e.g. Q3 Financial Report"
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={isGenerating}
/>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="doc-prompt" className="text-sm font-medium">
Content description
</label>
<Textarea
id="doc-prompt"
rows={5}
placeholder="Describe the content, data, or topic to include in the document..."
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
disabled={isGenerating}
/>
</div>
<Button
type="button"
onClick={() => void handleSubmit()}
disabled={isGenerating || !prompt.trim()}
className="w-full"
>
{isGenerating ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" aria-hidden="true" />
Generating...
</>
) : (
"Generate PDF"
)}
</Button>
{isGenerating && (
<Progress
value={job.progress}
role="progressbar"
aria-valuenow={job.progress}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Document generation progress"
/>
)}
{job.status === "failed" && job.errorMessage && (
<p className="text-sm text-destructive">
Generation failed {job.errorMessage}. Try again.
</p>
)}
{bundle ? (
<div className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{bundle.title || bundle.docType}</span>
<Badge variant="secondary">{bundle.docType}</Badge>
</div>
<Button
type="button"
variant="default"
className="w-full"
onClick={handleDownloadPdf}
>
Download PDF
</Button>
</div>
) : isIdle ? (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<p className="text-xl font-semibold">No document yet</p>
<p className="text-sm text-muted-foreground">
Select a document type, enter a title and description, then click Generate PDF.
</p>
</div>
) : null}
</CardContent>
</Card>
);
}

View file

@ -4,6 +4,8 @@ import { DiagramGeneratePanel } from "../components/DiagramGeneratePanel";
import { IconGeneratePanel } from "../components/IconGeneratePanel";
import { WallpaperGeneratePanel } from "../components/WallpaperGeneratePanel";
import { SocialPostPanel } from "../components/SocialPostPanel";
import { DocumentGeneratePanel } from "../components/DocumentGeneratePanel";
import { BrandKitPanel } from "../components/BrandKitPanel";
import { ThemeSeedInput } from "../components/ThemeSeedInput";
import { ThemePaletteGrid } from "../components/ThemePaletteGrid";
import { ThemePreviewPanel } from "../components/ThemePreviewPanel";
@ -29,6 +31,8 @@ export function ContentStudio() {
<TabsTrigger value="themes">Themes</TabsTrigger>
<TabsTrigger value="wallpapers">Wallpapers</TabsTrigger>
<TabsTrigger value="social">Social</TabsTrigger>
<TabsTrigger value="documents">Documents</TabsTrigger>
<TabsTrigger value="brand">Brand</TabsTrigger>
</TabsList>
<TabsContent value="diagrams" className="mt-4">
@ -98,6 +102,22 @@ export function ContentStudio() {
<p className="text-sm text-muted-foreground">Select a company to get started.</p>
)}
</TabsContent>
<TabsContent value="documents" className="mt-4">
{companyId ? (
<DocumentGeneratePanel companyId={companyId} />
) : (
<p className="text-sm text-muted-foreground">Select a company to get started.</p>
)}
</TabsContent>
<TabsContent value="brand" className="mt-4">
{companyId ? (
<BrandKitPanel companyId={companyId} />
) : (
<p className="text-sm text-muted-foreground">Select a company to get started.</p>
)}
</TabsContent>
</Tabs>
</div>
);