A content-first document system with a precision layout engine, cloud sync, and an AI assistant.
Edit one JSON file. The layout engine figures out the rest — one or two pages, sidebar width, section placement — and produces a pixel-accurate PDF and a live web view from the same source.
Built as a portfolio project and practical tool. The engineering is the point.
Live site: blemmy.dev
The name comes from the Blemmyes — a mythological headless people described by Herodotus and Pliny, whose faces were mounted on their chests. A creature for whom headlessness is the natural state, not a surgical act. The right analogy for headless architecture.
Most CV tools couple content to layout. Change a job title and you're fighting with text boxes. Change the design and you're re-entering all your data. Blemmy separates them completely:
src/data/cv-demo.json ← Default bundled content (Blemmy demo; import your own JSON)
src/lib/engine/ ← Decides how to lay it out
src/styles/global.css ← Design tokens — change once, PDF follows
The JSON is the single source of truth. Every render reads from it. The layout engine searches the candidate space to find the best fit for the current content — automatically.
This is a Vite + TypeScript SPA with no framework. The layout engine runs client-side in the browser; the same page that renders the web view also renders the PDF (captured by Puppeteer via @media print).
The core of the project. Given a rendered document DOM, the engine runs a constrained search to find the optimal layout configuration:
1. PROFILE Measure width-sensitivity of each section
2. GENERATE Produce constraint-valid layout candidates
3. SEARCH Score each candidate (height balance + preference affinity)
4. CLUSTER Group results into perceptually distinct peaks
5. SIDEBAR Binary-search the winning sidebar width (mm precision)
6. FILL Step through fill levels within the A4 page budget
7. SLACK Absorb remaining column height into flex gaps
8. ALIGNMENT Cross-column landmark snapping + safe-zone guard
What it decides automatically:
- One page or two, and where to split work entries
- Sidebar width (binary search in mm)
- Masthead mode (4 variants depending on space)
- Section placement (skills/languages/interests: sidebar vs. footer vs. page 2)
- Density and fill tiers within the page budget
All decisions are written as CSS custom properties. The DOM is never reflowed from scratch between candidates — the engine mutates properties and re-measures.
See docs/layout-engine.md for the full technical specification.
blemmy/
├── index.html # Entry point — fonts, dark mode script
├── src/
│ ├── main.ts # App bootstrap
│ ├── types/
│ │ └── cv.ts # TypeScript interfaces for cv-demo.json
│ ├── data/
│ │ ├── cv-demo.json # ← Default demo (Blemmy Kerning — meta tour)
│ │ └── cv-demo-alex-meridian.json # Legacy realistic demo JSON (import manually)
│ ├── lib/
│ │ ├── engine/ # @blemmy/engine — AGPL v3
│ │ │ ├── LICENSE
│ │ │ ├── package.json
│ │ │ ├── layout-engine.ts # 8-stage layout pipeline
│ │ │ ├── layout-candidate.ts # Candidate generation + scoring
│ │ │ ├── layout-profile.ts # Section width-sensitivity measurement
│ │ │ ├── layout-align.ts # Cross-column alignment snapping
│ │ │ ├── layout-slack.ts # Flex gap absorption
│ │ │ ├── cv-layout-snapshot.ts # Serialisable layout state capture
│ │ │ └── layout-audit.ts # Edit-sequence audit log
│ │ ├── cv-chat.ts # Multi-provider LLM client (streaming)
│ │ ├── cv-chat-prompts.ts # System prompt + schema context builder
│ │ ├── cv-cloud.ts # Supabase auth + document storage
│ │ ├── cv-sync.ts # Auto-save with debounce + conflict guard
│ │ ├── cv-filter.ts # Tag-based content filtering
│ │ ├── cv-loader.ts # JSON validation + import
│ │ ├── cv-prefs.ts # Layout preference state + events
│ │ └── share-link-url.ts # Share link token handling
│ ├── renderer/
│ │ ├── cv-renderer.ts # Builds full document DOM from CVData
│ │ ├── cv-editor.ts # Sidebar UI — preferences, filters, tools
│ │ ├── chat-panel.ts # AI assistant panel + response parser
│ │ ├── document-panel.ts # Cloud document management UI
│ │ ├── auth-panel.ts # Auth flow (magic link, OAuth)
│ │ └── ui-components.ts # Shared DOM helpers
│ └── styles/
│ ├── global.css # Design tokens + all layout (screen + print)
│ ├── print.css # Minimal print-only layer (@page, A4 shell)
│ ├── blemmy-print-surface.css # Screen mirror of print surface for preview
│ └── blemmy-print-parity.css # Rules shared between print and preview
├── scripts/
│ ├── build-cv-pdf.mjs # Puppeteer HTML→PDF capture
│ ├── evaluate-layout-engine.mjs # Layout quality eval across test cases
│ ├── test-layout-determinism.mjs # Property test: same input → same output
│ ├── test-layout-recalc.mjs # Recalculation stability under edits
│ ├── audit-layout-edit-sequences.mjs # Sequential edit regression suite
│ └── run-retention-cleanup.mjs # GDPR retention enforcement script
└── docs/
├── layout-engine.md # Layout engine technical specification
├── cv-print-single-source.md # Print/preview CSS architecture
├── supabase-cloud-schema.sql # Full database schema
├── gdpr-data-inventory.md # GDPR data mapping
├── gdpr-dsar-runbook.md # Data subject access request runbook
└── privacy-policy.md # Privacy policy template
- Single JSON source — all document content in one file, all fields typed
- Tag-based filtering — tag work/education entries; filter to a target role without deleting content
- Visibility controls — hide individual entries or whole sections without removing them from the JSON
- Import — upload a
.docx,.txt, or paste text; the AI generates a complete JSON
- Automatic layout search — finds the best configuration for the current content length
- Preference controls — density, fill level, sidebar section order, masthead mode
- Multiple alternatives — when the search finds distinct layout peaks, offers a choice
- Debug overlay —
?debug-layout=1shows column slack, grid shortfall, and alignment gaps visually
- Provider-agnostic — works with Anthropic (Claude) and Google (Gemini); model is configurable
- Layout-aware — system prompt includes the current layout state (pages, disposition, near-overflow)
- Schema-aware — the AI understands the full JSON structure and returns valid patches
- Generate from scratch — upload background material, get a complete document JSON
- Auth — magic link, GitHub OAuth, Google OAuth
- Document storage — multiple documents per account, named and versioned
- Auto-save — debounced sync with conflict guard; visual sync indicator
- Share links — revocable, expiring read-only links per document
- Version history — load any previous version of a document
- Personal data fields documented in
docs/gdpr-data-inventory.md - Row-level security on all user data tables
- Share access event logging
- Retention cleanup script (
npm run gdpr:retention-cleanup) - DSAR runbook in
docs/gdpr-dsar-runbook.md
Requirements: Node.js 20+
git clone https://github.com/VilhelmC/blemmy.git
cd blemmy
npm install
npm run dev
# → http://localhost:5923The app works fully without cloud configuration. Load src/data/cv-demo.json (the playful Blemmy Kerning tour) or import src/data/cv-demo-alex-meridian.json for the older realistic sample, or use the AI assistant to generate from your own background material.
To discard browser-persisted CV data and remount the bundled demos in place, open DevTools and run:
window.blemmyResetToBundledDefaults()
That clears blemmy-user-data, blemmy-edit-draft, blemmy-app-session-state, letter draft, and chat source keys — plus legacy cv-* keys if still present. Your portrait cache (blemmy-portrait-cache / IndexedDB) is kept so a re-upload can reuse it; bundled demo CVs without portraitDataUrl show /blemmy.png.
- Create a Supabase project
- Run
docs/supabase-cloud-schema.sqlin the SQL editor - Enable Email (magic link), GitHub, and Google providers under Authentication → Providers
- Copy
.env.exampleto.envand fill in your project URL and anon key
cp .env.example .env
# Edit .env with your Supabase credentialsThe AI assistant stores API keys in localStorage — no backend required. On first use, paste an Anthropic or Google AI Studio key into the key field in the chat panel. Keys never leave your browser.
npm run pdf
# Builds the site, serves it, captures via Puppeteer → dist/cv.pdfCI sets PUPPETEER_SKIP_DOWNLOAD so npm ci does not fetch Chromium. For local
npm run pdf, use a normal npm install, or run npx puppeteer browsers install chrome if Puppeteer cannot find a browser.
The PDF is generated from the same HTML as the web view, using @media print. What you see in Print Preview is what you get in the PDF.
The layout engine has a dedicated test suite covering properties that are easy to break during development:
npm run test:unit # Unit tests (vitest) — schema, URL, keys
npm run test:layout-determinism # Same input must produce same layout every time
npm run test:layout-recalc # Layout must be stable under sequential edits
npm run test:layout-audit # Edit sequence regression suite
npm run test:layout # Full quality evaluation across test casesThe determinism and recalculation tests are property-based: they run multiple passes and assert that measurements converge. Layout changes that introduce instability fail these tests before they reach the PDF.
All design tokens live in global.css :root. Changing a token updates both the web view and the PDF simultaneously.
| Token group | What it controls |
|---|---|
--color-sidebar |
Sidebar background (drives all sidebar text/border derivations) |
--color-ink-* |
Main column text hierarchy (ink, dark, mid, muted, border) |
--color-teal-* |
Accent colors in main column (dates, positions, bullet markers) |
--sidebar-width |
Default sidebar width before engine override |
--text-* |
Type scale (name, label, section, body, meta, bullet, …) |
--font-body / --font-heading |
Font families (used throughout; swap here to change all) |
Why a client-side layout engine rather than a build-time template? Build-time templates fix the layout at compile time. A client-side engine can respond to the actual rendered heights of the current content — which vary with font loading, OS rendering, and content length. The engine measures the DOM after fonts load and searches for the best configuration for that measurement. The PDF captures the same DOM state, so the PDF matches the preview exactly.
Why no framework (React, Vue, etc.)? The layout engine manipulates the DOM directly by ID and CSS custom property. A virtual DOM layer would add reconciliation overhead between engine passes and make it harder to reason about exactly what is in the DOM at measurement time. Vanilla TypeScript with a thin DOM helper layer is easier to reason about for this specific problem.
Why TypeScript throughout (including scripts)?
The CVData type is the schema contract between content, renderer, AI assistant, and cloud layer. TypeScript catches mismatches at the border before they reach the layout engine.
Why Supabase? Row-level security policies enforce user data isolation at the database layer, not just the application layer. For a tool that stores personal document data, that is the right level to enforce the boundary.
Why AGPL for the engine?
The layout engine (src/lib/engine/) is separately licensed under AGPL v3. This means it is fully open source — readable, forkable, and usable in open source projects — but any company using it in a proprietary network service must release their modifications. Commercial licences are available for companies that need different terms. See src/lib/engine/LICENSE.
Every push to main deploys automatically via GitHub Actions to GitHub Pages.
Production URL: blemmy.dev
To use a custom domain, add a CNAME file to /public/:
public/
└── CNAME ← contains your domain, e.g. blemmy.dev
Blemmy supports read-only embeds for portfolio and published CV use cases.
- Portfolio embed mode:
https://blemmy.dev/?blemmy-portfolio=1 - Published document embed mode:
https://blemmy.dev/embed/<publicId> - Shared read-only link mode:
https://blemmy.dev/share/<token>
Recommended integration pattern:
- Desktop: live iframe embed
- Mobile: lightweight preview card + "Open full demo" CTA
Example iframe snippet:
<iframe
src="https://blemmy.dev/?blemmy-portfolio=1"
title="Blemmy demo"
loading="lazy"
style="width:100%;min-height:720px;border:0;"
></iframe>For user-published documents:
<iframe
src="https://blemmy.dev/embed/YOUR_PUBLIC_ID"
title="Embedded CV"
loading="lazy"
style="width:100%;min-height:720px;border:0;"
></iframe>The embed views are read-only and intentionally hide authoring UI.
Build output includes dist/blemmy-doc.js plus shared dist/assets/* chunks. Serve them from the same origin (module imports are relative), then:
<script type="module" src="/blemmy-doc.js"></script>
<blemmy-doc src="/path/to/document.json"></blemmy-doc>- Optional attribute
doctype="cv"ordoctype="letter"forces the type; otherwise it is inferred from the JSON. - Optional top-level JSON key
blemmyDocumentStylemay hold a partialDocumentStyleobject (colours, bundled font ids,customFontCssUrl, custom font-family strings, etc.). - One
<blemmy-doc>per page is supported today (fixed#blemmy-doc-rootids). - While an embed is connected,
<html>gets the classblemmy-doc-embed-typographysorem-based document typography stays aligned with print preview.
Local demo: embed-demo.html. Use npm run dev then
http://localhost:5923/embed-demo.html, or npm run build + npm run preview
then http://localhost:4173/embed-demo.html (keep preview running). Shortcuts:
npm run dev:embed / npm run preview:embed. Do not use file://; dev and preview
use different ports.
The animation below shows the layout engine iterating through candidate configurations before converging on the final document layout.
-
DocumentStyleschema — bot-addressable colour swatches, font pairs, print sidebar mode - Review mode — structured annotation layer with share-link reviewer access
- Generic
DocumentLayoutSpec— decouple engine from CV schema (@blemmy/enginev1.0) - Cover letter as second document type
- Standalone HTML export
- Assistant review before apply — optional staged apply for assistant JSON (layout prefs → Assistant → Review first)
- Embeddable custom element —
<blemmy-doc src="...">+dist/blemmy-doc.js(see Embedding) - Bundled default fonts —
@fontsource/*insrc/styles/fonts.cssfor offline/PDF; optionalcustomFontCssUrl(Google or Bunny Fonts CSS) plus custom font-family fields for other faces; assistant unchanged
The application layer is MIT licensed — see LICENSE.
The layout engine (src/lib/engine/) is licensed under AGPL v3 with a commercial licence option — see src/lib/engine/LICENSE.
