11 KiB
Live Document Preview — Design (HQ console)
Status: approved (founder, 2026-07-13, via ultracode design panel + brainstorm). Origin: spec 14-SPEC-HQ-CONSOLE.md §3 already promised a "live PDF preview on letterhead" in the composer; this is that, designed properly and extended to three cheap sibling surfaces.
1. The one idea that makes it safe
The preview must never drift from what prints. That is guaranteed architecturally,
not by testing: the preview shows the output of the same documentHtml() template
and the same computeBill() tax engine that the PDF uses — there is no second
renderer and no client-side money math. The browser renders that HTML directly in an
iframe (puppeteer only rasterizes the identical HTML for the actual PDF).
Winning approach = engineering-minimalist core (smallest correct change, one shared compute path) + grafted UX pieces (server-truth totals strip, permissive preview, no-blank swap). Chosen by a 2-judge panel (value judge 8.5 ux / cost judge 9 minimal → minimal core carrying ux's high-value grafts).
2. Server: one new pure function + one route
Refactor (no behavior change): extract the compute block of createDraft in
apps/hq/src/repos-documents.ts into a pure prepareDraft(db, input): { lines, totals, payload } that does not persist. createDraft calls prepareDraft then inserts —
so save and preview cannot diverge by construction.
New route POST /api/documents/preview (requireAuth, api.ts style):
- Body: the exact
DraftInputshapePOST /api/documentstakes. - Builds a synthetic unsaved
Doc(docNo: null→ template prints "DRAFT";docDate: today;fyviafyOf), runsprepareDraft+documentHtml. - Returns
{ ok: true, html: string, totals: BillTotals, warnings: string[] }. - Persists nothing; writes no audit row (it is a read). Cost = a few SQLite reads + string templating (single-digit ms); no puppeteer, no browser contention.
Permissive vs strict: the preview endpoint is permissive — a line with no
price-book row and no manual rate computes at ₹0 and is reported in warnings[] (so
staff can keep typing); POST /api/documents (save) stays strict and still rejects
the same bad document. Preview forgives; save refuses.
Fidelity test: one golden test asserts the preview route and renderPdf consume
string-identical HTML for a fixture draft + a prepareDraft↔createDraft
deep-equal parity test (totals/payload). Guards anyone ever forking the template.
3. Client: the composer becomes a split view
apps/hq-web/src/pages/NewDocument.tsx, layout "form + A4 paper fills the rest"
(founder pick):
- Left, fixed ~600px, own scroll: the existing form. Its bottom gets a sticky
totals strip — Taxable / (CGST+SGST or IGST) / Round-off / Payable — sourced from
the response's
totals, deleting the client-side subtotal reducer atNewDocument.tsx:91(the last client money math; its removal is the single biggest trust win — the IGST split now appears live for out-of-state clients). - Right, fills remaining width, sticky under the header: grey backdrop + a white
A4 paper card (210mm aspect,
transform: scaleto fit width) holding a sandboxedsrcdociframe of the real template HTML. - Narrow (<800px, owner's phone): single-column form; a pinned bottom bar always
shows Payable + GST + a Preview button that opens the paper as a full-screen sheet
(fixed-dimension wrapper +
transform: scale— iOS Safari expands bare iframes). The totals bar never disappears at any width.
Update mechanics:
- A shared client helper
buildDraftBody()(extracted from the save path) builds the request body, so preview and save POST byte-identical bodies. - Immediate refire (0ms) on commit-shaped events (client pick, doctype radio, module/kind/pack select, add/remove line, field blur); 400ms trailing debounce on keystrokes (qty/unit/description/content/terms).
- One in-flight request max:
AbortControllercancels superseded calls; a sequence guard drops stale responses; skip the POST entirely when the serialized body is unchanged. - Never blank the pane: keep the previous paper visible, show a 2px shimmer + dim
totals to 60% while in flight, then double-buffer swap (write into a hidden stacked
iframe, restore
scrollTop, 120ms cross-fade) — no white flash, no scroll jump. Changed totals cells pulse 250ms (cause→effect made visible).
Empty/error states (client): zero valid lines → real letterhead, empty table, ₹0
(never a mock); no client picked → muted "— pick a client —" block, totals shown with a
"split pending client" tag (18% total is invariant) that clears with a pulse on pick;
warnings[] price gaps → inline "No price — enter Unit ₹" + amber rate-cell flag;
server unreachable/400 → keep last good paper, slim "Preview paused — retrying" banner
with as-of timestamp, totals greyed (never present stale numbers as fresh);
company.state_code unset → actionable link to Settings → Company.
Two honest screen/print deltas: @page 14mm margins → the card draws 14mm padding
with a faint toggleable "Show print area" guide; pagination is puppeteer's → an
approximate dashed page-break line at ~269mm content height plus an on-demand "Exact PDF"
button (real puppeteer render) for the rare must-be-one-page quote. The ~5-line
@media screen paper styles live inside templates.ts (with a "page.pdf renders
print media" comment), not injected client-side, so the paper itself is template-owned;
only preview chrome (guides, pulses, tags) is client overlay.
4. Editable template data (founder addition, 2026-07-13)
All the text the letterhead prints must be DB-editable from a page — one fixed
(well-designed) layout for now, not a visual layout editor (that is the store
product's "Print Templates" surface, doc 09, out of scope). GST split logic
(CGST/SGST vs IGST) is already derived from company.state_code vs the client state and
stays automatic; this is about the displayed data and boilerplate text.
Model: everything is a setting row (the existing setting table + getSetting/
setSetting), so a change is a DB write that the next render (and the live preview)
reflects immediately — no release. Existing company.* (name, address, gstin,
state_code, phone, email, bank) already work; add these keys:
template.terms(multi-line terms & conditions)template.declaration(GST declaration line, e.g. "We declare that this invoice shows the actual price of the goods/services described and that all particulars are true.")template.jurisdiction(e.g. "Subject to jurisdiction")template.footer_note(thank-you / support line)template.signatory_label(e.g. "For — Authorised Signatory")template.logo(image as a data: URI stored in the setting — no file store needed; size-capped on upload)template.title_<doctype>(optional per-type title override; falls back to the built-in TAX INVOICE / QUOTATION / PROFORMA INVOICE / CREDIT NOTE / RECEIPT)
templates.ts renders these where it currently hardcodes/omits them. All are optional —
absent keys render the sensible built-in default, so nothing breaks pre-configuration.
Edit surface: a Document Template page in hq-web (owner-gated, audited), grouping
the company block + the new template.* fields, with the live letterhead preview beside
the form (same preview mechanism as the composer, sample doc) so every edit is seen on
the page as it prints. This supersedes the smaller "company profile editor preview" idea
below — the Company Profile page's letterhead fields fold into this one Document Template
page. Routes: extend GET/PUT /api/settings/company to a GET/PUT /api/settings/template
covering both groups (owner-gated, each field audited via setSetting), plus a small
POST /api/settings/template/logo that validates + stores the data URI.
5. Two more cheap sibling surfaces (same round)
Both reuse the one preview route/mechanism — each is S effort:
- Module quote-content editor (Modules page) — live render of how the "what's
included" bullets actually print (the 10px grey
ul.line-content), using a one-line sample doc. Today they're written blind. - Reminder email preview (manual queue / send) — the exact outgoing email (subject +
body from the pure
reminderEmail(kind, ctx)), viaGET /api/reminders/:id/preview. Staff currently fire client-facing dunning mail unseen.
6. Deliberately NOT this round
Credit-note & receipt preview (mechanically derived, low value now); store-product
surfaces — documented as handoff to that workstream, not built here: Print Templates
visual editor (doc 09 explicitly specs it), Label Printing (already has a popup preview),
Price-list bulk revise, Schemes editor, WhatsApp/message-catalog text, and the POS
printer-settings receipt preview (high value — packages/printing already renders
42/32-col receipts as pure functions, so it's cheap when that team wants it).
7. Files
apps/hq/src/repos-documents.ts— extractprepareDraft;createDraftcalls it.apps/hq/src/api.ts—POST /api/documents/preview,GET /api/reminders/:id/preview.apps/hq/src/templates.ts— add@media screenpaper styles + sample-doc helpers for company/quote-content previews.apps/hq-web/src/pages/NewDocument.tsx— split layout, totals strip, LivePreview.apps/hq-web/src/components/LivePreview.tsx— new: debounced fetch + double-buffer iframe (kept modest per YAGNI — a composer helper, generalized only if a 2nd/3rd surface proves the shape).apps/hq-web/src/pages/DocumentTemplate.tsx— new owner-gated page: company block +template.*fields + logo upload, live letterhead preview beside the form (supersedes the CompanyProfile letterhead preview; keep CompanyProfile or fold it in).apps/hq/src/api.ts—GET/PUT /api/settings/template,POST /api/settings/template/logo.apps/hq-web/src/pages/Modules.tsx, dashboard reminder queue — wire the sibling previews.- Tests: string-identity golden + prepareDraft/createDraft parity + preview-permissive / save-strict divergence + route auth.
8. Success criteria
Staff draft a quotation and watch the letterhead build itself in under ~400ms per edit, with GST (incl. IGST split for out-of-state) always shown by the server; the on-screen paper is byte-identical to the PDF that later prints; no blank flashes; save remains strict so bad drafts never persist; the same mechanism previews the company letterhead, quote-content bullets, and reminder emails.