Phase 1 / commit 2 of 14 — adds the admin-facing logo upload that the
brand-kit Header pulls in for every internal-only PDF.
Server pipeline (src/lib/services/logo.service.ts):
- magic-byte format check via sharp metadata
- rejects animated/multi-frame inputs
- SVGs sanitized via svgo preset-default + post-pass regex check
(rejects <script>, on*=, javascript:, external href, <foreignObject>),
then rasterized to PNG at 300 DPI
- HEIC/HEIF/AVIF/WEBP all auto-converted to PNG by sharp
- optional crop coords applied server-side (bounds-checked first)
- auto-trim near-white borders
- resize so longest edge <= 1200px, sRGB, palette-PNG
- rejects undersized output (< 200px any side) or > 1MB
- atomic system_settings upsert; soft-archives prior file row + storage object
API:
GET /api/v1/admin/branding/logo current logo metadata
POST /api/v1/admin/branding/logo multipart upload + crop
DELETE /api/v1/admin/branding/logo clear; future PDFs fall back
to port-name text header
GET /api/v1/admin/branding/logo/sample-pdf renders branding-sample.tsx
with the current logo so
admins can spot-check
letterboxing in real shell
UI:
src/components/admin/branding/pdf-logo-uploader.tsx
- react-image-crop with Wide 3:1 / Square 1:1 / Freeform aspect toggle
- file picker accepts PNG/JPEG/WEBP/SVG/HEIC/HEIF/AVIF (up to 5 MB)
- dark-band preview swatch shows how the logo lands in the header
- post-upload warnings panel surfaces every server-side normalization
(resized, trimmed, JPEG no-alpha warning, SVG rasterized, etc.)
- "Test with sample PDF" button streams a real PDF for spot-check
- "Remove" tears down the file + storage object + setting
Wired into the existing /admin/branding settings page beneath the
Identity and Email-branding cards.
Audit:
Two new AuditAction enum values added: branding.logo.uploaded and
branding.logo.archived. Captured per upload + per archived prior logo.
Tests:
tests/unit/logo-service.test.ts (11 tests): sharp pipeline happy path,
undersized rejection, empty/oversized rejection, non-image rejection,
out-of-bounds crop rejection, in-bounds crop, SVG rasterization, SVG
with embedded script rejection, SVG with external href rejection,
JPEG-with-no-alpha warning collection.
1308/1308 vitest green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 / commit 1 of 14 — installs deps and lays down the brand-kit
primitives used by every internal-only PDF. No callers wired yet.
Adds:
@react-pdf/renderer 4.5.1 one engine for internal exports
unpdf 1.6.2 reserved for berth-PDF parser tier-2
react-image-crop 11.0.10 admin logo crop UI (commit 2)
svgo 4.0.1 SVG sanitization on logo upload (commit 2)
brand-kit/
tokens.ts single source of truth for colors/fonts/spacing
logo.ts resolvePortLogo() — cached, soft-fallback
DocumentShell <Document><Page> + fixed Header + fixed Footer
Header dark band, logo slot (letterboxed) + text fallback
Footer page N of M + generated-at + confidential tag
Section heading + bottom border
KeyValueGrid 2-col (default) or stacked label/value
DataTable zebra rows + sticky header + totals row + empty state
Badge 5 tone pills
charts/
BarChart pure SVG, 4-tick y-axis, optional value labels
LineChart pure SVG, line + markers + grid
PieChart pure SVG, donut-or-pie + side legend
FunnelChart pure SVG, slope-cut slices for pipeline stages
render.ts renderToBuffer + renderToStream wrappers, typed
svg-primitives.tsx <SvgLabel> wraps react-pdf SVG <Text> to bridge
missing TS declarations for fontSize/fontFamily
Smoke test renders a kitchen-sink Document including every primitive
and every chart, plus an empty-data path. 1293+4 vitest tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>