diff --git a/.github/workflows/react-starter.yml b/.github/workflows/react-starter.yml
new file mode 100644
index 0000000..94ad094
--- /dev/null
+++ b/.github/workflows/react-starter.yml
@@ -0,0 +1,58 @@
+name: React starter
+
+on:
+ push:
+ branches: [main]
+ paths: ['examples/react/**', 'tools/check-react-starter.py', 'tools/prepare-react-check.mjs', 'tools/browser-requirements.txt', '.github/workflows/react-starter.yml']
+ pull_request:
+ paths: ['examples/react/**', 'tools/check-react-starter.py', 'tools/prepare-react-check.mjs', 'tools/browser-requirements.txt', '.github/workflows/react-starter.yml']
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ browser:
+ name: React starter (${{ matrix.browser }})
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ strategy:
+ fail-fast: false
+ matrix:
+ browser: [chrome, firefox, webkit]
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-node@v6
+ with:
+ node-version: '22'
+ cache: npm
+ cache-dependency-path: examples/react/package-lock.json
+ - uses: actions/setup-python@v6
+ with:
+ python-version: '3.12'
+ - run: npm ci --ignore-scripts --no-audit --no-fund
+ working-directory: examples/react
+ - run: npm run build
+ working-directory: examples/react
+ - run: node tools/prepare-react-check.mjs
+ - run: python -m pip install -r tools/browser-requirements.txt
+ - run: python -m playwright install --with-deps ${{ matrix.browser }}
+ - run: python tools/check-react-starter.py --dist examples/react/dist --out output/react-${{ matrix.browser }} --browser ${{ matrix.browser }}
+ - name: Build React development runtime with StrictMode
+ if: matrix.browser == 'chrome'
+ env:
+ NODE_ENV: development
+ run: npx vite build --mode strict --outDir dist-strict --sourcemap
+ working-directory: examples/react
+ - name: Check StrictMode lifecycle and downloads
+ if: matrix.browser == 'chrome'
+ run: python tools/check-react-starter.py --dist examples/react/dist-strict --out output/react-strict --browser chrome --strict
+ - uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: react-starter-${{ matrix.browser }}
+ path: |
+ output/react-baseline/
+ output/react-${{ matrix.browser }}/
+ output/react-strict/
+ retention-days: 14
diff --git a/README.md b/README.md
index 4a25bb1..90ed7ab 100644
--- a/README.md
+++ b/README.md
@@ -81,6 +81,9 @@ data; connect your own authenticated record lookup when adapting it.
## Use it in a browser
+For React, use the [React and TypeScript starter](examples/react) with automatic
+previews, editable templates, cancellation, and a reusable component-lifecycle hook.
+
Start with the [complete browser application](examples/browser): editable HTML/CSS,
invoice and report designs, previews, cancellation, and PDF downloads.
diff --git a/docs/browser.md b/docs/browser.md
index f09fe2b..77de753 100644
--- a/docs/browser.md
+++ b/docs/browser.md
@@ -7,6 +7,9 @@ optional PNG previews. No PDF server or account is required.
The [runnable browser starter](../examples/browser) includes editable templates,
previews, downloads, and production build settings.
+The [React and TypeScript starter](../examples/react) adds form-driven automatic
+previews and a reusable hook that cancels stale work and releases output URLs
+when inputs change or the component unmounts.
## Copy the runtime into your site
diff --git a/examples/react/.gitignore b/examples/react/.gitignore
new file mode 100644
index 0000000..2c8108b
--- /dev/null
+++ b/examples/react/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+dist/
+dist-strict/
+public/fullbleed/
+output/
diff --git a/examples/react/LICENSE b/examples/react/LICENSE
new file mode 100644
index 0000000..4c99bbd
--- /dev/null
+++ b/examples/react/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Fullbleed Contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/examples/react/README.md b/examples/react/README.md
new file mode 100644
index 0000000..6d9684e
--- /dev/null
+++ b/examples/react/README.md
@@ -0,0 +1,109 @@
+# Fullbleed React PDF starter
+
+A React and TypeScript application with automatic PDF previews, editable HTML/CSS
+templates, cancellation, and downloads. Change a form field and the actual PDF
+updates after a short pause. Includes a designed invoice and three-page report;
+all sample organizations, records, and amounts are fictional. MIT licensed.
+
+## Run
+
+Use Node.js 22.12 or newer for development and builds:
+
+```sh
+npm ci
+npm run dev
+```
+
+Open the localhost URL printed by Vite. The browser loads the engine and fonts
+from that server and renders in a Web Worker. This app does not upload document
+inputs to a PDF service. Changes last until reload or closing the editor.
+Turn off **Update automatically** to render only when you choose **Generate PDF**.
+
+## Connect your data
+
+`src/App.tsx` holds the React form, templates, and preview UI. The invoice uses
+`{{customer}}` and `{{reference}}` in HTML and `{{ink}}` in CSS. The app escapes
+customer text before filling the template and accepts a six-digit hex ink color.
+Replace the fixed line items, totals, addresses, and dates with your data and
+calculations when building a real application.
+
+Use **Edit the HTML & CSS** to try another layout. To keep it, edit the matching
+`src/invoice.html`, `src/invoice.css`, `src/report.html`, or `src/report.css` file.
+Use [supported print CSS](https://docs.fullbleed.dev/css-coverage/); the engine
+renders supplied static HTML/CSS rather than capturing the React page.
+
+## Reuse the hook
+
+Copy `src/usePdfPreview.ts` into your client application, install
+`fullbleed@0.3.0`, and copy the runtime with
+`npx fullbleed-browser-assets public/fullbleed`. Keep the document input stable
+with `useMemo` so ordinary component updates do not schedule another render:
+
+```tsx
+import { useMemo } from 'react';
+import { usePdfPreview } from './usePdfPreview';
+
+export function DocumentPreview({ html, css }: { html: string; css: string }) {
+ const input = useMemo(() => ({
+ html, css, previewDpi: 96, maxPages: 20, timeoutMs: 30_000,
+ }), [html, css]);
+ const pdf = usePdfPreview(input, '/fullbleed/');
+ return
+ { void pdf.generate(); }}>Generate PDF
+ Cancel
+ {pdf.error?.message ?? pdf.status}
+ {pdf.result && <>
+ Download PDF
+ {pdf.result.previews.map((url, index) =>
+ )}
+ >}
+ ;
+}
+```
+
+The hook schedules a preview after 450 ms without input changes. Use its third
+argument `{ auto: false }` for on-demand generation, or `{ delayMs: 800 }` for a
+longer pause. Cancel stops a pending timer or active job until the next input
+change or `generate()` call. Changing preview mode also resets its output.
+
+It hides stale downloads immediately, aborts superseded work, ignores obsolete
+results, and revokes replaced Blob URLs. Unmounting aborts the active request and
+releases all output URLs. The example runs inside React `StrictMode`, including
+its extra development setup/cleanup cycle. See React's guidance on
+[Effect cleanup](https://react.dev/reference/react/useEffect#connecting-to-an-external-system).
+Keep the hook in a client component; it depends on browser APIs.
+
+Each hook owns one active worker handle. Multiple mounted components can still
+render concurrently, so bound them for your target devices. Browser worker
+termination does not provide a native thread-exit promise. The
+[browser SDK guide](https://github.com/fullbleed-engine/fullbleed-node/blob/main/docs/browser.md)
+covers limits, custom font/image bytes, hosting policy, and structured errors.
+
+## Build and deploy
+
+```sh
+npm run build
+npm run preview
+```
+
+Deploy the entire `dist/` directory to a static HTTPS host. Relative asset paths
+support nested deployment URLs. The asset-copy step verifies the installed
+client, worker, engine, fonts, build manifest, and notices; deploy them together.
+The deployed site needs no Node server. HTTPS or localhost, Web Workers,
+WebAssembly, and Web Crypto are required.
+
+## Versions and verification
+
+The lockfile pins Fullbleed 0.3.0 / engine 2.5.8, React 19.3.0, TypeScript 7.0.2,
+and Vite 8.3.2. No Fullbleed engine or package release is needed for this example.
+
+The repository's `React starter` workflow builds the locked project and checks
+real PDF downloads with an independent reader. It compares default invoice and
+report PDF bytes with the installed Node package, checks mobile layout, exercises
+input races, cancellation, editing, recovery, and unmount/remount, and records
+worker API handles and Blob URL cleanup. A separate React development build
+checks the same flow with StrictMode enabled. Checks run in Chrome, Firefox and
+Playwright WebKit; WebKit testing is not branded Safari certification.
+
+This is ordinary PDF generation and preview rendering. PDF/A, PDF/UA, PDF/X and
+VDP options are not exposed by this browser entry.
diff --git a/examples/react/index.html b/examples/react/index.html
new file mode 100644
index 0000000..6ebf6d7
--- /dev/null
+++ b/examples/react/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+ React PDF starter | Fullbleed
+
+
+
+
+
+
diff --git a/examples/react/package-lock.json b/examples/react/package-lock.json
new file mode 100644
index 0000000..eb968fd
--- /dev/null
+++ b/examples/react/package-lock.json
@@ -0,0 +1,1320 @@
+{
+ "name": "fullbleed-react-starter",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "fullbleed-react-starter",
+ "version": "1.0.0",
+ "dependencies": {
+ "fullbleed": "0.3.0",
+ "react": "19.3.0",
+ "react-dom": "19.3.0"
+ },
+ "devDependencies": {
+ "@types/react": "19.3.0",
+ "@types/react-dom": "19.3.0",
+ "@vitejs/plugin-react": "6.1.2",
+ "typescript": "7.0.2",
+ "vite": "8.3.2"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@bjorn3/browser_wasi_shim": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/@bjorn3/browser_wasi_shim/-/browser_wasi_shim-0.4.2.tgz",
+ "integrity": "sha512-/iHkCVUG3VbcbmEHn5iIUpIrh7a7WPiwZ3sHy4HZKZzBdSadwdddYDZAII2zBvQYV0Lfi8naZngPCN7WPHI/hA==",
+ "license": "MIT OR Apache-2.0"
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.152.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz",
+ "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/oxc-project"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm-eabi": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz",
+ "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz",
+ "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz",
+ "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz",
+ "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz",
+ "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz",
+ "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz",
+ "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz",
+ "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz",
+ "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz",
+ "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz",
+ "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz",
+ "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz",
+ "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz",
+ "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz",
+ "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
+ "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
+ "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.3.0"
+ }
+ },
+ "node_modules/@typescript/typescript-aix-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
+ "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
+ "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
+ "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
+ "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
+ "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
+ "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-loong64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
+ "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-mips64el": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
+ "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
+ "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-riscv64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
+ "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-s390x": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
+ "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
+ "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-sunos-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
+ "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
+ "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
+ "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz",
+ "integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "oxc-transform-react": "^0.152.0",
+ "vite": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@rolldown/plugin-babel": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ },
+ "oxc-transform-react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/fullbleed": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/fullbleed/-/fullbleed-0.3.0.tgz",
+ "integrity": "sha512-9HimnoHTEpgtGhqi9gF+IMSEW8g3GRK11yUkmL6OYD40D/RhWoIF7SqKuDs6nrHF7loWnhtNDWz3Hp/DNaBbMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@bjorn3/browser_wasi_shim": "0.4.2"
+ },
+ "bin": {
+ "fullbleed-browser-assets": "src/copy-browser-assets.cjs"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.20",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
+ "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.29",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz",
+ "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.19",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
+ "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
+ "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.28.0"
+ },
+ "peerDependencies": {
+ "react": "^19.3.0"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz",
+ "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.152.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm-eabi": "1.2.12",
+ "@rolldown/binding-android-arm64": "1.2.12",
+ "@rolldown/binding-darwin-arm64": "1.2.12",
+ "@rolldown/binding-darwin-x64": "1.2.12",
+ "@rolldown/binding-freebsd-x64": "1.2.12",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.12",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.12",
+ "@rolldown/binding-linux-arm64-musl": "1.2.12",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.12",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.12",
+ "@rolldown/binding-linux-x64-gnu": "1.2.12",
+ "@rolldown/binding-linux-x64-musl": "1.2.12",
+ "@rolldown/binding-openharmony-arm64": "1.2.12",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.12",
+ "@rolldown/binding-win32-x64-msvc": "1.2.12"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
+ "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
+ "license": "MIT"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
+ "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
+ "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc"
+ },
+ "engines": {
+ "node": ">=16.20.0"
+ },
+ "optionalDependencies": {
+ "@typescript/typescript-aix-ppc64": "7.0.2",
+ "@typescript/typescript-darwin-arm64": "7.0.2",
+ "@typescript/typescript-darwin-x64": "7.0.2",
+ "@typescript/typescript-freebsd-arm64": "7.0.2",
+ "@typescript/typescript-freebsd-x64": "7.0.2",
+ "@typescript/typescript-linux-arm": "7.0.2",
+ "@typescript/typescript-linux-arm64": "7.0.2",
+ "@typescript/typescript-linux-loong64": "7.0.2",
+ "@typescript/typescript-linux-mips64el": "7.0.2",
+ "@typescript/typescript-linux-ppc64": "7.0.2",
+ "@typescript/typescript-linux-riscv64": "7.0.2",
+ "@typescript/typescript-linux-s390x": "7.0.2",
+ "@typescript/typescript-linux-x64": "7.0.2",
+ "@typescript/typescript-netbsd-arm64": "7.0.2",
+ "@typescript/typescript-netbsd-x64": "7.0.2",
+ "@typescript/typescript-openbsd-arm64": "7.0.2",
+ "@typescript/typescript-openbsd-x64": "7.0.2",
+ "@typescript/typescript-sunos-x64": "7.0.2",
+ "@typescript/typescript-win32-arm64": "7.0.2",
+ "@typescript/typescript-win32-x64": "7.0.2"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz",
+ "integrity": "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.7",
+ "postcss": "^8.5.28",
+ "rolldown": "~1.2.11",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.7.1",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/examples/react/package.json b/examples/react/package.json
new file mode 100644
index 0000000..9c275d0
--- /dev/null
+++ b/examples/react/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "fullbleed-react-starter",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "engines": { "node": ">=22.12.0" },
+ "scripts": {
+ "assets": "fullbleed-browser-assets public/fullbleed",
+ "dev": "npm run assets && vite --host 127.0.0.1",
+ "check": "tsc --noEmit",
+ "build": "npm run check && npm run assets && vite build",
+ "preview": "vite preview --host 127.0.0.1"
+ },
+ "dependencies": {
+ "fullbleed": "0.3.0",
+ "react": "19.3.0",
+ "react-dom": "19.3.0"
+ },
+ "devDependencies": {
+ "@types/react": "19.3.0",
+ "@types/react-dom": "19.3.0",
+ "@vitejs/plugin-react": "6.1.2",
+ "typescript": "7.0.2",
+ "vite": "8.3.2"
+ }
+}
diff --git a/examples/react/public/favicon.svg b/examples/react/public/favicon.svg
new file mode 100644
index 0000000..842f2ec
--- /dev/null
+++ b/examples/react/public/favicon.svg
@@ -0,0 +1 @@
+
diff --git a/examples/react/src/App.tsx b/examples/react/src/App.tsx
new file mode 100644
index 0000000..848e70e
--- /dev/null
+++ b/examples/react/src/App.tsx
@@ -0,0 +1,125 @@
+// SPDX-License-Identifier: MIT
+import { useMemo, useState } from 'react';
+import { usePdfPreview } from './usePdfPreview';
+import invoiceHtml from './invoice.html?raw';
+import invoiceCss from './invoice.css?raw';
+import reportHtml from './report.html?raw';
+import reportCss from './report.css?raw';
+
+const defaults = { invoice: { html: invoiceHtml, css: invoiceCss }, report: { html: reportHtml, css: reportCss } };
+type Design = keyof typeof defaults;
+const assetBaseUrl = new URL('fullbleed/', new URL(import.meta.env.BASE_URL, location.href)).href;
+const escapeHtml = (value: string) => value.replace(/[&<>"']/g, character => ({
+ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
+})[character]!);
+
+function DocumentEditor() {
+ const [design, setDesign] = useState('invoice');
+ const [templates, setTemplates] = useState(defaults);
+ const [customer, setCustomer] = useState('Maple & Finch');
+ const [reference, setReference] = useState('NS-1042');
+ const [ink, setInk] = useState('#17382e');
+ const [autoPreview, setAutoPreview] = useState(true);
+ const template = templates[design];
+ const inputError = !template.html.trim() ? 'Enter document HTML before generating.'
+ : design === 'invoice' && (!customer.trim() || !reference.trim()) ? 'Enter a customer name and invoice reference.'
+ : design === 'invoice' && !/^#[\da-f]{6}$/i.test(ink) ? 'Enter a six-digit ink color, such as #17382e.' : '';
+ const input = useMemo(() => {
+ const values: Record = { customer: customer.trim(), reference: reference.trim() };
+ const valid = design !== 'invoice' || (values.customer && values.reference && /^#[\da-f]{6}$/i.test(ink));
+ return {
+ html: valid ? template.html.replace(/{{(customer|reference)}}/g, (_, key: string) => escapeHtml(values[key])) : '',
+ css: template.css.replaceAll('{{ink}}', /^#[\da-f]{6}$/i.test(ink) ? ink : '#17382e'),
+ previewDpi: 96, maxPages: 20, timeoutMs: 30_000,
+ };
+ }, [template, customer, reference, ink, design]);
+ const pdf = usePdfPreview(input, assetBaseUrl, { auto: autoPreview });
+ const filename = design === 'invoice' ? `invoice-${reference.replace(/[^\w-]+/g, '-').slice(0, 32)}.pdf` : 'community-report.pdf';
+ const pending = pdf.status === 'queued' || pdf.status === 'rendering';
+ const editTemplate = (field: 'html' | 'css', value: string) => setTemplates(previous => ({
+ ...previous, [design]: { ...previous[design], [field]: value },
+ }));
+ const messages: Record = {
+ MISSING_GLYPHS: 'Some characters need another font. Add font bytes to the input or edit the text.',
+ ENGINE_LOAD_FAILED: 'The PDF runtime could not load. Check that all runtime files were deployed.',
+ VERSION_MISMATCH: 'The app and runtime versions differ. Rebuild and deploy the complete project.',
+ TIMEOUT: 'This document took too long. Reduce its size or generate it again.',
+ };
+ let status = 'Generate a PDF when you are ready.';
+ if (pdf.status === 'queued') status = 'Preview updates after you pause typing.';
+ if (pdf.status === 'rendering') status = 'Rendering locally. The first preview also loads the engine and fonts.';
+ if (pdf.status === 'cancelled') status = 'Cancelled. Edit a field or choose Generate PDF to continue.';
+ if (pdf.status === 'error') status = inputError ? inputError
+ : messages[pdf.error!.code] ?? pdf.error!.message;
+ if (pdf.result) status = `Ready. ${pdf.result.pages} ${pdf.result.pages === 1 ? 'page' : 'pages'} · ${Math.ceil(pdf.result.bytes / 1024)} KiB. Your PDF is ready to download.`;
+
+ return <>
+
+
+
+ 02
The actual PDF
+ {pdf.result && `${pdf.result.pages} ${pdf.result.pages === 1 ? 'page' : 'pages'}`}
+ {pdf.result ?
Download PDF ↓
+ :
Download PDF ↓ }
+
+
+ {pdf.result ? pdf.result.previews.map((url, index) =>
)
+ :
{pending ? 'Making your document…' : 'Your next preview will appear here.'}
}
+
+
+
+
+ Edit the HTML & CSS
+ Paste your own print template. {autoPreview ? 'Changes update the PDF automatically.' : 'Choose Generate PDF to apply your changes.'} Invoice values use {'{{customer}}'}, {'{{reference}}'}, and {'{{ink}}'}. These edits last until reload.
+
+
Document HTML
+
Print CSS
+
+ setTemplates(previous => ({ ...previous, [design]: defaults[design] }))}>Reset this template
+
Keep templates in the downloaded project's src/ directory.
+
+ >;
+}
+
+export default function App() {
+ const [open, setOpen] = useState(true);
+ return <>
+
+
+ YOUR FORM → YOUR PDF
+
A change in your form. A change on the page.
+
Edit the details. Watch the actual PDF update. A React starter with HTML/CSS templates and local rendering.
+ {open ? 'Document editor' : 'Editor closed'}
+ setOpen(value => !value)}>{open ? 'Close editor' : 'Open editor'}
+ {open ? : Ready when you are. Open the editor to start a fresh document.
}
+
+
+ >;
+}
diff --git a/examples/react/src/invoice.css b/examples/react/src/invoice.css
new file mode 100644
index 0000000..0c83bd8
--- /dev/null
+++ b/examples/react/src/invoice.css
@@ -0,0 +1,48 @@
+@page { size: A4; margin: 0; }
+* { box-sizing: border-box; }
+html, body { margin: 0; padding: 0; }
+body { font-family: Inter; font-size: 10pt; line-height: 1.45; color: {{ink}}; }
+h1, h2, h3, p, figure { margin: 0; }
+h1, h2, h3 { font-weight: 400; }
+.page { width: 210mm; height: 296.9mm; padding: 15mm 16mm; position: relative; break-after: page; background: #f8f5ec; }
+.page:last-child { break-after: auto; }
+.kicker { font-size: 7.2pt; font-weight: 600; letter-spacing: 1.4pt; text-transform: uppercase; }
+.serif { font-family: 'DM Serif Display'; font-weight: 400; }
+.display { font-family: 'Bebas Neue'; font-weight: 400; }
+.muted { color: #5b6c64; }
+.small { font-size: 8pt; }
+.rule { border-top: .6pt solid #9eafa4; }
+.row { display: flex; justify-content: space-between; gap: 16pt; }
+.grid-2 { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); gap: 24pt; }
+.grid-2 > * { min-width: 0; }
+.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16pt; }
+.footer { position: absolute; left: 16mm; right: 16mm; bottom: 12mm; border-top: .6pt solid #9eafa4; padding-top: 8pt; display: flex; justify-content: space-between; gap: 12pt; font-size: 6.6pt; letter-spacing: .45pt; }
+table { width: 100%; border-collapse: collapse; font-size: 9pt; }
+th { font-size: 7.5pt; font-weight: 600; text-align: left; }
+th, td { padding: 10pt 0; border-bottom: .55pt solid #bdc8bf; vertical-align: top; }
+.number { text-align: right; white-space: nowrap; }
+.money { font-variant-numeric: tabular-nums; }
+strong { font-weight: 600; }
+a { color: inherit; }
+
+.invoice .masthead { display: grid; grid-template-columns: 1.05fr .8fr 1fr; gap: 18pt; align-items: center; padding-bottom: 22pt; border-bottom: 1pt solid {{ink}}; }
+.invoice .brand { font: 31pt/.88 'DM Serif Display'; letter-spacing: -1.3pt; }
+.invoice .brand span { display: block; line-height: .98; }
+.invoice .brand small { display: block; font: 6.5pt Inter; letter-spacing: 2.1pt; margin-top: 11pt; }
+.invoice .brand-note { border-left: 1pt solid #c24929; padding: 4pt 0 4pt 16pt; line-height: 1.8; }
+.invoice .address { font-size: 7.5pt; line-height: 1.7; border-left: 1pt solid #c24929; padding-left: 16pt; }
+.invoice .title-row { display: grid; grid-template-columns: 3.3fr 1fr; gap: 20pt; align-items: center; padding: 22pt 0 16pt; }
+.invoice h1 { font: 91pt/.9 'Bebas Neue'; letter-spacing: .3pt; }
+.invoice .title-note { color: #b44123; font: 14pt/1.2 'Bebas Neue'; letter-spacing: .5pt; }
+.invoice .details { border-top: .6pt solid #9eafa4; border-bottom: .6pt solid #9eafa4; padding: 15pt 0; margin-bottom: 10pt; }
+.invoice .details p { margin-top: 5pt; font-size: 8.2pt; }
+.invoice .details > div + div { border-left: .6pt solid #c7cdc4; padding-left: 14pt; }
+.invoice th { color: #52665a; }
+.invoice td { padding: 12pt 0; }
+.invoice .item-note { color: #617069; font-size: 8pt; margin-top: 4pt; }
+.invoice .amounts { display: grid; grid-template-columns: 1fr 1.25fr; gap: 27pt; margin-top: 23pt; }
+.invoice .thanks { font: italic 22pt/1.2 'DM Serif Display'; color: #b44123; padding-top: 8pt; }
+.invoice .summary-line { display: flex; justify-content: space-between; padding: 5pt 0; font-size: 8.5pt; }
+.invoice .total { background: {{ink}}; color: #fff8e8; padding: 16pt; margin-top: 9pt; border-left: 4pt solid #d96238; }
+.invoice .total .value { font: 38pt/1.1 'Bebas Neue'; margin-top: 7pt; }
+.invoice .payment { margin-top: 20pt; padding-top: 12pt; border-top: .6pt solid #9eafa4; display: grid; grid-template-columns: 1fr 1fr; gap: 24pt; font-size: 8pt; }
diff --git a/examples/react/src/invoice.html b/examples/react/src/invoice.html
new file mode 100644
index 0000000..4d49034
--- /dev/null
+++ b/examples/react/src/invoice.html
@@ -0,0 +1,12 @@
+Northstar Studio — Invoice {{reference}}
+
+
+INVOICE GOOD WORK. CLEAR DETAILS. LET'S MAKE WHAT'S NEXT.
+Prepared for {{customer}} Attn. Avery Morgan Portland, Oregon
The project Autumn launch Brand workshop, digital design & handoff
Invoice {{reference}} Issued · 01 October 2026 Due · 31 October 2026 Currency · USD
+DESCRIPTION HOURS RATE AMOUNT
+Discovery & brand workshop Positioning, priorities, and a clear creative direction
8 $125.00 $1,000.00 Digital design & implementation A cohesive visual system, from layout to launch
6 $95.00 $570.00 Review & studio handoff Final refinements, documentation, and delivery
2 $150.00 $300.00
+
+A little clarity. A lot of possibility.
Thank you for inviting us to help shape your next chapter.
Subtotal $1,870.00
Tax · 0% in this sample $0.00
+Payment details Reference {{reference}} with your payment. Payment instructions are supplied separately.
A note from the studio Questions about a line item? Write to hello@example.invalid.
+
+
diff --git a/examples/react/src/main.tsx b/examples/react/src/main.tsx
new file mode 100644
index 0000000..afebfbd
--- /dev/null
+++ b/examples/react/src/main.tsx
@@ -0,0 +1,7 @@
+// SPDX-License-Identifier: MIT
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import App from './App';
+import './style.css';
+
+createRoot(document.getElementById('root')!).render( );
diff --git a/examples/react/src/report.css b/examples/react/src/report.css
new file mode 100644
index 0000000..585be7c
--- /dev/null
+++ b/examples/react/src/report.css
@@ -0,0 +1,60 @@
+@page { size: A4; margin: 0; }
+* { box-sizing: border-box; }
+html, body { margin: 0; padding: 0; }
+body { font-family: Inter; font-size: 10pt; line-height: 1.45; color: #17382e; }
+h1, h2, h3, p, figure { margin: 0; }
+h1, h2, h3 { font-weight: 400; }
+.page { width: 210mm; height: 296.9mm; padding: 15mm 16mm; position: relative; break-after: page; background: #f8f5ec; }
+.page:last-child { break-after: auto; }
+.kicker { font-size: 7.2pt; font-weight: 600; letter-spacing: 1.4pt; text-transform: uppercase; }
+.serif { font-family: 'DM Serif Display'; font-weight: 400; }
+.display { font-family: 'Bebas Neue'; font-weight: 400; }
+.muted { color: #5b6c64; }
+.small { font-size: 8pt; }
+.rule { border-top: .6pt solid #9eafa4; }
+.row { display: flex; justify-content: space-between; gap: 16pt; }
+.grid-2 { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); gap: 24pt; }
+.grid-2 > * { min-width: 0; }
+.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16pt; }
+.footer { position: absolute; left: 16mm; right: 16mm; bottom: 12mm; border-top: .6pt solid #9eafa4; padding-top: 8pt; display: flex; justify-content: space-between; gap: 12pt; font-size: 6.6pt; letter-spacing: .45pt; }
+table { width: 100%; border-collapse: collapse; font-size: 9pt; }
+th { font-size: 7.5pt; font-weight: 600; text-align: left; }
+th, td { padding: 10pt 0; border-bottom: .55pt solid #bdc8bf; vertical-align: top; }
+.number { text-align: right; white-space: nowrap; }
+.money { font-variant-numeric: tabular-nums; }
+strong { font-weight: 600; }
+a { color: inherit; }
+
+.report .masthead { display: flex; justify-content: space-between; border-bottom: .8pt solid #183d2b; padding-bottom: 11pt; margin-bottom: 22pt; }
+.report .brand { font: 15pt/1 'DM Serif Display'; }
+.report .cover-title { font: 106pt/.83 'Bebas Neue'; letter-spacing: -.5pt; }
+.report .cover-deck { display: grid; grid-template-columns: 1fr 1fr; align-items: start; margin-top: 23pt; }
+.report .cover-deck p { font: 20pt/1.08 'DM Serif Display'; }
+.report .year { font: 58pt/.9 'Bebas Neue'; color: #c65328; text-align: right; }
+.report .landscape { display: block; width: 100%; height: 92mm; margin-top: 10pt; }
+.report .contents { display: grid; grid-template-columns: .8fr 1fr 1fr; gap: 12pt; margin-top: 18pt; border-top: .7pt solid #8da28e; padding-top: 14pt; }
+.report .contents > div + div { border-left: .7pt solid #c65328; padding-left: 13pt; }
+.report .contents .serif { font-size: 12pt; margin-top: 4pt; line-height: 1.1; }
+.report h1.section-title { font: 50pt/.96 'Bebas Neue'; margin: 12pt 0 14pt; }
+.report h2 { font: 21pt/1.12 'DM Serif Display'; margin-bottom: 10pt; }
+.report .deck { font-size: 11pt; line-height: 1.55; max-width: 450pt; }
+.report .metrics { border-top: 1pt solid #244535; border-bottom: 1pt solid #244535; padding: 16pt 0 14pt; margin: 18pt 0; }
+.report .metrics .value { font: 44pt/1 'Bebas Neue'; color: #b74624; margin-bottom: 5pt; }
+.report .metric-caption { font-size: 8pt; }
+.report .chart-panel { padding: 18pt; background: #1f4435; color: #f9f2dd; margin: 18pt 0; }
+.report .chart-panel h2 { font-size: 21pt; }
+.report .chart-bars { height: 127pt; display: flex; gap: 20pt; align-items: flex-end; margin: 12pt 0 8pt; border-bottom: .5pt solid #83a48b; }
+.report .chart-col { width: 20%; text-align: center; }
+.report .chart-value { font: 17pt 'Bebas Neue'; margin-bottom: 5pt; }
+.report .bar { width: 100%; background: linear-gradient(180deg,#d9bc77,#a9b884); }
+.report .chart-labels { display: flex; gap: 20pt; font-size: 7pt; }
+.report .chart-labels span { display: block; flex: 1; text-align: center; }
+.report .quote { font: 26pt/1.16 'DM Serif Display'; color: #a53e25; }
+.report .note { font-size: 8.5pt; line-height: 1.55; }
+.report .funding-table td { padding: 8pt 0; }
+.report .funding-table .share { width: 88pt; }
+.report .share-track { height: 5pt; background: #e3e5d7; width: 76pt; margin-top: 7pt; }
+.report .share-fill { height: 5pt; background: #c65328; }
+.report .allocation-total { background: #e4eadc; padding: 14pt; margin: 17pt 0 19pt; display: flex; align-items: baseline; justify-content: space-between; }
+.report .allocation-total strong { font: 39pt 'Bebas Neue'; }
+.report .callout { border-left: 3pt solid #c65328; padding-left: 14pt; }
diff --git a/examples/react/src/report.html b/examples/react/src/report.html
new file mode 100644
index 0000000..ec533e9
--- /dev/null
+++ b/examples/react/src/report.html
@@ -0,0 +1,36 @@
+Common Ground — Community Review 2026
+
+
+COMMON GROUND Stronger places. More room for everyone.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+A YEAR IN COMMON
02 / OUR REACH
Small actions, shared outcomes.
03 / OUR RESOURCES
Where the support goes.
+
+
+
+A PLACE TO BEGIN
Small actions. Shared outcomes. An open door, a working kitchen, a garden plot. This year, our fictional neighborhood programs connected more people with practical spaces to learn, make, and belong.
+1,240
households took part across four neighborhoods
4,860
volunteer hours shared across our programs
12
community spaces kept open and active
+
More neighbors, each quarter. PARTICIPATING HOUSEHOLDS Q1 Q2 Q3 Q4
Quarterly participation counts; a household can participate in more than one quarter.
+“Belonging starts with somewhere to show up.”
OUR PROGRAM PRINCIPLE
Built around daily life. Evening workshops, shared meals, and weekend garden sessions made participation easier for people with different schedules. The next step is a longer opening window at each neighborhood space.
+
+
+FOLLOW THE SUPPORT
Good intentions. Clear allocations. A practical view of this fictional operating budget. Every category supports the same aim: useful places, open to more people.
+PROGRAM AREA ALLOCATION SHARE Neighborhood spaces $168,000.00 40% Learning & workshops $126,000.00 30% Shared meals & gardens $84,000.00 20% Access & transport $42,000.00 10%
+TOTAL OPERATING ALLOCATION · USD $420,000.00
+What we count. Households are deduplicated within the annual program register. Volunteer hours include recorded event and maintenance time. Space counts refer to year-round locations, not individual events.
Quarterly counts show participation within each period and must not be added to estimate unique annual reach.
What comes next. Extend evening access. Improve step-free entrances. Publish a simple monthly view of program activity and spending.
A shared tomorrow is built in the everyday.
+
+
diff --git a/examples/react/src/style.css b/examples/react/src/style.css
new file mode 100644
index 0000000..954963d
--- /dev/null
+++ b/examples/react/src/style.css
@@ -0,0 +1,13 @@
+@font-face{font-family:Inter;src:url('/fullbleed/fonts/Inter-Variable.ttf') format('truetype');font-weight:100 900;font-display:swap}
+:root{font-family:Inter,sans-serif;color:#173b35;background:#f6f5ef;font-synthesis:none;font-size:15px;line-height:1.5}
+*{box-sizing:border-box}body{margin:0}button,input,select{font:inherit}button,a,input,select{touch-action:manipulation}a{color:inherit}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid #d45d34;outline-offset:4px}
+.site-header{max-width:1440px;margin:auto;padding:22px 5vw;display:flex;align-items:center;gap:28px;border-bottom:1px solid #d8ded5}.wordmark{font-weight:850;font-size:29px;letter-spacing:-1.6px;text-decoration:none}.wordmark span{color:#d85330}.header-note{font-size:10px;letter-spacing:2px;font-weight:700;margin-left:8px}.docs-link{margin-left:auto;text-decoration:none;font-size:13px;font-weight:650}.docs-link span{margin-left:10px}
+main{max-width:1440px;margin:auto;padding:0 5vw}.intro{padding:58px 0 38px}.eyebrow{font-size:10px;letter-spacing:2px;font-weight:750;display:flex;align-items:center;gap:9px;margin:0 0 16px}.eyebrow span{width:7px;height:7px;border-radius:100%;background:#d85330}h1{font-size:clamp(34px,4.6vw,62px);font-weight:650;letter-spacing:-2.5px;line-height:1.05;margin:0 0 18px}.lede{font-size:16px;color:#54645b;line-height:1.65;margin:0}.workspace{display:grid;grid-template-columns:minmax(260px,340px) minmax(0,1fr);gap:32px;align-items:start}.controls{border-top:2px solid #173b35;padding-top:21px}.panel-heading,.preview-toolbar>div{display:flex;align-items:center;gap:12px}.step{color:#a64625;border:1px solid #d0c8ba;padding:3px 6px;font-size:10px;font-weight:700;letter-spacing:1px}h2{font-size:17px;letter-spacing:-.35px;margin:0;font-weight:650}.panel-heading{margin-bottom:22px}fieldset{border:0;margin:0;padding:0}label{display:block;font-size:12px;font-weight:650;margin:19px 0 7px}select,input:not([type=color]){width:100%;height:44px;border:1px solid #bdc7bd;border-radius:0;background:#fffef9;color:#173b35;padding:9px 11px}select{margin-bottom:3px}.color-row{display:flex;align-items:center;gap:10px;height:44px;border:1px solid #bdc7bd;background:#fffef9;padding:5px 8px}.color-row input{padding:0;border:0;width:30px;height:30px;background:transparent;cursor:pointer}.color-row input::-webkit-color-swatch-wrapper{padding:0}.color-row input::-webkit-color-swatch{border:0}.color-row span{font-size:11px;letter-spacing:.5px}.color-row .color-hint{color:#657163;margin-left:auto;letter-spacing:0}.sample-note{font-size:11px;line-height:1.6;color:#657163;margin:16px 0 20px}.actions{display:flex;gap:8px}button{border:1px solid #173b35;background:#173b35;color:#fffdf6;min-height:43px;padding:10px 15px;font-size:12px;font-weight:650;cursor:pointer}button:disabled{opacity:.45;cursor:default}.actions #generate{flex:1;text-align:left;display:flex;justify-content:space-between;gap:15px}.secondary{background:transparent;color:#173b35}button[hidden]{display:none}#status{font-size:12px;min-height:36px;margin:12px 0 24px;color:#54645b;overflow-wrap:anywhere}#status.error{color:#9c321c}.local-note{display:flex;align-items:flex-start;gap:9px;font-size:11px;color:#59695e;line-height:1.6;border-top:1px solid #d8ded5;padding-top:18px}.local-note p{margin:0}.local-note strong{color:#173b35}.local-dot{width:6px;height:6px;flex-shrink:0;background:#297254;border-radius:50%;margin-top:6px}.source-note{margin-top:29px;padding-top:19px;border-top:1px solid #d8ded5}.source-note h3{font-size:13px;font-weight:700;margin:0 0 7px}.source-note p{font-size:12px;color:#657163;line-height:1.6;margin:0 0 12px}.source-note a{font-size:11px;font-weight:650;text-underline-offset:4px}
+.preview-panel{background:#e7e9e1;min-width:0;border:1px solid #d0d8cd}.preview-toolbar{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:17px 20px;background:#fdfcf6;border-bottom:1px solid #d0d8cd}.preview-toolbar h2{font-size:14px}.preview-toolbar button{font-size:11px;white-space:nowrap;padding:8px 12px;min-height:35px}.preview-toolbar button span{margin-left:8px}#page-count{font-size:10px;color:#657163;white-space:nowrap}.preview-area{padding:28px;min-height:550px;max-height:1000px;overflow:auto;background-image:radial-gradient(#c9cec3 .6px,transparent .6px);background-size:8px 8px;display:flex;flex-direction:column;align-items:center;gap:24px}.preview-area img{display:block;width:100%;max-width:680px;height:auto;box-shadow:0 10px 34px #173b3522;background:white}.placeholder{min-height:480px;width:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;color:#647166}.placeholder p{font-size:12px}.paper-icon{height:68px;width:49px;border:1px solid #91a08f;background:linear-gradient(#bfc9bb 0 0) 10px 17px/27px 1px no-repeat,linear-gradient(#bfc9bb 0 0) 10px 24px/27px 1px no-repeat,linear-gradient(#bfc9bb 0 0) 10px 31px/18px 1px no-repeat;background-color:#f5f5ee;transform:rotate(-6deg)}.preview-area[aria-busy=true] .paper-icon{animation:breathe 1.3s ease-in-out infinite}@keyframes breathe{50%{opacity:.35}}footer{display:flex;justify-content:space-between;gap:15px;border-top:1px solid #d8ded5;margin:50px 0 0;padding:20px 0 26px;font-size:11px;color:#657163}footer p{margin:0}footer a{text-underline-offset:4px}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}[hidden]{display:none!important}
+@media(max-width:850px){.workspace{grid-template-columns:1fr;gap:28px}.controls{max-width:none}.intro{padding-top:36px}.header-note{display:none}h1{letter-spacing:-1.7px}.preview-area{max-height:none;min-height:400px;padding:16px}.source-note{margin-top:18px}.local-note{font-size:12px}.preview-toolbar{padding:14px;flex-wrap:wrap}.preview-toolbar>div{gap:8px}footer{margin-top:30px;flex-direction:column}.wide-only{display:none}}
+@media(max-width:380px){.site-header{padding:16px 5vw}.wordmark{font-size:26px}.docs-link{font-size:11px}.intro{padding-top:29px}h1{font-size:33px;letter-spacing:-1.4px}.lede{font-size:14px}.preview-toolbar{gap:10px}.preview-toolbar button{width:100%}.preview-area{padding:10px}}
+@media(prefers-reduced-motion:reduce){.preview-area[aria-busy=true] .paper-icon{animation:none}}
+
+.template-editor{border-top:1px solid #bdc7bd;margin-top:36px;padding-top:22px}.template-editor summary{font-size:17px;font-weight:650;cursor:pointer}.editor-note{color:#54645b;font-size:12px;max-width:760px}.editor-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px}.editor-grid label{margin-top:0}.editor-grid textarea{width:100%;min-height:320px;resize:vertical;display:block;font:12px/1.6 monospace;padding:15px;background:#fffef9;color:#173b35;border:1px solid #bdc7bd;tab-size:2}.editor-actions{display:flex;gap:12px;align-items:center;margin-top:14px}.editor-actions p{font-size:11px;color:#657163;margin:0}code{font-size:.95em}@media(max-width:850px){.editor-grid{grid-template-columns:1fr}.editor-grid textarea{min-height:240px}}
+select{appearance:none;line-height:1.25;padding-right:36px;background-image:linear-gradient(45deg,transparent 50%,#173b35 50%),linear-gradient(135deg,#173b35 50%,transparent 50%);background-position:calc(100% - 16px) 50%,calc(100% - 12px) 50%;background-size:4px 4px;background-repeat:no-repeat}.color-row input.color-text{width:90px;font:12px Inter,sans-serif;cursor:text}
+.workspace-switch{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:14px 0;margin-bottom:24px;border-top:1px solid #d8ded5;border-bottom:1px solid #d8ded5;font-size:11px;font-weight:650}.workspace-switch>span{display:flex;align-items:center;gap:9px}.live-dot{height:6px;width:6px;border-radius:50%;background:#297254}.text-button{padding:3px 0;min-height:28px;border:0;background:transparent;color:#173b35;text-decoration:underline;text-underline-offset:4px}.button{display:inline-flex;align-items:center;justify-content:center;gap:12px;background:#173b35;color:#fffdf6;text-decoration:none;padding:8px 12px;font-size:11px;font-weight:650;min-height:35px}.closed-panel{min-height:330px;border:1px solid #d0d8cd;background:#fdfcf6;padding:40px}.closed-panel p{font-size:14px;color:#54645b}.color-text+span{display:none}.editor-grid textarea:focus-visible{outline:3px solid #d45d34;outline-offset:3px}.auto-toggle{display:flex;align-items:center;gap:9px;margin:0 0 18px;font-size:12px}.auto-toggle input{width:17px!important;height:17px!important;margin:0;accent-color:#173b35}@media(max-width:380px){.preview-toolbar .button{width:100%}}
diff --git a/examples/react/src/usePdfPreview.ts b/examples/react/src/usePdfPreview.ts
new file mode 100644
index 0000000..c17732c
--- /dev/null
+++ b/examples/react/src/usePdfPreview.ts
@@ -0,0 +1,95 @@
+// SPDX-License-Identifier: MIT
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { createRenderer, FullbleedError } from 'fullbleed/browser';
+import type { BrowserRenderOptions } from 'fullbleed/browser';
+
+export type PdfInput = Omit;
+export interface PdfPreview {
+ url: string;
+ previews: string[];
+ pages: number;
+ bytes: number;
+ engineVersion: string;
+}
+type Status = 'idle' | 'queued' | 'rendering' | 'ready' | 'cancelled' | 'error';
+interface State {
+ input: PdfInput;
+ assetBaseUrl: string;
+ status: Status;
+ result?: PdfPreview;
+ error?: { code: string; message: string };
+}
+
+/** Keep input stable with useMemo. This hook owns its workers, timers and Blob URLs. */
+export function usePdfPreview(input: PdfInput, assetBaseUrl: string, { auto = true, delayMs = 450 } = {}) {
+ const [state, setState] = useState({ input, assetBaseUrl, status: 'idle' });
+ const work = useRef({ mounted: false, serial: 0, controller: null as AbortController | null,
+ timer: undefined as ReturnType | undefined, urls: [] as string[] });
+
+ const release = useCallback(() => {
+ const current = work.current;
+ current.serial += 1;
+ clearTimeout(current.timer);
+ current.timer = undefined;
+ current.controller?.abort();
+ current.controller = null;
+ current.urls.forEach(url => URL.revokeObjectURL(url));
+ current.urls = [];
+ }, []);
+
+ const generate = useCallback(async () => {
+ const current = work.current;
+ release();
+ if (!current.mounted) return;
+ const serial = current.serial;
+ const controller = new AbortController();
+ current.controller = controller;
+ setState({ input, assetBaseUrl, status: 'rendering' });
+ try {
+ const renderer = createRenderer({ assetBaseUrl });
+ const rendered = await renderer.renderPdf({ ...input, signal: controller.signal });
+ // An aborted or superseded request must never replace a newer document.
+ if (!current.mounted || serial !== current.serial) return;
+ const makeUrl = (bytes: Uint8Array, type: string) => {
+ const url = URL.createObjectURL(new Blob([bytes], { type }));
+ current.urls.push(url);
+ return url;
+ };
+ const url = makeUrl(rendered.pdf, 'application/pdf');
+ const previews = rendered.previews.map(bytes => makeUrl(bytes, 'image/png'));
+ setState({ input, assetBaseUrl, status: 'ready', result: { url, previews, pages: rendered.pages,
+ bytes: rendered.pdf.byteLength, engineVersion: rendered.engineVersion } });
+ } catch (error) {
+ if (!current.mounted || serial !== current.serial) return;
+ current.urls.forEach(url => URL.revokeObjectURL(url));
+ current.urls = [];
+ setState({ input, assetBaseUrl, status: 'error', error: {
+ code: error instanceof FullbleedError ? error.code : 'UNKNOWN',
+ message: error instanceof Error ? error.message : String(error),
+ } });
+ } finally {
+ if (serial === current.serial) current.controller = null;
+ }
+ }, [input, assetBaseUrl, release]);
+
+ useEffect(() => {
+ work.current.mounted = true;
+ release();
+ setState({ input, assetBaseUrl, status: auto ? 'queued' : 'idle' });
+ if (auto) work.current.timer = setTimeout(() => { void generate(); }, delayMs);
+ return () => {
+ work.current.mounted = false;
+ release();
+ };
+ }, [input, assetBaseUrl, auto, delayMs, generate, release]);
+
+ const cancel = useCallback(() => {
+ release();
+ if (work.current.mounted) setState({ input, assetBaseUrl, status: 'cancelled' });
+ }, [input, assetBaseUrl, release]);
+
+ // Hide an old download during the render before the input-change Effect runs.
+ const visible: State = state.input === input && state.assetBaseUrl === assetBaseUrl
+ ? state : { input, assetBaseUrl, status: auto ? 'queued' : 'idle' };
+ return { ...visible, generate, cancel };
+}
diff --git a/examples/react/tsconfig.json b/examples/react/tsconfig.json
new file mode 100644
index 0000000..6a87ad9
--- /dev/null
+++ b/examples/react/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "verbatimModuleSyntax": true,
+ "types": ["vite/client"]
+ },
+ "include": ["src"]
+}
diff --git a/examples/react/vite.config.js b/examples/react/vite.config.js
new file mode 100644
index 0000000..5ab8019
--- /dev/null
+++ b/examples/react/vite.config.js
@@ -0,0 +1,8 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({
+ base: './',
+ plugins: [react()],
+ build: { assetsInlineLimit: 0 },
+});
diff --git a/tools/check-react-starter.py b/tools/check-react-starter.py
new file mode 100644
index 0000000..54f47be
--- /dev/null
+++ b/tools/check-react-starter.py
@@ -0,0 +1,241 @@
+"""Check actual React PDF downloads, input races, cancellation and component cleanup."""
+import argparse
+from datetime import datetime, timezone
+import hashlib
+from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
+from importlib.metadata import version
+import json
+from pathlib import Path
+import re
+import threading
+from urllib.parse import unquote, urlsplit
+
+from playwright.sync_api import sync_playwright, expect
+from pypdf import PdfReader
+
+parser = argparse.ArgumentParser(description=__doc__)
+parser.add_argument('--dist', type=Path, required=True)
+parser.add_argument('--out', type=Path, required=True)
+parser.add_argument('--baseline', type=Path, default=Path('output/react-baseline'))
+parser.add_argument('--browser', choices=['chrome', 'firefox', 'webkit'], default='chrome')
+parser.add_argument('--strict', action='store_true', help='Require a React development build with StrictMode enabled.')
+args = parser.parse_args()
+root = args.dist.resolve()
+out = args.out.resolve()
+out.mkdir(parents=True, exist_ok=False)
+prefix = '/nested/react/'
+policy = "default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'; img-src 'self' blob:; font-src 'self'; style-src 'self'; object-src 'none'; base-uri 'none'"
+paused = threading.Event()
+requested = threading.Event()
+released = threading.Event()
+if args.strict:
+ sources = [source for path in (root / 'assets').glob('*.map') for source in json.loads(path.read_text(encoding='utf-8'))['sources']]
+ assert any('react-dom-client.development.js' in source for source in sources), 'React development runtime is required.'
+ assert '' in (Path(__file__).resolve().parents[1] / 'examples/react/src/main.tsx').read_text(encoding='utf-8')
+
+
+class Handler(SimpleHTTPRequestHandler):
+ def __init__(self, *a, **kw):
+ super().__init__(*a, directory=str(root), **kw)
+
+ def translate_path(self, path):
+ name = unquote(urlsplit(path).path)
+ if not name.startswith(prefix):
+ return str(root / 'not-found')
+ resolved = (root / name[len(prefix):]).resolve()
+ return str(resolved if resolved.is_relative_to(root) else root / 'not-found')
+
+ def end_headers(self):
+ self.send_header('Content-Security-Policy', policy)
+ self.send_header('Cache-Control', 'no-store')
+ super().end_headers()
+
+ def do_GET(self):
+ if paused.is_set() and urlsplit(self.path).path.endswith('/fullbleed/engine.wasm'):
+ requested.set()
+ released.wait(15)
+ super().do_GET()
+
+ def copyfile(self, source, outputfile):
+ try:
+ super().copyfile(source, outputfile)
+ except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
+ pass
+
+ def log_message(self, *a):
+ pass
+
+
+server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
+thread = threading.Thread(target=server.serve_forever, daemon=True)
+thread.start()
+url = f'http://127.0.0.1:{server.server_port}{prefix}'
+checks, documents, network, errors = [], [], [], []
+
+
+def check(name, passed):
+ assert passed, name
+ checks.append(name)
+ print(name + ': passed', flush=True)
+
+
+audit_script = """(() => {
+ const live = new Set(), urls = new Set();
+ const audit = {started: 0, terminated: 0, peak: 0, created: 0, revoked: 0};
+ const RealWorker = window.Worker;
+ window.Worker = class extends RealWorker {
+ constructor(...args) { super(...args); live.add(this); audit.started++; audit.peak = Math.max(audit.peak, live.size); }
+ terminate() { if (live.delete(this)) audit.terminated++; return super.terminate(); }
+ };
+ const create = URL.createObjectURL.bind(URL), revoke = URL.revokeObjectURL.bind(URL);
+ URL.createObjectURL = (...args) => { const url = create(...args); urls.add(url); audit.created++; return url; };
+ URL.revokeObjectURL = url => { if (urls.delete(url)) audit.revoked++; return revoke(url); };
+ window.pdfLifecycle = () => ({...audit, live: live.size, urls: urls.size});
+})();"""
+try:
+ with sync_playwright() as pw:
+ browser = pw.chromium.launch(channel='chrome') if args.browser == 'chrome' else getattr(pw, args.browser).launch()
+ context = browser.new_context(viewport={'width': 1440, 'height': 1000}, accept_downloads=True)
+ context.add_init_script(audit_script)
+ page = context.new_page()
+ page.on('pageerror', lambda error: errors.append(str(error)))
+ page.on('request', lambda request: network.append({'url': request.url, 'method': request.method, 'body': request.post_data}))
+
+ def ready():
+ expect(page.locator('#download')).to_have_attribute('href', re.compile(r'^blob:'), timeout=45000)
+ expect(page.locator('#status')).to_contain_text('Ready.')
+
+ def hold_engine():
+ requested.clear()
+ released.clear()
+ paused.set()
+
+ def resume_engine():
+ paused.clear()
+ released.set()
+
+ def download(name, count, text):
+ with page.expect_download() as event:
+ page.locator('#download').click()
+ path = out / (name + '.pdf')
+ event.value.save_as(path)
+ pdf = PdfReader(path)
+ contents = '\n'.join(item.extract_text() for item in pdf.pages)
+ check(name + ': real PDF has expected pages and text', len(pdf.pages) == count and all(item in contents for item in text))
+ documents.append({'name': name, 'pages': len(pdf.pages), 'filename': event.value.suggested_filename,
+ 'sha256': hashlib.sha256(path.read_bytes()).hexdigest()})
+ return path
+
+ try:
+ page.goto(url, wait_until='domcontentloaded')
+ ready()
+ initial = download('invoice', 1, ['Maple & Finch', 'NS-1042', '1,870.00'])
+ check('invoice matches the installed Node package', initial.read_bytes() == (args.baseline / 'invoice.pdf').read_bytes())
+ check('initial automatic render starts one worker', page.evaluate('pdfLifecycle().started === 1'))
+ check('settled render terminates its worker', page.evaluate('pdfLifecycle().live === 0'))
+ check('runtime resolves beneath a nested deployment path', any('/nested/react/fullbleed/worker.js' in item['url'] for item in network))
+ check('bundled interface font loads', page.evaluate("document.fonts.check('15px Inter')"))
+ for width, height in [(1440, 1000), (390, 844), (320, 760)]:
+ page.set_viewport_size({'width': width, 'height': height})
+ check(f'{width}px: no horizontal overflow', page.evaluate('document.documentElement.scrollWidth <= innerWidth'))
+ page.screenshot(path=str(out / f'width-{width}.png'), full_page=True)
+ page.set_viewport_size({'width': 1440, 'height': 1000})
+
+ page.locator('#auto-preview').uncheck()
+ starts = page.evaluate('pdfLifecycle().started')
+ page.locator('#customer').fill('Manual Mode')
+ page.wait_for_timeout(800)
+ check('on-demand mode does not schedule a render after edits', page.evaluate('pdfLifecycle().started') == starts and page.locator('#download').is_disabled())
+ page.locator('#generate').click()
+ ready()
+ download('manual', 1, ['Manual Mode', 'NS-1042'])
+ page.locator('#customer').fill('Maple & Finch')
+ page.locator('#auto-preview').check()
+ ready()
+ check('automatic rendering can be re-enabled', page.locator('#download').get_attribute('href').startswith('blob:'))
+
+ hold_engine()
+ page.locator('#customer').fill('Superseded record')
+ expect(page.locator('#download')).to_be_disabled()
+ check('edits immediately hide the old preview and release its URLs', page.locator('#preview img').count() == 0 and page.evaluate('pdfLifecycle().urls === 0'))
+ assert requested.wait(8), 'Expected the first automatic render to request its engine.'
+ starts = page.evaluate('pdfLifecycle().started')
+ page.locator('#customer').fill('Birch & ')
+ page.locator('#reference').fill('REACT-2042')
+ page.locator('#accent').fill('#233b79')
+ expect(page.locator('#download')).to_be_disabled()
+ resume_engine()
+ ready()
+ updated = download('latest-input', 1, ['Birch & ', 'REACT-2042'])
+ check('superseded render cannot overwrite the latest input', 'Superseded record' not in PdfReader(updated).pages[0].extract_text())
+ check('a burst of edits is debounced into one new worker', page.evaluate('pdfLifecycle().started') == starts + 1)
+ check('replacement does not overlap active worker handles', page.evaluate('pdfLifecycle().peak === 1'))
+ check('only current PDF and preview URLs remain', page.evaluate('pdfLifecycle().urls === 2'))
+
+ page.locator('#template-editor summary').click()
+ page.locator('#html-source').fill('React template {{customer}} / {{reference}}
')
+ page.locator('#css-source').fill('@page { size: A5; margin: 15mm } h1 { color: {{ink}}; font-size: 24pt }')
+ ready()
+ edited = download('edited-template', 1, ['React template', 'Birch & ', 'REACT-2042'])
+ check('edited print CSS changes the page size', abs(float(PdfReader(edited).pages[0].mediabox.width) - 419.52756) < 1)
+ check('document scripts are not executed in the app', page.evaluate('window.documentScriptExecuted !== true'))
+ page.screenshot(path=str(out / 'edited-template.png'), full_page=True)
+ page.locator('#kind').select_option('report')
+ ready()
+ report = download('report', 3, ['COMMON', '1,240', '420,000.00'])
+ check('report matches the installed Node package', report.read_bytes() == (args.baseline / 'report.pdf').read_bytes())
+ check('all report pages have previews', page.locator('#preview img').count() == 3)
+ page.locator('#kind').select_option('invoice')
+ check('each design preserves its template edits', 'React template' in page.locator('#html-source').input_value())
+ page.locator('#html-source').fill('')
+ expect(page.locator('#status')).to_contain_text('Enter document HTML')
+ check('invalid input cannot leave an old download', page.locator('#download').is_disabled())
+ page.locator('#reset-source').click()
+ ready()
+
+ hold_engine()
+ page.locator('#generate').click()
+ assert requested.wait(8), 'Expected a held engine request before cancellation.'
+ page.locator('#cancel').click()
+ expect(page.locator('#status')).to_contain_text('Cancelled.')
+ check('cancel removes the worker and stale download', page.locator('#download').is_disabled() and page.evaluate('pdfLifecycle().live === 0 && pdfLifecycle().urls === 0'))
+ resume_engine()
+ page.locator('#generate').click()
+ ready()
+ download('recovered', 1, ['Birch & ', 'REACT-2042'])
+
+ page.locator('#toggle-editor').click()
+ check('unmount releases ready PDF and preview URLs', page.evaluate('pdfLifecycle().live === 0 && pdfLifecycle().urls === 0'))
+ hold_engine()
+ page.locator('#toggle-editor').click()
+ assert requested.wait(8), 'Expected the reopened editor to start rendering.'
+ page.locator('#toggle-editor').click()
+ check('unmount aborts a pending worker', page.evaluate('pdfLifecycle().live === 0 && pdfLifecycle().urls === 0'))
+ resume_engine()
+ page.locator('#toggle-editor').click()
+ ready()
+ reopened = download('remounted', 1, ['Maple & Finch', 'NS-1042'])
+ check('remount starts from clean state and recovers', reopened.read_bytes() == initial.read_bytes())
+ page.locator('#toggle-editor').click()
+ lifecycle = page.evaluate('pdfLifecycle()')
+ check('all created URLs and workers are released at final unmount', lifecycle['live'] == lifecycle['urls'] == 0 and lifecycle['created'] == lifecycle['revoked'] and lifecycle['started'] == lifecycle['terminated'])
+ blob_origin = f'blob:http://127.0.0.1:{server.server_port}/'
+ check('no uploads or cross-origin document requests', all(item['method'] == 'GET' and not item['body'] and item['url'].startswith((url, blob_origin)) for item in network))
+ check('no uncaught page errors', not errors)
+ (out / 'verification.json').write_text(json.dumps({'ok': True, 'checked_at': datetime.now(timezone.utc).isoformat(),
+ 'browser_engine': args.browser, 'browser': browser.version, 'react_mode': 'development StrictMode' if args.strict else 'production',
+ 'playwright': version('playwright'), 'pypdf': version('pypdf'), 'checks': checks, 'documents': documents,
+ 'lifecycle': lifecycle, 'network': network, 'page_errors': errors, 'csp': policy,
+ 'scope': 'Synthetic inputs; real installed package; worker counts observe API handles, not native thread-exit events.'}, indent=2) + '\n', encoding='utf-8')
+ except Exception:
+ page.screenshot(path=str(out / 'failure.png'), full_page=True)
+ (out / 'failure.json').write_text(json.dumps({'checks': checks, 'errors': errors, 'network': network}, indent=2) + '\n', encoding='utf-8')
+ raise
+ finally:
+ context.close()
+ browser.close()
+finally:
+ released.set()
+ server.shutdown()
+ server.server_close()
+ thread.join(timeout=5)
diff --git a/tools/prepare-react-check.mjs b/tools/prepare-react-check.mjs
new file mode 100644
index 0000000..2b21c62
--- /dev/null
+++ b/tools/prepare-react-check.mjs
@@ -0,0 +1,26 @@
+// SPDX-License-Identifier: MIT
+import { createRequire } from 'node:module';
+import { readFile, mkdir, writeFile } from 'node:fs/promises';
+import { createHash } from 'node:crypto';
+import path from 'node:path';
+
+const app = path.resolve('examples/react');
+const out = path.resolve('output/react-baseline');
+await mkdir(out, { recursive: true });
+const require = createRequire(path.join(app, 'package.json'));
+const { renderPdf } = require('fullbleed');
+const records = [];
+for (const design of ['invoice', 'report']) {
+ const html = (await readFile(path.join(app, 'src', design + '.html'), 'utf8'))
+ .replaceAll('{{customer}}', 'Maple & Finch').replaceAll('{{reference}}', 'NS-1042');
+ const css = (await readFile(path.join(app, 'src', design + '.css'), 'utf8')).replaceAll('{{ink}}', '#17382e');
+ const result = await renderPdf({ html, css, previewDpi: 96, maxPages: 20, timeoutMs: 30_000 });
+ await writeFile(path.join(out, design + '.pdf'), result.pdf);
+ for (const [index, bytes] of result.previews.entries()) await writeFile(path.join(out, `${design}-${index + 1}.png`), bytes);
+ records.push({ design, pages: result.pages, pdfSha256: createHash('sha256').update(result.pdf).digest('hex'),
+ previews: result.previews.map(bytes => createHash('sha256').update(bytes).digest('hex')) });
+}
+const installed = JSON.parse(await readFile(path.join(app, 'node_modules/fullbleed/package.json'), 'utf8'));
+await writeFile(path.join(out, 'verification.json'), JSON.stringify({ package: installed.version,
+ engine: installed.fullbleed.engineVersion, node: process.version, records }, null, 2) + '\n');
+console.log(JSON.stringify({ ok: true, out, records }));