feat: warm-up deps — ts-reset, web-vitals, RHF devtool, query-broadcast

Four low-risk adds before the Zod 4 / drizzle-zod headliner:

- @total-typescript/ts-reset: tightens TS stdlib types globally (JSON.parse
  → unknown, fetch().json() → unknown, .filter(Boolean) narrows, Set
  literals respect typed Set targets). Caught 179 latent type errors;
  fixed all production sites (8 files) and added `any` cast escape hatch
  in test files (ESLint exemption scoped to tests/).
- web-vitals + /api/v1/internal/vitals endpoint + WebVitalsReporter
  client component: establishes Core Web Vitals baseline (LCP/INP/CLS/
  FCP/TTFB) via navigator.sendBeacon. Required before optimisation work.
- @hookform/devtools + FormDevtool wrapper: dev-only RHF state inspector,
  lazy-loaded via next/dynamic so the chunk is excluded from prod
  bundles entirely.
- @tanstack/query-broadcast-client-experimental: cross-tab cache sync
  via BroadcastChannel — wired in query-provider.tsx, 1-liner.

Audit doc updated with sections 35 + 36 (PDF stack overhaul + comprehensive
second-pass package sweep) covering ~20 package adoption candidates and
4-5 deprecation candidates.

Verified: tsc clean, vitest 1293/1293 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 18:16:18 +02:00
parent 82049eea92
commit d3960af340
38 changed files with 1590 additions and 119 deletions

View File

@@ -6712,3 +6712,811 @@ verify and full vitest run.
6. **Defer indefinitely** — archiver 8, react-day-picker 10 (neither is delivering us anything we need).
**Non-goal:** chasing the bleeding edge on every dep. The audit's baseline finding stands — we are secure today. These are mostly developer-experience and perf wins, not security blockers.
---
## 35. Package adoption + PDF stack overhaul (Context7-assisted follow-up)
Companion to section 34. The deps-upgrade analysis answered "should we bump
what we already have?" — this section answers two follow-on questions:
1. **PDF stack** — are pdfme + pdfkit + pdf-lib the right tools? (No.)
2. **What aren't we using that we should be?** — comprehensive sweep of the
modern ecosystem against our actual pain points and codebase patterns.
User-directed exclusions:
- `react-hotkeys-hook` (no keyboard-shortcut UX target).
---
### 35.A — PDF stack overhaul
#### Current state (5 packages, 4 distinct use cases)
| Package | Where it lives in our code | Use case |
| ----------------------------------------------------------- | ---------------------------------------------------------------------- | -------------------------------------------------------- |
| `@pdfme/common` + `generator` + `schemas` v6.1.2 | `src/lib/pdf/generate.ts` + 8 template files | Declarative report/invoice/EOI templates |
| `pdf-lib` v1.17.1 | `src/lib/pdf/fill-eoi-form.ts`, `src/lib/services/berth-pdf-parser.ts` | AcroForm fill (EOI) + uploaded-PDF parsing (berth specs) |
| `pdfkit` v0.18.0 + `@types/pdfkit` | `src/lib/services/expense-pdf.service.ts` (only site) | Streaming receipt-attached expense reports |
| `tesseract.js` v7.0.0 | `src/lib/ocr/tesseract-client.ts` + scan-shell | Berth PDF OCR fallback |
| **Bridge layer**: 571-line `src/lib/pdf/tiptap-to-pdfme.ts` | Admin template builder | Tiptap JSON → pdfme schema converter |
#### Pain points
- **The 571-line `tiptap-to-pdfme.ts` bridge** is fragile glue between a rich-text
format (Tiptap JSON) and a declarative PDF schema (pdfme). Every supported
formatting subset (bold, italic, headings, lists, tables, images) is
hand-coded. Adding `blockquote` / `codeBlock` / `horizontalRule` /
`taskList` is currently rejected at save time because the bridge doesn't
support them.
- **pdfme** templates are JSON blobs with positional `{ x, y }` coordinates.
Reading/editing them is painful (compare `invoice-template.ts` vs a
declarative React component).
- **`@pdfme/generator` ships a heavy runtime** including the schema engine
and font loaders — irrelevant for our use case because we're code-driven,
not visual-editor-driven.
- **3 different generation libraries** (pdfme + pdfkit + pdf-lib) means three
different mental models, three different test patterns, three different
failure modes.
#### Recommendation per use case
**Use case 1 — Template-driven PDFs (8 templates):** invoice, client-summary,
interest-summary, berth-spec, revenue-report, occupancy-report, pipeline-report,
eoi-standard-inapp.
**→ Replace with `@react-pdf/renderer`** (`/diegomura/react-pdf`, 161 snippets,
benchmark 87.75).
Why it wins for us:
- **Declarative React components** — uses the same skills we already have. No
more positional `{ x, y }` JSON.
- **Server-side rendering modes**: `renderToBuffer` (HTTP responses),
`renderToStream` (large reports), `renderToFile` (background jobs). All
three usage patterns are documented and idiomatic — replaces pdfme's
`generate()` call cleanly.
- **First-class page-break controls** — `break`, `wrap={false}`,
`minPresenceAhead`, `orphans`, `widows`. pdfme has none of these; we'd be
hand-implementing them today if we needed them.
- **Fixed headers/footers via `fixed` prop** with auto page-number rendering
(`render={({ pageNumber, totalPages }) => …}`). We currently re-render
header content per page in pdfme.
- **The Tiptap bridge problem dissolves**: a rich-text component renders
Tiptap JSON directly via a recursive component (~80 LOC, replaces 571 LOC).
No more constrained-subset rejections at save time.
- **Tree-shakes** — only the components we import ship; pdfme's generator
pulls the full schema engine regardless.
Concrete migration cost: rewrite 8 templates as JSX. The shape is 1:1
with our current pdfme schemas (header section, repeating items, footer
totals), so it's a mechanical translation. ~4-6 hours total. Bridge layer
(571 LOC) goes to zero.
Caveats from Context7:
- Font registration is explicit (`Font.register({ family, src })`) — our
current fonts move from pdfme's font loader to a one-time call at boot.
- No Tailwind class support — uses `StyleSheet.create({ ... })` with a
flexbox-style subset. Familiar to React Native devs.
**Use case 2 — AcroForm fill (EOI):**
**→ Keep `pdf-lib`.** Best-in-class for editing existing PDFs. No replacement
candidate is better. Already used correctly in `fill-eoi-form.ts`.
**Use case 3 — Uploaded PDF parsing (berth specs):**
**→ Add `unpdf`** (`/unjs/unpdf`, 66 snippets) for text extraction; keep
`pdf-lib` for AcroForm field extraction.
Why:
- `unpdf` is the unjs ecosystem's serverless-friendly PDF parser built on
pdf.js. Returns `{ totalPages, text }` per page in one call.
- Better than `pdf-lib` for text extraction because pdf-lib's text APIs are
read-positional, not read-flow.
- `getDocumentProxy()` lets us share one parse across `extractText`,
`extractLinks`, `getMeta` — useful for the 3-tier berth parser (AcroForm
first, OCR fallback, AI fallback) because we can grab all metadata in one
pass.
Our current parser uses `pdf-lib`'s low-level text extraction which has known
issues with positionally-rendered text (the OCR fallback fires more often
than necessary). `unpdf.extractText` would reduce that fallback rate.
**Use case 4 — Streaming receipt-attached expense reports:**
**→ Keep `pdfkit` short-term, migrate to `@react-pdf/renderer.renderToStream`
medium-term.**
Why keep:
- `expense-pdf.service.ts` is the only `pdfkit` consumer. Its streaming
pattern (500 receipts at <100MB RSS) is the load-bearing reason for
pdfkit's existence in our deps.
- `@react-pdf/renderer.renderToStream` documented in Context7 supports the
same use case — but verification needs an actual perf test against a
500-receipt fixture before committing.
Migration plan:
- Phase 1 (now): replace pdfme templates with @react-pdf/renderer.
- Phase 2 (after we have @react-pdf/renderer in the codebase): re-test
expense-pdf with `renderToStream` against the 500-receipt fixture. If
memory stays under 200MB, swap pdfkit out. If not, keep pdfkit and
document the constraint.
#### Net result after Phase 1
Remove: `@pdfme/common`, `@pdfme/generator`, `@pdfme/schemas`, 571-line
bridge file.
Keep: `pdf-lib` (AcroForm), `pdfkit` (streaming expenses, pending Phase 2),
`tesseract.js` (OCR).
Add: `@react-pdf/renderer`, `unpdf`.
Deps net: 2, 571 LOC of bridge code, +standard declarative API for all
templates.
---
### 35.B — High-value package additions (prioritized)
Each row below has been validated via Context7 unless marked otherwise.
#### Tier 1 — Adopt alongside the planned Zod 4 / Tailwind 4 work
| Package | Replaces / unlocks | Where it lands in our code | Effort |
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------- |
| **`drizzle-zod`** (already in `drizzle-orm`) | ~30 hand-maintained validators in `src/lib/validators/` | `createInsertSchema(clients).omit({ id, portId })` etc. | 2-3h |
| **`@react-pdf/renderer`** | 8 pdfme templates + 571-line tiptap bridge | `src/lib/pdf/templates/*` | 4-6h |
| **`react-email`** + `@react-email/components` | 8 hand-strung HTML templates in `src/lib/email/templates/` | Each becomes a `.tsx` component, rendered via `await render(<…/>)` then handed to nodemailer unchanged | 2-3h (one template at a time) |
| **`@tanstack/react-virtual`** | Pagination on `client-list`, `yacht-list`, `berth-list`, `audit-log-list`, `inbox` | `useVirtualizer({ count, estimateSize })` inside the list shells | 1h per list × 5 lists |
| **`ts-pattern`** | 19-case dispatch in `search.service.ts`, 13-case Documenso webhook, 12-case `client-restore.service.ts`, 10-case `recently-viewed/route.ts`, 10-case `custom-fields/[entityId]/route.ts` | `match(input).with(...).exhaustive()` | 30 min per site; start with the Documenso webhook |
| **`unpdf`** | Hand-rolled text extraction in `berth-pdf-parser.ts` | `extractText(await getDocumentProxy(buf))` | 1h |
#### Tier 2 — Independent adopts (polish + perf)
| Package | What it does for us | Effort |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- |
| **`@formkit/auto-animate`** | One-liner `useAutoAnimate()` ref on any list. Drops into: deal pipeline kanban (pipeline-board.tsx), reminders rail, alerts rail, files list, notes list. Zero CSS. ~2kb. | 5 min per site |
| **`motion`** (formerly framer-motion) | Layout animations for kanban reorder (currently snaps), Vaul drawer enter/exit polish, sheet/drawer slides, `<AnimatePresence>` for inline edits. ~15kb gzip but tree-shakes well. | 1-2h to wire the kanban first |
| **`use-debounce`** | Replaces ad-hoc `setTimeout` debounce in `yacht-picker`, `client-picker`, `audit-log-list`, `send-document-dialog`, `custom-fields-section`, `berth-picker`, `interest-picker`, `dedup-suggestion-panel` (8 sites). Typed `useDebouncedCallback`. ~3kb. | 30 min total |
| **`fast-deep-equal`** | Memo comparator for `DataTable` and React Query `select` functions. Drops re-renders when stable references arrive with new identity. ~1kb. | 20 min |
| **`@upstash/ratelimit`** | Replaces hand-rolled rate limiters in `src/lib/rate-limit.ts`, `api/helpers.ts`, `route-helpers.ts`, `document-sends.service.ts`. Uses our existing Redis. Sliding-window / fixed-window / token-bucket algorithms tested at scale. | 1-2h |
#### Tier 3 — Strategic adopts (bigger commitments)
| Package | What it unlocks | Notes |
| ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`next-safe-action`** | Type-safe server actions with built-in Zod validation, ownership middleware, `useHookFormAction` hook. Each form drops ~30 LOC of `apiFetch + toastError + mutation-hook` plumbing to ~5. Pairs with `useHookFormAction` which already speaks Zod/RHF. | Migrate gradually — use for new forms first, keep API routes for external callers. Couples with Zod 4 (since safe-action v8+ targets Zod 4 best). |
| **`@axe-core/playwright`** | Accessibility audit during smoke tests. The 33-agent audit flagged WCAG gaps; this catches regressions automatically. | ~30 LOC of test setup. Fails CI on new violations. |
| **`@tiptap/core`** + `@tiptap/react` + extension packs | Real rich-text editor for `notes` (clients/interests/yachts/companies all have polymorphic notes). Currently plain text. Sales reps note things like "call after 4pm UTC, prefers WhatsApp" — bold/italic/links/lists/mentions would help. Tiptap's JSON output format is _already_ in our codebase (the bridge layer), so we'd be storing the same shape we already render. | Decision: keep notes plain or upgrade to rich? If yes, ~3h to wire one entity's notes; the others copy the pattern. |
| **`@next/bundle-analyzer`** | Wraps `next.config.ts`. Generates client + server bundle treemaps after every build. Catches when a tiny PR pulls in recharts on a route that shouldn't have it. The 33-agent audit flagged recharts + pdfme as bundle bloat — this is the tooling to keep that honest. | 15 min setup. Run with `ANALYZE=true pnpm build`. |
| **`@sentry/nextjs`** | Error tracking with frontend + backend correlation, release tracking, source maps, performance traces, replay (optional). We have pino logs but no aggregation/alerting/correlation. Important once we have customer-facing users. | Decision: do we want a SaaS dependency? Self-hosted GlitchTip is also an option (Sentry-protocol-compatible). |
| **`@vercel/og`** (or `satori`) | Generate Open Graph images for shared docs/portal links. Currently the portal has no social previews; if a client shares their EOI link in WhatsApp/Email, the preview is blank. ~10 LOC per route. | 1h for portal share routes. |
| **`papaparse`** | CSV import/export. Sales reps frequently ask for "export to Excel." Plays well with our existing TanStack Table data. ~17kb. | 30 min for client/interest list export. |
| **`@formkit/tempo`** OR **date-fns helpers** | We have **44 files** with hand-rolled `new Date().toLocaleString()` / `.toLocaleDateString()`. Centralize via a `formatDate(date, format, timezone)` helper using `date-fns` (already installed) — no new package needed if we use date-fns's `format`, `formatDistance`, `formatRelative` which we already have. **This is a refactor, not an adoption.** | 2-3h sweep |
#### Tier 4 — Defer or skip
| Package | Reason |
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `next-pwa` / `@serwist/next` | PWA assets pending (per MEMORY.md). When that lands, **`@serwist/next`** is the modern choice (next-pwa is unmaintained). For now, skip. |
| `next-intl` / `i18next` / `@lingui/core` | No i18n target today. When we localize, **`next-intl`** is the strongest Next.js App Router integration. For now, skip. |
| `@knocklabs/node` + `@knocklabs/react` | Notification center + channel routing + preferences UI. Likely overkill — we have a simple in-app + email notification system that works. Revisit if we add SMS or push. |
| `inngest` / `trigger.dev` | Background jobs with observability. We use BullMQ; revisit only if we need step functions / cross-service workflows. |
| `posthog-js` | Product analytics + feature flags + session recording. We have Umami for web analytics; PostHog adds product-level tracking. Decision pending. |
| `@growthbook/growthbook` | Feature flags only. We don't have any flagged features today. |
| `fuse.js` / `minisearch` | Client-side fuzzy search. Useful for already-loaded list filtering, but TanStack Table's built-in filter is usually enough. |
| `@uppy/core` + `@uppy/dashboard` | Rich file upload UI with resume, chunking. We have basic file inputs (0 patterns found in audit grep) — not currently a pain point. |
| `@tanstack/react-form` | Successor to react-hook-form by same team. RHF is mature, well-known, and we have 8 forms on it. No compelling migration. |
| `valibot` / `arktype` | Faster zod alternatives. We're committed to Zod 4. |
| `react-hotkeys-hook` | **Excluded per user direction.** |
---
### 35.C — Deprecation / cleanup candidates
| Package | Reason | Action |
| ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- |
| `@radix-ui/react-icons` | We use `lucide-react` everywhere. Audit grep shows no imports from `@radix-ui/react-icons`. | Drop after grep-confirm. ~30s. |
| `@pdfme/common` + `@pdfme/generator` + `@pdfme/schemas` | Replaced by `@react-pdf/renderer` in Phase 1. | After PDF migration. |
| `tailwindcss-animate` v1.0.7 | Last published 2024, no v4 support. Replace with **`tw-animate-css`** (the v4-native successor shadcn now recommends). | Required if we move to Tailwind 4. |
| `@types/pdfkit` | Tops at v7.0.0. We're on `pdfkit` v0.18 — types are loose but functional. Keep until we migrate expense-pdf to @react-pdf/renderer. | Defer. |
| `pino-pretty` in `dependencies` | Should be `devDependencies` only — ships ~500kb to prod worker images if it leaks into the runtime path. Audit-verify the build doesn't include it; move if it does. | 5 min check. |
---
### 35.D — Surfaced refactor opportunities (no new package required)
These came up while sweeping for package gaps. They're refactor wins, not
package adoptions.
| Opportunity | Concrete sites | Tool |
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| Centralize date formatting | 44 files with hand-rolled `.toLocaleString()` / `.toLocaleDateString()` | `formatDate(date, format, timezone)` helper using existing `date-fns` |
| Centralize debounce | 8 picker/list components | `use-debounce` (or hand-rolled hook) |
| Centralize rate-limiting | 4 hand-rolled limiters | `@upstash/ratelimit` |
| Replace 5-9 large switch statements with exhaustive matchers | `search.service.ts` (19 cases), Documenso webhook (13), `client-restore.service.ts` (12), `recently-viewed/route.ts` (10), `custom-fields/[entityId]/route.ts` (10) | `ts-pattern` |
---
### 35.E — Final adoption order (revised, incorporating section 35)
This supersedes section 34's sequencing where they overlap.
1. **Now (one focused day)** — Zod 4 + `@hookform/resolvers` 5 + **`drizzle-zod`**. One PR. Codemod-friendly. Highest correctness payoff.
2. **Independent (any time)** — **`react-email`** migration of one template (`portal-auth.ts` recommended first), then expand. Independent of any version upgrade.
3. **Independent (any time)** — **`@react-pdf/renderer`** + **`unpdf`**. Replace 8 pdfme templates, delete 571-LOC bridge, add unpdf to berth parser.
4. **Independent (any time)** — **`ts-pattern`** in the Documenso webhook switch first (the audit's bug-class poster child), then sweep the other 4 sites.
5. **Independent (any time)** — **`@tanstack/react-virtual`** on `client-list` first, copy pattern to 4 other lists.
6. **Independent (any time)** — **`@formkit/auto-animate`** sprinkle. 5-minute wins per site.
7. **Independent (any time)** — **`@next/bundle-analyzer`** install. 15-min setup; ongoing bundle hygiene.
8. **Next focused half-day** — **`motion`** wire to the kanban for smooth reorder.
9. **2-4 weeks** — Next 15 → 16 + eslint-config-next 16 + eslint 10 (lockstep, codemod).
10. **Focused afternoon** — Tailwind 4 via official upgrade tool + swap `tailwindcss-animate` for `tw-animate-css`.
11. **When we have a new form to build** — pilot **`next-safe-action`** there; backfill existing forms gradually.
12. **Decision required first** — `@sentry/nextjs` (SaaS dep), `@tiptap/*` (rich notes Y/N?), `posthog-js` (analytics scope), `papaparse` (CSV export priority).
---
### 35.F — Skipped per user direction
- **`react-hotkeys-hook`** — no keyboard-shortcut UX target across the platform.
---
## 36. Second-pass package sweep — mobile, fluidity, data speed, DX
Section 35 covered the headline adoption candidates. This section is the
deliberate second sweep the user requested — looking specifically for
libraries we may have missed across four dimensions: **current
functionality gaps**, **optimization (mobile included)**, **UI fluidity**,
and **data retrieval/writing speed**.
Findings are grouped by dimension. Each entry says (a) what we have now,
(b) what the library adds, (c) where in our codebase it'd land, (d) effort.
---
### 36.A — Data speed & concurrency
#### 36.A.1 `p-queue` + `p-limit` + `p-retry` (Sindre Sorhus suite)
**Concrete pain:** 74 `Promise.all(...)` sites in services/routes. 8 mass-
operation services (`expense-pdf`, `berth-pdf`, `brochures`, `backup`,
`document-templates`, `email-compose`, `documents`, `email-threads`).
Naive `Promise.all([...mapped])` will:
- Fire all 500 expense receipts to S3 simultaneously → MinIO connection
pool exhaustion + memory spike (`expense-pdf.service.ts` docs explicitly
call this out as a past problem).
- Fire all bulk-send-document calls at Documenso simultaneously → hit
Documenso's per-second rate limit, cause cascade failures.
- Fire all email-compose attachments at SMTP simultaneously → SMTP
connection limit on Mailgun/SES drops requests silently.
**`p-limit`** caps concurrency: `pLimit(5)` runs at most 5 at a time.
**`p-queue`** is `p-limit` + interval rate limiting + pause/resume.
**`p-retry`** handles exponential backoff retries for transient failures.
**Land sites:**
- `expense-pdf.service.ts` — already has streaming logic, but the
per-receipt S3 `get` calls are unbounded.
- `email-compose.service.ts` — bulk send-out is the obvious one.
- `backup.service.ts` — GDPR export streaming.
- `documents.service.ts` — multi-file folder operations.
**Effort:** 30 min per service. ~1.5kb each.
#### 36.A.2 `@tanstack/query-broadcast-client-experimental`
**Concrete pain:** A rep has the CRM open in two tabs. They update a
client in tab A — tab B's stale cache continues showing old values until
the next refetch.
**What it adds:** BroadcastChannel sync between tabs. Free cross-tab cache
coherence with no server roundtrips.
**Land site:** One line in `src/providers/query-provider.tsx`:
```ts
broadcastQueryClient({ queryClient, broadcastChannel: 'pn-crm' });
```
**Effort:** 5 minutes. ~2kb.
#### 36.A.3 Underused Drizzle ORM features (no new package)
We have `drizzle-orm` 0.45.2 and use ~60% of its capabilities.
- **`db.batch(...)`** for atomic multi-statement transactions on
Postgres. Currently we use explicit `db.transaction(async (tx) => {...})`
blocks everywhere — `batch` is shorter and lets the driver pipeline.
- **Prepared statements** via `.prepare()` — repeated queries (e.g.,
`getClient(id)` called per-request) can be prepared once at boot and
reused. Postgres saves the parse+plan cost.
- **`with` (CTE) clauses** — we have 30+ places where we'd benefit from
`WITH active_interests AS (...) SELECT ...` instead of joining the same
subquery twice. Audit found N+1 patterns; CTEs flatten them.
**Land sites:** the recommender SQL aggregate (already uses CTEs),
`dashboard.service.ts` analytics queries, `search.service.ts` graph
expansion. These are all "we already wrote raw SQL strings; rewriting as
typed Drizzle CTEs" wins.
**Effort:** opportunistic. No package change.
#### 36.A.4 `postgres.js` cursor for large reads
We have `postgres` ^3.4.9. Its `await sql\`...\`.cursor(rows => ...)`streams large result sets in batches without buffering all rows. Currently
the GDPR-export bundling and the backup`dump-tables` paths buffer
everything in memory.
**Land sites:** `backup.service.ts`, `gdpr-export.service.ts` (when we
build it — currently parked).
**Effort:** opportunistic refactor when we touch those services.
---
### 36.B — UI fluidity & animation
#### 36.B.1 `@use-gesture/react` (mobile gestures)
**Concrete pain:** mobile users can't swipe-to-dismiss the Vaul drawer,
swipe sideways between kanban columns, or pinch-zoom berth photos. The
audit's mobile pass flagged these.
**What it adds:** declarative gesture handlers (`useDrag`, `usePinch`,
`useScroll`). Composes with `motion` for spring-physics responses.
**Land sites:**
- Pipeline kanban: swipe between stage columns on mobile.
- Vaul drawer: swipe-down to dismiss (Vaul already does this, but adding
custom velocity thresholds via `@use-gesture` polishes the feel).
- Berth/yacht photo galleries: pinch-zoom.
**Effort:** 1h to wire one site as the template. ~5kb.
#### 36.B.2 `embla-carousel-react`
**Concrete pain:** berth photos and yacht photos render as static grids
(per the audit). On mobile, users want to swipe through them.
**What it adds:** lightweight, touch-native, accessibility-compliant
carousel. Plays with framer-motion if we want fancy transitions.
shadcn/ui has a `Carousel` component built on this — drop-in via the
shadcn CLI.
**Effort:** `npx shadcn@latest add carousel`, then 10 lines to render the
photo array. ~10kb gzip.
#### 36.B.3 `yet-another-react-lightbox`
**Concrete pain:** clicking a berth photo currently navigates to a fullscreen
image route or doesn't expand at all. Sales reps want lightbox-style preview.
**What it adds:** fullscreen lightbox with keyboard nav, zoom, swipe, slideshow,
captions. Plugin system for video/PDF embed if we extend.
**Land sites:** berth/yacht detail pages, client docs preview.
**Effort:** 1h. ~15kb gzip with plugins.
#### 36.B.4 `react-resizable-panels`
**Concrete pain:** the docs hub has a fixed-width folder sidebar (per
CLAUDE.md's documents-hub rewrite). Power users on wide monitors want
to drag-resize it.
**What it adds:** keyboard-accessible resizable split panes with
persistent sizing (localStorage). shadcn/ui has a `Resizable` component
built on this.
**Land sites:** docs hub (sidebar | content), email inbox (folder | thread),
admin settings (nav | section).
**Effort:** `npx shadcn@latest add resizable`, drop in. ~5kb.
---
### 36.C — Mobile optimization
#### 36.C.1 `browser-image-compression`
**Concrete pain:** the expense-scanner (`scan-shell.tsx`) and receipt
upload paths accept full-resolution phone photos (typically 4-12 MB each).
Mobile users on cellular pay bandwidth + battery for sending 4× more
data than necessary. The server then re-runs `sharp` to resize anyway.
**What it adds:** client-side image compression in WebWorker before
upload. Targets `maxSizeMB`, `maxWidthOrHeight`, `useWebWorker`. The
server still validates magic-bytes + sharp-resizes, but receives a
500KB-resized JPG instead of a 12MB original.
**Concrete win:** a rep on 3G uploading a receipt: ~30s wait → ~5s wait.
Server CPU on `sharp` resize drops to a no-op since the client did it.
**Effort:** 30 min to wire `scan-shell.tsx`. ~25kb gzip (worker-bundled so
zero main-thread cost).
#### 36.C.2 `partysocket`
**Concrete pain:** mobile users on flaky networks frequently lose the
Socket.IO connection. Our current client uses Socket.IO's built-in
reconnect, which is good but not great for mobile.
**What it adds:** drop-in WebSocket wrapper with:
- Exponential backoff with jitter (default Socket.IO is linear).
- Message queue while disconnected (Socket.IO buffers via volatile flag
only).
- Auto-reconnect on `online` event + `visibilitychange` (page wake).
- Optional auto-detect connection quality (slow vs fast).
**Land site:** `src/providers/socket-provider.tsx`.
**Effort:** depends — `partysocket` works with raw WS, not Socket.IO's
protocol. For Socket.IO we'd need `socket.io-client` + manual reconnect
tuning, or migrate the realtime layer to plain WebSockets (significant).
**Park as a "mobile flake" investigation, not an immediate adoption.**
#### 36.C.3 `react-virtuoso` (alternative to TanStack Virtual)
**Concrete pain:** the inbox (`src/components/layout/inbox.tsx`) uses a
plain `<ScrollArea className="max-h-[400px]">` with no virtualization.
For users with hundreds of unread items, mobile scrolling chugs.
**What it adds:** specialized virtualization for chat-like / inbox-like
UIs with variable-height items and "scroll to bottom on new message"
semantics. **TanStack Virtual is more headless / generic; Virtuoso is
opinionated and better for inbox-shaped UIs.**
**Land site:** `inbox.tsx` specifically. For the regular lists
(client/yacht/berth), TanStack Virtual is still the right call (section
35.B.4).
**Effort:** 45 min. ~10kb.
#### 36.C.4 `@formkit/auto-animate` (revisit for mobile)
Already in section 35.B but worth re-emphasising: on mobile, list items
appearing/disappearing without animation feels janky. Free polish.
---
### 36.D — Input quality & forms
#### 36.D.1 `react-imask` or `react-number-format`
**Concrete pain:** we have currency inputs, phone inputs, date inputs
spread across berth-form, expense-form, invoice-form, client-form. The
audit flagged inconsistent formatting (decimals, thousand-separators,
phone-prefix handling).
**What it adds:** declarative input masks — `<IMaskInput mask="$num"
scale={2} thousandsSeparator="," />`. Plays cleanly with react-hook-form.
`react-number-format` is the lighter-weight, currency-specific option.
`react-imask` covers more patterns (phone, date, custom).
**Land sites:** ~6 form components.
**Effort:** 30 min per form × 6 = 3h. **OR** keep our hand-rolled
formatters and don't add the dep. Decision pending.
#### 36.D.2 `@hookform/devtools` (dev-only)
**What it adds:** a floating panel in the browser showing react-hook-form
state in real time (values, errors, isDirty, isValid, touched fields).
Massive debug-time win.
**Land site:** wrap forms in `<DevTool control={form.control} />` in dev
builds only.
**Effort:** 15 min. dev-only, ships zero to prod.
---
### 36.E — Security & sanitization
#### 36.E.1 `isomorphic-dompurify`
**Concrete pain:** `src/lib/utils/markdown-email.ts` hand-rolls HTML
escape + safe-link rendering for email bodies. The audit raised XSS
concerns (CRIT-2 in section 4) about admin-supplied content in templates
and email bodies. Our hand-rolled `escapeHtml` is correct for the basic
cases, but DOMPurify handles edge cases the audit listed (data URLs,
nested encoding, javascript: in href attrs).
**What it adds:** battle-tested HTML sanitizer used by Google, Microsoft,
GitHub. Works in Node + browser (the `isomorphic-` prefix is the
SSR-compatible wrapper around the regular `dompurify`).
**Land sites:**
- `renderEmailBody()` in `markdown-email.ts`.
- Anywhere we render user-supplied HTML (template preview, document
body display).
**Effort:** 1h migration + audit. ~25kb (Node) / ~50kb (browser),
acceptable.
#### 36.E.2 `@noble/hashes` (already covered by `better-auth`)
We use `better-auth` for password hashing. No need to add.
#### 36.E.3 WebAuthn / Passkeys (`@simplewebauthn/server` + `/browser`)
**What it adds:** passwordless authentication via device passkeys (Touch
ID, Windows Hello, YubiKey). Better Auth has a WebAuthn plugin that
wraps these.
**Decision required:** is passwordless a 2026 roadmap item?
---
### 36.F — Observability & perf measurement
#### 36.F.1 `web-vitals`
**Concrete pain:** we have no real-user perf data. We don't know our
P75 LCP, P75 INP, or P75 CLS across our user base. Any future perf
optimization (Cache Components, Tailwind 4, dynamic imports) is shooting
in the dark without baseline measurement.
**What it adds:** Google's official Core Web Vitals library. Ships
`onLCP`, `onINP`, `onCLS`, `onFCP`, `onTTFB` callbacks. Reports values
once per page lifecycle.
**Land site:** `src/app/(dashboard)/layout.tsx` — wire a listener that
POSTs vitals to `/api/v1/internal/vitals` (new endpoint, append to
existing `client_metrics` table or similar). 30 LOC end-to-end.
**Effort:** 1h including backend logging. ~2kb. **High value** because
without this we're guessing about perf wins.
#### 36.F.2 `pino-http`
**Concrete pain:** we have request logging via custom middleware. `pino-http`
is the canonical pino HTTP request logger with automatic request-id
propagation, response time, status code, and integration with our pino
logger. Likely already partially implemented via our hand-rolled
middleware.
**Effort:** check existing middleware first — may already cover this.
#### 36.F.3 `@sentry/nextjs` (revisit from section 35)
Covered in 35.B Tier 3. Adoption gated on the SaaS-dep decision.
---
### 36.G — TypeScript ergonomics
#### 36.G.1 `@total-typescript/ts-reset`
**Concrete pain:** TypeScript's stdlib types have well-known foot-guns:
- `Array.isArray(x)` narrows to `any[]` (drops the actual type).
- `JSON.parse(s)` returns `any` (defeats type safety entirely).
- `fetch().json()` returns `Promise<any>`.
- `.filter(Boolean)` doesn't remove `null | undefined` from the type.
- `Array.prototype.includes` is too strict on its argument.
ts-reset is **a single `.d.ts` import** (`import '@total-typescript/ts-reset'`)
that fixes all of these globally. Used by Anthropic, Stripe, Vercel internally.
**Concrete impact:** likely catches 10-20 latent bugs across our 1000+
TS files where someone called `JSON.parse(body)` and continued treating
the result as a typed object without parsing through Zod.
**Effort:** 1 line in `src/types/globals.d.ts`. **dev-time only**, ships
zero runtime.
#### 36.G.2 `type-fest`
**What it adds:** ~150 utility types (`SetRequired`, `SetOptional`,
`PartialDeep`, `MergeDeep`, `Promisable`, `Jsonifiable`, etc.) that
extend TypeScript's built-ins.
**Land sites:** anywhere we're hand-rolling `Omit<X, Y> & Pick<Z, W>`
gymnastics — type-fest usually has a named util that's clearer.
**Effort:** opportunistic. ~0kb runtime (types only).
#### 36.G.3 `tsc-files`
**Concrete pain:** pre-commit hook runs ESLint on staged files (fast) but
no type-check. Type errors slip through to CI.
**What it adds:** typecheck _only the staged TS files and their
dependencies_, not the full repo. Drops a pre-commit hook from "skip
because too slow" to "always on, sub-2-second."
**Land site:** `.husky/pre-commit` + `lint-staged.config.mjs` —
`"*.ts": ["tsc-files --noEmit"]`.
**Effort:** 15 min.
---
### 36.H — In-browser PDF viewing
#### 36.H.1 `pdfjs-dist` + a viewer wrapper
**Concrete pain:** the docs hub (per CLAUDE.md) lets users upload and
file PDFs. There's currently no in-app preview — clicking a file likely
downloads it or opens in a new tab. A real CRM should preview the PDF
inline.
**What it adds:**
- **`pdfjs-dist`** is Mozilla's pdf.js — the engine.
- **`@react-pdf-viewer/core`** is the most feature-rich React wrapper
(zoom, search, annotations).
- Alternatively, **`react-pdf`** (Wojtek Maj's, not @react-pdf/renderer)
is a lighter wrapper.
**Land site:** docs hub file detail / preview pane. EOI signing preview
in admin.
**Effort:** 2-3h for a polished viewer with zoom + page nav. ~150kb gzip
(pdf.js is unavoidable; lazy-load only when preview opens).
**Note vs section 35.A:** `@react-pdf/renderer` (generator) and `pdfjs-dist`
(viewer) are complementary. We need both: one to _make_ PDFs, one to
_show_ them.
---
### 36.I — Testing & development data
#### 36.I.1 `@faker-js/faker`
**Concrete pain:** seed data is currently hand-maintained (mostly).
Faker would replace hand-rolled fake names, emails, addresses, phone
numbers, vehicle/yacht names, dates, marina locations with reproducible,
locale-aware fakes.
**Land site:** `src/lib/db/seed.ts`, `src/lib/db/seed-synthetic.ts`.
**Effort:** 1-2h. ~3MB gzip — **dev-only**, not shipped.
#### 36.I.2 `msw` (Mock Service Worker)
**Concrete pain:** integration tests that hit external services
(Documenso, SMTP, IMAP) either skip in CI or fail intermittently.
**`msw`** intercepts fetch/HTTP at the network layer in tests so we can
mock external responses deterministically.
**Land site:** `tests/integration/` setup — wrap Documenso + SMTP
clients with MSW handlers.
**Effort:** 2-3h. dev-only.
---
### 36.J — Workflow & state machines
#### 36.J.1 `@xstate/react`
Audit found only one multi-step flow (`send-document-dialog.tsx`).
EOI signing has steps but they're sequential, not state-machine-y. The
GDPR export job is a backend state machine but `bullmq` handles it.
**Verdict:** **not warranted right now.** Revisit if we build the
client-onboarding flow or the multi-step EOI-with-multi-berth-and-
payment-and-signing wizard the roadmap mentions.
---
### 36.K — Search & filtering
#### 36.K.1 Postgres-native FTS (no new package — schema migration)
**Concrete pain:** `search.service.ts` uses `LIKE '%term%'` on client/yacht/
company tables. Slow at scale; doesn't rank.
**What we could add:** Postgres `tsvector` columns + `GIN` indexes + a
single `to_tsquery()` call per search. This is **all native Postgres**
— no new npm dep. Drizzle supports it via `sql\`...\`` template literals.
**Effort:** migration (30 min) + service refactor (2h) + e2e re-run.
#### 36.K.2 External search engines (`meilisearch`, `typesense`)
**Verdict:** overkill until we're past 100k clients per port. Postgres
FTS will hold for years. **Defer indefinitely.**
---
### 36.L — Final updated adoption order (incorporating section 36)
Layered on section 35.E:
**Same-day adopts (low-risk, high-leverage):**
- **`@total-typescript/ts-reset`** — 1-line type-safety upgrade. Do this
before any Zod 4 work — it'll catch latent bugs along the way.
- **`web-vitals`** — establish perf baseline before any optimization.
- **`@hookform/devtools`** — dev-only DX win.
**Adopt alongside section 35.B Tier 1:**
- **`p-limit`** — pair with the section 35 mass-operation refactors. The
Documenso bulk-send path is the highest-priority site.
- **`@tanstack/query-broadcast-client-experimental`** — 1-liner in the
query provider.
**Adopt with mobile/UX work:**
- **`browser-image-compression`** — wire into scan-shell first.
- **`embla-carousel-react`** + **`yet-another-react-lightbox`** — pair
with berth/yacht photo gallery work.
- **`react-resizable-panels`** — pair with docs hub UX work.
- **`@use-gesture/react`** — pair with kanban-on-mobile polish.
**Adopt with security pass:**
- **`isomorphic-dompurify`** — replaces hand-rolled escapeHtml. Pair
with the audit's XSS hardening pass.
**Adopt with the docs hub Phase 2:**
- **`pdfjs-dist`** + viewer wrapper — when in-app PDF preview becomes a
user request.
**Park / defer:**
- `partysocket` (requires Socket.IO investigation first).
- `@xstate/react` (no current target).
- External search engines.
- WebAuthn / passkeys (roadmap decision).
---
### 36.M — Final summary
The first sweep (section 35) found the headline replacements:
**Zod 4 + drizzle-zod + react-email + @react-pdf/renderer** is the
single highest-leverage week of work.
This second sweep (section 36) found the **operational hardening
layer**:
- **`p-limit` family** for the 74 unbounded `Promise.all` sites.
- **`@total-typescript/ts-reset`** for free type safety across 1000+ files.
- **`web-vitals`** to establish a perf baseline before we optimize.
- **`isomorphic-dompurify`** to harden the email/template rendering.
- **`browser-image-compression`** for mobile bandwidth / battery.
- **`@tanstack/query-broadcast-client-experimental`** for free cross-tab
cache sync.
- **`react-resizable-panels`** + **`embla-carousel-react`** +
**`yet-another-react-lightbox`** for the photo/preview surfaces.
Together with section 35, this gives us a concrete shopping list of
~20 packages with explicit land-sites in our code and effort estimates,
plus 5-6 cleanup-candidate removals. Adopting all of them would shed
~600 LOC of hand-rolled code, eliminate ~5 categories of latent bugs
(timezone, XSS, race conditions, type stdlib quirks, missing
exhaustiveness), and meaningfully improve mobile UX + perf measurability.
---
**Bottom line:** the deps audit (section 34) showed we're secure today.
This section (35) shows where we can make the codebase _meaningfully better_
— smaller, cleaner, more declarative — by leveraging packages we don't yet
use. The single highest-leverage move is **Zod 4 + drizzle-zod + react-email
in the same focused day**: it kills the validator-drift problem, lands the
14× parse-perf win, and starts paying down the hand-strung-email-templates
debt all at once. The PDF stack overhaul (35.A) is the second-highest-leverage
move: removing pdfme + the 571-line Tiptap bridge in favor of declarative
React components is a category-of-bug eliminator, not just a refactor.