Files
pn-new-crm/docs/superpowers/specs/2026-05-12-pdf-stack-overhaul-design.md
Matt 81a98c6695 docs(superpowers): pdf stack overhaul design (react-pdf + unpdf)
Lays out the plan to replace pdfme with @react-pdf/renderer, add unpdf
for berth-PDF tier-2 rasterization, and add port-level logo upload
(sharp normalization + react-image-crop UI + svgo sanitization +
rasterize-SVG-to-PNG-on-upload).

Scope locked to internal-only PDFs (reports, expenses, record exports).
Invoice + admin TipTap-to-PDF removed entirely; in-app EOI pathway
(pdf-lib AcroForm fill) stays untouched.

14 commits planned. Single source of truth for tokens. Three orthogonal
PDF paths post-migration with no overlap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:36:54 +02:00

35 KiB
Raw Blame History

PDF Stack Overhaul — Design

Date: 2026-05-12 Branch: feat/documents-folders Status: Design approved; pending user review of spec; implementation planned via writing-plans skill.

Goal

Replace pdfme (3 deps, 8 hand-coded coordinate templates, 571-line TipTap-to-pdfme bridge) with @react-pdf/renderer (JSX components, real layout primitives). Add unpdf for berth-PDF tier-2 rasterization. Add port-level logo upload with quality safeguards. Migrate only the internal-only PDF surfaces; remove invoice and admin-TipTap PDF generation entirely (they violate the new "no client-facing CRM-generated PDFs" rule).

Scope (locked)

KEEP & migrate to @react-pdf/renderer (internal-only)

Surface Current location Caller
Activity report src/lib/pdf/templates/reports/activity-report.ts src/lib/services/reports.service.ts
Revenue report src/lib/pdf/templates/reports/revenue-report.ts same
Pipeline report src/lib/pdf/templates/reports/pipeline-report.ts same
Occupancy report src/lib/pdf/templates/reports/occupancy-report.ts same
Client summary export src/lib/pdf/templates/client-summary-template.ts src/lib/services/record-export.ts
Berth spec export src/lib/pdf/templates/berth-spec-template.ts same
Interest summary export src/lib/pdf/templates/interest-summary-template.ts same
Expense sheet src/lib/services/expense-pdf.service.ts (currently uses pdfme indirectly via expense-export.ts) same

REMOVE entirely

Removal Reason
src/lib/pdf/templates/invoice-template.ts + generatePdf call in invoices.ts:604 + API route /api/v1/invoices/[id]/generate-pdf Invoices are client-facing; no CRM-generated client-facing PDFs. Future invoice rendering will use the deferred AcroForm-fill admin-template feature.
src/lib/pdf/tiptap-to-pdfme.ts (571 lines) + API route /api/v1/admin/templates/preview + generatePdf block in document-templates.ts:516 TipTap document templates are Documenso seed bodies; CRM does not render them to PDF anymore.
src/lib/pdf/templates/eoi-standard-inapp.ts (337 lines, HTML seed) + seed-data references Only used as the seed bodyHtml text on a document_templates row. The in-app EOI is rendered by fill-eoi-form.ts (pdf-lib), not from this HTML. Safe to drop.
src/lib/pdf/generate.ts (24 lines) Pdfme wrapper; replaced by src/lib/pdf/render.ts.
Deps: @pdfme/common, @pdfme/generator, @pdfme/schemas Replaced by @react-pdf/renderer.

STAYS UNTOUCHED

  • src/lib/pdf/fill-eoi-form.ts (pdf-lib AcroForm fill on assets/eoi-template.pdf) — the in-app EOI pathway.
  • src/lib/services/berth-pdf-parser.ts tier-1 (pdf-lib AcroForm read) and tier-3 (AI fallback). Tier-2 (Tesseract OCR) gets unpdf for PDF→image rasterization.
  • pdf-lib dep (still needed by fill-eoi-form.ts and berth-pdf-parser.ts).
  • All Documenso integration code.

Architecture

Three orthogonal PDF paths post-migration, each with a single owner:

┌──────────────────────────┐  ┌──────────────────────────┐  ┌────────────────────────┐
│  react-pdf (this phase)  │  │  pdf-lib AcroForm fill   │  │  Documenso (external)  │
│  Internal only           │  │  Standardized + signing  │  │  Client-facing signed  │
│                          │  │                          │  │  docs                  │
│  • Reports (×4)          │  │  • In-app EOI            │  │                        │
│  • Expenses              │  │  • Future admin-upload   │  │  (handled outside our  │
│  • Record exports (×3)   │  │    invoice templates     │  │   system)              │
│  • Future internal lists │  │    (deferred)            │  │                        │
└────────────┬─────────────┘  └────────────┬─────────────┘  └────────────────────────┘
             │                             │
             ▼                             ▼
    src/lib/pdf/render.ts          src/lib/pdf/fill-eoi-form.ts
    (renderToBuffer +              (unchanged this phase)
     renderToStream)
             │
             ▼
    src/lib/pdf/brand-kit/
    ├─ DocumentShell.tsx
    ├─ Header.tsx
    ├─ Footer.tsx
    ├─ DataTable.tsx
    ├─ KeyValueGrid.tsx
    ├─ Section.tsx
    ├─ Badge.tsx
    ├─ charts/{Bar,Line,Pie,Funnel}Chart.tsx
    ├─ tokens.ts
    └─ logo.ts

Module boundaries

  • brand-kit/ — pure presentation primitives. No DB access, no CRM domain knowledge. Each component has typed props and renders react-pdf elements.
  • templates/ — one .tsx per document type. Imports brand-kit primitives + receives typed data props. No DB access; data fetching stays in the calling service.
  • render.ts — the only module that touches @react-pdf/renderer's renderToBuffer / renderToStream. Services call renderPdf(<MyTemplate {...data} />) or renderPdfStream(<MyTemplate {...data} />).
  • logo.tsresolvePortLogo(portId) reads system_settings.port_logo_file_id and returns { source, buffer, mimeType }. Cached per request via React cache().
  • Chart rendering — pure SVG components emitting react-pdf's native <Svg> primitive. No JSDOM, no headless Chrome, no canvas. Server-rendered like any other PDF component.
  • Photo embedding (expense PDFs) — sharp (existing dep) compresses each receipt to ~150KB JPEG before embed. Stream-renders pages so memory stays bounded with hundreds of entries.

Header layout constraint

The brand-kit <Header> reserves a fixed logo slot:

maxWidth:  200 (≈ 56mm)
maxHeight: 60  (≈ 17mm)
objectFit: contain      // letterbox, never stretch
align:     left, vertically centered within the dark header band
fallback:  when resolvePortLogo returns 'fallback', render <Text style={bold}>{port.name}</Text>
           at the same slot. The port-name + doc-title combination keeps the header visually balanced.

This is enforced inside <Header>, not at upload time, so the upload pipeline can accept any 200-1200px logo and trust the layout to letterbox correctly.

Brand kit tokens

// src/lib/pdf/brand-kit/tokens.ts
export const PDF_TOKENS = {
  colors: {
    text: '#111111',
    textMuted: '#666666',
    border: '#e5e7eb',
    headerBand: '#0f172a', // dark slate — matches CRM sidebar
    headerText: '#ffffff',
    accentBlue: '#1d4ed8',
    zebra: '#f9fafb',
    success: '#16a34a',
    warning: '#d97706',
    danger: '#dc2626',
  },
  fonts: {
    sans: 'Helvetica',
    sansBold: 'Helvetica-Bold',
    mono: 'Courier',
  },
  sizes: {
    docTitle: 18,
    sectionH: 13,
    body: 10,
    small: 8,
    caption: 7,
  },
  spacing: {
    pagePadding: 36,
    sectionGap: 18,
    rowGap: 6,
  },
} as const;

Single source of truth. Future design pass = edit this file, every PDF updates.

Logo handling

Layer 1 — Server-side sharp normalization (required)

upload → magic-byte check via sharp metadata (PNG | JPEG | WEBP | SVG | HEIC | HEIF | AVIF)
       → reject animated GIF / multi-frame PNG / multi-page TIFF
       → size cap 5MB raw
       → if SVG:
           sanitize first via svgo (strip <script>, on*=, <foreignObject>, external href)
           reject if sanitization removed dangerous nodes
           rasterize to PNG via sharp(buf, { density: 300 })  // 300 DPI from vector
       → standard pipeline:
           sharp(buf)
             .extract({ left: cropX, top: cropY, width: cropW, height: cropH })  ← from client crop
             .trim({ threshold: 10 })
             .resize({ width: 1200, height: 1200, fit: 'inside', withoutEnlargement: true })
             .toColorspace('srgb')
             .removeAlpha()-if-jpeg-source-and-near-white
             .png({ compressionLevel: 9, palette: true })  ← palette where possible for smaller files
             .toBuffer()
       → reject if final > 1MB
       → reject if min dimension after trim < 200px
       → store via getStorageBackend().put()
       → set system_settings.port_logo_file_id = files.id (atomic upsert)
       → soft-archive previous logo's files row (archivedAt = now)
       → write audit_logs entry: action=branding.logo.uploaded, by=user.id
       → collect warnings: [trimmed, resized, noAlpha, jpegSource, svgRasterized, heicConverted]

Why rasterize SVGs to PNG at upload time: react-pdf's <Svg> primitive supports a subset of SVG (Path, Rect, Circle, Line, Text, gradients, clip-paths) but not filters, animations, embedded fonts, or all the quirks of a designer-exported SVG. Sharp rasterizes via librsvg at 300 DPI on upload, eliminating runtime surprises. Single PNG to embed at render time. The vector source is captured-in-time; if the admin later needs higher resolution, they re-upload.

Why HEIC/AVIF support: iPhone photo exports default to HEIC; common admin pain point. Sharp handles both natively via libheif; converts to PNG in the pipeline. Less common but worth supporting.

Layer 2 — Live upload UI

Admin opens Port Settings → Branding → Logo. The dialog shows:

  1. Rules above the dropzone:

    • Use PNG or SVG with a transparent background
    • Minimum 200×200px; recommended 600×200px (wide) or 400×400px (square)
    • Max 5MB; we'll auto-trim and optimize
    • Avoid JPEGs unless the background is solid white
  2. react-image-crop cropper with aspect-ratio toggle (Wide 3:1 / Square 1:1 / Freeform).

  3. Live HTML preview rendering the actual brand-kit <Header> React component beside the cropper, with the user's logo. Two preview swatches: dark header band (where the logo actually appears) and a colored background (to spot the "white box" problem with non-transparent JPEGs).

  4. Post-upload warnings displayed in the preview:

    • "JPEG with no alpha channel — white background will show on dark headers"
    • "Logo trimmed to remove whitespace borders"
    • "Resized from 4000×4000 to 1200×1200"
  5. "Test with sample PDF" button — hits a sample-PDF endpoint that renders a minimal report header and streams it back. Browser opens in a new tab.

Layer 3 — react-image-crop integration

Client renders the original image inside react-image-crop with a constrained aspect ratio. On save:

  1. Client sends multipart/form-data with file + { cropX, cropY, cropW, cropH } JSON sidecar.
  2. Server runs the sharp pipeline above with the crop applied as the first step.

This keeps sharp as the single source of truth (no canvas-tainted-CORS issues client-side; the actual crop happens server-side using the user-provided coordinates).

Storage path

Logos use the existing pluggable storage backend (src/lib/storage/). Object key shape:

ports/{portId}/branding/logo-{uuid}.png

The same backend currently serves brochures, berth PDFs, gdpr exports, etc. — s3 for prod, filesystem for single-node dev. Logos inherit whatever's configured; no special routing. Trivial-image-inline-in-DB would save one S3 round-trip per PDF render but break consistency with every other file artifact; not worth it.

Permission gating

The upload endpoint is wrapped with withAuth(withPermission('port_settings', 'manage', …)) (same gate currently used for brochures admin, send-from accounts, etc.). Audit trail goes to audit_logs (action: branding.logo.uploaded, entityType: port, entityId: portId). Soft-archive of the prior logo file row is logged as branding.logo.archived.

Resolution at render time

// src/lib/pdf/brand-kit/logo.ts
export const resolvePortLogo = cache(
  async (
    portId: string,
  ): Promise<{
    source: 'logo' | 'fallback';
    buffer: Buffer | null;
    mimeType: 'image/png' | 'image/svg+xml' | null;
  }> => {
    const setting = await getSystemSetting(portId, 'port_logo_file_id');
    if (!setting) return { source: 'fallback', buffer: null, mimeType: null };
    const file = await db.query.files.findFirst({ where: eq(files.id, setting) });
    if (!file || file.archivedAt) return { source: 'fallback', buffer: null, mimeType: null };
    const backend = await getStorageBackend();
    const buffer = await backend.get(file.storageKey);
    return { source: 'logo', buffer, mimeType: file.mimeType as 'image/png' | 'image/svg+xml' };
  },
);

Brand-kit <DocumentShell> internally calls this and passes the buffer down through context. Every template that wraps in <DocumentShell port={port}>...</DocumentShell> gets the logo automatically. No per-template wiring. When no logo is set, the header renders the port name as bold text instead.

Per-template designs

Reports — shared shell

┌──────────────────────────────────────────────────────────────────┐
│ [LOGO]   PORT NAME                            REPORT TITLE       │
│          generated 2026-05-12 18:44           Date-range badge   │
├──────────────────────────────────────────────────────────────────┤
│  Summary cards (3-4 KPI stat boxes)                              │
│  ┌──────┬──────┬──────┐                                          │
│                                                                  │
│  ◌ CHART (full-width SVG)                                        │
│                                                                  │
│  Detail Table (zebra rows, columns vary per report)              │
├──────────────────────────────────────────────────────────────────┤
│  Port Name · Confidential · Page 1 of 3 · Generated …            │
└──────────────────────────────────────────────────────────────────┘
Report Summary stat cards Chart Detail table columns
Activity total events, top action, top user, busiest day Stacked bar — events per day by action date · action · entity type · entity · user
Revenue total revenue, paid, outstanding, avg invoice Line — revenue per month + small pie paid/outstanding invoice # · client · issued · due · amount · status
Pipeline total interests, win rate, avg cycle days, top stage Funnel — count per stage interest · client · stage · lead category · days in stage
Occupancy total berths, occupied %, available %, under-offer % Time-series — occupancy % over period + small pie current status berth # · status · current interest · last change

Expense PDF

┌──────────────────────────────────────────────────────────────────┐
│ [LOGO]  PORT NAME — Expense Sheet                                │
│         Period: 2026-04-01 → 2026-04-30 · 247 entries            │
├──────────────────────────────────────────────────────────────────┤
│  Summary cards: total · by category · by status                  │
├──────────────────────────────────────────────────────────────────┤
│  Expense entries (one row per entry, multi-page)                 │
│  ┌──┬──────────┬──────────┬────────┬─────────┬─────────┐         │
│  │# │ Date     │ Category │ Vendor │ Amount  │ Receipt │         │
│  │  │ Notes: <inline notes line, optional>             │         │
│  │  │ [receipt photo, max 200×200, ~150KB JPEG]        │         │
│  └──┴──────────┴──────────┴────────┴─────────┴─────────┘         │
│  Page break inserted between entries when remaining vertical     │
│  space < 200px (no orphan partial rows)                          │
├──────────────────────────────────────────────────────────────────┤
│  Page 1 of 47 · Total: $48,232 · 247 entries                     │
└──────────────────────────────────────────────────────────────────┘

Critical: stream-render via renderToStream because 247 entries × ~150KB photos = 37MB peak memory if all loaded at once. Stream renders one page at a time, freeing buffers as it goes. Each photo passes through sharp.resize(800, 800, { fit: 'inside' }).jpeg({ quality: 70 }) once and is cached for the lifetime of the request.

Record exports

  • Client Summary — brand shell + key/value grid for client info + table for yachts + table for interests + activity timeline at bottom.
  • Berth Spec — brand shell + two-column key/value grid (info / dimensions / pricing / tenure) + infrastructure table + waiting-list table + maintenance-log table.
  • Interest Summary — brand shell + stage badge in header + key/value grids for client/yacht/berth + notes block + activity timeline.

Data flow

Caller migration pattern

Before:

import { generatePdf } from '@/lib/pdf/generate';
import {
  activityReportTemplate,
  buildActivityInputs,
} from '@/lib/pdf/templates/reports/activity-report';
const inputs = buildActivityInputs(data, port.name);
const pdfBytes = await generatePdf(activityReportTemplate, inputs);

After:

import { renderPdf } from '@/lib/pdf/render';
import { ActivityReportPdf } from '@/lib/pdf/templates/reports/activity-report';
const pdfBytes = await renderPdf(<ActivityReportPdf port={port} data={data} />);

Render module

// src/lib/pdf/render.ts
import { renderToBuffer, renderToStream } from '@react-pdf/renderer';
import type { ReactElement } from 'react';
import { logger } from '@/lib/logger';

export async function renderPdf(element: ReactElement): Promise<Buffer> {
  try {
    return await renderToBuffer(element);
  } catch (err) {
    logger.error({ err }, 'PDF render failed');
    throw new Error('Failed to render PDF');
  }
}

export async function renderPdfStream(element: ReactElement): Promise<NodeJS.ReadableStream> {
  return renderToStream(element);
}

Chart rendering (sketch)

// src/lib/pdf/brand-kit/charts/BarChart.tsx
import { Svg, Line, Rect, Text as SvgText } from '@react-pdf/renderer';
import { PDF_TOKENS } from '../tokens';

export function BarChart({
  data,
  width = 480,
  height = 200,
  color = PDF_TOKENS.colors.accentBlue,
}) {
  const max = Math.max(...data.map((d) => d.value));
  const barW = (width - 60) / data.length;
  return (
    <Svg width={width} height={height}>
      <Line
        x1={40}
        y1={20}
        x2={40}
        y2={height - 30}
        strokeWidth={1}
        stroke={PDF_TOKENS.colors.border}
      />
      <Line
        x1={40}
        y1={height - 30}
        x2={width - 10}
        y2={height - 30}
        strokeWidth={1}
        stroke={PDF_TOKENS.colors.border}
      />
      {data.map((d, i) => {
        const h = (d.value / max) * (height - 60);
        return (
          <Rect
            key={i}
            x={50 + i * barW}
            y={height - 30 - h}
            width={barW - 4}
            height={h}
            fill={color}
          />
        );
      })}
      {data.map((d, i) => (
        <SvgText
          key={i}
          x={50 + i * barW + (barW - 4) / 2}
          y={height - 14}
          textAnchor="middle"
          fontSize={7}
        >
          {d.label}
        </SvgText>
      ))}
    </Svg>
  );
}

Same pattern for LineChart / PieChart / FunnelChart. ~60-100 lines each.

Error handling

Failure mode Detection Surface
Logo file missing at render time resolvePortLogo returns source: 'fallback' Header renders port-name text only; structured log warning.
Logo file corrupt sharp throws on load 500 via errorResponse(InternalError); structured log; admin sees "Logo file is unreadable, please re-upload."
Chart data empty Component prop validation in template Render "No data for selected period" placeholder; no crash.
Receipt photo missing (expense PDF) Storage backend get throws Skip photo for that entry; render "Receipt unavailable" placeholder text; continue; collect into warnings[] and log.
Receipt photo unprocessable by sharp sharp throws on resize Same as above.
Stream-render aborted mid-page renderToStream rejects Caller drains stream into try/catch; surface errorResponse(error); partial bytes not stored.
OOM on huge expense PDF Heap monitor Stream-render keeps peak bounded; cap entries at 1000 per PDF; prompt admin to split into multiple periods.
Sharp pipeline rejects upload Specific error code 422 ValidationError with the rejection reason ("file > 5MB", "dimension < 200px", "unsupported format: GIF animated").
SVG with embedded JS or external href svgo strips scripts; post-sanitize node-count check Reject with ValidationError('SVG contained disallowed nodes').
Concurrent logo uploads (admin clicks save twice / two browser tabs) Last-writer-wins via atomic system_settings upsert Both files rows persist; only newer is pointed at. Soft-archive doesn't race because it operates on the OLD setting's file_id captured before the upsert.
Mid-render logo upload resolvePortLogo reads at render-start In-flight PDF uses whichever logo was current when the request entered. Next request gets the new one. No mid-PDF logo swap.
Logo dimensions wildly off the header aspect ratio Brand-kit <Header> constrains logo to maxWidth: 200, maxHeight: 60 with objectFit: contain Logo letterboxes inside its slot; never distorts.
Cropper coords out of bounds Server-side validation against image metadata before sharp extract 422 ValidationError('Crop coordinates out of image bounds').
File mime header lies (claims PNG, bytes are HTML) Sharp's metadata() reads actual magic bytes, ignores declared mime Sharp throws → 422 ValidationError('File contents do not match a supported image format').
Storage backend put fails (network glitch) Catch around backend.put Roll back: do not insert files row, do not change system_settings; return 503 with retry hint.
port_logo_file_id setting points at archived/deleted file resolvePortLogo checks archivedAt Treat as missing; fall back to text header; structured log warning so ops notices.

Testing

Unit (vitest)

  • brand-kit/charts/*.test.tsx — snapshot SVG output for known inputs.
  • brand-kit/logo.test.tsresolvePortLogo with fixtures for: configured / missing / archived / corrupt.
  • pdf/render.test.ts — round-trip a tiny <Page> and verify the output starts with %PDF-.
  • services/logo-upload.test.ts — sharp pipeline for: PNG-with-alpha (passes) / JPEG (warning) / undersized (rejects) / oversized (resizes) / SVG (passthrough) / animated GIF (rejects) / SVG with script tag (rejects).

Integration (vitest)

  • Each template renders to bytes without throwing, given representative fixtures from seed data.
  • reports.service.test.ts — generate each of the 4 reports for a seeded port; assert PDF magic byte + non-zero length.
  • record-export.test.ts — generate client / berth / interest summaries for seeded entities.
  • expense-export.test.ts — generate expense PDF for 250 seeded entries; assert pages > 5; assert peak heap delta < 200MB (proxy for stream-render working).

Playwright (smoke)

  • New spec: branding-logo-upload.spec.ts — upload PNG, see preview, save, generate sample PDF, assert PDF downloads.
  • New spec: reports-pdf-export.spec.ts — for each of the 4 reports, click export, assert PDF downloads.
  • Existing specs: anywhere clicking "export PDF" was tied to pdfme, update assertion.

Visual regression (existing visual project)

  • 4 new baselines (one per report) using seed port's logo.
  • 3 new baselines (client / berth / interest summary).
  • 1 new baseline (expense PDF, first 2 pages).
  • Snapshots stored as PNG (rendered from PDF via first-page extraction).

Migration sequence

# Commit Files touched Verifies
1 Foundation: install deps + brand kit +@react-pdf/renderer, +unpdf, +react-image-crop, +svgo; new src/lib/pdf/brand-kit/*, src/lib/pdf/render.ts brand kit unit tests pass; nothing wired yet
2 Logo upload feature new src/lib/services/logo.service.ts, src/app/api/v1/admin/branding/logo/*, admin UI in port settings, system_settings.port_logo_file_id key upload + preview + sample-PDF test work in dev
3 Migrate activity report port activity-report.tsactivity-report.tsx; rewire reports.service.ts caller; visual baseline report exports work; visual diff approved
4 Migrate revenue report same shape same
5 Migrate pipeline report same shape same
6 Migrate occupancy report same shape same
7 Migrate client summary port client-summary-template.ts.tsx; rewire record-export.ts same
8 Migrate berth spec same same
9 Migrate interest summary same same
10 Migrate expense PDF port expense-pdf.service.ts to react-pdf streaming; sharp photo compression 250-entry seed test passes
11 Remove invoice PDF generation delete invoice-template.ts, the generatePdf call in invoices.ts, the API route /api/v1/invoices/[id]/generate-pdf; remove UI link invoice list still works minus PDF button
12 Remove TipTap-→-pdfme bridge delete tiptap-to-pdfme.ts, the preview route, the generatePdf block in document-templates.ts:516, the getStandardEoiTemplateHtml seed reference admin template editor still saves; preview removed
13 Add unpdf to berth parser tier-2 wire unpdf into berth-pdf-parser.ts for PDF→image rasterization; keep tesseract.js berth PDF upload still parses
14 Cleanup: drop pdfme deps remove @pdfme/common, @pdfme/generator, @pdfme/schemas from package.json; delete generate.ts, eoi-standard-inapp.ts; clean up unused validators pnpm install clean; no remaining imports

Total: 14 commits. Most are small (5-15 file diffs). Commits 2, 10, and 12 are the heaviest. Vitest + tsc stay green throughout; each commit only flips behavior after its tests pass.

Deferred (added to BACKLOG)

  • Admin-uploaded PDF templates with AcroForm-fill (the invoice template-fill pattern). Needs: new pdf_templates table + field-mapping editor + admin upload UI + generalized fillAcroForm() utility. Likely ~1 week solo.
  • Port brand color tokens (admin sets brand color → flows into PDF accent color). ~2h.
  • Per-template logo override (different logo for invoices vs reports). YAGNI unless asked.
  • Optical receipt-photo rotation/deskew (auto-rotate phone-upload receipts to readable orientation). ~half day.
  • Replace tesseract.js with cloud OCR (AWS Textract / Google Vision) for berth parsing tier-2. Out of scope.

Open questions

None blocking. Implementation can begin after user spec review.