Typographic social media card generator · CLI + Web UI · Slides/Carousel support Author: lordvins226 · Version 1.1
- Product Requirements Document (PRD)
- Architecture & File Structure
- Content Model & Schemas
- Social Media Formats Reference
- Slides / Carousel Specification
- CLI Specification
- Web UI Specification
- Templates & Themes System
- Build Plan (Phases)
- CLAUDE.md: Project Constraints
- Prompt Contracts for Claude Code
Creating high-impact typographic content cards for social media (the kind that perform well on Twitter/X, LinkedIn, Instagram, Facebook) currently requires either Canva (slow, web-dependent, bad for batching) or Figma (too manual). There is no developer-native, scriptable, version-controllable tool that produces pixel-perfect cards AND multi-slide carousels from structured content definitions with full control over typography and color.
QuoteForge is a developer-native content card and slide deck generator. You define content in JSON, pick a theme, run one command, and get production-ready PNGs or a ZIP of carousel slides. Optionally, open a live WYSIWYG Web UI with a full multi-slide deck editor. Everything is local, no cloud, no subscriptions, no API keys needed.
| Priority | Goal |
|---|---|
| P0 | Generate a pixel-perfect PNG from a JSON content file in one command |
| P0 | Support all major social media formats: Twitter, LinkedIn, Instagram, Facebook, Story |
| P0 | Full color theme system: swap any theme with a single flag |
| P0 | Generate a slide deck (carousel) as a numbered PNG sequence from a single JSON file |
| P1 | Live Web UI preview with hot-reload |
| P1 | WYSIWYG block editor in the Web UI |
| P1 | Slide deck editor: add/remove/reorder slides, preview all slides in sequence |
| P2 | Theme editor (create/edit themes visually) |
| P2 | Batch generation from a directory of JSON files |
| P2 | Export slide deck as a ZIP of numbered PNGs ready for carousel upload |
| P3 | Export to multiple formats simultaneously |
- No cloud hosting, no SaaS, no backend
- No user accounts or auth
- No AI content generation (QuoteForge renders, not invents)
- No mobile app
- No database
As a developer/creator, I want to:
US-01 Define a content card in JSON and generate a PNG with one command
US-02 Switch color themes without touching the content file
US-03 Preview my card live in the browser while I edit the JSON
US-04 Edit blocks visually in a Web UI and export the result
US-05 Reuse my brand colors across all cards via a persistent theme file
US-06 Generate multiple cards from a folder in one batch command
US-07 Save my card configuration and regenerate it later, exactly the same
US-08 Use custom fonts (Google Fonts or local) per theme
US-09 Target Facebook (post, square, cover, event cover) with correct dimensions
US-10 Create a slide deck (carousel) from a single JSON with multiple slides
US-11 Preview all slides in sequence in the Web UI with slide navigation
US-12 Export a slide deck as a ZIP of numbered PNGs ready to upload as a carousel
US-13 Share a theme and template across all slides in a deck, override per slide
US-14 Regenerate a single slide from a deck by index (--slide 3)
US-15 Show a slide counter overlay on each slide (e.g. "3 / 7"), toggleable
- Time from
bun installto first generated PNG < 2 minutes quoteforge generatecompletes in < 3 seconds per single cardquoteforge slidesgenerates a 10-slide deck and ZIP in < 25 seconds- Theme swap produces visually correct output with zero manual changes
- Web UI hot-reload latency < 200ms
- Slide navigation in Web UI is instantaneous (client-side, no re-render needed)
quoteforge/
│
├── CLAUDE.md
├── package.json
├── bunfig.toml
│
├── src/
│ ├── cli/
│ │ ├── index.ts # CLI entrypoint (Commander.js)
│ │ ├── commands/
│ │ │ ├── generate.ts # bun qf generate <file> , single card → PNG
│ │ │ ├── slides.ts # bun qf slides <deck-file> , deck → PNGs + ZIP
│ │ │ ├── preview.ts # bun qf preview <file> , live browser preview
│ │ │ ├── new.ts # bun qf new , interactive creator
│ │ │ ├── themes.ts # bun qf themes list|create|...
│ │ │ └── batch.ts # bun qf batch <dir> , folder → PNGs
│ │ └── utils/
│ │ ├── logger.ts # Chalk logger (info|success|error|warn)
│ │ ├── validator.ts # Zod schemas for cards, decks, themes
│ │ └── zip.ts # ZIP archive builder (archiver)
│ │
│ ├── renderer/
│ │ ├── renderer.ts # (content, theme, size, scale?) → Buffer (Puppeteer)
│ │ ├── slide-renderer.ts # (deck, opts?) → Buffer[] with numbering + counter
│ │ ├── template-engine.ts # Nunjucks render + CSS var injection from theme
│ │ └── font-loader.ts # Resolve Google Fonts / local font URLs
│ │
│ └── server/ # Bun.serve Web UI backend
│ ├── server.ts
│ ├── routes/
│ │ ├── preview.ts # GET /preview?slide=N&thumb=true
│ │ ├── export.ts # POST /export → PNG Buffer
│ │ ├── export-deck.ts # POST /export-deck → ZIP Buffer
│ │ └── themes.ts # GET /themes · POST /themes · PUT /themes/:name
│ └── ws.ts # WebSocket broadcast on content change
│
├── studio/ # Vite + React WYSIWYG studio
│ ├── index.html
│ ├── vite.config.ts
│ ├── src/
│ │ ├── main.tsx
│ │ ├── App.tsx # Mode switch: card vs deck (from loaded file type)
│ │ ├── components/
│ │ │ ├── Editor/
│ │ │ │ ├── BlockList.tsx # dnd-kit sortable blocks (scoped to active slide)
│ │ │ │ ├── BlockEditor.tsx # Per-block form (all 7 block types)
│ │ │ │ ├── SlideList.tsx # Deck panel: thumbnails, drag/add/remove slides
│ │ │ │ ├── SlideNav.tsx # ◀ 2 / 7 ▶ navigation + counter toggle
│ │ │ │ ├── ThemePicker.tsx # Theme grid with color swatches
│ │ │ │ ├── SizePicker.tsx # Format/platform picker (grouped by platform)
│ │ │ │ └── Toolbar.tsx # New/Open/Save/Theme/Size/Export/Export Deck
│ │ │ ├── Preview/
│ │ │ │ ├── PreviewPane.tsx # <iframe> + zoom controls
│ │ │ │ ├── PreviewControls.tsx
│ │ │ │ └── DeckStrip.tsx # Horizontal filmstrip of slide thumbnails
│ │ │ └── ui/
│ │ │ ├── Button.tsx
│ │ │ ├── Input.tsx
│ │ │ ├── Select.tsx
│ │ │ ├── ColorPicker.tsx
│ │ │ ├── Toast.tsx
│ │ │ └── Modal.tsx
│ │ ├── hooks/
│ │ │ ├── useCard.ts
│ │ │ ├── useDeck.ts
│ │ │ ├── useTheme.ts
│ │ │ └── useLivePreview.ts # WebSocket → preview iframe sync
│ │ ├── store/
│ │ │ ├── cardStore.ts # Zustand: single card + undo/redo
│ │ │ └── deckStore.ts # Zustand: deck/carousel + undo/redo
│ │ └── types/
│ │ └── index.ts # All shared TypeScript types
│ └── package.json
│
├── templates/
│ ├── _blocks/ # Shared Nunjucks partials (all templates use these)
│ │ ├── headline.njk
│ │ ├── blockquote.njk
│ │ ├── bullet-list.njk
│ │ ├── callout.njk
│ │ ├── text.njk
│ │ ├── divider.njk
│ │ ├── spacer.njk
│ │ └── slide-counter.njk # Counter overlay (injected when showCounter=true)
│ ├── manifesto/ # Style from the reference image
│ │ ├── template.njk
│ │ └── style.css
│ ├── quote/
│ │ ├── template.njk
│ │ └── style.css
│ ├── list/
│ │ ├── template.njk
│ │ └── style.css
│ └── minimal/
│ ├── template.njk
│ └── style.css
│
├── themes/
│ ├── dark-teal.json # Reference image: #1a1a1a bg, #4ecdc4 accent
│ ├── dark-orange.json
│ ├── light-minimal.json
│ ├── brand-midnight.json
│ └── _schema.json
│
├── content/ # Single-card JSON files
│ ├── examples/
│ │ ├── manifesto-wiki.json # Exact replica of the brief image
│ │ └── quote-sample.json
│ └── _schema.json
│
├── decks/ # Slide deck JSON files
│ ├── examples/
│ │ └── intro-deck.json # 5-slide example carousel
│ └── _schema.json
│
└── outputs/ # PNGs + ZIPs (gitignored)
└── .gitkeep
{
"$schema": "../content/_schema.json",
"template": "manifesto",
"theme": "dark-teal",
"size": "twitter",
"meta": {
"title": "Wiki is disposable",
"created": "2024-01-15",
"tags": ["devops", "knowledge-management"]
},
"blocks": [
{
"id": "headline",
"type": "headline",
"parts": [
{ "text": "The wiki is disposable. That is the ", "style": "normal" },
{ "text": "point.", "style": "accent-italic" }
]
},
{
"id": "intro",
"type": "blockquote",
"parts": [
{ "text": "raw/ is the only thing you protect.", "style": "bold" },
{ "text": " Everything else can be recompiled from scratch, any time, cleaner than before.", "style": "normal" }
]
},
{
"id": "breakdown",
"type": "bullet-list",
"items": [
{ "label": "raw/", "text": "sacred, append-only" },
{ "label": "wiki/", "text": "regenerable, trust the compile" },
{ "label": "outputs/", "text": "dated, cited, feed back" }
]
},
{
"id": "callout",
"type": "callout",
"items": [
{
"label": "No original content lives only in wiki/.",
"text": "If Claude wrote it, raw/ can rebuild it."
}
]
}
]
}| Type | Description | Key Fields |
|---|---|---|
headline |
Large display text: supports mixed inline styles | parts[] |
blockquote |
Left-bordered quote block | parts[] |
bullet-list |
Label + text items with accent dot | items[]{label, text} |
callout |
Rounded box highlight | items[]{label, text} |
text |
Plain body paragraph | content |
divider |
Full-width horizontal rule | - |
spacer |
Vertical whitespace | size: sm|md|lg |
| Style | Renders as |
|---|---|
normal |
Regular body weight |
bold |
Bold / strong |
italic |
Italic |
accent |
Accent color from theme |
accent-italic |
Accent color + italic |
mono |
Monospace font (for raw/ style labels) |
muted |
Reduced opacity |
{
"name": "dark-teal",
"displayName": "Dark Teal",
"colors": {
"background": "#1a1a1a",
"headline": "#f0ebe0",
"accent": "#4ecdc4",
"body": "#c8c8c8",
"label": "#4ecdc4",
"blockquote-border": "#4ecdc4",
"blockquote-text": "#e0dbd0",
"callout-bg": "#252525",
"callout-border": "#333333",
"bullet-dot": "#4ecdc4",
"slide-counter-bg": "#00000066",
"slide-counter-text": "#ffffff"
},
"typography": {
"font-headline": "Playfair Display",
"font-headline-url": "https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,700;1,700",
"font-body": "JetBrains Mono",
"font-body-url": "https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700",
"headline-size": "clamp(3.5rem, 8vw, 6rem)",
"body-size": "1rem",
"line-height": "1.5"
},
"spacing": {
"padding": "64px",
"block-gap": "2rem"
}
}All formats with exact pixel dimensions. Facebook is fully integrated.
export const SIZES = {
// Twitter / X
"twitter": { w: 1200, h: 675, ratio: "16:9", label: "Twitter post" },
"twitter-square": { w: 1080, h: 1080, ratio: "1:1", label: "Twitter square" },
// LinkedIn
"linkedin": { w: 1200, h: 627, ratio: "1.91:1", label: "LinkedIn post" },
"linkedin-square": { w: 1080, h: 1080, ratio: "1:1", label: "LinkedIn square" },
// Instagram
"instagram-sq": { w: 1080, h: 1080, ratio: "1:1", label: "Instagram square" },
"instagram-port": { w: 1080, h: 1350, ratio: "4:5", label: "Instagram portrait" },
"instagram-land": { w: 1080, h: 566, ratio: "1.91:1", label: "Instagram landscape" },
// Facebook
"facebook-post": { w: 1200, h: 630, ratio: "1.91:1", label: "Facebook post / link" },
"facebook-square": { w: 1080, h: 1080, ratio: "1:1", label: "Facebook square post" },
"facebook-cover": { w: 1640, h: 624, ratio: "2.63:1", label: "Facebook page cover" },
"facebook-event": { w: 1920, h: 1080, ratio: "16:9", label: "Facebook event cover" },
"facebook-group-cover": { w: 1640, h: 856, ratio: "1.91:1", label: "Facebook group cover" },
// Stories, same dimensions across Instagram, Facebook, TikTok
"story": { w: 1080, h: 1920, ratio: "9:16", label: "Story (IG / FB / TW)" },
// Custom
"custom": { w: 0, h: 0, ratio: "free", label: "Custom dimensions" },
} as constfacebook-post(1200×630): Standard single-image post and link previews.facebook-square(1080×1080): Best choice for Facebook carousels, renders equally well in feed and when shared. Recommended for all FB carousel decks.facebook-cover(1640×624): Very wide aspect ratio, useminimalorlisttemplates, notmanifesto(headline will overflow).facebook-event(1920×1080): 16:9, identical layout totwitter, just larger canvas.facebook-group-cover(1640×856): Group page header banner.- When generating a carousel deck for Facebook, always use
--size facebook-square. The CLI will warn if a non-square size is used with a deck targeting Facebook.
The SizePicker groups formats by platform for clarity:
Twitter/X ──── twitter · twitter-square
LinkedIn ──── linkedin · linkedin-square
Instagram ──── instagram-sq · instagram-port · instagram-land
Facebook ──── facebook-post · facebook-square · facebook-cover
facebook-event · facebook-group-cover
Stories ──── story
Custom ──── custom (W × H inputs appear)
A deck is a single JSON file with "type": "deck" that defines a sequence of slides.
Each slide is a standalone card using the same block model, sharing deck-level defaults
(theme, size, template) but able to override any of them individually.
The slides command outputs:
- N numbered PNGs:
deck-name-01.png,deck-name-02.png, ... - One ZIP:
deck-name.zipcontaining all slides in order
{
"$schema": "../decks/_schema.json",
"type": "deck",
"meta": {
"title": "Why your docs rot",
"created": "2024-01-15",
"tags": ["devops", "knowledge"]
},
"defaults": {
"template": "manifesto",
"theme": "dark-teal",
"size": "instagram-sq",
"showCounter": true,
"counter": {
"format": "{current} / {total}",
"position": "bottom-right",
"style": "pill"
}
},
"slides": [
{
"id": "slide-01",
"label": "Cover",
"blocks": [
{
"type": "headline",
"parts": [
{ "text": "Why your ", "style": "normal" },
{ "text": "docs", "style": "accent-italic" },
{ "text": " always rot.", "style": "normal" }
]
},
{ "type": "spacer", "size": "lg" },
{ "type": "text", "content": "A 7-slide breakdown." }
]
},
{
"id": "slide-02",
"label": "The Problem",
"blocks": [
{
"type": "headline",
"parts": [{ "text": "The wiki is disposable.", "style": "normal" }]
},
{
"type": "blockquote",
"parts": [
{ "text": "raw/ is the only thing you protect.", "style": "bold" },
{ "text": " Everything else can be recompiled from scratch.", "style": "normal" }
]
},
{
"type": "bullet-list",
"items": [
{ "label": "raw/", "text": "sacred, append-only" },
{ "label": "wiki/", "text": "regenerable, trust the compile" },
{ "label": "outputs/", "text": "dated, cited, feed back" }
]
}
]
},
{
"id": "slide-07",
"label": "CTA",
"template": "minimal",
"theme": "dark-orange",
"showCounter": false,
"blocks": [
{
"type": "headline",
"parts": [{ "text": "Follow for more.", "style": "accent-italic" }]
},
{ "type": "text", "content": "@lordvins226" }
]
}
]
}Each slide can override these deck defaults fields:
| Field | Type | Notes |
|---|---|---|
template |
string | Use a different template for this slide only |
theme |
string | Use a different theme for this slide only |
size |
SizeName | Override size: caution: ZIP should be consistent |
showCounter |
boolean | Hide/show counter for this slide only |
counter |
object | Override counter format/position/style for this slide |
When showCounter: true, each slide gets a badge overlay.
┌─────────────────────────────┐
│ │
│ [slide content] │
│ │
│ ┌──────┐ │
│ │ 2/7 │ │ ← bottom-right pill (default)
│ └──────┘ │
└─────────────────────────────┘
Counter config:
interface CounterConfig {
format: string // "{current} / {total}" · "{current}" · "slide {current}"
position: "bottom-right" | "bottom-left" | "bottom-center" | "top-right"
style: "pill" // rounded badge with semi-transparent bg
| "plain" // text only, no background
| "dots" // filled dot sequence (● ● ○ ○ ○)
}deck.json
│
▼
slide-renderer.ts
│
├── For each slide[i]:
│ ├── Merge: deck.defaults ← slide overrides
│ ├── Inject meta: { slideIndex: i, slideTotal: N,
│ │ showCounter, counterConfig }
│ ├── template-engine.ts → HTML string
│ └── renderer.ts → PNG Buffer
│
├── Buffer[] collected (concurrency: P-limit, default 4)
│
├── Write files: outputs/deck-name/deck-name-01.png ...
│ (zero-padded to deck length)
│
└── zip.ts → outputs/deck-name.zip
Mode is auto-detected: if the loaded file has "type": "deck", the UI switches to
deck mode. The user can also toggle manually via a pill toggle in the toolbar.
Deck mode layout:
┌───────────────────────────────────────────────────────────────────────────┐
│ TOOLBAR [New] [Open] [Save] QuoteForge [Theme▾] [Size▾] [PNG] [Deck▾] │
├─────────────────┬──────────────────────────┬──────────────────────────────┤
│ │ │ │
│ SLIDE LIST │ EDITOR PANEL │ PREVIEW PANEL │
│ (220px) │ (360px) │ │
│ │ │ ┌────────────────────────┐ │
│ ┌───────────┐ │ ┌─ Blocks ────────────┐ │ │ │ │
│ │01 Cover ● │ │ │ ☰ headline │ │ │ [Active slide] │ │
│ │02 Problem │ │ │ ☰ blockquote │ │ │ │ │
│ │03 Rule 1 │ │ │ ☰ bullet-list │ │ └────────────────────────┘ │
│ │04 Rule 2 │ │ │ [+ Add Block] │ │ │
│ │05 CTA │ │ └─────────────────────┘ │ ◀ 2 / 5 ▶ [●counter] │
│ │ │ │ │ │
│ │[+ Slide] │ │ ┌─ Block Editor ──────┐ │ ┌─ DeckStrip ───────────┐ │
│ └───────────┘ │ │ (active block form) │ │ │ [1] [●2] [3] [4] [5] │ │
│ │ └─────────────────────┘ │ └───────────────────────┘ │
└─────────────────┴──────────────────────────┴──────────────────────────────┘
DeckStrip, horizontal scrollable filmstrip at the bottom of the preview panel:
- 120px-wide thumbnails fetched lazily from
GET /preview?slide=N&thumb=true&w=120 - Active slide has a 2px accent-colored border
- Clicking a thumbnail activates that slide
SlideList, left panel:
- Rows:
[⠿] [01] [Cover, editable label] [×] - Drag-to-reorder via dnd-kit
- Right-click: Duplicate / Delete / Move to top / Move to bottom
[+ Add Slide]appends a blank slide inheriting deck defaults
git clone https://github.com/lordvins226/quoteforge
cd quoteforge
bun install
bun link # optional: enables `qf` global aliasquoteforge generate <content-file> [options]
-t, --theme <name> Override theme
-s, --size <name> Override size (see full list in §4)
-o, --output <path> Output path (default: outputs/<file>-<timestamp>.png)
--scale <n> Pixel ratio, default 2 (retina)
--open Open output after generation
--no-timestamp Omit timestamp from filename
Examples:
quoteforge generate content/wiki-post.json
quoteforge generate content/wiki-post.json --size facebook-post --theme dark-orangequoteforge slides <deck-file> [options]
-t, --theme <name> Override theme for all slides
-s, --size <name> Override size for all slides
-o, --output <dir> Output directory (default: outputs/<deck-name>/)
--slide <n> Render only slide N (1-indexed), no ZIP
--no-zip Skip ZIP creation
--no-counter Disable counter overlay for all slides
--concurrency <n> Parallel render workers (default: 4)
--scale <n> Pixel ratio, default 2
--open Open output folder after generation
Outputs:
outputs/deck-name/deck-name-01.png
outputs/deck-name/deck-name-02.png
...
outputs/deck-name.zip
Examples:
quoteforge slides decks/intro-deck.json
quoteforge slides decks/intro-deck.json --size facebook-square
quoteforge slides decks/intro-deck.json --slide 3 # only slide 3
quoteforge slides decks/intro-deck.json --no-counter --size instagram-sqquoteforge preview <content-or-deck-file> [options]
-p, --port <n> Port (default: 4242)
--no-open Don't auto-open browser
--slide <n> Start on slide N for deck files (default: 1)
Behavior:
- Watches content/deck + theme for changes, hot-reloads via SSE (< 500ms)
- Deck files: shows ← → nav, keyboard arrow keys workquoteforge studio [content-or-deck-file] [options]
-p, --port <n> Port (default: 4242)
--no-open
Behavior:
- Detects card vs deck from file type field, opens correct editor mode
- Ctrl+S saves, Export PNG/Export Deck ZIP in toolbar
Examples:
quoteforge studio
quoteforge studio decks/intro-deck.json # opens in deck modequoteforge new [options]
--type <card|deck> (interactive prompt if omitted)
--template <name>
--theme <name>
--slides <n> Blank slides to create (deck only, default: 5)
--size <name>
--name <filename>quoteforge themes list
quoteforge themes show <name>
quoteforge themes create <name>
quoteforge themes duplicate <name> <new-name>
quoteforge themes validate <file>quoteforge batch <directory> [options]
-t, --theme <name>
-s, --size <name>
-o, --output <dir>
--concurrency <n>
--decks Also process deck files → individual ZIPsquoteforge validate <file>
# Exits 0 if valid, 1 with full Zod error tree if invalid
# Auto-detects card vs deck from "type" field| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Validation error (Zod failure, bad JSON) |
| 2 | Theme not found |
| 3 | Template not found |
| 4 | Puppeteer / render error |
| 5 | Output directory not writable |
| 6 | Slide index out of range |
| Card Mode | Deck Mode | |
|---|---|---|
| Loaded from | file with "type": "card" or no type |
file with "type": "deck" |
| Left panel | Block list | Slide list |
| Bottom of preview | - | DeckStrip (thumbnail filmstrip) |
| Export | Export PNG | Export PNG (active slide) + Export Deck ZIP |
| Undo/redo scope | Block changes | Block + slide changes |
| Control | Card Mode | Deck Mode |
|---|---|---|
| New | New card | New deck |
| Open | Load card .json |
Load deck .json |
Save (Ctrl+S) |
Write card file | Write deck file |
| Theme ▾ | Theme picker | Deck-level default theme |
| Size ▾ | Size/platform picker | Deck-level default size |
| Export PNG | Current card → PNG download | Active slide → PNG download |
| Deck ▾ | N/A | [Export all slides as ZIP] / [Export individual PNGs] |
Headline / Blockquote / Text:
Parts:
[text: _______________________________________] [Style: accent-italic ▾] [×]
[+ Add Part]
Bullet List / Callout:
Items:
Label: [__________] Text: [_____________________________] [×]
[+ Add Item]
Spacer:
Size: [sm ▾] (sm | md | lg)
The SizePicker shows sizes grouped by platform, not as a flat list:
── Twitter/X ────────── twitter · twitter-square
── LinkedIn ─────────── linkedin · linkedin-square
── Instagram ────────── instagram-sq · instagram-port · instagram-land
── Facebook ─────────── facebook-post · facebook-square · facebook-cover
facebook-event · facebook-group-cover
── Stories ──────────── story
── Custom ───────────── [W px] × [H px]
When in deck mode and a Facebook non-square size is selected, a yellow inline warning appears: "Facebook carousels render best with facebook-square (1080×1080)."
interface DeckStore {
deck: DeckContent
isDirty: boolean
filePath: string | null
activeSlideIndex: number
selectedBlockId: string | null
zoom: number
showCounter: boolean
// Undo/redo
past: DeckContent[]
future: DeckContent[]
// Actions
setActiveSlide: (index: number) => void
addSlide: (afterIndex?: number) => void
removeSlide: (index: number) => void
duplicateSlide: (index: number) => void
reorderSlides: (from: number, to: number) => void
setSlideLabel: (index: number, label: string) => void
setBlock: (slideIndex: number, blockId: string, block: Block) => void
addBlock: (slideIndex: number, type: BlockType, afterId?: string) => void
removeBlock: (slideIndex: number, blockId: string) => void
reorderBlocks: (slideIndex: number, from: number, to: number) => void
setDeckTheme: (name: string) => void
setDeckSize: (size: SizeName) => void
toggleCounter: () => void
undo: () => void
redo: () => void
save: () => Promise<void>
load: (file: File) => Promise<void>
exportSlide: (index: number) => Promise<void>
exportDeckZip: () => Promise<void>
}Every template receives:
card: the current slide/card content objecttheme: the resolved theme objectmeta:{ slideIndex: number, slideTotal: number, showCounter: boolean, counter: CounterConfig }
All colors come from the theme via CSS vars, never hardcoded:
:root {
--bg: {{ theme.colors.background }};
--headline: {{ theme.colors.headline }};
--accent: {{ theme.colors.accent }};
--body: {{ theme.colors.body }};
--label: {{ theme.colors.label }};
--bq-border: {{ theme.colors['blockquote-border'] }};
--callout-bg: {{ theme.colors['callout-bg'] }};
--counter-bg: {{ theme.colors['slide-counter-bg'] }};
--counter-text: {{ theme.colors['slide-counter-text'] }};
--padding: {{ theme.spacing.padding }};
--gap: {{ theme.spacing['block-gap'] }};
}(card or deck-slide) + theme
│
▼
template-engine.ts
Nunjucks → HTML string with injected CSS vars + font @import
│
▼
renderer.ts
Puppeteer:
newPage()
→ setViewport({ width, height, deviceScaleFactor: 2 })
→ setContent(html, { waitUntil: 'networkidle0' }) ← waits for fonts
→ screenshot({ type: 'png' })
→ Buffer
│
▼
single card → write PNG
deck slides → collect Buffer[] → zip.ts → ZIP
<!-- templates/_blocks/slide-counter.njk -->
<div class="counter counter--{{ meta.counter.position }}
counter--{{ meta.counter.style }}">
{{ meta.counter.format
| replace('{current}', meta.slideIndex + 1)
| replace('{total}', meta.slideTotal) }}
</div>- Bun project + TypeScript strict config
- Full directory structure from §2
- Install:
puppeteer,nunjucks,commander,zod,chalk,@clack/prompts,archiver -
CLAUDE.mdat root - Zod schemas:
CardContent,DeckContent,Theme, allBlocktypes -
dark-teal.jsontheme (exact reference colors) -
manifestotemplate with all block partials -
content/examples/manifesto-wiki.json(reference card) -
decks/examples/intro-deck.json(5-slide example)
Deliverable: quoteforge validate content/examples/manifesto-wiki.json → exits 0
Deliverable: quoteforge validate decks/examples/intro-deck.json → exits 0
-
template-engine.ts: Nunjucks + CSS var injection -
renderer.ts: Puppeteer pipeline (retina 2x, networkidle0) - All 7 block types render correctly
- All 14 sizes including all 5 Facebook formats
-
generatecommand:--theme,--size,--output,--scale,--open
Deliverable: quoteforge generate content/examples/manifesto-wiki.json produces
a pixel-perfect replica of the reference image. --size facebook-post produces 1200×630.
-
slide-renderer.ts: deck merge logic (defaults ← per-slide overrides) - Slide counter partial (all 3 styles, 4 positions)
-
zip.ts: archiver-based ZIP builder -
slidescommand:--slide,--no-zip,--no-counter,--concurrency - Zero-padded filenames (
01,02... or001if deck > 99 slides) - Facebook non-square size warning in
slidescommand -
decks/examples/intro-deck.jsongenerates 5 PNGs + 1 ZIP
Deliverable: quoteforge slides decks/examples/intro-deck.json --size instagram-sq
→ 5 PNGs + intro-deck.zip in outputs/intro-deck/
-
newcommand (@clack/prompts,--type card|deck) -
previewcommand (Bun.serve + chokidar + SSE + deck slide nav + arrow keys) -
themescommand suite (chalk swatches in list) -
batchcommand (concurrency +--decksflag) -
validatecommand (auto-detects card vs deck) - 3 additional themes + 3 additional templates
Deliverable: All CLI commands work end-to-end. --help is clean for all commands.
- Vite + React in
studio/ -
studiocommand (proxies Vite through Bun, single port 4242) - WebSocket live sync
- Card mode: BlockList + BlockEditor + ThemePicker + SizePicker + Toolbar
- Deck mode: SlideList + SlideNav + DeckStrip + deckStore
- Mode auto-detection from loaded file
- SizePicker grouped by platform with Facebook warning
- Export PNG (single) + Export Deck ZIP
- Ctrl+S save
Deliverable: quoteforge studio decks/examples/intro-deck.json → working deck
editor. Slides navigable, blocks editable, Export Deck ZIP downloads correctly.
- Theme Editor modal (create new themes from UI)
- Undo/Redo (both stores, max 50, immutable snapshots)
- Keyboard shortcuts:
Ctrl+S,Ctrl+Z/Y,Ctrl+E(export) - Counter toggle button in deck mode toolbar
- All error states (invalid JSON banner, render fail toast, font fallback)
- Toast system from scratch
- README with install guide, CLI reference, size table
# CLAUDE.md: QuoteForge Constraints
# Read this FIRST every session.
# Echo the stack and the 3 most important hard rules before doing anything.
## Stack (non-negotiable)
- Runtime: Bun (not Node.js, not npm scripts)
- Language: TypeScript strict mode everywhere, no `any`, use `unknown`
- CLI: Commander.js (not yargs, not meow)
- Templating: Nunjucks (not Handlebars, not EJS, not JSX for templates)
- Rendering: Puppeteer (not node-canvas, not sharp, not playwright)
- Validation: Zod for all content, deck, and theme schemas
- ZIP: archiver (not adm-zip, not jszip, not fflate)
- Web UI: Vite + React 18 (not Next.js, not Remix, not Astro)
- Web UI state: Zustand, cardStore.ts (single card) + deckStore.ts (deck)
- Web UI DnD: dnd-kit (not react-beautiful-dnd)
- Web UI styling: Tailwind CSS utility classes only (no CSS modules, no styled-components)
- Web UI icons: lucide-react, import individually (no barrel: `import { X } from 'lucide-react'`)
- CLI prompts: @clack/prompts (not inquirer)
- CLI logger: chalk (not picocolors)
## Hard Rules
1. NEVER install a package without asking the user first
2. NEVER use a UI component library: no shadcn, Radix, MUI, Ant Design, PrimeNG, etc.
All Web UI components are built from scratch with Tailwind
3. NEVER hardcode colors in template CSS: every color is a CSS custom property
injected from the theme JSON at :root level
4. NEVER write to a file unless the user explicitly triggers it
(--output flag, Save button, or Ctrl+S)
5. NEVER put business logic inside CLI command files: commands are thin:
parse args → validate with Zod → call renderer → log result
6. outputs/ is gitignored: never treat it as a source of truth
7. React components MUST NOT make direct filesystem calls: all FS goes
through Bun server routes (/export, /export-deck, /themes, etc.)
8. Facebook carousel decks should use facebook-square (1080×1080).
Warn (don't block) if the user picks a non-square size for a deck.
## Content Type Detection
- `"type": "deck"` at root → deck file (use DeckContent schema, deck mode in studio)
- `"type": "card"` or no type field → single card (use CardContent schema, card mode)
- validate, preview, studio, and slides commands all auto-detect from this field
## File Naming Conventions
- TypeScript: camelCase.ts
- React: PascalCase.tsx
- Templates: template.njk (in named folder under templates/)
- Themes: kebab-case.json (in themes/)
- Card content: kebab-case.json (in content/)
- Deck content: kebab-case.json (in decks/)
- Block partials: <block-type>.njk (in templates/_blocks/)Start every Claude Code session with:
"Read CLAUDE.md and confirm you understand the constraints by echoing the stack and the 3 most important hard rules before doing anything."
Bootstrap the QuoteForge project: structure, schemas, example files, validate command
GOAL: Both of these exit 0:
quoteforge validate content/examples/manifesto-wiki.jsonquoteforge validate decks/examples/intro-deck.jsonBoth exit 1 with Zod error details when given a malformed file.CONSTRAINTS:
- Follow CLAUDE.md exactly
- Install only: puppeteer, nunjucks, commander, zod, chalk, @clack/prompts, archiver
- Zod schemas in src/cli/utils/validator.ts: CardContent, DeckContent, Theme, all Blocks
- manifesto-wiki.json must have all 4 block types from the reference image
- dark-teal.json: bg #1a1a1a, accent #4ecdc4, exact match to reference
- intro-deck.json must have "type": "deck" at root and 5 slides
- validate auto-detects card vs deck from "type" field
FORMAT:
- package.json: deps + "quoteforge"/"qf" bin entries
- tsconfig.json: strict mode
- src/cli/utils/validator.ts: all Zod schemas
- content/_schema.json · decks/_schema.json · themes/_schema.json
- content/examples/manifesto-wiki.json
- decks/examples/intro-deck.json (5 slides, "type": "deck")
- themes/dark-teal.json
- src/cli/commands/validate.ts
- src/cli/index.ts (validate wired)
FAILURE CONDITIONS:
- Any unlisted package installed
anyTypeScript type used- intro-deck.json missing "type": "deck" root field
- validate does not auto-detect card vs deck schema
- dark-teal.json colors don't match (#1a1a1a bg, #4ecdc4 accent)
Implement single-card rendering: CardContent + Theme → PNG via Puppeteer
GOAL:
quoteforge generate content/examples/manifesto-wiki.jsonproduces a PNG matching the reference image (fonts, colors, all 4 blocks). < 5 seconds.--size facebook-postproduces 1200×630.--size facebook-coverproduces 1640×624.CONSTRAINTS:
- renderer.ts: pure function (content, theme, size, scale) → Promise
- No hardcoded colors in any CSS: all CSS custom properties
- Puppeteer: waitUntil 'networkidle0', deviceScaleFactor 2
- All 14 sizes from §4 in the SIZES constant (including all 5 Facebook formats)
- generate command: thin, only arg parse + Zod validate + call renderCard + log
FORMAT:
- src/renderer/renderer.ts: renderCard(...) → Promise
- src/renderer/template-engine.ts: renderTemplate(content, theme, meta?) → string
- src/renderer/font-loader.ts
- templates/_blocks/: all 7 block partials + slide-counter.njk
- templates/manifesto/template.njk + style.css (zero hardcoded colors)
- src/cli/commands/generate.ts: thin command
- src/cli/index.ts (generate wired)
FAILURE CONDITIONS:
- Any color hardcoded in CSS
- Puppeteer not using networkidle0
- Missing any Facebook size in SIZES
- generate command has rendering logic inside it
- Font falls back to system font in the PNG
Add full deck rendering: DeckContent → N PNGs + ZIP
GOAL:
quoteforge slides decks/examples/intro-deck.json --size instagram-sqproduces outputs/intro-deck/intro-deck-01.png through 05.png (zero-padded) plus intro-deck.zip. Each slide shows counter "X / 5" pill bottom-right.--slide 3produces only slide 3, no ZIP.CONSTRAINTS:
- slide-renderer.ts: pure (deck, opts?) → Promise<Buffer[]>
- Merge order: deck.defaults ← slide overrides (all 5 overrideable fields)
- Zero-padding: 2 digits for decks ≤ 99 slides, 3 for > 99
- archiver only for ZIP: no other library
- Concurrency: p-limit or manual semaphore, default 4 workers
- Facebook non-square deck: log a yellow chalk warning (don't block)
- --slide N: render only that slide, skip ZIP entirely
FORMAT:
- src/renderer/slide-renderer.ts
- src/cli/utils/zip.ts: buildZip(buffers, names) → Promise
- templates/_blocks/slide-counter.njk (all 3 styles, 4 positions)
- src/cli/commands/slides.ts: thin command
- src/cli/index.ts (slides wired)
FAILURE CONDITIONS:
- Filenames not zero-padded
- Counter rendered when showCounter is false
- Per-slide theme/template override ignored
- Any ZIP library other than archiver
- --slide still renders all slides before filtering
Complete all remaining CLI commands
GOAL: (1)
bun qf new --type deck --slides 5 --name test→ decks/test.json (2)bun qf preview decks/test.json→ browser opens, arrow keys navigate slides, reload < 500ms (3)bun qf themes list→ chalk color swatch table (4)bun qf batch content/examples/ --size facebook-post→ 2 PNGs (5) 3 new themes + 3 new templates are validCONSTRAINTS:
- @clack/prompts for new command interactive flow
- preview: Bun.serve + chokidar + SSE (not WebSocket)
- preview for deck: keyboard arrow nav + SSE slide-change events
- Each command file has zero imports from other command files
FORMAT:
- src/cli/commands/new.ts
- src/cli/commands/preview.ts
- src/cli/commands/themes.ts
- src/cli/commands/batch.ts
- src/cli/utils/logger.ts
- themes/dark-orange.json · light-minimal.json · brand-midnight.json
- templates/quote/ · list/ · minimal/ (template.njk + style.css each)
- src/cli/index.ts: all commands wired, --help polished
FAILURE CONDITIONS:
- preview opens browser before server is ready
- Deck preview has no keyboard navigation
- batch ignores --concurrency
- new writes file without user confirmation
- Any cross-command imports
Build the Vite + React WYSIWYG studio: card mode + deck mode
GOAL:
quoteforge studio decks/examples/intro-deck.json→ http://localhost:4242 in deck mode. All 5 slides in SlideList and DeckStrip. Clicking a thumbnail activates it. Editing a block updates preview < 200ms. Export Deck ZIP downloads a working ZIP.quoteforge studio content/examples/manifesto-wiki.json→ card mode. SizePicker shows Facebook sizes grouped under "Facebook" heading.CONSTRAINTS:
- NO UI component libraries
- cardStore.ts + deckStore.ts (Zustand): no Context for global state
- Mode auto-detected from "type" field on load
- dnd-kit for all drag-and-drop (slide list + block list)
- Preview <iframe> updated via WS push (not page navigation)
- DeckStrip thumbnails fetched lazily (not all on load)
- studio: Vite proxied through Bun → single port 4242
- SizePicker grouped by platform (§4 grouping), FB non-square warning in deck mode
FORMAT:
- studio/vite.config.ts
- studio/src/types/index.ts
- studio/src/store/cardStore.ts + deckStore.ts (full per §7.5)
- studio/src/hooks/: useCard, useDeck, useTheme, useLivePreview
- studio/src/components/Editor/: BlockList, BlockEditor, SlideList, SlideNav, > ThemePicker, SizePicker (with platform groups), Toolbar
- studio/src/components/Preview/: PreviewPane, PreviewControls, DeckStrip
- studio/src/components/ui/: Button, Input, Select, ColorPicker, Modal, Toast
- studio/src/App.tsx (mode switch)
- src/server/server.ts + routes/ (preview, export, export-deck, themes) + ws.ts
- src/cli/commands/studio.ts
FAILURE CONDITIONS:
- Any UI component library imported
- Global state in React Context
- DeckStrip loads all thumbnails at once on mount
- Export Deck re-implements rendering (must use POST /export-deck)
- BlockEditor missing any of the 7 block types
- Studio requires two browser ports
- Facebook non-square warning absent in deck mode SizePicker
Add Theme Editor, undo/redo, keyboard shortcuts, error states
GOAL: (1) [+ New Theme] → theme editor modal → save creates .json and applies it. (2) Ctrl+Z / Ctrl+Y undo/redo works in both card and deck mode. (3) All error states handled: invalid JSON shows error banner, render fail shows toast with message, missing font falls back without crashing.
CONSTRAINTS:
- Undo/redo: immutable snapshots in Zustand, max 50, works for both stores
- Color inputs: native , no third-party color picker library
- Google Fonts list: fetched once, cached in sessionStorage
- Toasts built from scratch: no react-hot-toast, sonner, or any toast library
- Error boundaries wrap preview iframe AND editor panel separately
- Facebook size warning is non-blocking (yellow inline text, not a modal)
FORMAT:
- studio/src/components/Editor/ThemeEditorModal.tsx
- studio/src/store/cardStore.ts: add past[], future[], undo(), redo()
- studio/src/store/deckStore.ts: add past[], future[], undo(), redo()
- studio/src/hooks/useKeyboardShortcuts.ts (Ctrl+S, Z, Y, E)
- studio/src/components/ui/Toast.tsx: queue, auto-dismiss 4s
- studio/src/components/ui/Modal.tsx: accessible wrapper
- studio/src/hooks/useFontList.ts: Google Fonts API + sessionStorage cache
- src/server/routes/themes.ts: POST + PUT /themes/:name
- README.md (install, CLI reference, size table with Facebook formats)
FAILURE CONDITIONS:
- Undo/redo corrupts state (must be standard past/future stack pattern)
- Theme saved without Zod validation
- Toast library installed
- Google Fonts re-fetched on every modal open
- Error in one slide crashes the entire studio
- Facebook non-square warning blocks export (must only warn)