From 73184c51e03c7c052c7bc6076343755736ad1656 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 12 May 2026 20:45:28 +0200 Subject: [PATCH] feat(pdf): brand kit foundation for @react-pdf/renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 + 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 wraps react-pdf SVG 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) --- package.json | 4 + pnpm-lock.yaml | 345 +++++++++++++++++++ src/lib/pdf/brand-kit/Badge.tsx | 42 +++ src/lib/pdf/brand-kit/DataTable.tsx | 165 +++++++++ src/lib/pdf/brand-kit/DocumentShell.tsx | 61 ++++ src/lib/pdf/brand-kit/Footer.tsx | 52 +++ src/lib/pdf/brand-kit/Header.tsx | 72 ++++ src/lib/pdf/brand-kit/KeyValueGrid.tsx | 92 +++++ src/lib/pdf/brand-kit/Section.tsx | 40 +++ src/lib/pdf/brand-kit/charts/BarChart.tsx | 155 +++++++++ src/lib/pdf/brand-kit/charts/FunnelChart.tsx | 91 +++++ src/lib/pdf/brand-kit/charts/LineChart.tsx | 146 ++++++++ src/lib/pdf/brand-kit/charts/PieChart.tsx | 140 ++++++++ src/lib/pdf/brand-kit/charts/index.ts | 4 + src/lib/pdf/brand-kit/index.ts | 20 ++ src/lib/pdf/brand-kit/logo.ts | 57 +++ src/lib/pdf/brand-kit/svg-primitives.tsx | 50 +++ src/lib/pdf/brand-kit/tokens.ts | 51 +++ src/lib/pdf/render.ts | 32 ++ src/types/react-pdf-augment.d.ts | 8 + tests/unit/pdf-brand-kit.test.tsx | 125 +++++++ vitest.config.ts | 7 +- 22 files changed, 1758 insertions(+), 1 deletion(-) create mode 100644 src/lib/pdf/brand-kit/Badge.tsx create mode 100644 src/lib/pdf/brand-kit/DataTable.tsx create mode 100644 src/lib/pdf/brand-kit/DocumentShell.tsx create mode 100644 src/lib/pdf/brand-kit/Footer.tsx create mode 100644 src/lib/pdf/brand-kit/Header.tsx create mode 100644 src/lib/pdf/brand-kit/KeyValueGrid.tsx create mode 100644 src/lib/pdf/brand-kit/Section.tsx create mode 100644 src/lib/pdf/brand-kit/charts/BarChart.tsx create mode 100644 src/lib/pdf/brand-kit/charts/FunnelChart.tsx create mode 100644 src/lib/pdf/brand-kit/charts/LineChart.tsx create mode 100644 src/lib/pdf/brand-kit/charts/PieChart.tsx create mode 100644 src/lib/pdf/brand-kit/charts/index.ts create mode 100644 src/lib/pdf/brand-kit/index.ts create mode 100644 src/lib/pdf/brand-kit/logo.ts create mode 100644 src/lib/pdf/brand-kit/svg-primitives.tsx create mode 100644 src/lib/pdf/brand-kit/tokens.ts create mode 100644 src/lib/pdf/render.ts create mode 100644 src/types/react-pdf-augment.d.ts create mode 100644 tests/unit/pdf-brand-kit.test.tsx diff --git a/package.json b/package.json index fc04aa1b..882d1591 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@react-email/components": "^1.0.12", + "@react-pdf/renderer": "^4.5.1", "@socket.io/redis-adapter": "^8.3.0", "@tanstack/query-broadcast-client-experimental": "^5.100.10", "@tanstack/react-query": "^5.100.10", @@ -97,15 +98,18 @@ "react-easy-crop": "^5.5.7", "react-email": "^6.1.3", "react-hook-form": "^7.75.0", + "react-image-crop": "^11.0.10", "recharts": "^3.8.1", "sharp": "^0.34.5", "socket.io": "^4.8.3", "socket.io-client": "^4.8.3", "sonner": "^2.0.7", + "svgo": "^4.0.1", "tailwind-merge": "^3.6.0", "tailwindcss-animate": "^1.0.7", "tesseract.js": "^7.0.0", "ts-pattern": "^5.9.0", + "unpdf": "^1.6.2", "vaul": "^1.1.2", "web-vitals": "^5.2.0", "zod": "^4.4.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e29857f..16d17e99 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -100,6 +100,9 @@ importers: '@react-email/components': specifier: ^1.0.12 version: 1.0.12(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@react-pdf/renderer': + specifier: ^4.5.1 + version: 4.5.1(react@19.2.6) '@socket.io/redis-adapter': specifier: ^8.3.0 version: 8.3.0(socket.io-adapter@2.5.6) @@ -220,6 +223,9 @@ importers: react-hook-form: specifier: ^7.75.0 version: 7.75.0(react@19.2.6) + react-image-crop: + specifier: ^11.0.10 + version: 11.0.10(react@19.2.6) recharts: specifier: ^3.8.1 version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@18.3.1)(react@19.2.6)(redux@5.0.1) @@ -235,6 +241,9 @@ importers: sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + svgo: + specifier: ^4.0.1 + version: 4.0.1 tailwind-merge: specifier: ^3.6.0 version: 3.6.0 @@ -247,6 +256,9 @@ importers: ts-pattern: specifier: ^5.9.0 version: 5.9.0 + unpdf: + specifier: ^1.6.2 + version: 1.6.2 vaul: specifier: ^1.1.2 version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -2030,6 +2042,49 @@ packages: resolution: {integrity: sha512-L2eAxN46Vq2Ss3nDegrH7wQVMeWH03ahawp+OdzUtQWqL3cq6Bt149q9XhY3cWc9fJsxuWjLfCn+3T9uApIlBA==} hasBin: true + '@react-pdf/fns@3.1.3': + resolution: {integrity: sha512-0I7pApDr1/RLAKbizuLy/IHTEa93LSPy/bEwYniboC3Xqnp6Od8xFJKbKEzGw2wh/5zKFFwl00g4t9RwgIMc3w==} + + '@react-pdf/font@4.0.8': + resolution: {integrity: sha512-deNd+emtZAJho1IlzKL9bRoLAGv/6oXOIKO2oZfs4RuXUrK1onLHbJO7e2YoVLPFP/sQxisRTnzdJFtd35iKwA==} + + '@react-pdf/image@3.1.0': + resolution: {integrity: sha512-ks7Ry8v711r8NvKWSELehj0BXBNPRihSnWsM09nDD8Ur175zbWBCK217LLwQMKDNYDVpkZaipdoJPom1LGaE9g==} + + '@react-pdf/layout@4.6.1': + resolution: {integrity: sha512-gN6PmWoEffvlIkifLfEhMsVucRywVMyH3rnxdyOVOhGy0nWJKKGpHyPc4plbDdpP6EfZ0r8prHXujDSkIG2nSA==} + + '@react-pdf/pdfkit@5.1.1': + resolution: {integrity: sha512-wNcdSsNlNYyGHGAgIdt453egBF7fiF9UxpRlklUfVvu8OWCrUppG9xiUrPLVoKiqWet5tMi0w6LmuFUJuYqjEg==} + + '@react-pdf/primitives@4.3.0': + resolution: {integrity: sha512-nYXoZ36pvwNzbc54+DbL8RCn15jU7woJ9D/svnh5tpUXekJ+CbI4mZLo6boSv24CvJgychOu6h7gxX03B4ps0A==} + + '@react-pdf/reconciler@2.0.0': + resolution: {integrity: sha512-7zaPRujpbHSmCpIrZ+b9HSTJHthcVZzX0Wx7RzvQGsGBUbHP4p6s5itXrAIOuQuPvDepoHGNOvf6xUuMVvdoyw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@react-pdf/render@4.5.1': + resolution: {integrity: sha512-IW/N4HWJWtioBXCf7n02IR24VJJ8gbdS3jGypf+vW/rSErEx3/URRzh9UK6Ma8Fpog9+T/W6GE2NHJ5AAKHhVA==} + + '@react-pdf/renderer@4.5.1': + resolution: {integrity: sha512-5r1VQrE6FRLXX5wWUxwZzM24E2BJMo6g8AQWuS8WyPs9ugu5yMnb2g8/RpPYka/Z6J+RUEWc32wty2NoUJF42Q==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@react-pdf/stylesheet@6.2.1': + resolution: {integrity: sha512-2+UEk+7e+z8baaWi2l5kPLWmwtJeOI+T5wW9GGeN3iDH7vd3kbTqOpN1yt9mmfNVZFxQsnDHpznFb5v5UF983A==} + + '@react-pdf/svg@1.1.0': + resolution: {integrity: sha512-cTIHXiz9x1HrbfqzfxfZP3FRdDwUXG77QWF6Fb5MP/lV3ONxR+g0Z3hwtBatCS9HeGBQCpxX/Lzb8wHE+co1PA==} + + '@react-pdf/textkit@6.3.0': + resolution: {integrity: sha512-v6+V8nAcVwm7s2s1jIG2MD3Iw//x/k+XrH1foWOELBE4b32pyDgKyPXN/6KJE0dnX7+fVy27uctLNCLNMvzKzQ==} + + '@react-pdf/types@2.11.1': + resolution: {integrity: sha512-i9xQgfaDU9QoeNnbp6rltXCWg1huEh195rpOuN8cE4BZ2FuLdQrsIcb2dhFF9aOxXf+XBA6LOSpIW051MDD/bw==} + '@reduxjs/toolkit@2.11.2': resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==} peerDependencies: @@ -2536,6 +2591,9 @@ packages: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} + abs-svg-path@0.1.1: + resolution: {integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==} + accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -2859,6 +2917,9 @@ packages: bmp-js@0.1.0: resolution: {integrity: sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==} + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + brace-expansion@1.1.14: resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} @@ -3020,6 +3081,10 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + commander@13.1.0: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} @@ -3085,15 +3150,30 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-tree@2.2.1: + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + css-tree@3.2.1: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} hasBin: true + csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -3398,6 +3478,9 @@ packages: electron-to-chromium@1.5.352: resolution: {integrity: sha512-9wHk8x6dyuimoe18EdiDPWKExNdxYqo4fn4FwOVVper6RxT3cmpBwBkWWfSOCYJjQdIco/nPhJhNLmn4Ufg1Yg==} + emoji-regex-xs@1.0.0: + resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -3693,6 +3776,9 @@ packages: picomatch: optional: true + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -3862,6 +3948,12 @@ packages: hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + hsl-to-hex@1.0.0: + resolution: {integrity: sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==} + + hsl-to-rgb-for-reals@1.1.1: + resolution: {integrity: sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==} + html-encoding-sniffer@6.0.0: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -3884,6 +3976,9 @@ packages: engines: {node: '>=18'} hasBin: true + hyphen@1.14.1: + resolution: {integrity: sha512-kvL8xYl5QMTh+LwohVN72ciOxC0OEV79IPdJSTwEXok9y9QHebXGdFgrED4sWfiax/ODx++CAMk3hMy4XPJPOw==} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -4125,6 +4220,9 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jay-peg@1.1.1: + resolution: {integrity: sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==} + jiti@1.21.7: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true @@ -4415,9 +4513,15 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + media-engine@1.0.3: + resolution: {integrity: sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==} + memory-pager@1.5.0: resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==} @@ -4609,12 +4713,18 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + normalize-svg-path@1.1.0: + resolution: {integrity: sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==} + normalize-wheel@1.0.1: resolution: {integrity: sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==} notepack.io@3.0.1: resolution: {integrity: sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==} + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + nypm@0.6.6: resolution: {integrity: sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q==} engines: {node: '>=18'} @@ -4753,6 +4863,9 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse-svg-path@0.1.2: + resolution: {integrity: sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==} + parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} @@ -4848,6 +4961,9 @@ packages: png-js@1.1.0: resolution: {integrity: sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==} + png-js@2.0.0: + resolution: {integrity: sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -4951,6 +5067,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + queue@6.0.2: + resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==} + quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} @@ -4994,6 +5113,11 @@ packages: peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 + react-image-crop@11.0.10: + resolution: {integrity: sha512-+5FfDXUgYLLqBh1Y/uQhIycpHCbXkI50a+nbfkB1C0xXXUTwkisHDo2QCB1SQJyHCqIuia4FeyReqXuMDKWQTQ==} + peerDependencies: + react: '>=16.13.1' + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -5197,6 +5321,9 @@ packages: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} + scheduler@0.25.0-rc-603e6108-20241029: + resolution: {integrity: sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -5494,6 +5621,14 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svg-arc-to-cubic-bezier@3.2.0: + resolution: {integrity: sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==} + + svgo@4.0.1: + resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==} + engines: {node: '>=16'} + hasBin: true + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -5691,6 +5826,14 @@ packages: unload@2.4.1: resolution: {integrity: sha512-IViSAm8Z3sRBYA+9wc0fLQmU9Nrxb16rcDmIiR6Y9LJSZzI7QY5QsDhqPpKOjAn0O9/kfK1TfNEMMAGPTIraPw==} + unpdf@1.6.2: + resolution: {integrity: sha512-zQ80ySoPuPHOsvIoRp/nJyQt8TOUoTh1+WBCGcBvlddQNgKDLRwm0AY3x8Q35I7+kIiRSgqMx+Ma2pl9McIp7A==} + peerDependencies: + '@napi-rs/canvas': ^0.1.69 + peerDependenciesMeta: + '@napi-rs/canvas': + optional: true + unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} @@ -5755,6 +5898,10 @@ packages: victory-vendor@37.3.6: resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + vite-compatible-readable-stream@3.6.1: + resolution: {integrity: sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==} + engines: {node: '>= 6'} + vite@8.0.5: resolution: {integrity: sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -6005,6 +6152,9 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} + yoga-layout@3.2.1: + resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + zip-stream@6.0.1: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'} @@ -7557,6 +7707,110 @@ snapshots: smol-toml: 1.6.1 tinyexec: 1.1.2 + '@react-pdf/fns@3.1.3': {} + + '@react-pdf/font@4.0.8': + dependencies: + '@react-pdf/pdfkit': 5.1.1 + '@react-pdf/types': 2.11.1 + fontkit: 2.0.4 + is-url: 1.2.4 + + '@react-pdf/image@3.1.0': + dependencies: + '@react-pdf/svg': 1.1.0 + jay-peg: 1.1.1 + png-js: 2.0.0 + + '@react-pdf/layout@4.6.1': + dependencies: + '@react-pdf/fns': 3.1.3 + '@react-pdf/image': 3.1.0 + '@react-pdf/primitives': 4.3.0 + '@react-pdf/stylesheet': 6.2.1 + '@react-pdf/textkit': 6.3.0 + '@react-pdf/types': 2.11.1 + emoji-regex-xs: 1.0.0 + queue: 6.0.2 + yoga-layout: 3.2.1 + + '@react-pdf/pdfkit@5.1.1': + dependencies: + '@babel/runtime': 7.29.2 + '@noble/ciphers': 1.3.0 + '@noble/hashes': 1.8.0 + browserify-zlib: 0.2.0 + fontkit: 2.0.4 + jay-peg: 1.1.1 + js-md5: 0.8.3 + linebreak: 1.1.0 + png-js: 2.0.0 + vite-compatible-readable-stream: 3.6.1 + + '@react-pdf/primitives@4.3.0': {} + + '@react-pdf/reconciler@2.0.0(react@19.2.6)': + dependencies: + object-assign: 4.1.1 + react: 19.2.6 + scheduler: 0.25.0-rc-603e6108-20241029 + + '@react-pdf/render@4.5.1': + dependencies: + '@babel/runtime': 7.29.2 + '@react-pdf/fns': 3.1.3 + '@react-pdf/primitives': 4.3.0 + '@react-pdf/textkit': 6.3.0 + '@react-pdf/types': 2.11.1 + abs-svg-path: 0.1.1 + color-string: 2.1.4 + normalize-svg-path: 1.1.0 + parse-svg-path: 0.1.2 + svg-arc-to-cubic-bezier: 3.2.0 + + '@react-pdf/renderer@4.5.1(react@19.2.6)': + dependencies: + '@babel/runtime': 7.29.2 + '@react-pdf/fns': 3.1.3 + '@react-pdf/font': 4.0.8 + '@react-pdf/layout': 4.6.1 + '@react-pdf/pdfkit': 5.1.1 + '@react-pdf/primitives': 4.3.0 + '@react-pdf/reconciler': 2.0.0(react@19.2.6) + '@react-pdf/render': 4.5.1 + '@react-pdf/types': 2.11.1 + events: 3.3.0 + object-assign: 4.1.1 + prop-types: 15.8.1 + queue: 6.0.2 + react: 19.2.6 + + '@react-pdf/stylesheet@6.2.1': + dependencies: + '@react-pdf/fns': 3.1.3 + '@react-pdf/types': 2.11.1 + color-string: 2.1.4 + hsl-to-hex: 1.0.0 + media-engine: 1.0.3 + postcss-value-parser: 4.2.0 + + '@react-pdf/svg@1.1.0': + dependencies: + '@react-pdf/primitives': 4.3.0 + + '@react-pdf/textkit@6.3.0': + dependencies: + '@react-pdf/fns': 3.1.3 + bidi-js: 1.0.3 + hyphen: 1.14.1 + unicode-properties: 1.4.1 + + '@react-pdf/types@2.11.1': + dependencies: + '@react-pdf/font': 4.0.8 + '@react-pdf/primitives': 4.3.0 + '@react-pdf/stylesheet': 6.2.1 + '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1))(react@19.2.6)': dependencies: '@standard-schema/spec': 1.1.0 @@ -8021,6 +8275,8 @@ snapshots: dependencies: event-target-shim: 5.0.1 + abs-svg-path@0.1.1: {} + accepts@1.3.8: dependencies: mime-types: 2.1.35 @@ -8324,6 +8580,8 @@ snapshots: bmp-js@0.1.0: {} + boolbase@1.0.0: {} + brace-expansion@1.1.14: dependencies: balanced-match: 1.0.2 @@ -8499,6 +8757,8 @@ snapshots: colorette@2.0.20: {} + commander@11.1.0: {} + commander@13.1.0: {} commander@14.0.3: {} @@ -8567,13 +8827,32 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-tree@2.2.1: + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.2.1 + css-tree@3.2.1: dependencies: mdn-data: 2.27.1 source-map-js: 1.2.1 + css-what@6.2.2: {} + cssesc@3.0.0: {} + csso@5.0.5: + dependencies: + css-tree: 2.2.1 + csstype@3.2.3: {} d3-array@3.2.4: @@ -8765,6 +9044,8 @@ snapshots: electron-to-chromium@1.5.352: {} + emoji-regex-xs@1.0.0: {} + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -9230,6 +9511,8 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fflate@0.8.2: {} + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -9412,6 +9695,12 @@ snapshots: dependencies: react-is: 16.13.1 + hsl-to-hex@1.0.0: + dependencies: + hsl-to-rgb-for-reals: 1.1.1 + + hsl-to-rgb-for-reals@1.1.1: {} + html-encoding-sniffer@6.0.0(@noble/hashes@2.2.0): dependencies: '@exodus/bytes': 1.15.0(@noble/hashes@2.2.0) @@ -9439,6 +9728,8 @@ snapshots: husky@9.1.7: {} + hyphen@1.14.1: {} + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -9689,6 +9980,10 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jay-peg@1.1.1: + dependencies: + restructure: 3.0.2 + jiti@1.21.7: {} jiti@2.4.2: {} @@ -9962,8 +10257,12 @@ snapshots: math-intrinsics@1.1.0: {} + mdn-data@2.0.28: {} + mdn-data@2.27.1: {} + media-engine@1.0.3: {} + memory-pager@1.5.0: optional: true @@ -10134,10 +10433,18 @@ snapshots: normalize-path@3.0.0: {} + normalize-svg-path@1.1.0: + dependencies: + svg-arc-to-cubic-bezier: 3.2.0 + normalize-wheel@1.0.1: {} notepack.io@3.0.1: {} + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + nypm@0.6.6: dependencies: citty: 0.2.2 @@ -10281,6 +10588,8 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse-svg-path@0.1.2: {} + parse5@8.0.1: dependencies: entities: 8.0.0 @@ -10390,6 +10699,10 @@ snapshots: dependencies: browserify-zlib: 0.2.0 + png-js@2.0.0: + dependencies: + fflate: 0.8.2 + possible-typed-array-names@1.1.0: {} postcss-import@15.1.0(postcss@8.5.14): @@ -10474,6 +10787,10 @@ snapshots: queue-microtask@1.2.3: {} + queue@6.0.2: + dependencies: + inherits: 2.0.4 + quick-format-unescaped@4.0.4: {} react-day-picker@10.0.0(react@19.2.6): @@ -10536,6 +10853,10 @@ snapshots: dependencies: react: 19.2.6 + react-image-crop@11.0.10(react@19.2.6): + dependencies: + react: 19.2.6 + react-is@16.13.1: {} react-is@18.3.1: {} @@ -10774,6 +11095,8 @@ snapshots: dependencies: xmlchars: 2.2.0 + scheduler@0.25.0-rc-603e6108-20241029: {} + scheduler@0.27.0: {} secure-json-parse@4.1.0: {} @@ -11143,6 +11466,18 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svg-arc-to-cubic-bezier@3.2.0: {} + + svgo@4.0.1: + dependencies: + commander: 11.1.0 + css-select: 5.2.2 + css-tree: 3.2.1 + css-what: 6.2.2 + csso: 5.0.5 + picocolors: 1.1.1 + sax: 1.6.0 + symbol-tree@3.2.4: {} tagged-tag@1.0.0: {} @@ -11387,6 +11722,8 @@ snapshots: unload@2.4.1: {} + unpdf@1.6.2: {} + unrs-resolver@1.11.1: dependencies: napi-postinstall: 0.3.4 @@ -11478,6 +11815,12 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 + vite-compatible-readable-stream@3.6.1: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + vite@8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@20.19.41)(esbuild@0.28.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4): dependencies: lightningcss: 1.32.0 @@ -11695,6 +12038,8 @@ snapshots: yoctocolors@2.1.2: {} + yoga-layout@3.2.1: {} + zip-stream@6.0.1: dependencies: archiver-utils: 5.0.2 diff --git a/src/lib/pdf/brand-kit/Badge.tsx b/src/lib/pdf/brand-kit/Badge.tsx new file mode 100644 index 00000000..cae829fe --- /dev/null +++ b/src/lib/pdf/brand-kit/Badge.tsx @@ -0,0 +1,42 @@ +import { StyleSheet, Text, View } from '@react-pdf/renderer'; + +import { PDF_TOKENS } from './tokens'; + +export type BadgeTone = 'neutral' | 'accent' | 'success' | 'warning' | 'danger'; + +const toneStyles: Record = { + neutral: { background: PDF_TOKENS.colors.border, foreground: PDF_TOKENS.colors.text }, + accent: { background: PDF_TOKENS.colors.accentBlue, foreground: '#ffffff' }, + success: { background: PDF_TOKENS.colors.success, foreground: '#ffffff' }, + warning: { background: PDF_TOKENS.colors.warning, foreground: '#ffffff' }, + danger: { background: PDF_TOKENS.colors.danger, foreground: '#ffffff' }, +}; + +const styles = StyleSheet.create({ + pill: { + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 999, + alignSelf: 'flex-start', + }, + label: { + fontFamily: PDF_TOKENS.fonts.sansBold, + fontSize: PDF_TOKENS.sizes.caption, + textTransform: 'uppercase', + letterSpacing: 0.5, + }, +}); + +export interface BadgeProps { + text: string; + tone?: BadgeTone; +} + +export function Badge({ text, tone = 'neutral' }: BadgeProps) { + const t = toneStyles[tone]; + return ( + + {text} + + ); +} diff --git a/src/lib/pdf/brand-kit/DataTable.tsx b/src/lib/pdf/brand-kit/DataTable.tsx new file mode 100644 index 00000000..4ea2e21e --- /dev/null +++ b/src/lib/pdf/brand-kit/DataTable.tsx @@ -0,0 +1,165 @@ +import { StyleSheet, Text, View } from '@react-pdf/renderer'; +import type { ReactNode } from 'react'; + +import { PDF_TOKENS } from './tokens'; + +const styles = StyleSheet.create({ + wrap: { + flexDirection: 'column', + borderTopWidth: 1, + borderTopColor: PDF_TOKENS.colors.border, + }, + headerRow: { + flexDirection: 'row', + backgroundColor: PDF_TOKENS.colors.headerBand, + paddingVertical: 6, + paddingHorizontal: 8, + }, + bodyRow: { + flexDirection: 'row', + paddingVertical: 6, + paddingHorizontal: 8, + borderBottomWidth: 1, + borderBottomColor: PDF_TOKENS.colors.border, + }, + zebraRow: { + backgroundColor: PDF_TOKENS.colors.zebra, + }, + totalsRow: { + flexDirection: 'row', + paddingVertical: 6, + paddingHorizontal: 8, + borderTopWidth: 1, + borderTopColor: PDF_TOKENS.colors.text, + borderBottomWidth: 1, + borderBottomColor: PDF_TOKENS.colors.text, + }, + headerCell: { + fontFamily: PDF_TOKENS.fonts.sansBold, + fontSize: PDF_TOKENS.sizes.small, + color: PDF_TOKENS.colors.headerText, + textTransform: 'uppercase', + letterSpacing: 0.4, + }, + bodyCell: { + fontFamily: PDF_TOKENS.fonts.sans, + fontSize: PDF_TOKENS.sizes.body, + color: PDF_TOKENS.colors.text, + }, + bodyCellMuted: { + fontFamily: PDF_TOKENS.fonts.sans, + fontSize: PDF_TOKENS.sizes.small, + color: PDF_TOKENS.colors.textMuted, + }, + totalsCell: { + fontFamily: PDF_TOKENS.fonts.sansBold, + fontSize: PDF_TOKENS.sizes.body, + color: PDF_TOKENS.colors.text, + }, +}); + +export type Align = 'left' | 'right' | 'center'; + +export interface TableColumn { + header: string; + /** flex-grow weight (default 1) — controls column width proportions. */ + flex?: number; + align?: Align; + render: (row: Row, rowIndex: number) => ReactNode; +} + +export interface DataTableProps { + columns: TableColumn[]; + rows: Row[]; + /** Render a bold totals row beneath the body. Cell value-or-null per column. */ + totals?: (string | null)[]; + /** Apply zebra background to alternate rows (default true). */ + zebra?: boolean; + /** Empty-state text shown when rows.length === 0. */ + emptyMessage?: string; +} + +function cellStyle(align: Align | undefined, base: Record) { + return { + flex: 1, + paddingHorizontal: 4, + textAlign: align ?? 'left', + ...base, + } as const; +} + +export function DataTable({ + columns, + rows, + totals, + zebra = true, + emptyMessage = 'No entries', +}: DataTableProps) { + return ( + + + {columns.map((c, i) => ( + + {c.header} + + ))} + + {rows.length === 0 ? ( + + + {emptyMessage} + + + ) : ( + rows.map((row, i) => ( + + {columns.map((c, ci) => ( + + {(() => { + const node = c.render(row, i); + if (typeof node === 'string' || typeof node === 'number') { + return {node}; + } + return node; + })()} + + ))} + + )) + )} + {totals ? ( + + {columns.map((c, i) => ( + + {totals[i] ?? ''} + + ))} + + ) : null} + + ); +} diff --git a/src/lib/pdf/brand-kit/DocumentShell.tsx b/src/lib/pdf/brand-kit/DocumentShell.tsx new file mode 100644 index 00000000..b681f060 --- /dev/null +++ b/src/lib/pdf/brand-kit/DocumentShell.tsx @@ -0,0 +1,61 @@ +import { Document, Page, StyleSheet, View } from '@react-pdf/renderer'; +import type { ReactNode } from 'react'; + +import { Footer } from './Footer'; +import { Header } from './Header'; +import { PDF_TOKENS } from './tokens'; + +const styles = StyleSheet.create({ + page: { + fontFamily: PDF_TOKENS.fonts.sans, + color: PDF_TOKENS.colors.text, + fontSize: PDF_TOKENS.sizes.body, + backgroundColor: PDF_TOKENS.colors.surface, + paddingBottom: PDF_TOKENS.spacing.pagePaddingBottom, + }, + body: { + paddingHorizontal: PDF_TOKENS.spacing.pagePadding, + paddingTop: PDF_TOKENS.spacing.sectionGap, + flexDirection: 'column', + gap: PDF_TOKENS.spacing.sectionGap, + }, +}); + +export interface DocumentShellProps { + portName: string; + docTitle: string; + docMeta?: string; + logoBuffer: Buffer | null; + /** ISO timestamp shown in footer. Defaults to render time. */ + generatedAt?: Date; + /** Title shown in the PDF metadata (cmd+I in Preview). */ + pdfTitle?: string; + /** Author shown in the PDF metadata. Defaults to port name. */ + pdfAuthor?: string; + children: ReactNode; +} + +export function DocumentShell({ + portName, + docTitle, + docMeta, + logoBuffer, + generatedAt, + pdfTitle, + pdfAuthor, + children, +}: DocumentShellProps) { + return ( + + +
+ {children} +