diff --git a/CLAUDE.md b/CLAUDE.md
index fd103e7..270b8e4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,7 +4,7 @@ extractkit is an open-source TypeScript document-extraction engine: Zod schema +
## Current Phase
-**Phase 2 (evals) harness shipped; live runs and the DocILE half are pending.** `packages/core` and the `packages/evals` harness are implemented and tested against mock models. The 25 CORD-v2 receipts are pinned in `packages/evals/data/manifest.json`; the 25 DocILE invoices are blocked on the dataset token (ROADMAP Phase 0, pending human action) — loader and curation script are ready. The eval lineup spans Anthropic, OpenAI, and Google Gemini (`packages/evals/src/models.ts`); a run includes every provider whose API key is set, or the subset named in `EVAL_PROVIDERS`. The first live eval run (needs at least one provider key — `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or `GOOGLE_GENERATIVE_AI_API_KEY`) doubles as core's first live-provider validation and fills the benchmark page + README table via `pnpm report`. `apps/playground` does not exist yet. Keep README/ROADMAP/docs in sync with what actually ships.
+**Phase 3 (playground) shipped against mock models; the first live provider run is the remaining gate across evals + playground.** `packages/core`, the `packages/evals` harness, and `apps/playground` are all implemented and tested against mock models. The 25 CORD-v2 receipts are pinned in `packages/evals/data/manifest.json`; the 25 DocILE invoices are blocked on the dataset token (ROADMAP Phase 0, pending human action) — loader and curation script are ready. The eval lineup spans Anthropic, OpenAI, and Google Gemini (`packages/evals/src/models.ts`); a run includes every provider whose API key is set, or the subset named in `EVAL_PROVIDERS`. The playground (`apps/playground`) is a Hono API + Vite/React client: `GET /api/config`, `POST /api/extract` streamed as SSE via core's `streamExtract`, preset invoice/receipt schemas defined server-side, and a model registry resolved from whichever provider keys are present. It needs the same provider keys to run live, and its model registry is injectable so the routes test against a mock model. The first live run (any one of `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GOOGLE_GENERATIVE_AI_API_KEY`) doubles as core's first live-provider validation, fills the benchmark page + README table via `pnpm report`, and enables capturing the playground demo GIF. Keep README/ROADMAP/docs in sync with what actually ships.
## Planned Architecture
diff --git a/README.md b/README.md
index 1712404..d922760 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
**Extraction you can audit.** Define a Zod schema, feed it a PDF or image, get back schema-validated JSON where every field carries provenance — the page and bounding box it came from — plus a confidence score.
-> **Status: in development.** The core library ([`packages/core`](./packages/core)) is implemented and tested against mock models; the eval benchmark and playground are next. See [ROADMAP.md](./ROADMAP.md).
+> **Status: in development.** The core library ([`packages/core`](./packages/core)), the eval harness ([`packages/evals`](./packages/evals)), and the playground ([`apps/playground`](./apps/playground)) are implemented and tested against mock models. What's left is the first live provider run, which publishes the benchmark table and the demo GIF. See [ROADMAP.md](./ROADMAP.md).
## Why
@@ -12,7 +12,7 @@ TypeScript has structured-output libraries (instructor-js, AI SDK `generateObjec
- **Core library** (`packages/core`, shipped) — Zod schema + PDF/image → validated JSON with per-field `{ value, confidence, page, bbox }`. Provider-agnostic via the Vercel AI SDK. Document validation, typed failure handling, repair retries, streaming, and cost tracking built in. [Usage docs →](./packages/core/README.md)
- **Eval harness** (`packages/evals`, harness shipped) — public benchmark on ~50 pinned real documents (CORD-v2 receipts + DocILE invoices): field accuracy per model, grounding accuracy, cost per 1k docs. Fully reproducible — documents pinned by checksum, reports generated only from recorded runs. [Reproduce it →](./packages/evals/README.md)
-- **Playground** (planned) — drag-drop a document, watch fields extract; hover a JSON field to highlight its source region on the page.
+- **Playground** (`apps/playground`, built) — drag-drop a document, watch fields stream in, hover a field to highlight its source region on the page. Hono API + Vite/React client, running `extractkit` against a live model. [Run it →](./apps/playground/README.md)
See [ROADMAP.md](./ROADMAP.md) for the build plan.
diff --git a/ROADMAP.md b/ROADMAP.md
index 59fa914..f9df8c7 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -24,9 +24,9 @@ Tested against mock models only so far; first live-provider validation happens w
## Phase 3 — Playground (`apps/playground`)
-- [ ] Hono API + Vite/React client
-- [ ] Drag-drop extraction with hover-field → highlight-source-bbox interaction
-- [ ] Demo GIF for the README
+- [x] Hono API + Vite/React client — `GET /api/config`, `POST /api/extract` streamed as SSE; routes tested against a mock model
+- [x] Drag-drop extraction with hover-field → highlight-source-bbox interaction (images + PDFs via pdfjs)
+- [ ] Demo GIF for the README (needs one live run — same provider key as the Phase 2 first run)
## Phase 4 — Release
diff --git a/apps/playground/.env.example b/apps/playground/.env.example
new file mode 100644
index 0000000..c10d0b8
--- /dev/null
+++ b/apps/playground/.env.example
@@ -0,0 +1,8 @@
+# Set at least one provider key; the playground offers the models whose key is present.
+# The server reads these from the environment (or a local .env file, git-ignored).
+ANTHROPIC_API_KEY=
+OPENAI_API_KEY=
+GOOGLE_GENERATIVE_AI_API_KEY=
+
+# Port the Hono API server listens on (default 8787). The Vite dev server proxies /api here.
+PORT=8787
diff --git a/apps/playground/README.md b/apps/playground/README.md
new file mode 100644
index 0000000..fc21177
--- /dev/null
+++ b/apps/playground/README.md
@@ -0,0 +1,62 @@
+# extractkit playground
+
+The hosted demo for [extractkit](../../packages/core): drop in a PDF or image, watch fields
+stream in as they extract, and **hover any field to highlight the exact region it came from** on the
+page. A Vite + React client talks to a small Hono API that runs `extractkit` against a live model.
+
+## Run it
+
+From the repo root (the core library must be built once so the server can import it):
+
+```sh
+pnpm install
+pnpm --filter extractkit build
+```
+
+Set at least one provider key — copy `.env.example` to `.env` and fill one in, or export it:
+
+```sh
+# .env (git-ignored)
+ANTHROPIC_API_KEY=sk-ant-...
+# or OPENAI_API_KEY=... / GOOGLE_GENERATIVE_AI_API_KEY=...
+```
+
+Then, from `apps/playground`:
+
+```sh
+pnpm dev # Hono API on :8787 + Vite client on :5173 (open this)
+```
+
+The client offers whichever models have a key set. With no key, the UI loads but extraction is
+disabled and tells you which variable to set.
+
+### Production preview
+
+```sh
+pnpm build # bundles the client to dist/client
+pnpm start # Hono serves the API and the built client on :8787
+```
+
+## How it works
+
+- **`src/server`** — a Hono app (`app.ts`) with two routes:
+ - `GET /api/config` — the preset schemas and the models available in this environment.
+ - `POST /api/extract` — a multipart upload streamed back as Server-Sent Events. It calls
+ `streamExtract` from core and forwards each field as it completes, then a final `result` or a
+ typed `error`. The model registry is injected, so tests exercise the routes against a mock model
+ with no API key.
+- **`src/client`** — the React app. `DocumentViewer` renders images directly and PDFs via `pdfjs`,
+ overlaying each field's bounding box (normalized 0–1 coordinates from core) on the page.
+ `ResultPanel` shows fields streaming in, then the validated tree with per-field confidence, token
+ usage, and cost.
+- **`src/shared`** — the DTOs both sides share.
+
+Schemas are fixed presets (invoice, receipt) defined server-side — the playground does not accept
+arbitrary schemas from the browser.
+
+## Test & typecheck
+
+```sh
+pnpm test # server routes (mock model) + client logic (SSE, geometry, fields, upload)
+pnpm typecheck # client (DOM) and server (node) tsconfigs
+```
diff --git a/apps/playground/index.html b/apps/playground/index.html
new file mode 100644
index 0000000..1f9d21f
--- /dev/null
+++ b/apps/playground/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ extractkit · playground
+
+
+
+
+
+
diff --git a/apps/playground/package.json b/apps/playground/package.json
new file mode 100644
index 0000000..623ef81
--- /dev/null
+++ b/apps/playground/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "@extractkit/playground",
+ "private": true,
+ "version": "0.0.0",
+ "description": "Hosted demo for extractkit: drag-drop a document, watch fields extract, hover a field to highlight its source region on the page.",
+ "type": "module",
+ "engines": {
+ "node": ">=20.19"
+ },
+ "scripts": {
+ "dev": "concurrently -k -n server,client -c blue,green \"pnpm dev:server\" \"pnpm dev:client\"",
+ "dev:server": "tsx watch src/server/index.ts",
+ "dev:client": "vite",
+ "build": "vite build",
+ "start": "NODE_ENV=production tsx src/server/index.ts",
+ "test": "vitest run",
+ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.node.json --noEmit"
+ },
+ "dependencies": {
+ "@ai-sdk/anthropic": "^4.0.10",
+ "@ai-sdk/google": "^4.0.12",
+ "@ai-sdk/openai": "^4.0.11",
+ "@hono/node-server": "^2.0.8",
+ "ai": "^7.0.16",
+ "extractkit": "workspace:*",
+ "hono": "^4.12.29",
+ "pdfjs-dist": "^6.1.200",
+ "react": "^19.2.7",
+ "react-dom": "^19.2.7",
+ "zod": "^4.4.3"
+ },
+ "devDependencies": {
+ "@types/node": "^26.1.0",
+ "@types/react": "^19.2.17",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.3",
+ "concurrently": "^10.0.3",
+ "tsx": "^4.23.0",
+ "typescript": "^6.0.3",
+ "vite": "^8.1.4",
+ "vitest": "^4.1.10"
+ }
+}
diff --git a/apps/playground/src/client/App.tsx b/apps/playground/src/client/App.tsx
new file mode 100644
index 0000000..e22e133
--- /dev/null
+++ b/apps/playground/src/client/App.tsx
@@ -0,0 +1,174 @@
+import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import type { ApiError, ConfigResponse, SerializedResult } from '../shared/api';
+import { fetchConfig, runExtract } from './lib/api';
+import type { FieldEntry } from './lib/fields';
+import { fieldEntries, pathKey } from './lib/fields';
+import { Dropzone } from './components/Dropzone';
+import { Toolbar } from './components/Toolbar';
+import { ResultPanel } from './components/ResultPanel';
+
+// The viewer pulls in pdfjs; load it only once a document is opened.
+const DocumentViewer = lazy(() =>
+ import('./components/DocumentViewer').then((m) => ({ default: m.DocumentViewer })),
+);
+
+export type Phase = 'idle' | 'running' | 'done' | 'error';
+
+interface RunState {
+ phase: Phase;
+ live: FieldEntry[];
+ liveKeys: Set;
+ result: SerializedResult | null;
+ error: ApiError | null;
+}
+
+const IDLE_RUN: RunState = { phase: 'idle', live: [], liveKeys: new Set(), result: null, error: null };
+
+export function App() {
+ const [config, setConfig] = useState(null);
+ const [configError, setConfigError] = useState(null);
+ const [schemaId, setSchemaId] = useState('');
+ const [modelId, setModelId] = useState('');
+ const [file, setFile] = useState(null);
+ const [docUrl, setDocUrl] = useState(null);
+ const [run, setRun] = useState(IDLE_RUN);
+ const [activeKey, setActiveKey] = useState(null);
+ const abortRef = useRef(null);
+
+ useEffect(() => {
+ const controller = new AbortController();
+ fetchConfig(controller.signal)
+ .then((cfg) => {
+ setConfig(cfg);
+ setSchemaId(cfg.defaults.schema);
+ setModelId(cfg.defaults.model ?? '');
+ })
+ .catch((err: unknown) => {
+ if (!controller.signal.aborted) {
+ setConfigError(err instanceof Error ? err.message : 'Failed to load configuration.');
+ }
+ });
+ return () => controller.abort();
+ }, []);
+
+ useEffect(() => {
+ if (docUrl === null) return;
+ return () => URL.revokeObjectURL(docUrl);
+ }, [docUrl]);
+
+ const selectFile = useCallback((next: File) => {
+ setFile(next);
+ setDocUrl(URL.createObjectURL(next));
+ setActiveKey(null);
+ setRun(IDLE_RUN);
+ }, []);
+
+ const cancel = useCallback(() => {
+ abortRef.current?.abort();
+ }, []);
+
+ const extract = useCallback(async () => {
+ if (file === null || modelId === '') return;
+ abortRef.current?.abort();
+ const controller = new AbortController();
+ abortRef.current = controller;
+ setActiveKey(null);
+ setRun({ phase: 'running', live: [], liveKeys: new Set(), result: null, error: null });
+
+ try {
+ for await (const ev of runExtract({ file, schema: schemaId, model: modelId, signal: controller.signal })) {
+ if (ev.type === 'field') {
+ const key = pathKey(ev.path);
+ setRun((prev) => {
+ if (prev.liveKeys.has(key)) return prev;
+ const liveKeys = new Set(prev.liveKeys);
+ liveKeys.add(key);
+ return { ...prev, live: [...prev.live, { key, path: ev.path, field: ev.field }], liveKeys };
+ });
+ } else if (ev.type === 'result') {
+ setRun((prev) => ({ ...prev, phase: 'done', result: ev.result }));
+ } else {
+ setRun((prev) => ({ ...prev, phase: 'error', error: ev.error }));
+ }
+ }
+ } catch (err) {
+ if (controller.signal.aborted) {
+ setRun(IDLE_RUN);
+ } else {
+ setRun((prev) => ({
+ ...prev,
+ phase: 'error',
+ error: { name: 'NetworkError', code: null, message: err instanceof Error ? err.message : 'Extraction failed.' },
+ }));
+ }
+ }
+ }, [file, modelId, schemaId]);
+
+ const entries = useMemo(
+ () => (run.result !== null ? fieldEntries(run.result.fields) : run.live),
+ [run.result, run.live],
+ );
+ const boxes = useMemo(
+ () => entries.filter((e) => e.field.bbox !== null && e.field.page !== null),
+ [entries],
+ );
+
+ return (
+
+
+
+ extractkit
+ playground
+
+ Drag in a document, watch fields extract, hover a field to see where it came from.
+
+ GitHub ↗
+
+
+
+
void extract()}
+ onCancel={cancel}
+ />
+
+
+
+ {file !== null && docUrl !== null ? (
+ Loading viewer… }>
+
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/playground/src/client/components/DocumentViewer.tsx b/apps/playground/src/client/components/DocumentViewer.tsx
new file mode 100644
index 0000000..3e4f399
--- /dev/null
+++ b/apps/playground/src/client/components/DocumentViewer.tsx
@@ -0,0 +1,185 @@
+import { useEffect, useRef, useState } from 'react';
+import type { DragEvent } from 'react';
+import { GlobalWorkerOptions, getDocument } from 'pdfjs-dist';
+import type { PDFDocumentLoadingTask, PDFPageProxy } from 'pdfjs-dist';
+import workerSrc from 'pdfjs-dist/build/pdf.worker.min.mjs?url';
+import type { FieldEntry } from '../lib/fields';
+import { formatPath, formatValue } from '../lib/fields';
+import { bboxToStyle } from '../lib/geometry';
+import { isPdf, pickFile } from '../lib/upload';
+
+GlobalWorkerOptions.workerSrc = workerSrc;
+
+interface ViewerProps {
+ file: File;
+ docUrl: string;
+ boxes: FieldEntry[];
+ activeKey: string | null;
+ onActivate: (key: string | null) => void;
+ onFile: (file: File) => void;
+}
+
+export function DocumentViewer(props: ViewerProps) {
+ const { file, docUrl, boxes, activeKey, onActivate, onFile } = props;
+ const [dragging, setDragging] = useState(false);
+
+ const onDrop = (e: DragEvent) => {
+ e.preventDefault();
+ setDragging(false);
+ const result = pickFile(e.dataTransfer.files);
+ if ('file' in result) onFile(result.file);
+ };
+
+ return (
+ {
+ e.preventDefault();
+ setDragging(true);
+ }}
+ onDragLeave={() => setDragging(false)}
+ onDrop={onDrop}
+ >
+ {isPdf(file) ? (
+
+ ) : (
+
+
+
+ )}
+
+ );
+}
+
+interface PageBoxProps {
+ boxes: FieldEntry[];
+ pageIndex: number;
+ activeKey: string | null;
+ onActivate: (key: string | null) => void;
+}
+
+function ImagePage({ src, ...page }: { src: string } & PageBoxProps) {
+ return (
+
+
+
+
+ );
+}
+
+function PdfView({ file, boxes, activeKey, onActivate }: { file: File } & Omit) {
+ const [pages, setPages] = useState([]);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ let loadingTask: PDFDocumentLoadingTask | null = null;
+ setPages([]);
+ setError(null);
+ void (async () => {
+ try {
+ const buffer = await file.arrayBuffer();
+ if (cancelled) return;
+ loadingTask = getDocument({ data: new Uint8Array(buffer) });
+ const doc = await loadingTask.promise;
+ const proxies = await Promise.all(
+ Array.from({ length: doc.numPages }, (_, i) => doc.getPage(i + 1)),
+ );
+ if (cancelled) return;
+ setPages(proxies);
+ } catch {
+ if (!cancelled) setError('This PDF could not be rendered in the browser.');
+ }
+ })();
+ return () => {
+ cancelled = true;
+ if (loadingTask !== null) void loadingTask.destroy();
+ };
+ }, [file]);
+
+ if (error !== null) return {error}
;
+
+ return (
+
+ {pages.map((page, index) => (
+
+ ))}
+
+ );
+}
+
+function PdfCanvas({ page }: { page: PDFPageProxy }) {
+ const canvasRef = useRef(null);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (canvas === null) return;
+ const context = canvas.getContext('2d');
+ if (context === null) return;
+
+ const base = page.getViewport({ scale: 1 });
+ const scale = Math.min(2, 1400 / base.width);
+ const viewport = page.getViewport({ scale });
+ canvas.width = Math.ceil(viewport.width);
+ canvas.height = Math.ceil(viewport.height);
+
+ const task = page.render({ canvas, canvasContext: context, viewport });
+ task.promise.catch(() => {
+ // Cancelled on unmount or superseded render; ignore.
+ });
+ return () => task.cancel();
+ }, [page]);
+
+ return ;
+}
+
+function BoxLayer({ boxes, pageIndex, activeKey, onActivate }: PageBoxProps) {
+ const onPage = boxes.filter((box) => (box.field.page ?? 0) === pageIndex);
+ return (
+
+ {onPage.map((box) => (
+
+ ))}
+
+ );
+}
+
+function BBox({
+ entry,
+ active,
+ onActivate,
+}: {
+ entry: FieldEntry;
+ active: boolean;
+ onActivate: (key: string | null) => void;
+}) {
+ const ref = useRef(null);
+ const { bbox } = entry.field;
+
+ useEffect(() => {
+ if (active) ref.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
+ }, [active]);
+
+ if (bbox === null) return null;
+ return (
+ onActivate(entry.key)}
+ onMouseLeave={() => onActivate(null)}
+ />
+ );
+}
diff --git a/apps/playground/src/client/components/Dropzone.tsx b/apps/playground/src/client/components/Dropzone.tsx
new file mode 100644
index 0000000..43d4167
--- /dev/null
+++ b/apps/playground/src/client/components/Dropzone.tsx
@@ -0,0 +1,68 @@
+import { useCallback, useRef, useState } from 'react';
+import type { DragEvent } from 'react';
+import { ACCEPT_ATTR, pickFile } from '../lib/upload';
+
+interface DropzoneProps {
+ onFile: (file: File) => void;
+}
+
+export function Dropzone({ onFile }: DropzoneProps) {
+ const inputRef = useRef
(null);
+ const [dragging, setDragging] = useState(false);
+ const [error, setError] = useState(null);
+
+ const accept = useCallback(
+ (files: FileList | null) => {
+ const result = pickFile(files);
+ if ('file' in result) {
+ setError(null);
+ onFile(result.file);
+ } else {
+ setError(result.error);
+ }
+ },
+ [onFile],
+ );
+
+ const onDrop = useCallback(
+ (e: DragEvent) => {
+ e.preventDefault();
+ setDragging(false);
+ accept(e.dataTransfer.files);
+ },
+ [accept],
+ );
+
+ return (
+ {
+ e.preventDefault();
+ setDragging(true);
+ }}
+ onDragLeave={() => setDragging(false)}
+ onDrop={onDrop}
+ onClick={() => inputRef.current?.click()}
+ role="button"
+ tabIndex={0}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') inputRef.current?.click();
+ }}
+ >
+
accept(e.target.files)}
+ />
+
+ ⤓
+
+
Drop a document here
+
or click to browse — PDF, PNG, JPEG, or WebP, up to 15 MB
+ {error !== null &&
{error}
}
+
+ );
+}
diff --git a/apps/playground/src/client/components/ResultPanel.tsx b/apps/playground/src/client/components/ResultPanel.tsx
new file mode 100644
index 0000000..d0ea37e
--- /dev/null
+++ b/apps/playground/src/client/components/ResultPanel.tsx
@@ -0,0 +1,274 @@
+import type { ApiError, SerializedResult } from '../../shared/api';
+import type { Phase } from '../App';
+import type { AnyField, FieldEntry } from '../lib/fields';
+import { formatPath, formatValue, isField, leafLabel, pathKey } from '../lib/fields';
+import type { FieldPath } from 'extractkit';
+
+interface ResultPanelProps {
+ phase: Phase;
+ live: FieldEntry[];
+ result: SerializedResult | null;
+ error: ApiError | null;
+ hasFile: boolean;
+ activeKey: string | null;
+ onActivate: (key: string | null) => void;
+}
+
+export function ResultPanel(props: ResultPanelProps) {
+ const { phase, live, result, error, hasFile, activeKey, onActivate } = props;
+
+ return (
+
+
+
Extraction
+
+
+
+
+ {error !== null &&
}
+ {error === null && result !== null && (
+
+
+
+ )}
+ {error === null && result === null && phase === 'running' && (
+
+ )}
+ {error === null && result === null && phase !== 'running' &&
}
+
+
+ {error === null && result !== null &&
}
+
+ );
+}
+
+function indentStyle(level: number) {
+ return { paddingLeft: `${12 + Math.max(0, level) * 14}px` };
+}
+
+interface NodeProps {
+ node: unknown;
+ path: FieldPath;
+ activeKey: string | null;
+ onActivate: (key: string | null) => void;
+}
+
+function FieldNode({ node, path, activeKey, onActivate }: NodeProps) {
+ if (node === null) {
+ return (
+
+ {leafLabel(path)}
+ —
+
+ );
+ }
+
+ if (isField(node)) {
+ const key = pathKey(path);
+ return (
+
+ );
+ }
+
+ if (Array.isArray(node)) {
+ return (
+
+
+ {leafLabel(path)} {node.length}
+
+ {node.map((child, i) => (
+
+ ))}
+
+ );
+ }
+
+ const entries = Object.entries(node as Record);
+ return (
+
+ {path.length > 0 && (
+
+ {leafLabel(path)}
+
+ )}
+ {entries.map(([key, child]) => (
+
+ ))}
+
+ );
+}
+
+function LiveList({
+ entries,
+ activeKey,
+ onActivate,
+}: {
+ entries: FieldEntry[];
+ activeKey: string | null;
+ onActivate: (key: string | null) => void;
+}) {
+ if (entries.length === 0) {
+ return (
+
+ Reading the document…
+
+ );
+ }
+ return (
+
+ {entries.map((entry) => (
+
+ ))}
+
+ );
+}
+
+function FieldRow({
+ label,
+ entry,
+ level,
+ active,
+ onActivate,
+}: {
+ label: string;
+ entry: FieldEntry;
+ level: number;
+ active: boolean;
+ onActivate: (key: string | null) => void;
+}) {
+ const { field } = entry;
+ return (
+ onActivate(entry.key)}
+ onMouseLeave={() => onActivate(null)}
+ >
+ {label}
+ {formatValue(field.value)}
+
+
+ );
+}
+
+function Confidence({ field }: { field: AnyField }) {
+ const pct = Math.round(field.confidence * 100);
+ const tier = field.confidence >= 0.8 ? 'high' : field.confidence >= 0.5 ? 'mid' : 'low';
+ const located = field.bbox !== null;
+ return (
+
+
+
+
+ {pct}%
+ {!located && (
+
+ ⚠
+
+ )}
+
+ );
+}
+
+function StatusPill({ phase, count }: { phase: Phase; count: number | undefined }) {
+ if (phase === 'running') {
+ return {count !== undefined && count > 0 ? `Extracting · ${count}` : 'Extracting…'} ;
+ }
+ if (phase === 'done') return Done ;
+ if (phase === 'error') return Error ;
+ return Idle ;
+}
+
+function EmptyHint({ hasFile }: { hasFile: boolean }) {
+ return (
+
+ {hasFile ? 'Press Extract to pull fields from this document.' : 'Upload a document to get started.'}
+
+ );
+}
+
+function ResultFooter({ result }: { result: SerializedResult }) {
+ const { usage } = result;
+ return (
+
+
+
+
+
+
+
+
+ {result.issues.length > 0 && (
+
+
+ {result.issues.length} provenance {result.issues.length === 1 ? 'note' : 'notes'}
+
+
+ {result.issues.map((issue, i) => (
+ {issue}
+ ))}
+
+
+ )}
+
+ );
+}
+
+function Stat({ label, value }: { label: string; value: string }) {
+ return (
+
+ {value}
+ {label}
+
+ );
+}
+
+const ERROR_TITLES: Record = {
+ DOCUMENT_UNREADABLE: 'Document unreadable',
+ MISSING_REQUIRED_FIELDS: "Some required fields weren't found",
+ EXTRACTION_FAILED: 'Extraction failed',
+ UNSUPPORTED_MEDIA_TYPE: 'Unsupported file type',
+ MEDIA_TYPE_MISMATCH: 'File type mismatch',
+ ENCRYPTED_DOCUMENT: 'Document is encrypted',
+ INVALID_DOCUMENT: 'Invalid document',
+ SCHEMA_UNSUPPORTED: 'Unsupported schema',
+};
+
+function ErrorBanner({ error }: { error: ApiError }) {
+ const title = (error.code !== null && ERROR_TITLES[error.code]) || error.name;
+ return (
+
+
{title}
+
{error.message}
+ {error.missingPaths !== undefined && error.missingPaths.length > 0 && (
+
+ {error.missingPaths.map((path) => (
+
+ {path}
+
+ ))}
+
+ )}
+
+ );
+}
+
+function formatUSD(amount: number): string {
+ if (amount === 0) return '$0';
+ if (amount < 0.01) return `$${amount.toFixed(4)}`;
+ return `$${amount.toFixed(2)}`;
+}
diff --git a/apps/playground/src/client/components/Toolbar.tsx b/apps/playground/src/client/components/Toolbar.tsx
new file mode 100644
index 0000000..64a9876
--- /dev/null
+++ b/apps/playground/src/client/components/Toolbar.tsx
@@ -0,0 +1,95 @@
+import { useRef } from 'react';
+import type { ConfigResponse } from '../../shared/api';
+import type { Phase } from '../App';
+import { ACCEPT_ATTR, pickFile } from '../lib/upload';
+
+interface ToolbarProps {
+ config: ConfigResponse | null;
+ configError: string | null;
+ schemaId: string;
+ modelId: string;
+ hasFile: boolean;
+ phase: Phase;
+ onSchema: (id: string) => void;
+ onModel: (id: string) => void;
+ onFile: (file: File) => void;
+ onExtract: () => void;
+ onCancel: () => void;
+}
+
+export function Toolbar(props: ToolbarProps) {
+ const { config, configError, schemaId, modelId, hasFile, phase } = props;
+ const inputRef = useRef(null);
+ const running = phase === 'running';
+ const noModels = config !== null && config.models.length === 0;
+ const canExtract = hasFile && modelId !== '' && !running;
+
+ return (
+
+
+ Schema
+ props.onSchema(e.target.value)}
+ >
+ {config?.schemas.map((schema) => (
+
+ {schema.label}
+
+ ))}
+
+
+
+
+ Model
+ props.onModel(e.target.value)}
+ >
+ {noModels && No models available }
+ {config?.models.map((model) => (
+
+ {model.label}
+
+ ))}
+
+
+
+
+ {
+ const result = pickFile(e.target.files);
+ if ('file' in result) props.onFile(result.file);
+ e.target.value = '';
+ }}
+ />
+ inputRef.current?.click()} disabled={running}>
+ {hasFile ? 'Replace' : 'Upload'}
+
+ {running ? (
+
+ Cancel
+
+ ) : (
+
+ Extract
+
+ )}
+
+
+ {configError !== null &&
{configError}
}
+ {noModels && configError === null && (
+
+ Set ANTHROPIC_API_KEY, OPENAI_API_KEY, or{' '}
+ GOOGLE_GENERATIVE_AI_API_KEY on the server to enable extraction.
+
+ )}
+
+ );
+}
diff --git a/apps/playground/src/client/lib/api.ts b/apps/playground/src/client/lib/api.ts
new file mode 100644
index 0000000..5c44442
--- /dev/null
+++ b/apps/playground/src/client/lib/api.ts
@@ -0,0 +1,50 @@
+import type { ConfigResponse, ExtractEvent } from '../../shared/api';
+import { readSSE } from './sse';
+
+/** Fetches the available schemas and models. */
+export async function fetchConfig(signal?: AbortSignal): Promise {
+ const res = await fetch('/api/config', signal ? { signal } : undefined);
+ if (!res.ok) throw new Error(`Failed to load config (${res.status}).`);
+ return (await res.json()) as ConfigResponse;
+}
+
+export interface ExtractInput {
+ file: File;
+ schema: string;
+ model: string;
+ signal?: AbortSignal;
+}
+
+/**
+ * Runs an extraction and yields each streamed event. Request-level failures
+ * (bad input, too large) come back as a single synthetic `error` event so
+ * callers only handle one shape.
+ */
+export async function* runExtract(input: ExtractInput): AsyncGenerator {
+ const form = new FormData();
+ form.set('file', input.file);
+ form.set('schema', input.schema);
+ form.set('model', input.model);
+
+ const res = await fetch('/api/extract', {
+ method: 'POST',
+ body: form,
+ ...(input.signal ? { signal: input.signal } : {}),
+ });
+
+ if (!res.ok || res.body === null) {
+ let message = `Extraction request failed (${res.status}).`;
+ try {
+ const body = (await res.json()) as { error?: string };
+ if (typeof body.error === 'string') message = body.error;
+ } catch {
+ // Non-JSON error body; keep the status-based message.
+ }
+ yield { type: 'error', error: { name: 'RequestError', code: null, message } };
+ return;
+ }
+
+ for await (const message of readSSE(res.body)) {
+ yield JSON.parse(message.data) as ExtractEvent;
+ }
+}
diff --git a/apps/playground/src/client/lib/fields.ts b/apps/playground/src/client/lib/fields.ts
new file mode 100644
index 0000000..b2afc14
--- /dev/null
+++ b/apps/playground/src/client/lib/fields.ts
@@ -0,0 +1,82 @@
+import type { ExtractedField, FieldPath } from 'extractkit';
+
+export type AnyField = ExtractedField;
+
+export interface FlatField {
+ path: FieldPath;
+ field: AnyField;
+}
+
+/** A flat field plus its stable key, ready for overlays and list rendering. */
+export interface FieldEntry extends FlatField {
+ key: string;
+}
+
+/** Stable string key for a field path, for map lookups and React keys. */
+export function pathKey(path: FieldPath): string {
+ return JSON.stringify(path);
+}
+
+/** True when `node` is a provenance leaf rather than a nested branch. */
+export function isField(node: unknown): node is AnyField {
+ if (typeof node !== 'object' || node === null) return false;
+ const record = node as Record;
+ return (
+ 'value' in record &&
+ 'confidence' in record &&
+ 'page' in record &&
+ 'bbox' in record &&
+ typeof record['confidence'] === 'number'
+ );
+}
+
+/** Depth-first list of every provenance leaf under a FieldMap, with its path. */
+export function flattenFields(node: unknown, base: FieldPath = []): FlatField[] {
+ if (node === null || node === undefined) return [];
+ if (isField(node)) return [{ path: base, field: node }];
+ if (Array.isArray(node)) return node.flatMap((child, i) => flattenFields(child, [...base, i]));
+ if (typeof node === 'object') {
+ return Object.entries(node).flatMap(([key, child]) => flattenFields(child, [...base, key]));
+ }
+ return [];
+}
+
+/** Every provenance leaf under a FieldMap, each with its stable key. */
+export function fieldEntries(node: unknown): FieldEntry[] {
+ return flattenFields(node).map(({ path, field }) => ({ key: pathKey(path), path, field }));
+}
+
+/** Reads the plain value at `path` from an extraction's `data`. */
+export function valueAtPath(data: unknown, path: FieldPath): unknown {
+ let current: unknown = data;
+ for (const key of path) {
+ if (current === null || current === undefined) return undefined;
+ current = (current as Record)[key];
+ }
+ return current;
+}
+
+/** The last path segment, as a display label (e.g. `description`, `[0]`). */
+export function leafLabel(path: FieldPath): string {
+ const last = path.at(-1);
+ if (last === undefined) return '(root)';
+ return typeof last === 'number' ? `[${last}]` : last;
+}
+
+/** Dotted, index-aware rendering of a whole path (e.g. `lineItems[0].amount`). */
+export function formatPath(path: FieldPath): string {
+ let out = '';
+ for (const segment of path) {
+ if (typeof segment === 'number') out += `[${segment}]`;
+ else out += out === '' ? segment : `.${segment}`;
+ }
+ return out === '' ? '(root)' : out;
+}
+
+/** Human-readable rendering of a leaf value for the field list. */
+export function formatValue(value: unknown): string {
+ if (value === null || value === undefined) return '—';
+ if (typeof value === 'string') return value === '' ? '(empty)' : value;
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
+ return String(value);
+}
diff --git a/apps/playground/src/client/lib/geometry.ts b/apps/playground/src/client/lib/geometry.ts
new file mode 100644
index 0000000..524b203
--- /dev/null
+++ b/apps/playground/src/client/lib/geometry.ts
@@ -0,0 +1,36 @@
+import type { BBox } from 'extractkit';
+
+export interface BoxStyle {
+ left: string;
+ top: string;
+ width: string;
+ height: string;
+}
+
+function clamp01(n: number): number {
+ if (Number.isNaN(n)) return 0;
+ return Math.min(1, Math.max(0, n));
+}
+
+function pct(n: number): string {
+ return `${(n * 100).toFixed(3)}%`;
+}
+
+/**
+ * Converts a normalized bbox (0–1, origin top-left) into percentage CSS for an
+ * overlay positioned inside a page container. Corners are ordered and clamped so
+ * a mildly malformed box still renders as a valid rectangle.
+ */
+export function bboxToStyle(bbox: BBox): BoxStyle {
+ const x0 = clamp01(Math.min(bbox.x0, bbox.x1));
+ const y0 = clamp01(Math.min(bbox.y0, bbox.y1));
+ const x1 = clamp01(Math.max(bbox.x0, bbox.x1));
+ const y1 = clamp01(Math.max(bbox.y0, bbox.y1));
+ return { left: pct(x0), top: pct(y0), width: pct(x1 - x0), height: pct(y1 - y0) };
+}
+
+/** Fit `intrinsicWidth` into `containerWidth` without upscaling past 1×. */
+export function fitScale(intrinsicWidth: number, containerWidth: number): number {
+ if (intrinsicWidth <= 0 || containerWidth <= 0) return 1;
+ return Math.min(1, containerWidth / intrinsicWidth);
+}
diff --git a/apps/playground/src/client/lib/sse.ts b/apps/playground/src/client/lib/sse.ts
new file mode 100644
index 0000000..86bcdb9
--- /dev/null
+++ b/apps/playground/src/client/lib/sse.ts
@@ -0,0 +1,53 @@
+export interface SSEMessage {
+ event: string;
+ data: string;
+}
+
+function parseRecord(raw: string): SSEMessage | null {
+ let event = 'message';
+ const dataLines: string[] = [];
+ for (const line of raw.replace(/\r/g, '').split('\n')) {
+ if (line === '' || line.startsWith(':')) continue;
+ if (line.startsWith('event:')) {
+ event = line.slice('event:'.length).trim();
+ } else if (line.startsWith('data:')) {
+ dataLines.push(line.slice('data:'.length).replace(/^ /, ''));
+ }
+ }
+ if (dataLines.length === 0) return null;
+ return { event, data: dataLines.join('\n') };
+}
+
+/**
+ * Parses a `fetch` response body as a Server-Sent Events stream, yielding one
+ * message per `\n\n`-delimited record. Enough of the SSE grammar for the
+ * playground's own server: `event:` and `data:` fields, comment lines ignored.
+ */
+export async function* readSSE(body: ReadableStream): AsyncGenerator {
+ const reader = body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = '';
+ try {
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buffer += decoder.decode(value, { stream: true });
+ // Fold CRLF to LF so records split on \n\n regardless of line endings. A
+ // trailing lone \r is left for the next chunk's \n to complete the pair.
+ buffer = buffer.replace(/\r\n/g, '\n');
+ let boundary = buffer.indexOf('\n\n');
+ while (boundary !== -1) {
+ const record = buffer.slice(0, boundary);
+ buffer = buffer.slice(boundary + 2);
+ const message = parseRecord(record);
+ if (message !== null) yield message;
+ boundary = buffer.indexOf('\n\n');
+ }
+ }
+ buffer += decoder.decode();
+ const tail = parseRecord(buffer);
+ if (tail !== null) yield tail;
+ } finally {
+ reader.releaseLock();
+ }
+}
diff --git a/apps/playground/src/client/lib/upload.ts b/apps/playground/src/client/lib/upload.ts
new file mode 100644
index 0000000..c0715f8
--- /dev/null
+++ b/apps/playground/src/client/lib/upload.ts
@@ -0,0 +1,33 @@
+/** Mirrors the server's upload ceiling so the client can reject early. */
+export const MAX_UPLOAD_BYTES = 15 * 1024 * 1024;
+
+const ACCEPTED_EXTENSIONS = ['.pdf', '.png', '.jpg', '.jpeg', '.webp'];
+const ACCEPTED_TYPE = /^(application\/pdf|image\/(png|jpe?g|webp))$/;
+
+/** The `accept` attribute for the file input. */
+export const ACCEPT_ATTR = '.pdf,.png,.jpg,.jpeg,.webp,application/pdf,image/png,image/jpeg,image/webp';
+
+export function isPdf(file: File): boolean {
+ return file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf');
+}
+
+/** A client-side pre-check; the server validates authoritatively. Returns an error message, or null when acceptable. */
+export function validateFile(file: File): string | null {
+ const name = file.name.toLowerCase();
+ const okExtension = ACCEPTED_EXTENSIONS.some((ext) => name.endsWith(ext));
+ const okType = file.type === '' ? okExtension : ACCEPTED_TYPE.test(file.type);
+ if (!okExtension && !okType) return 'Unsupported file type — upload a PDF, PNG, JPEG, or WebP.';
+ if (file.size > MAX_UPLOAD_BYTES) return 'File is larger than the 15 MB limit.';
+ return null;
+}
+
+/**
+ * First acceptable file from a drop or picker, or an error message. Typed as
+ * ArrayLike so it stays DOM-free (a browser FileList is assignable).
+ */
+export function pickFile(files: ArrayLike | null): { file: File } | { error: string } {
+ const file = files?.[0];
+ if (file === undefined) return { error: 'No file selected.' };
+ const error = validateFile(file);
+ return error === null ? { file } : { error };
+}
diff --git a/apps/playground/src/client/main.tsx b/apps/playground/src/client/main.tsx
new file mode 100644
index 0000000..17f65c0
--- /dev/null
+++ b/apps/playground/src/client/main.tsx
@@ -0,0 +1,13 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import { App } from './App';
+import './styles.css';
+
+const container = document.getElementById('root');
+if (container === null) throw new Error('Missing #root element.');
+
+createRoot(container).render(
+
+
+ ,
+);
diff --git a/apps/playground/src/client/styles.css b/apps/playground/src/client/styles.css
new file mode 100644
index 0000000..a8ffa8b
--- /dev/null
+++ b/apps/playground/src/client/styles.css
@@ -0,0 +1,536 @@
+:root {
+ --bg: #f6f7f9;
+ --surface: #ffffff;
+ --surface-2: #f0f2f5;
+ --border: #e2e5ea;
+ --text: #1a1d24;
+ --text-muted: #6b7280;
+ --accent: #2563eb;
+ --accent-soft: #eff4ff;
+ --highlight: #f59e0b;
+ --high: #16a34a;
+ --mid: #d97706;
+ --low: #dc2626;
+ --radius: 10px;
+ --shadow: 0 1px 2px rgba(20, 24, 33, 0.05), 0 4px 16px rgba(20, 24, 33, 0.04);
+ font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--text);
+ -webkit-font-smoothing: antialiased;
+}
+
+code {
+ font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
+ font-size: 0.85em;
+ background: var(--surface-2);
+ padding: 1px 5px;
+ border-radius: 5px;
+}
+
+.app {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+}
+
+/* Header */
+.app-header {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 14px 22px;
+ background: var(--surface);
+ border-bottom: 1px solid var(--border);
+}
+.brand {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+}
+.brand-mark {
+ font-weight: 700;
+ font-size: 18px;
+ letter-spacing: -0.01em;
+}
+.brand-sub {
+ font-size: 12px;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+.tagline {
+ margin: 0;
+ color: var(--text-muted);
+ font-size: 13.5px;
+ flex: 1;
+}
+.repo-link {
+ color: var(--accent);
+ text-decoration: none;
+ font-size: 13px;
+ font-weight: 500;
+}
+.repo-link:hover {
+ text-decoration: underline;
+}
+
+/* Toolbar */
+.toolbar {
+ display: flex;
+ align-items: flex-end;
+ gap: 14px;
+ padding: 12px 22px;
+ background: var(--surface);
+ border-bottom: 1px solid var(--border);
+ flex-wrap: wrap;
+}
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+}
+.field-label {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--text-muted);
+}
+select {
+ appearance: none;
+ padding: 7px 30px 7px 11px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--surface)
+ url("data:image/svg+xml,%3Csvg width='10' height='6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%236b7280' stroke-width='1.5' fill='none' fill-rule='evenodd'/%3E%3C/svg%3E")
+ no-repeat right 11px center;
+ font-size: 14px;
+ color: var(--text);
+ min-width: 170px;
+ cursor: pointer;
+}
+select:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+}
+.toolbar-actions {
+ display: flex;
+ gap: 8px;
+ margin-left: auto;
+}
+.btn {
+ padding: 8px 16px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--surface);
+ color: var(--text);
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background 0.12s, border-color 0.12s;
+}
+.btn:hover:not(:disabled) {
+ background: var(--surface-2);
+}
+.btn.primary {
+ background: var(--accent);
+ border-color: var(--accent);
+ color: #fff;
+}
+.btn.primary:hover:not(:disabled) {
+ background: #1d4ed8;
+}
+.btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+.toolbar-note {
+ flex-basis: 100%;
+ margin: 2px 0 0;
+ font-size: 12.5px;
+ color: var(--text-muted);
+}
+.toolbar-note.error {
+ color: var(--low);
+}
+
+/* Workspace */
+.workspace {
+ flex: 1;
+ display: grid;
+ grid-template-columns: 1fr 420px;
+ min-height: 0;
+}
+.viewer-pane {
+ overflow: auto;
+ padding: 24px;
+ background: var(--surface-2);
+}
+.result-pane {
+ border-left: 1px solid var(--border);
+ background: var(--surface);
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+/* Dropzone */
+.dropzone {
+ height: 100%;
+ min-height: 320px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ border: 2px dashed var(--border);
+ border-radius: var(--radius);
+ background: var(--surface);
+ color: var(--text-muted);
+ cursor: pointer;
+ transition: border-color 0.15s, background 0.15s;
+}
+.dropzone:hover,
+.dropzone[data-dragging='true'] {
+ border-color: var(--accent);
+ background: var(--accent-soft);
+}
+.dropzone-icon {
+ font-size: 34px;
+ color: var(--accent);
+}
+.dropzone-title {
+ margin: 0;
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--text);
+}
+.dropzone-hint {
+ margin: 0;
+ font-size: 13px;
+}
+.dropzone-error {
+ margin: 6px 0 0;
+ color: var(--low);
+ font-size: 13px;
+}
+
+/* Document viewer */
+.viewer {
+ min-height: 100%;
+ border-radius: var(--radius);
+ outline: 2px solid transparent;
+ transition: outline-color 0.15s;
+}
+.viewer[data-dragging='true'] {
+ outline: 2px dashed var(--accent);
+ outline-offset: 6px;
+}
+.page-stack {
+ max-width: 820px;
+ margin: 0 auto;
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+}
+.page {
+ position: relative;
+ width: 100%;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ box-shadow: var(--shadow);
+ overflow: hidden;
+ line-height: 0;
+}
+.page-image,
+.page-canvas {
+ display: block;
+ width: 100%;
+ height: auto;
+}
+.box-layer {
+ position: absolute;
+ inset: 0;
+}
+.bbox {
+ position: absolute;
+ border: 1.5px solid rgba(37, 99, 235, 0.4);
+ background: rgba(37, 99, 235, 0.08);
+ border-radius: 2px;
+ cursor: pointer;
+ transition: background 0.1s, border-color 0.1s, box-shadow 0.1s;
+}
+.bbox:hover,
+.bbox[data-active='true'] {
+ border-color: var(--highlight);
+ background: rgba(245, 158, 11, 0.22);
+ box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.35);
+ z-index: 2;
+}
+.viewer-message {
+ padding: 40px;
+ text-align: center;
+ color: var(--text-muted);
+}
+.viewer-message.error {
+ color: var(--low);
+}
+
+/* Result panel */
+.result-panel {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ flex: 1;
+}
+.result-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 14px 18px;
+ border-bottom: 1px solid var(--border);
+}
+.result-head h2 {
+ margin: 0;
+ font-size: 15px;
+}
+.result-body {
+ flex: 1;
+ overflow: auto;
+ padding: 8px 0;
+}
+
+.pill {
+ font-size: 11.5px;
+ font-weight: 600;
+ padding: 3px 9px;
+ border-radius: 999px;
+ letter-spacing: 0.02em;
+}
+.pill.idle {
+ background: var(--surface-2);
+ color: var(--text-muted);
+}
+.pill.running {
+ background: var(--accent-soft);
+ color: var(--accent);
+}
+.pill.done {
+ background: #e7f6ec;
+ color: var(--high);
+}
+.pill.error {
+ background: #fdeaea;
+ color: var(--low);
+}
+
+/* Field tree */
+.tree {
+ display: flex;
+ flex-direction: column;
+}
+.tree-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 6px 18px 6px 12px;
+ cursor: default;
+ border-left: 2px solid transparent;
+}
+.tree-row:hover,
+.tree-row[data-active='true'] {
+ background: var(--accent-soft);
+ border-left-color: var(--highlight);
+}
+.tree-key {
+ font-size: 13px;
+ color: var(--text-muted);
+ min-width: 92px;
+ flex-shrink: 0;
+}
+.tree-value {
+ font-size: 13.5px;
+ font-weight: 500;
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.tree-row.muted .tree-value {
+ color: var(--text-muted);
+ font-weight: 400;
+}
+.tree-group-label {
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--text-muted);
+ padding: 10px 18px 4px 12px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+.badge {
+ background: var(--surface-2);
+ border-radius: 999px;
+ padding: 1px 7px;
+ font-size: 10px;
+ letter-spacing: 0;
+}
+
+/* Confidence */
+.confidence {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-shrink: 0;
+}
+.confidence-bar {
+ width: 46px;
+ height: 5px;
+ border-radius: 3px;
+ background: var(--surface-2);
+ overflow: hidden;
+}
+.confidence-fill {
+ display: block;
+ height: 100%;
+ border-radius: 3px;
+}
+.confidence-fill[data-tier='high'] {
+ background: var(--high);
+}
+.confidence-fill[data-tier='mid'] {
+ background: var(--mid);
+}
+.confidence-fill[data-tier='low'] {
+ background: var(--low);
+}
+.confidence-pct {
+ font-size: 11px;
+ color: var(--text-muted);
+ font-variant-numeric: tabular-nums;
+ width: 30px;
+ text-align: right;
+}
+.no-source {
+ color: var(--mid);
+ font-size: 12px;
+}
+
+/* Live + empty */
+.live-empty,
+.result-empty {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 24px 18px;
+ color: var(--text-muted);
+ font-size: 13.5px;
+}
+.result-empty {
+ justify-content: center;
+ text-align: center;
+}
+.spinner {
+ width: 15px;
+ height: 15px;
+ border: 2px solid var(--border);
+ border-top-color: var(--accent);
+ border-radius: 50%;
+ animation: spin 0.7s linear infinite;
+ display: inline-block;
+}
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+/* Footer stats */
+.result-footer {
+ border-top: 1px solid var(--border);
+ padding: 12px 18px;
+ background: var(--surface);
+}
+.stats {
+ display: grid;
+ grid-template-columns: repeat(5, 1fr);
+ gap: 8px;
+}
+.stat {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+.stat-value {
+ font-size: 14px;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+}
+.stat-label {
+ font-size: 10px;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+.issues {
+ margin-top: 12px;
+ font-size: 12.5px;
+ color: var(--text-muted);
+}
+.issues summary {
+ cursor: pointer;
+ font-weight: 600;
+}
+.issues ul {
+ margin: 8px 0 0;
+ padding-left: 18px;
+}
+.issues li {
+ margin-bottom: 4px;
+}
+
+/* Error */
+.error-banner {
+ margin: 12px 18px;
+ padding: 14px 16px;
+ border: 1px solid #f3c9c9;
+ background: #fdf3f3;
+ border-radius: var(--radius);
+}
+.error-title {
+ font-weight: 600;
+ color: var(--low);
+ margin-bottom: 4px;
+}
+.error-message {
+ font-size: 13px;
+ color: var(--text);
+}
+.error-paths {
+ margin: 8px 0 0;
+ padding-left: 18px;
+ font-size: 12.5px;
+}
+
+@media (max-width: 860px) {
+ .workspace {
+ grid-template-columns: 1fr;
+ grid-template-rows: 1fr 1fr;
+ }
+ .result-pane {
+ border-left: none;
+ border-top: 1px solid var(--border);
+ }
+}
diff --git a/apps/playground/src/server/app.ts b/apps/playground/src/server/app.ts
new file mode 100644
index 0000000..79c1b8c
--- /dev/null
+++ b/apps/playground/src/server/app.ts
@@ -0,0 +1,101 @@
+import { ExtractKitError, MissingRequiredFieldsError, streamExtract } from 'extractkit';
+import type { ExtractResult } from 'extractkit';
+import { Hono } from 'hono';
+import { bodyLimit } from 'hono/body-limit';
+import { streamSSE } from 'hono/streaming';
+import type { z } from 'zod';
+import type { ApiError, ConfigResponse, ExtractEvent, SerializedResult } from '../shared/api';
+import type { PlaygroundModel } from './models';
+import { defaultModelId, modelInfos } from './models';
+import { DEFAULT_SCHEMA_ID, getPreset, schemaInfos } from './schemas';
+
+export interface AppOptions {
+ /** Models this instance can run. Injected so tests can pass a mock model. */
+ models: PlaygroundModel[];
+}
+
+/** Upload ceiling for the public demo; documents above this are rejected. */
+const MAX_UPLOAD_BYTES = 15 * 1024 * 1024;
+
+function toSerialized(result: ExtractResult): SerializedResult {
+ return {
+ data: result.data,
+ fields: result.fields,
+ issues: result.issues,
+ usage: result.usage,
+ pages: result.pages,
+ };
+}
+
+function toApiError(err: unknown): ApiError {
+ if (err instanceof ExtractKitError) {
+ const mapped: ApiError = { name: err.name, code: err.code, message: err.message };
+ if (err instanceof MissingRequiredFieldsError) mapped.missingPaths = err.missingPaths;
+ return mapped;
+ }
+ if (err instanceof Error) return { name: err.name, code: null, message: err.message };
+ return { name: 'Error', code: null, message: String(err) };
+}
+
+export function createApp(options: AppOptions) {
+ const modelById = new Map(options.models.map((m) => [m.id, m]));
+ const app = new Hono();
+
+ app.get('/api/config', (c) => {
+ const body: ConfigResponse = {
+ schemas: schemaInfos(),
+ models: modelInfos(options.models),
+ defaults: { schema: DEFAULT_SCHEMA_ID, model: defaultModelId(options.models) },
+ };
+ return c.json(body);
+ });
+
+ app.post(
+ '/api/extract',
+ bodyLimit({
+ maxSize: MAX_UPLOAD_BYTES,
+ onError: (c) => c.json({ error: 'Document exceeds the 15 MB upload limit.' }, 413),
+ }),
+ async (c) => {
+ const form = await c.req.parseBody();
+ const file = form['file'];
+ const schemaId = typeof form['schema'] === 'string' ? form['schema'] : '';
+ const modelId = typeof form['model'] === 'string' ? form['model'] : '';
+
+ if (!(file instanceof File)) {
+ return c.json({ error: 'Upload a document in the "file" field.' }, 400);
+ }
+ const preset = getPreset(schemaId);
+ if (preset === undefined) {
+ return c.json({ error: `Unknown schema "${schemaId}".` }, 400);
+ }
+ const model = modelById.get(modelId);
+ if (model === undefined) {
+ return c.json({ error: `Unknown or unavailable model "${modelId}".` }, 400);
+ }
+
+ const bytes = new Uint8Array(await file.arrayBuffer());
+
+ return streamSSE(c, async (sse) => {
+ const send = (event: ExtractEvent) => sse.writeSSE({ event: event.type, data: JSON.stringify(event) });
+ const stream = streamExtract({
+ schema: preset.schema,
+ document: { data: bytes, filename: file.name },
+ model: model.create(),
+ pricing: model.pricing,
+ });
+ try {
+ for await (const ev of stream) {
+ await send({ type: 'field', path: ev.path, field: ev.field });
+ }
+ const result = await stream.result;
+ await send({ type: 'result', result: toSerialized(result) });
+ } catch (err) {
+ await send({ type: 'error', error: toApiError(err) });
+ }
+ });
+ },
+ );
+
+ return app;
+}
diff --git a/apps/playground/src/server/index.ts b/apps/playground/src/server/index.ts
new file mode 100644
index 0000000..db849b1
--- /dev/null
+++ b/apps/playground/src/server/index.ts
@@ -0,0 +1,35 @@
+import { serve } from '@hono/node-server';
+import { serveStatic } from '@hono/node-server/serve-static';
+import { createApp } from './app';
+import { resolveModels } from './models';
+
+// Load a local .env if present; in deployment the vars come from the real env.
+try {
+ process.loadEnvFile('.env');
+} catch {
+ // No .env file — fall back to the ambient environment.
+}
+
+const models = resolveModels();
+const app = createApp({ models });
+
+// In production the Hono server also serves the built client (same origin as
+// the API). In dev the Vite server serves the client and proxies /api here.
+if (process.env['NODE_ENV'] === 'production') {
+ app.use('/*', serveStatic({ root: './dist/client' }));
+ app.get('*', serveStatic({ path: './dist/client/index.html' }));
+}
+
+const port = Number(process.env['PORT'] ?? 8787);
+
+serve({ fetch: app.fetch, port }, (info) => {
+ console.log(`extractkit playground → http://localhost:${info.port}`);
+ if (models.length === 0) {
+ console.warn(
+ 'No provider API key found. Set ANTHROPIC_API_KEY, OPENAI_API_KEY, or ' +
+ 'GOOGLE_GENERATIVE_AI_API_KEY to enable extraction.',
+ );
+ } else {
+ console.log(`Models available: ${models.map((m) => m.id).join(', ')}`);
+ }
+});
diff --git a/apps/playground/src/server/models.ts b/apps/playground/src/server/models.ts
new file mode 100644
index 0000000..ff5e02d
--- /dev/null
+++ b/apps/playground/src/server/models.ts
@@ -0,0 +1,106 @@
+import { anthropic } from '@ai-sdk/anthropic';
+import { google } from '@ai-sdk/google';
+import { openai } from '@ai-sdk/openai';
+import type { LanguageModel } from 'ai';
+import type { Pricing } from 'extractkit';
+import type { ModelInfo } from '../shared/api';
+
+/** A model the playground can run, plus how to build it and price it. */
+export interface PlaygroundModel {
+ id: string;
+ label: string;
+ provider: string;
+ pricing: Pricing;
+ create: () => LanguageModel;
+}
+
+interface CatalogEntry {
+ /** Provider model id, also used as the client-facing id. */
+ id: string;
+ label: string;
+ provider: string;
+ /** Env var the AI SDK provider reads for its key; gates availability. */
+ apiKeyEnv: string;
+ create: (id: string) => LanguageModel;
+ pricing: Pricing;
+}
+
+/**
+ * A demo-sized lineup: a strong and a cost-efficient model per provider, all
+ * vision-capable. Pricing is the public per-MTok list price (2026-07), matching
+ * the eval harness catalog, so the cost the playground shows is real.
+ */
+const CATALOG: CatalogEntry[] = [
+ {
+ id: 'claude-sonnet-5',
+ label: 'Claude Sonnet 5',
+ provider: 'anthropic',
+ apiKeyEnv: 'ANTHROPIC_API_KEY',
+ create: (id) => anthropic(id),
+ pricing: { inputPerMTokUSD: 3, outputPerMTokUSD: 15 },
+ },
+ {
+ id: 'claude-haiku-4-5',
+ label: 'Claude Haiku 4.5',
+ provider: 'anthropic',
+ apiKeyEnv: 'ANTHROPIC_API_KEY',
+ create: (id) => anthropic(id),
+ pricing: { inputPerMTokUSD: 1, outputPerMTokUSD: 5 },
+ },
+ {
+ id: 'gpt-5.6-luna',
+ label: 'GPT-5.6 Luna',
+ provider: 'openai',
+ apiKeyEnv: 'OPENAI_API_KEY',
+ create: (id) => openai(id),
+ pricing: { inputPerMTokUSD: 1, outputPerMTokUSD: 6 },
+ },
+ {
+ id: 'gpt-5.4-mini',
+ label: 'GPT-5.4 Mini',
+ provider: 'openai',
+ apiKeyEnv: 'OPENAI_API_KEY',
+ create: (id) => openai(id),
+ pricing: { inputPerMTokUSD: 0.75, outputPerMTokUSD: 4.5 },
+ },
+ {
+ id: 'gemini-2.5-flash',
+ label: 'Gemini 2.5 Flash',
+ provider: 'google',
+ apiKeyEnv: 'GOOGLE_GENERATIVE_AI_API_KEY',
+ create: (id) => google(id),
+ pricing: { inputPerMTokUSD: 0.3, outputPerMTokUSD: 2.5 },
+ },
+ {
+ id: 'gemini-2.5-flash-lite',
+ label: 'Gemini 2.5 Flash-Lite',
+ provider: 'google',
+ apiKeyEnv: 'GOOGLE_GENERATIVE_AI_API_KEY',
+ create: (id) => google(id),
+ pricing: { inputPerMTokUSD: 0.1, outputPerMTokUSD: 0.4 },
+ },
+];
+
+/** The models runnable in this environment: those whose provider key is set. */
+export function resolveModels(env: NodeJS.ProcessEnv = process.env): PlaygroundModel[] {
+ return CATALOG.filter((entry) => {
+ const key = env[entry.apiKeyEnv];
+ return key !== undefined && key !== '';
+ }).map((entry) => ({
+ id: entry.id,
+ label: entry.label,
+ provider: entry.provider,
+ pricing: entry.pricing,
+ create: () => entry.create(entry.id),
+ }));
+}
+
+export function modelInfos(models: PlaygroundModel[]): ModelInfo[] {
+ return models.map((m) => ({ id: m.id, label: m.label, provider: m.provider }));
+}
+
+/** The model to preselect: prefer Anthropic Sonnet, else the first available. */
+export function defaultModelId(models: PlaygroundModel[]): string | null {
+ const preferred = models.find((m) => m.id === 'claude-sonnet-5');
+ return preferred?.id ?? models[0]?.id ?? null;
+}
diff --git a/apps/playground/src/server/schemas.ts b/apps/playground/src/server/schemas.ts
new file mode 100644
index 0000000..ffbc301
--- /dev/null
+++ b/apps/playground/src/server/schemas.ts
@@ -0,0 +1,87 @@
+import { z } from 'zod';
+import type { SchemaInfo } from '../shared/api';
+
+/**
+ * Amounts are extracted as strings exactly as printed. Business documents mix
+ * "." and "," as decimal/thousands separators, so parsing them to numbers would
+ * test locale guessing rather than extraction — and a parsed number no longer
+ * maps to a region of the page, which breaks the provenance guarantee. Callers
+ * parse after extraction.
+ */
+const invoiceSchema = z.object({
+ vendorName: z.string().nullable().describe('Legal name of the party issuing the invoice'),
+ invoiceNumber: z.string().nullable().describe('Invoice identifier assigned by the vendor'),
+ issueDate: z.string().nullable().describe('Date the invoice was issued, as printed'),
+ dueDate: z.string().nullable().describe('Payment due date, as printed'),
+ currency: z.string().nullable().describe('Currency code or symbol used for the amounts, e.g. "USD" or "€"'),
+ subtotal: z.string().nullable().describe('Total before tax, as printed'),
+ tax: z.string().nullable().describe('Total tax amount, as printed'),
+ total: z.string().describe('Total amount due, as printed'),
+ lineItems: z
+ .array(
+ z.object({
+ description: z.string().describe('Line item description as printed'),
+ quantity: z.string().nullable().describe('Quantity as printed'),
+ unitPrice: z.string().nullable().describe('Per-unit price as printed'),
+ amount: z.string().nullable().describe('Line total as printed'),
+ }),
+ )
+ .describe('Every line item in printed order'),
+});
+
+const receiptSchema = z.object({
+ merchant: z.string().nullable().describe('Store or merchant name as printed'),
+ date: z.string().nullable().describe('Transaction date, as printed'),
+ lineItems: z
+ .array(
+ z.object({
+ description: z.string().describe('Item name as printed, e.g. "ICE BLACKCOFFE"'),
+ quantity: z.string().nullable().describe('Quantity as printed, e.g. "2" or "1X"'),
+ unitPrice: z.string().nullable().describe('Per-unit price as printed, e.g. "@11000"'),
+ amount: z.string().nullable().describe('Line total as printed, e.g. "24,000"'),
+ }),
+ )
+ .describe('Every purchased item in printed order, including sub-items listed under another item'),
+ subtotal: z.string().nullable().describe('Subtotal before tax/discount/service, as printed'),
+ tax: z.string().nullable().describe('Tax amount as printed'),
+ total: z.string().describe('Final charged total as printed'),
+});
+
+interface Preset {
+ schema: z.ZodObject;
+ label: string;
+ description: string;
+}
+
+/**
+ * The playground offers a fixed set of business-document schemas rather than
+ * accepting arbitrary Zod from the browser — the schema is the library's public
+ * API surface, and running caller-supplied code server-side is out of scope.
+ */
+const PRESETS: Record = {
+ invoice: {
+ schema: invoiceSchema,
+ label: 'Invoice',
+ description: 'Vendor, dates, currency, totals, and line items from an invoice.',
+ },
+ receipt: {
+ schema: receiptSchema,
+ label: 'Receipt',
+ description: 'Merchant, date, line items, and totals from a store receipt.',
+ },
+};
+
+export const DEFAULT_SCHEMA_ID = 'invoice';
+
+export function getPreset(id: string): Preset | undefined {
+ return PRESETS[id];
+}
+
+export function schemaInfos(): SchemaInfo[] {
+ return Object.entries(PRESETS).map(([id, preset]) => ({
+ id,
+ label: preset.label,
+ description: preset.description,
+ topLevelFields: Object.keys(preset.schema.shape),
+ }));
+}
diff --git a/apps/playground/src/shared/api.ts b/apps/playground/src/shared/api.ts
new file mode 100644
index 0000000..2e3d4c6
--- /dev/null
+++ b/apps/playground/src/shared/api.ts
@@ -0,0 +1,62 @@
+import type { ExtractedField, ExtractUsage, FieldPath } from 'extractkit';
+
+/** A single extracted leaf with its provenance, as sent over the wire. */
+export type WireField = ExtractedField;
+
+/** A preset extraction schema the playground offers. */
+export interface SchemaInfo {
+ id: string;
+ label: string;
+ description: string;
+ /** Top-level field names — a quick preview of what the schema pulls out. */
+ topLevelFields: string[];
+}
+
+/** A model the server can run, resolved from the API keys present in its env. */
+export interface ModelInfo {
+ id: string;
+ label: string;
+ provider: string;
+}
+
+/** Everything the client needs to render its controls before any extraction. */
+export interface ConfigResponse {
+ schemas: SchemaInfo[];
+ models: ModelInfo[];
+ /** Ids the client should select initially; `model` is null when no key is set. */
+ defaults: { schema: string; model: string | null };
+}
+
+/**
+ * An extract result with the Zod generic erased for JSON transport. `data`
+ * mirrors the schema; `fields` mirrors it with every leaf replaced by a
+ * WireField (extractkit's FieldMap).
+ */
+export interface SerializedResult {
+ data: unknown;
+ fields: unknown;
+ issues: string[];
+ usage: ExtractUsage;
+ pages: number;
+}
+
+/** A failed extraction, mapped from an extractkit or provider error. */
+export interface ApiError {
+ /** Error class name, e.g. "DocumentError" or "MissingRequiredFieldsError". */
+ name: string;
+ /** extractkit error code when present; null for provider/transport errors. */
+ code: string | null;
+ message: string;
+ /** Set on MissingRequiredFieldsError. */
+ missingPaths?: string[];
+}
+
+/**
+ * SSE payloads streamed from POST /api/extract. Each event's `type` is also its
+ * SSE `event:` name. Fields arrive as they complete; then exactly one terminal
+ * `result` or `error`.
+ */
+export type ExtractEvent =
+ | { type: 'field'; path: FieldPath; field: WireField }
+ | { type: 'result'; result: SerializedResult }
+ | { type: 'error'; error: ApiError };
diff --git a/apps/playground/test/app.test.ts b/apps/playground/test/app.test.ts
new file mode 100644
index 0000000..0330d4f
--- /dev/null
+++ b/apps/playground/test/app.test.ts
@@ -0,0 +1,134 @@
+import { describe, expect, it } from 'vitest';
+import type { ConfigResponse, ExtractEvent } from '../src/shared/api';
+import { createApp } from '../src/server/app';
+import { readSSE } from '../src/client/lib/sse';
+import { invoiceEnvelope, mockPlaygroundModel, tinyPng, unreadableEnvelope } from './helpers';
+
+function appWith(envelope: string) {
+ return createApp({ models: [mockPlaygroundModel('mock-model', envelope)] });
+}
+
+function extractForm(opts: { file?: Blob; schema?: string; model?: string }): FormData {
+ const form = new FormData();
+ if (opts.file !== undefined) form.set('file', opts.file, 'doc.png');
+ if (opts.schema !== undefined) form.set('schema', opts.schema);
+ if (opts.model !== undefined) form.set('model', opts.model);
+ return form;
+}
+
+function pngFile(): File {
+ return new File([tinyPng()], 'doc.png', { type: 'image/png' });
+}
+
+async function collectEvents(body: ReadableStream): Promise {
+ const events: ExtractEvent[] = [];
+ for await (const msg of readSSE(body)) {
+ events.push(JSON.parse(msg.data) as ExtractEvent);
+ }
+ return events;
+}
+
+describe('GET /api/config', () => {
+ it('returns preset schemas, injected models, and defaults', async () => {
+ const app = appWith(invoiceEnvelope());
+ const res = await app.request('/api/config');
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as ConfigResponse;
+
+ expect(body.schemas.map((s) => s.id)).toEqual(['invoice', 'receipt']);
+ const invoice = body.schemas.find((s) => s.id === 'invoice');
+ expect(invoice?.topLevelFields).toContain('total');
+ expect(body.models).toEqual([{ id: 'mock-model', label: 'mock-model', provider: 'mock' }]);
+ expect(body.defaults).toEqual({ schema: 'invoice', model: 'mock-model' });
+ });
+
+ it('reports a null default model when no keys are set', async () => {
+ const app = createApp({ models: [] });
+ const body = (await (await app.request('/api/config')).json()) as ConfigResponse;
+ expect(body.models).toEqual([]);
+ expect(body.defaults.model).toBeNull();
+ });
+});
+
+describe('POST /api/extract', () => {
+ it('streams field events then a validated result', async () => {
+ const app = appWith(invoiceEnvelope());
+ const res = await app.request('/api/extract', {
+ method: 'POST',
+ body: extractForm({ file: pngFile(), schema: 'invoice', model: 'mock-model' }),
+ });
+ expect(res.status).toBe(200);
+ expect(res.headers.get('content-type')).toContain('text/event-stream');
+
+ const events = await collectEvents(res.body as ReadableStream);
+ const fields = events.filter((e) => e.type === 'field');
+ const terminal = events.at(-1);
+
+ expect(fields.length).toBeGreaterThan(0);
+ expect(terminal?.type).toBe('result');
+ if (terminal?.type !== 'result') throw new Error('expected a result event');
+
+ const data = terminal.result.data as { total: string; vendorName: string; lineItems: unknown[] };
+ expect(data.total).toBe('1,250.50');
+ expect(data.vendorName).toBe('Acme Corp');
+ expect(data.lineItems).toHaveLength(1);
+ expect(terminal.result.pages).toBe(1);
+ // pricing was supplied, so cost is computed from the mock's token usage.
+ expect(terminal.result.usage.costUSD).toBeCloseTo((800 * 3 + 200 * 15) / 1_000_000, 10);
+ });
+
+ it('carries per-field provenance through to the result', async () => {
+ const app = appWith(invoiceEnvelope());
+ const res = await app.request('/api/extract', {
+ method: 'POST',
+ body: extractForm({ file: pngFile(), schema: 'invoice', model: 'mock-model' }),
+ });
+ const events = await collectEvents(res.body as ReadableStream);
+ const terminal = events.at(-1);
+ if (terminal?.type !== 'result') throw new Error('expected a result event');
+
+ const fields = terminal.result.fields as { total: { page: number; bbox: { x0: number } } };
+ expect(fields.total.page).toBe(0);
+ expect(fields.total.bbox.x0).toBeCloseTo(0.7, 10);
+ });
+
+ it('emits an error event when the model reports the document unreadable', async () => {
+ const app = appWith(unreadableEnvelope());
+ const res = await app.request('/api/extract', {
+ method: 'POST',
+ body: extractForm({ file: pngFile(), schema: 'invoice', model: 'mock-model' }),
+ });
+ expect(res.status).toBe(200);
+ const events = await collectEvents(res.body as ReadableStream);
+ const terminal = events.at(-1);
+ if (terminal?.type !== 'error') throw new Error('expected an error event');
+ expect(terminal.error.code).toBe('DOCUMENT_UNREADABLE');
+ });
+
+ it('rejects an unknown schema id with 400', async () => {
+ const app = appWith(invoiceEnvelope());
+ const res = await app.request('/api/extract', {
+ method: 'POST',
+ body: extractForm({ file: pngFile(), schema: 'nope', model: 'mock-model' }),
+ });
+ expect(res.status).toBe(400);
+ });
+
+ it('rejects an unknown model id with 400', async () => {
+ const app = appWith(invoiceEnvelope());
+ const res = await app.request('/api/extract', {
+ method: 'POST',
+ body: extractForm({ file: pngFile(), schema: 'invoice', model: 'ghost' }),
+ });
+ expect(res.status).toBe(400);
+ });
+
+ it('rejects a request with no file with 400', async () => {
+ const app = appWith(invoiceEnvelope());
+ const res = await app.request('/api/extract', {
+ method: 'POST',
+ body: extractForm({ schema: 'invoice', model: 'mock-model' }),
+ });
+ expect(res.status).toBe(400);
+ });
+});
diff --git a/apps/playground/test/fields.test.ts b/apps/playground/test/fields.test.ts
new file mode 100644
index 0000000..9e4f531
--- /dev/null
+++ b/apps/playground/test/fields.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it } from 'vitest';
+import {
+ flattenFields,
+ formatPath,
+ formatValue,
+ isField,
+ leafLabel,
+ pathKey,
+ valueAtPath,
+} from '../src/client/lib/fields';
+
+const leaf = (value: unknown) => ({ value, confidence: 0.9, page: 0, bbox: null });
+
+describe('isField', () => {
+ it('recognizes a provenance leaf', () => {
+ expect(isField(leaf('x'))).toBe(true);
+ expect(isField(leaf(null))).toBe(true);
+ });
+
+ it('rejects branches and primitives', () => {
+ expect(isField({ total: leaf('x') })).toBe(false);
+ expect(isField(null)).toBe(false);
+ expect(isField('x')).toBe(false);
+ expect(isField({ value: 1, confidence: 'high', page: 0, bbox: null })).toBe(false);
+ });
+});
+
+describe('flattenFields', () => {
+ it('walks objects and arrays into leaf paths', () => {
+ const fields = {
+ total: leaf('10'),
+ lineItems: [{ description: leaf('a') }, { description: leaf('b') }],
+ };
+ const flat = flattenFields(fields);
+ expect(flat.map((f) => f.path)).toEqual([
+ ['total'],
+ ['lineItems', 0, 'description'],
+ ['lineItems', 1, 'description'],
+ ]);
+ expect(flat[0]?.field.value).toBe('10');
+ });
+
+ it('skips null branches', () => {
+ expect(flattenFields({ a: null, b: leaf('x') })).toHaveLength(1);
+ });
+});
+
+describe('valueAtPath', () => {
+ const data = { total: '10', lineItems: [{ description: 'a' }] };
+ it('reads nested values', () => {
+ expect(valueAtPath(data, ['total'])).toBe('10');
+ expect(valueAtPath(data, ['lineItems', 0, 'description'])).toBe('a');
+ });
+ it('returns undefined off the end of the tree', () => {
+ expect(valueAtPath(data, ['lineItems', 5, 'description'])).toBeUndefined();
+ expect(valueAtPath(null, ['total'])).toBeUndefined();
+ });
+});
+
+describe('path and value formatting', () => {
+ it('formats paths with dotted keys and bracketed indices', () => {
+ expect(formatPath(['lineItems', 0, 'amount'])).toBe('lineItems[0].amount');
+ expect(formatPath(['total'])).toBe('total');
+ expect(formatPath([])).toBe('(root)');
+ });
+ it('labels a leaf by its last segment', () => {
+ expect(leafLabel(['lineItems', 2, 'amount'])).toBe('amount');
+ expect(leafLabel(['lineItems', 2])).toBe('[2]');
+ });
+ it('renders values, with a dash for null', () => {
+ expect(formatValue('USD')).toBe('USD');
+ expect(formatValue(null)).toBe('—');
+ expect(formatValue('')).toBe('(empty)');
+ expect(formatValue(false)).toBe('false');
+ });
+ it('keys a path stably', () => {
+ expect(pathKey(['lineItems', 0])).toBe('["lineItems",0]');
+ });
+});
diff --git a/apps/playground/test/geometry.test.ts b/apps/playground/test/geometry.test.ts
new file mode 100644
index 0000000..3982784
--- /dev/null
+++ b/apps/playground/test/geometry.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from 'vitest';
+import { bboxToStyle, fitScale } from '../src/client/lib/geometry';
+
+describe('bboxToStyle', () => {
+ it('maps a normalized box to percentage offsets and size', () => {
+ expect(bboxToStyle({ x0: 0.1, y0: 0.2, x1: 0.5, y1: 0.35 })).toEqual({
+ left: '10.000%',
+ top: '20.000%',
+ width: '40.000%',
+ height: '15.000%',
+ });
+ });
+
+ it('orders swapped corners into a valid rectangle', () => {
+ expect(bboxToStyle({ x0: 0.5, y0: 0.35, x1: 0.1, y1: 0.2 })).toEqual({
+ left: '10.000%',
+ top: '20.000%',
+ width: '40.000%',
+ height: '15.000%',
+ });
+ });
+
+ it('clamps out-of-range coordinates to [0, 1]', () => {
+ expect(bboxToStyle({ x0: -0.2, y0: 0, x1: 1.4, y1: 1 })).toEqual({
+ left: '0.000%',
+ top: '0.000%',
+ width: '100.000%',
+ height: '100.000%',
+ });
+ });
+});
+
+describe('fitScale', () => {
+ it('scales down to fit and never upscales past 1×', () => {
+ expect(fitScale(1000, 500)).toBe(0.5);
+ expect(fitScale(400, 800)).toBe(1);
+ });
+
+ it('is safe for degenerate inputs', () => {
+ expect(fitScale(0, 500)).toBe(1);
+ expect(fitScale(500, 0)).toBe(1);
+ });
+});
diff --git a/apps/playground/test/helpers.ts b/apps/playground/test/helpers.ts
new file mode 100644
index 0000000..b891355
--- /dev/null
+++ b/apps/playground/test/helpers.ts
@@ -0,0 +1,98 @@
+import { MockLanguageModelV4, simulateReadableStream } from 'ai/test';
+import type { LanguageModel } from 'ai';
+import type { Pricing } from 'extractkit';
+import type { PlaygroundModel } from '../src/server/models';
+
+/** A 12-byte buffer with a valid PNG signature — enough for normalizeDocument. */
+export function tinyPng(): Uint8Array {
+ return new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d]);
+}
+
+function splitEvery(text: string, size: number): string[] {
+ const parts: string[] = [];
+ for (let i = 0; i < text.length; i += size) parts.push(text.slice(i, i + size));
+ return parts;
+}
+
+/** A mock model that streams `text` back in small chunks as one response. */
+export function streamingModel(text: string): LanguageModel {
+ return new MockLanguageModelV4({
+ doStream: async () => ({
+ stream: simulateReadableStream({
+ chunks: [
+ { type: 'text-start' as const, id: '1' },
+ ...splitEvery(text, 24).map((delta) => ({ type: 'text-delta' as const, id: '1', delta })),
+ { type: 'text-end' as const, id: '1' },
+ {
+ type: 'finish' as const,
+ finishReason: { unified: 'stop' as const, raw: undefined },
+ usage: {
+ inputTokens: { total: 800, noCache: 800, cacheRead: undefined, cacheWrite: undefined },
+ outputTokens: { total: 200, text: 200, reasoning: undefined },
+ },
+ },
+ ],
+ }),
+ }),
+ });
+}
+
+/** Wire-format leaf with plausible provenance defaults (bbox is a 4-number array). */
+export function wireLeaf(value: unknown, over: Record = {}) {
+ const missing = value === null || value === undefined;
+ return {
+ value,
+ page: missing ? null : 0,
+ bbox: missing ? null : [0.1, 0.2, 0.4, 0.26],
+ confidence: missing ? 0 : 0.94,
+ ...over,
+ };
+}
+
+/** Valid wire `fields` for the invoice preset schema (every leaf present). */
+function invoiceFields() {
+ return {
+ vendorName: wireLeaf('Acme Corp', { bbox: [0.08, 0.05, 0.4, 0.09] }),
+ invoiceNumber: wireLeaf('INV-2026-014', { bbox: [0.62, 0.05, 0.92, 0.09] }),
+ issueDate: wireLeaf('2026-07-01'),
+ dueDate: wireLeaf('2026-07-31'),
+ currency: wireLeaf('USD'),
+ subtotal: wireLeaf('1,200.00'),
+ tax: wireLeaf('50.50'),
+ total: wireLeaf('1,250.50', { bbox: [0.7, 0.8, 0.92, 0.84], confidence: 0.99 }),
+ lineItems: [
+ {
+ description: wireLeaf('Consulting services', { bbox: [0.08, 0.4, 0.5, 0.44] }),
+ quantity: wireLeaf('10'),
+ unitPrice: wireLeaf('120.00'),
+ amount: wireLeaf('1,200.00'),
+ },
+ ],
+ };
+}
+
+/** A complete, valid wire envelope for the invoice preset schema. */
+export function invoiceEnvelope(): string {
+ return JSON.stringify({ readable: true, issues: [], fields: invoiceFields() });
+}
+
+/**
+ * An "unreadable document" envelope: the wire schema still requires `fields`, so
+ * they are present and valid — only the `readable` flag drives the error.
+ */
+export function unreadableEnvelope(): string {
+ return JSON.stringify({ readable: false, issues: ['Page is blank.'], fields: invoiceFields() });
+}
+
+const DEMO_PRICING: Pricing = { inputPerMTokUSD: 3, outputPerMTokUSD: 15 };
+
+/** A PlaygroundModel backed by a mock that streams `envelope`. */
+export function mockPlaygroundModel(id: string, envelope: string): PlaygroundModel {
+ return {
+ id,
+ label: id,
+ provider: 'mock',
+ pricing: DEMO_PRICING,
+ create: () => streamingModel(envelope),
+ };
+}
diff --git a/apps/playground/test/models.test.ts b/apps/playground/test/models.test.ts
new file mode 100644
index 0000000..f01e434
--- /dev/null
+++ b/apps/playground/test/models.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from 'vitest';
+import { defaultModelId, modelInfos, resolveModels } from '../src/server/models';
+
+describe('resolveModels', () => {
+ it('offers only models whose provider key is set', () => {
+ const models = resolveModels({ OPENAI_API_KEY: 'sk-test' });
+ expect(models.length).toBeGreaterThan(0);
+ expect(models.every((m) => m.provider === 'openai')).toBe(true);
+ });
+
+ it('treats an empty-string key as unset', () => {
+ expect(resolveModels({ ANTHROPIC_API_KEY: '' })).toEqual([]);
+ });
+
+ it('combines providers when several keys are present', () => {
+ const providers = new Set(
+ resolveModels({ ANTHROPIC_API_KEY: 'a', GOOGLE_GENERATIVE_AI_API_KEY: 'g' }).map((m) => m.provider),
+ );
+ expect(providers).toEqual(new Set(['anthropic', 'google']));
+ });
+
+ it('returns nothing when no key is present', () => {
+ expect(resolveModels({})).toEqual([]);
+ });
+});
+
+describe('defaultModelId', () => {
+ it('prefers Claude Sonnet when available', () => {
+ expect(defaultModelId(resolveModels({ ANTHROPIC_API_KEY: 'a' }))).toBe('claude-sonnet-5');
+ });
+ it('falls back to the first available model', () => {
+ expect(defaultModelId(resolveModels({ OPENAI_API_KEY: 'o' }))).toBe('gpt-5.6-luna');
+ });
+ it('is null when there are no models', () => {
+ expect(defaultModelId([])).toBeNull();
+ });
+});
+
+describe('modelInfos', () => {
+ it('exposes only id, label, and provider', () => {
+ const infos = modelInfos(resolveModels({ ANTHROPIC_API_KEY: 'a' }));
+ expect(infos[0]).toEqual({ id: 'claude-sonnet-5', label: 'Claude Sonnet 5', provider: 'anthropic' });
+ expect(Object.keys(infos[0] ?? {})).toEqual(['id', 'label', 'provider']);
+ });
+});
diff --git a/apps/playground/test/schemas.test.ts b/apps/playground/test/schemas.test.ts
new file mode 100644
index 0000000..9204342
--- /dev/null
+++ b/apps/playground/test/schemas.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from 'vitest';
+import { DEFAULT_SCHEMA_ID, getPreset, schemaInfos } from '../src/server/schemas';
+
+describe('schemaInfos', () => {
+ it('lists the invoice and receipt presets with their top-level fields', () => {
+ const infos = schemaInfos();
+ expect(infos.map((s) => s.id)).toEqual(['invoice', 'receipt']);
+ const invoice = infos.find((s) => s.id === 'invoice');
+ expect(invoice?.topLevelFields).toEqual([
+ 'vendorName',
+ 'invoiceNumber',
+ 'issueDate',
+ 'dueDate',
+ 'currency',
+ 'subtotal',
+ 'tax',
+ 'total',
+ 'lineItems',
+ ]);
+ });
+});
+
+describe('getPreset', () => {
+ it('returns a parseable Zod schema for a known id', () => {
+ const preset = getPreset('receipt');
+ expect(preset).toBeDefined();
+ const parsed = preset?.schema.safeParse({
+ merchant: 'Cafe',
+ date: null,
+ lineItems: [{ description: 'Coffee', quantity: '1', unitPrice: '3.00', amount: '3.00' }],
+ subtotal: '3.00',
+ tax: '0.00',
+ total: '3.00',
+ });
+ expect(parsed?.success).toBe(true);
+ });
+
+ it('is undefined for an unknown id', () => {
+ expect(getPreset('contract')).toBeUndefined();
+ });
+
+ it('names a valid default schema', () => {
+ expect(getPreset(DEFAULT_SCHEMA_ID)).toBeDefined();
+ });
+});
diff --git a/apps/playground/test/sse.test.ts b/apps/playground/test/sse.test.ts
new file mode 100644
index 0000000..b33a7a3
--- /dev/null
+++ b/apps/playground/test/sse.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from 'vitest';
+import { readSSE } from '../src/client/lib/sse';
+
+function streamFrom(chunks: string[]): ReadableStream {
+ const encoder = new TextEncoder();
+ return new ReadableStream({
+ start(controller) {
+ for (const chunk of chunks) controller.enqueue(encoder.encode(chunk));
+ controller.close();
+ },
+ });
+}
+
+async function collect(chunks: string[]) {
+ const out: Array<{ event: string; data: string }> = [];
+ for await (const message of readSSE(streamFrom(chunks))) out.push(message);
+ return out;
+}
+
+describe('readSSE', () => {
+ it('parses event and data fields', async () => {
+ const out = await collect(['event: field\ndata: {"a":1}\n\n', 'event: result\ndata: {"b":2}\n\n']);
+ expect(out).toEqual([
+ { event: 'field', data: '{"a":1}' },
+ { event: 'result', data: '{"b":2}' },
+ ]);
+ });
+
+ it('reassembles records split across chunk boundaries', async () => {
+ const out = await collect(['event: fie', 'ld\ndata: {"a"', ':1}\n\nevent: result\ndata:{"b":2}\n\n']);
+ expect(out.map((m) => m.event)).toEqual(['field', 'result']);
+ expect(out[0]?.data).toBe('{"a":1}');
+ expect(out[1]?.data).toBe('{"b":2}');
+ });
+
+ it('defaults the event name and ignores comments and blank lines', async () => {
+ const out = await collect([': keep-alive\ndata: hello\n\n']);
+ expect(out).toEqual([{ event: 'message', data: 'hello' }]);
+ });
+
+ it('joins multi-line data with newlines', async () => {
+ const out = await collect(['data: line1\ndata: line2\n\n']);
+ expect(out[0]?.data).toBe('line1\nline2');
+ });
+
+ it('handles CRLF line endings across multiple records', async () => {
+ const out = await collect(['event: field\r\ndata: {"a":1}\r\n\r\nevent: result\r\ndata: {"b":2}\r\n\r\n']);
+ expect(out).toEqual([
+ { event: 'field', data: '{"a":1}' },
+ { event: 'result', data: '{"b":2}' },
+ ]);
+ });
+
+ it('handles a CRLF pair split across chunk boundaries', async () => {
+ const out = await collect(['event: field\r\ndata: {"a":1}\r', '\n\r\nevent: result\r\ndata: {"b":2}\r\n\r\n']);
+ expect(out.map((m) => m.event)).toEqual(['field', 'result']);
+ });
+
+ it('emits a trailing record with no final blank line', async () => {
+ const out = await collect(['event: result\ndata: done']);
+ expect(out).toEqual([{ event: 'result', data: 'done' }]);
+ });
+});
diff --git a/apps/playground/test/upload.test.ts b/apps/playground/test/upload.test.ts
new file mode 100644
index 0000000..af98654
--- /dev/null
+++ b/apps/playground/test/upload.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from 'vitest';
+import { MAX_UPLOAD_BYTES, isPdf, pickFile, validateFile } from '../src/client/lib/upload';
+
+function file(name: string, type: string, size = 100): File {
+ const f = new File([new Uint8Array(4)], name, { type });
+ Object.defineProperty(f, 'size', { value: size });
+ return f;
+}
+
+describe('validateFile', () => {
+ it('accepts supported documents', () => {
+ expect(validateFile(file('invoice.pdf', 'application/pdf'))).toBeNull();
+ expect(validateFile(file('receipt.PNG', 'image/png'))).toBeNull();
+ expect(validateFile(file('scan.jpg', 'image/jpeg'))).toBeNull();
+ });
+
+ it('accepts by extension when the browser reports no type', () => {
+ expect(validateFile(file('receipt.webp', ''))).toBeNull();
+ });
+
+ it('rejects unsupported types', () => {
+ expect(validateFile(file('notes.txt', 'text/plain'))).toMatch(/unsupported/i);
+ expect(validateFile(file('archive.zip', ''))).toMatch(/unsupported/i);
+ });
+
+ it('rejects files over the size limit', () => {
+ expect(validateFile(file('big.pdf', 'application/pdf', MAX_UPLOAD_BYTES + 1))).toMatch(/15 MB/);
+ });
+});
+
+describe('isPdf', () => {
+ it('detects PDFs by type or extension', () => {
+ expect(isPdf(file('a.pdf', 'application/pdf'))).toBe(true);
+ expect(isPdf(file('a.PDF', ''))).toBe(true);
+ expect(isPdf(file('a.png', 'image/png'))).toBe(false);
+ });
+});
+
+describe('pickFile', () => {
+ it('returns the first acceptable file', () => {
+ const result = pickFile([file('a.pdf', 'application/pdf')]);
+ expect('file' in result).toBe(true);
+ });
+
+ it('reports an error for an empty selection', () => {
+ expect(pickFile(null)).toEqual({ error: 'No file selected.' });
+ expect(pickFile([])).toEqual({ error: 'No file selected.' });
+ });
+
+ it('reports the validation error for an unsupported file', () => {
+ const result = pickFile([file('a.txt', 'text/plain')]);
+ expect('error' in result).toBe(true);
+ });
+});
diff --git a/apps/playground/tsconfig.json b/apps/playground/tsconfig.json
new file mode 100644
index 0000000..32808f2
--- /dev/null
+++ b/apps/playground/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
+ "jsx": "react-jsx",
+ "types": ["vite/client"]
+ },
+ "include": ["src/client", "src/shared"]
+}
diff --git a/apps/playground/tsconfig.node.json b/apps/playground/tsconfig.node.json
new file mode 100644
index 0000000..a3e22db
--- /dev/null
+++ b/apps/playground/tsconfig.node.json
@@ -0,0 +1,7 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "types": ["node"]
+ },
+ "include": ["src/server", "src/shared", "test", "vite.config.ts", "vitest.config.ts"]
+}
diff --git a/apps/playground/vite.config.ts b/apps/playground/vite.config.ts
new file mode 100644
index 0000000..d9e996e
--- /dev/null
+++ b/apps/playground/vite.config.ts
@@ -0,0 +1,23 @@
+import react from '@vitejs/plugin-react';
+import { defineConfig, loadEnv } from 'vite';
+
+// The client is a static SPA served by Vite in dev and by the Hono server in
+// prod. In dev, /api is proxied to the Hono server so the browser sees one
+// origin. PORT is read from the same .env the server loads, so they stay in sync.
+export default defineConfig(({ mode }) => {
+ const env = loadEnv(mode, process.cwd(), '');
+ const apiPort = env['PORT'] ?? '8787';
+ return {
+ plugins: [react()],
+ server: {
+ port: 5173,
+ proxy: {
+ '/api': `http://localhost:${apiPort}`,
+ },
+ },
+ build: {
+ outDir: 'dist/client',
+ emptyOutDir: true,
+ },
+ };
+});
diff --git a/apps/playground/vitest.config.ts b/apps/playground/vitest.config.ts
new file mode 100644
index 0000000..43e56f4
--- /dev/null
+++ b/apps/playground/vitest.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ include: ['test/**/*.test.ts'],
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f10c6be..7000eab 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,6 +8,70 @@ importers:
.: {}
+ apps/playground:
+ dependencies:
+ '@ai-sdk/anthropic':
+ specifier: ^4.0.10
+ version: 4.0.10(zod@4.4.3)
+ '@ai-sdk/google':
+ specifier: ^4.0.12
+ version: 4.0.12(zod@4.4.3)
+ '@ai-sdk/openai':
+ specifier: ^4.0.11
+ version: 4.0.11(zod@4.4.3)
+ '@hono/node-server':
+ specifier: ^2.0.8
+ version: 2.0.8(hono@4.12.29)
+ ai:
+ specifier: ^7.0.16
+ version: 7.0.16(zod@4.4.3)
+ extractkit:
+ specifier: workspace:*
+ version: link:../../packages/core
+ hono:
+ specifier: ^4.12.29
+ version: 4.12.29
+ pdfjs-dist:
+ specifier: ^6.1.200
+ version: 6.1.200
+ react:
+ specifier: ^19.2.7
+ version: 19.2.7
+ react-dom:
+ specifier: ^19.2.7
+ version: 19.2.7(react@19.2.7)
+ zod:
+ specifier: ^4.4.3
+ version: 4.4.3
+ devDependencies:
+ '@types/node':
+ specifier: ^26.1.0
+ version: 26.1.0
+ '@types/react':
+ specifier: ^19.2.17
+ version: 19.2.17
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.3(@types/react@19.2.17)
+ '@vitejs/plugin-react':
+ specifier: ^6.0.3
+ version: 6.0.3(vite@8.1.4(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0))
+ concurrently:
+ specifier: ^10.0.3
+ version: 10.0.3
+ tsx:
+ specifier: ^4.23.0
+ version: 4.23.0
+ typescript:
+ specifier: ^6.0.3
+ version: 6.0.3
+ vite:
+ specifier: ^8.1.4
+ version: 8.1.4(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0)
+ vitest:
+ specifier: ^4.1.10
+ version: 4.1.10(@types/node@26.1.0)(vite@8.1.4(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0))
+
packages/core:
dependencies:
pdf-lib:
@@ -28,7 +92,7 @@ importers:
version: 6.0.3
vitest:
specifier: ^4.1.10
- version: 4.1.10(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0))
+ version: 4.1.10(@types/node@26.1.0)(vite@8.1.4(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0))
zod:
specifier: ^4.4.3
version: 4.4.3
@@ -68,7 +132,7 @@ importers:
version: 6.0.3
vitest:
specifier: ^4.1.10
- version: 4.1.10(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0))
+ version: 4.1.10(@types/node@26.1.0)(vite@8.1.4(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0))
packages:
@@ -308,6 +372,12 @@ packages:
cpu: [x64]
os: [win32]
+ '@hono/node-server@2.0.8':
+ resolution: {integrity: sha512-GuCWzLxwg218fy1JaHculFsdcuY12hxit83V+algozTPnwhNjLrRL/Alg9OYjLZLoUZ1rw/S4CdTMsnkSKCmFA==}
+ engines: {node: '>=20'}
+ peerDependencies:
+ hono: ^4
+
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -321,6 +391,76 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+ '@napi-rs/canvas-android-arm64@1.0.2':
+ resolution: {integrity: sha512-IMXKVQod0ol4vt3gmClUfXz4JAgHYESGPCUqmH3lQxBoL0K/2greJaQE1HVBVxWWFKfLc4OLZVdxg7kXVyXv+g==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [android]
+
+ '@napi-rs/canvas-darwin-arm64@1.0.2':
+ resolution: {integrity: sha512-Sc8tPi6cF+5lqOzCCKFALJHhDiRwyMzTPYm3bbhdXsOunU0lQO5f05ucyOzN2r55I23Hg5bsjH63uSCvWp3EgQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@napi-rs/canvas-darwin-x64@1.0.2':
+ resolution: {integrity: sha512-niDXZ9LhKB1zLrUdYB64RHQFDGz9rr0eGx061qtJJU3U20EMMIx28ADF5fVYbhtOgkWQrBjFicfaye1yM0U62A==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@napi-rs/canvas-linux-arm-gnueabihf@1.0.2':
+ resolution: {integrity: sha512-sgatQL9JxGRH/Amzcvu0P3t8Am3duou74CisfuJ41Dwt8cWy723z/9KZ8LlgmxfypEwEZxSTNFJtU8d281lmhQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm]
+ os: [linux]
+
+ '@napi-rs/canvas-linux-arm64-gnu@1.0.2':
+ resolution: {integrity: sha512-dgKuX0peF3xwY6ZF5QxGS4wbfDqpoFAJYXiLSp+guZKARQUKMkRqZSDrXKj7nfrec3UCMzC0PFCPte0ES98AiA==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@napi-rs/canvas-linux-arm64-musl@1.0.2':
+ resolution: {integrity: sha512-qwROoDIC9upfvDoRLuPn2aNg9CGW1x0Ygr4k2Or+8paA9d0qBLwk87U+g8KQpoOviKoPoiwl97kvBYuYD7qZoA==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@napi-rs/canvas-linux-riscv64-gnu@1.0.2':
+ resolution: {integrity: sha512-fXRjnPihdnbO6qy1QQOgxAonb68A0TCEG7rj1x7v7rxNElsE8EVIKIEUTvyDtU+sthYSbX+8e7g3oZiLGnOmxw==}
+ engines: {node: '>= 10'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@napi-rs/canvas-linux-x64-gnu@1.0.2':
+ resolution: {integrity: sha512-nPR97DXhbWIAy7yazF3jc06kEPMqYMLmPzFOVNlwKPfIoSChnI+x7dc0hTLaihz3jxrjL6j4BbA7earxfx4X3g==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+
+ '@napi-rs/canvas-linux-x64-musl@1.0.2':
+ resolution: {integrity: sha512-l7zZY5+jL5qnBZtDz7CoBtY6p7EkHu422g/0zWwrOrzIwWyWxZFRfZZORY1UG7YApymPLx+UbOkN206xXn/c1Q==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+
+ '@napi-rs/canvas-win32-arm64-msvc@1.0.2':
+ resolution: {integrity: sha512-yE0koHCFF4PIbMc2o2SEALhnipz7WBISh5glLvQiomtIoCcW0np3H4Lw93ceJAfJttTTeIIWFbwH84F7EVzjMQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@napi-rs/canvas-win32-x64-msvc@1.0.2':
+ resolution: {integrity: sha512-okU8/t2foV6C31n0GtvEMbfD5rOFc70+/6xUNME9Guld29sgSOIGUEDScAWFlcP3k5TYQRl9TNkwJEEjh15w8A==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [win32]
+
+ '@napi-rs/canvas@1.0.2':
+ resolution: {integrity: sha512-EYEqlMYaCbpZDz+IgDH5xp9MTd3ui4dmGqbQYryhMLnSRxrhHKq5KQWHHKxFUcEP4Hp8/BWgvqXocX4j7iSbOQ==}
+ engines: {node: '>= 10'}
+
'@napi-rs/wasm-runtime@1.1.6':
resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
peerDependencies:
@@ -452,10 +592,31 @@ packages:
'@types/node@26.1.0':
resolution: {integrity: sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==}
+ '@types/react-dom@19.2.3':
+ resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
+ peerDependencies:
+ '@types/react': ^19.2.0
+
+ '@types/react@19.2.17':
+ resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
+
'@vercel/oidc@3.2.0':
resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==}
engines: {node: '>= 20'}
+ '@vitejs/plugin-react@6.0.3':
+ resolution: {integrity: sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==}
+ 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
+ vite: ^8.0.0
+ peerDependenciesMeta:
+ '@rolldown/plugin-babel':
+ optional: true
+ babel-plugin-react-compiler:
+ optional: true
+
'@vitest/expect@4.1.10':
resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==}
@@ -494,6 +655,14 @@ packages:
peerDependencies:
zod: ^3.25.76 || ^4.1.8
+ ansi-regex@6.2.2:
+ resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
+ engines: {node: '>=12'}
+
+ ansi-styles@6.2.3:
+ resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
+ engines: {node: '>=12'}
+
ansis@4.3.1:
resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==}
engines: {node: '>=14'}
@@ -517,9 +686,25 @@ packages:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
+ chalk@5.6.2:
+ resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
+ engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
+
+ cliui@9.0.1:
+ resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==}
+ engines: {node: '>=20'}
+
+ concurrently@10.0.3:
+ resolution: {integrity: sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==}
+ engines: {node: '>=22'}
+ hasBin: true
+
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+ csstype@3.2.3:
+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
defu@6.1.7:
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
@@ -536,6 +721,9 @@ packages:
oxc-resolver:
optional: true
+ emoji-regex@10.6.0:
+ resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
+
empathic@2.0.1:
resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==}
engines: {node: '>=14'}
@@ -548,6 +736,10 @@ packages:
engines: {node: '>=18'}
hasBin: true
+ escalade@3.2.0:
+ resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
+ engines: {node: '>=6'}
+
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
@@ -573,10 +765,22 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
+ get-caller-file@2.0.5:
+ resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
+ engines: {node: 6.* || 8.* || >= 10.*}
+
+ get-east-asian-width@1.6.0:
+ resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==}
+ engines: {node: '>=18'}
+
get-tsconfig@5.0.0-beta.5:
resolution: {integrity: sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==}
engines: {node: '>=20.20.0'}
+ hono@4.12.29:
+ resolution: {integrity: sha512-1hNiRjawYrLq/4m3DQQjPGFg0VZkk4RjQJDff/excI6Dm9BiL75qxGrd7/c6YOxPdq6AscP3LiXhQ6fKFC1Waw==}
+ engines: {node: '>=16.9.0'}
+
hookable@6.1.1:
resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==}
@@ -686,6 +890,10 @@ packages:
pdf-lib@1.17.1:
resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==}
+ pdfjs-dist@6.1.200:
+ resolution: {integrity: sha512-o8MolyzirkkLrcdsae/HEOiIcXWI7DS5zGpvqW8xTC2YUsW30rltFw2bDGvw/fskUdEMrQm2br68jzDS5BH2vw==}
+ engines: {node: '>=22.13.0 || >=24'}
+
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -700,6 +908,15 @@ packages:
quansync@1.0.0:
resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==}
+ react-dom@19.2.7:
+ resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==}
+ peerDependencies:
+ react: ^19.2.7
+
+ react@19.2.7:
+ resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==}
+ engines: {node: '>=0.10.0'}
+
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
@@ -727,11 +944,21 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
+ rxjs@7.8.2:
+ resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
+
+ scheduler@0.27.0:
+ resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
+
semver@7.8.5:
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
engines: {node: '>=10'}
hasBin: true
+ shell-quote@1.8.4:
+ resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==}
+ engines: {node: '>= 0.4'}
+
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
@@ -745,6 +972,18 @@ packages:
std-env@4.1.0:
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
+ string-width@7.2.0:
+ resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
+ engines: {node: '>=18'}
+
+ strip-ansi@7.2.0:
+ resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
+ engines: {node: '>=12'}
+
+ supports-color@10.2.2:
+ resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
+ engines: {node: '>=18'}
+
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -820,8 +1059,8 @@ packages:
undici-types@8.3.0:
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
- vite@8.1.3:
- resolution: {integrity: sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==}
+ vite@8.1.4:
+ resolution: {integrity: sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
@@ -909,6 +1148,22 @@ packages:
engines: {node: '>=8'}
hasBin: true
+ wrap-ansi@9.0.2:
+ resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
+ engines: {node: '>=18'}
+
+ y18n@5.0.8:
+ resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
+ engines: {node: '>=10'}
+
+ yargs-parser@22.0.0:
+ resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=23}
+
+ yargs@18.0.0:
+ resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=23}
+
zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
@@ -1087,6 +1342,10 @@ snapshots:
'@esbuild/win32-x64@0.28.1':
optional: true
+ '@hono/node-server@2.0.8(hono@4.12.29)':
+ dependencies:
+ hono: 4.12.29
+
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -1101,6 +1360,54 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
+ '@napi-rs/canvas-android-arm64@1.0.2':
+ optional: true
+
+ '@napi-rs/canvas-darwin-arm64@1.0.2':
+ optional: true
+
+ '@napi-rs/canvas-darwin-x64@1.0.2':
+ optional: true
+
+ '@napi-rs/canvas-linux-arm-gnueabihf@1.0.2':
+ optional: true
+
+ '@napi-rs/canvas-linux-arm64-gnu@1.0.2':
+ optional: true
+
+ '@napi-rs/canvas-linux-arm64-musl@1.0.2':
+ optional: true
+
+ '@napi-rs/canvas-linux-riscv64-gnu@1.0.2':
+ optional: true
+
+ '@napi-rs/canvas-linux-x64-gnu@1.0.2':
+ optional: true
+
+ '@napi-rs/canvas-linux-x64-musl@1.0.2':
+ optional: true
+
+ '@napi-rs/canvas-win32-arm64-msvc@1.0.2':
+ optional: true
+
+ '@napi-rs/canvas-win32-x64-msvc@1.0.2':
+ optional: true
+
+ '@napi-rs/canvas@1.0.2':
+ optionalDependencies:
+ '@napi-rs/canvas-android-arm64': 1.0.2
+ '@napi-rs/canvas-darwin-arm64': 1.0.2
+ '@napi-rs/canvas-darwin-x64': 1.0.2
+ '@napi-rs/canvas-linux-arm-gnueabihf': 1.0.2
+ '@napi-rs/canvas-linux-arm64-gnu': 1.0.2
+ '@napi-rs/canvas-linux-arm64-musl': 1.0.2
+ '@napi-rs/canvas-linux-riscv64-gnu': 1.0.2
+ '@napi-rs/canvas-linux-x64-gnu': 1.0.2
+ '@napi-rs/canvas-linux-x64-musl': 1.0.2
+ '@napi-rs/canvas-win32-arm64-msvc': 1.0.2
+ '@napi-rs/canvas-win32-x64-msvc': 1.0.2
+ optional: true
+
'@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)':
dependencies:
'@emnapi/core': 1.11.1
@@ -1195,8 +1502,21 @@ snapshots:
dependencies:
undici-types: 8.3.0
+ '@types/react-dom@19.2.3(@types/react@19.2.17)':
+ dependencies:
+ '@types/react': 19.2.17
+
+ '@types/react@19.2.17':
+ dependencies:
+ csstype: 3.2.3
+
'@vercel/oidc@3.2.0': {}
+ '@vitejs/plugin-react@6.0.3(vite@8.1.4(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0))':
+ dependencies:
+ '@rolldown/pluginutils': 1.0.1
+ vite: 8.1.4(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0)
+
'@vitest/expect@4.1.10':
dependencies:
'@standard-schema/spec': 1.1.0
@@ -1206,13 +1526,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.0
- '@vitest/mocker@4.1.10(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0))':
+ '@vitest/mocker@4.1.10(vite@8.1.4(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0))':
dependencies:
'@vitest/spy': 4.1.10
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
- vite: 8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0)
+ vite: 8.1.4(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0)
'@vitest/pretty-format@4.1.10':
dependencies:
@@ -1247,6 +1567,10 @@ snapshots:
'@ai-sdk/provider-utils': 5.0.5(zod@4.4.3)
zod: 4.4.3
+ ansi-regex@6.2.2: {}
+
+ ansi-styles@6.2.3: {}
+
ansis@4.3.1: {}
assertion-error@2.0.1: {}
@@ -1263,14 +1587,35 @@ snapshots:
chai@6.2.2: {}
+ chalk@5.6.2: {}
+
+ cliui@9.0.1:
+ dependencies:
+ string-width: 7.2.0
+ strip-ansi: 7.2.0
+ wrap-ansi: 9.0.2
+
+ concurrently@10.0.3:
+ dependencies:
+ chalk: 5.6.2
+ rxjs: 7.8.2
+ shell-quote: 1.8.4
+ supports-color: 10.2.2
+ tree-kill: 1.2.2
+ yargs: 18.0.0
+
convert-source-map@2.0.0: {}
+ csstype@3.2.3: {}
+
defu@6.1.7: {}
detect-libc@2.1.2: {}
dts-resolver@3.0.0: {}
+ emoji-regex@10.6.0: {}
+
empathic@2.0.1: {}
es-module-lexer@2.3.0: {}
@@ -1304,6 +1649,8 @@ snapshots:
'@esbuild/win32-ia32': 0.28.1
'@esbuild/win32-x64': 0.28.1
+ escalade@3.2.0: {}
+
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.9
@@ -1319,10 +1666,16 @@ snapshots:
fsevents@2.3.3:
optional: true
+ get-caller-file@2.0.5: {}
+
+ get-east-asian-width@1.6.0: {}
+
get-tsconfig@5.0.0-beta.5:
dependencies:
resolve-pkg-maps: 1.0.0
+ hono@4.12.29: {}
+
hookable@6.1.1: {}
hyparquet@1.26.2: {}
@@ -1401,6 +1754,10 @@ snapshots:
pako: 1.0.11
tslib: 1.14.1
+ pdfjs-dist@6.1.200:
+ optionalDependencies:
+ '@napi-rs/canvas': 1.0.2
+
picocolors@1.1.1: {}
picomatch@4.0.5: {}
@@ -1413,6 +1770,13 @@ snapshots:
quansync@1.0.0: {}
+ react-dom@19.2.7(react@19.2.7):
+ dependencies:
+ react: 19.2.7
+ scheduler: 0.27.0
+
+ react@19.2.7: {}
+
resolve-pkg-maps@1.0.0: {}
rolldown-plugin-dts@0.26.0(rolldown@1.1.4)(typescript@6.0.3):
@@ -1452,8 +1816,16 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.1.4
'@rolldown/binding-win32-x64-msvc': 1.1.4
+ rxjs@7.8.2:
+ dependencies:
+ tslib: 2.8.1
+
+ scheduler@0.27.0: {}
+
semver@7.8.5: {}
+ shell-quote@1.8.4: {}
+
siginfo@2.0.0: {}
source-map-js@1.2.1: {}
@@ -1462,6 +1834,18 @@ snapshots:
std-env@4.1.0: {}
+ string-width@7.2.0:
+ dependencies:
+ emoji-regex: 10.6.0
+ get-east-asian-width: 1.6.0
+ strip-ansi: 7.2.0
+
+ strip-ansi@7.2.0:
+ dependencies:
+ ansi-regex: 6.2.2
+
+ supports-color@10.2.2: {}
+
tinybench@2.9.0: {}
tinyexec@1.2.4: {}
@@ -1503,8 +1887,7 @@ snapshots:
tslib@1.14.1: {}
- tslib@2.8.1:
- optional: true
+ tslib@2.8.1: {}
tsx@4.23.0:
dependencies:
@@ -1521,7 +1904,7 @@ snapshots:
undici-types@8.3.0: {}
- vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0):
+ vite@8.1.4(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.5
@@ -1534,10 +1917,10 @@ snapshots:
fsevents: 2.3.3
tsx: 4.23.0
- vitest@4.1.10(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0)):
+ vitest@4.1.10(@types/node@26.1.0)(vite@8.1.4(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0)):
dependencies:
'@vitest/expect': 4.1.10
- '@vitest/mocker': 4.1.10(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0))
+ '@vitest/mocker': 4.1.10(vite@8.1.4(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0))
'@vitest/pretty-format': 4.1.10
'@vitest/runner': 4.1.10
'@vitest/snapshot': 4.1.10
@@ -1554,7 +1937,7 @@ snapshots:
tinyexec: 1.2.4
tinyglobby: 0.2.17
tinyrainbow: 3.1.0
- vite: 8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0)
+ vite: 8.1.4(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 26.1.0
@@ -1566,4 +1949,23 @@ snapshots:
siginfo: 2.0.0
stackback: 0.0.2
+ wrap-ansi@9.0.2:
+ dependencies:
+ ansi-styles: 6.2.3
+ string-width: 7.2.0
+ strip-ansi: 7.2.0
+
+ y18n@5.0.8: {}
+
+ yargs-parser@22.0.0: {}
+
+ yargs@18.0.0:
+ dependencies:
+ cliui: 9.0.1
+ escalade: 3.2.0
+ get-caller-file: 2.0.5
+ string-width: 7.2.0
+ y18n: 5.0.8
+ yargs-parser: 22.0.0
+
zod@4.4.3: {}