You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/docs/superpowers/specs/2026-07-13-live-preview-des...

11 KiB

Live Document Preview — Design (HQ console)

STATUS: DELIVERED (2026-07-13). Kept as the design/task record; current state lives in STATUS.md.

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 DraftInput shape POST /api/documents takes.
  • Builds a synthetic unsaved Doc (docNo: null → template prints "DRAFT"; docDate: today; fy via fyOf), runs prepareDraft + 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 prepareDraftcreateDraft 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 at NewDocument.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: scale to fit width) holding a sandboxed srcdoc iframe 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: AbortController cancels 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:

  1. 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.
  2. Reminder email preview (manual queue / send) — the exact outgoing email (subject + body from the pure reminderEmail(kind, ctx)), via GET /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 (separate repo) — 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 — extract prepareDraft; createDraft calls it.
  • apps/hq/src/api.tsPOST /api/documents/preview, GET /api/reminders/:id/preview.
  • apps/hq/src/templates.ts — add @media screen paper 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.tsGET/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.