From 05bd89e66409368207994d524a7e0373a092b62e Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 20:55:24 +0400
Subject: [PATCH 01/63] chore(schema): install zod
Co-Authored-By: Claude Opus 4.8
---
package-lock.json | 12 +++++++++++-
package.json | 3 ++-
2 files changed, 13 insertions(+), 2 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 7148dfa..73a9d7b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,7 +13,8 @@
"onnxruntime-web": "^1.26.0",
"pdfjs-dist": "^4.10.38",
"react": "^18.3.1",
- "react-dom": "^18.3.1"
+ "react-dom": "^18.3.1",
+ "zod": "^3.25.76"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
@@ -8233,6 +8234,15 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
+ },
+ "node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
}
}
}
diff --git a/package.json b/package.json
index b0b3fd3..94f2632 100644
--- a/package.json
+++ b/package.json
@@ -27,7 +27,8 @@
"onnxruntime-web": "^1.26.0",
"pdfjs-dist": "^4.10.38",
"react": "^18.3.1",
- "react-dom": "^18.3.1"
+ "react-dom": "^18.3.1",
+ "zod": "^3.25.76"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
From 202bc05a00421fad97bae2fdd7a5873aa517e588 Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 20:55:24 +0400
Subject: [PATCH 02/63] feat(schema): add invoice v1 zod schema with field
confidence wrapper
Co-Authored-By: Claude Opus 4.8
---
src/schema/invoice.ts | 62 +++++++++++++++++++++++++++++++++++++++++++
1 file changed, 62 insertions(+)
create mode 100644 src/schema/invoice.ts
diff --git a/src/schema/invoice.ts b/src/schema/invoice.ts
new file mode 100644
index 0000000..76cf623
--- /dev/null
+++ b/src/schema/invoice.ts
@@ -0,0 +1,62 @@
+// InvoiceV1 — the validated, confidence-annotated invoice schema. Every leaf is
+// wrapped as a Field so the UI and validation layer can track, per value,
+// whether it was directly extracted, inferred, or missing.
+
+import { z } from 'zod';
+
+export const confidenceSchema = z.enum(['extracted', 'inferred', 'missing']);
+export type Confidence = z.infer;
+
+export interface Field {
+ value: T | null;
+ confidence: Confidence;
+}
+
+function field(
+ inner: T,
+): z.ZodObject<{
+ value: z.ZodNullable;
+ confidence: typeof confidenceSchema;
+}> {
+ return z.object({ value: inner.nullable(), confidence: confidenceSchema });
+}
+
+const stringField = field(z.string());
+const numberField = field(z.number());
+
+export const partySchema = z.object({
+ name: stringField,
+ address: stringField,
+ trn: stringField,
+ phone: stringField,
+ email: stringField,
+});
+
+export const lineItemSchema = z.object({
+ description: stringField,
+ quantity: numberField,
+ unitPrice: numberField,
+ amount: numberField,
+ taxRate: numberField.optional(),
+});
+
+export const invoiceV1Schema = z.object({
+ vendor: partySchema,
+ buyer: partySchema.optional(),
+ invoiceNumber: stringField,
+ issueDate: stringField,
+ dueDate: stringField.optional(),
+ currency: stringField,
+ lineItems: z.array(lineItemSchema),
+ subtotal: numberField,
+ taxAmount: numberField,
+ total: numberField,
+ paymentTerms: stringField.optional(),
+ notes: stringField.optional(),
+ /** Field paths the model flagged as uncertain (e.g. "subtotal"). */
+ uncertain: z.array(z.string()),
+});
+
+export type InvoiceV1 = z.infer;
+export type Party = z.infer;
+export type LineItem = z.infer;
From c879a027834515b5d8d6a69710daecd413f027e1 Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 20:55:24 +0400
Subject: [PATCH 03/63] feat(schema): add raw json contract and invoice mapper
Co-Authored-By: Claude Opus 4.8
---
src/schema/json-types.ts | 144 +++++++++++++++++++++++++++++++++++++++
1 file changed, 144 insertions(+)
create mode 100644 src/schema/json-types.ts
diff --git a/src/schema/json-types.ts b/src/schema/json-types.ts
new file mode 100644
index 0000000..d1974b7
--- /dev/null
+++ b/src/schema/json-types.ts
@@ -0,0 +1,144 @@
+// The raw, flat JSON contract the model is asked to emit (no Field wrappers),
+// plus a mapper into the confidence-annotated InvoiceV1.
+
+import { z } from 'zod';
+import {
+ type Confidence,
+ type Field,
+ type InvoiceV1,
+ type LineItem,
+ type Party,
+} from './invoice.ts';
+
+const rawString = z.string().nullable().optional();
+// Numbers may arrive as numbers or numeric strings ("1,234.50").
+const rawNumber = z.union([z.number(), z.string()]).nullable().optional();
+
+const rawPartySchema = z
+ .object({
+ name: rawString,
+ address: rawString,
+ trn: rawString,
+ phone: rawString,
+ email: rawString,
+ })
+ .nullable()
+ .optional();
+
+const rawLineItemSchema = z.object({
+ description: rawString,
+ quantity: rawNumber,
+ unitPrice: rawNumber,
+ amount: rawNumber,
+ taxRate: rawNumber,
+});
+
+export const rawInvoiceSchema = z.object({
+ vendor: rawPartySchema,
+ buyer: rawPartySchema,
+ invoiceNumber: rawString,
+ issueDate: rawString,
+ dueDate: rawString,
+ currency: rawString,
+ lineItems: z.array(rawLineItemSchema).nullable().optional(),
+ subtotal: rawNumber,
+ taxAmount: rawNumber,
+ total: rawNumber,
+ paymentTerms: rawString,
+ notes: rawString,
+ _uncertain: z.array(z.string()).nullable().optional(),
+});
+
+export type RawInvoice = z.infer;
+
+/** Parse a numeric value that may be a number or a formatted string. */
+export function parseNumber(value: number | string | null | undefined): number | null {
+ if (value === null || value === undefined) return null;
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null;
+ const cleaned = value.replace(/[^0-9.+-]/g, '');
+ if (cleaned === '' || cleaned === '-' || cleaned === '+') return null;
+ const parsed = Number.parseFloat(cleaned);
+ return Number.isFinite(parsed) ? parsed : null;
+}
+
+function confidenceFor(value: unknown, path: string, uncertain: Set): Confidence {
+ if (value === null || value === undefined) return 'missing';
+ if (uncertain.has(path)) return 'inferred';
+ return 'extracted';
+}
+
+function stringFieldFrom(
+ value: string | null | undefined,
+ path: string,
+ uncertain: Set,
+): Field {
+ return { value: value ?? null, confidence: confidenceFor(value, path, uncertain) };
+}
+
+function numberFieldFrom(
+ value: number | string | null | undefined,
+ path: string,
+ uncertain: Set,
+): Field {
+ const parsed = parseNumber(value);
+ return { value: parsed, confidence: confidenceFor(parsed, path, uncertain) };
+}
+
+function partyFrom(
+ raw: NonNullable | null | undefined,
+ prefix: string,
+ uncertain: Set,
+): Party {
+ const p = raw ?? {};
+ return {
+ name: stringFieldFrom(p.name, `${prefix}.name`, uncertain),
+ address: stringFieldFrom(p.address, `${prefix}.address`, uncertain),
+ trn: stringFieldFrom(p.trn, `${prefix}.trn`, uncertain),
+ phone: stringFieldFrom(p.phone, `${prefix}.phone`, uncertain),
+ email: stringFieldFrom(p.email, `${prefix}.email`, uncertain),
+ };
+}
+
+/** Map a validated raw model JSON object into the confidence-annotated InvoiceV1. */
+export function mapRawToInvoice(raw: RawInvoice): InvoiceV1 {
+ const uncertain = new Set(raw._uncertain ?? []);
+
+ const lineItems: LineItem[] = (raw.lineItems ?? []).map((item, i) => {
+ const base = `lineItems.${String(i)}`;
+ const line: LineItem = {
+ description: stringFieldFrom(item.description, `${base}.description`, uncertain),
+ quantity: numberFieldFrom(item.quantity, `${base}.quantity`, uncertain),
+ unitPrice: numberFieldFrom(item.unitPrice, `${base}.unitPrice`, uncertain),
+ amount: numberFieldFrom(item.amount, `${base}.amount`, uncertain),
+ };
+ if (item.taxRate !== null && item.taxRate !== undefined) {
+ line.taxRate = numberFieldFrom(item.taxRate, `${base}.taxRate`, uncertain);
+ }
+ return line;
+ });
+
+ const invoice: InvoiceV1 = {
+ vendor: partyFrom(raw.vendor, 'vendor', uncertain),
+ invoiceNumber: stringFieldFrom(raw.invoiceNumber, 'invoiceNumber', uncertain),
+ issueDate: stringFieldFrom(raw.issueDate, 'issueDate', uncertain),
+ currency: stringFieldFrom(raw.currency, 'currency', uncertain),
+ lineItems,
+ subtotal: numberFieldFrom(raw.subtotal, 'subtotal', uncertain),
+ taxAmount: numberFieldFrom(raw.taxAmount, 'taxAmount', uncertain),
+ total: numberFieldFrom(raw.total, 'total', uncertain),
+ uncertain: [...uncertain],
+ };
+
+ if (raw.buyer) invoice.buyer = partyFrom(raw.buyer, 'buyer', uncertain);
+ if (raw.dueDate !== null && raw.dueDate !== undefined) {
+ invoice.dueDate = stringFieldFrom(raw.dueDate, 'dueDate', uncertain);
+ }
+ if (raw.paymentTerms !== null && raw.paymentTerms !== undefined) {
+ invoice.paymentTerms = stringFieldFrom(raw.paymentTerms, 'paymentTerms', uncertain);
+ }
+ if (raw.notes !== null && raw.notes !== undefined) {
+ invoice.notes = stringFieldFrom(raw.notes, 'notes', uncertain);
+ }
+
+ return invoice;
+}
From 09a999f51283ea192b4a8ba5020ecf9e59e84615 Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 20:55:24 +0400
Subject: [PATCH 04/63] test(schema): cover schema parse, mapper null handling,
and uncertain paths
Co-Authored-By: Claude Opus 4.8
---
src/schema/json-types.test.ts | 88 +++++++++++++++++++++++++++++++++++
1 file changed, 88 insertions(+)
create mode 100644 src/schema/json-types.test.ts
diff --git a/src/schema/json-types.test.ts b/src/schema/json-types.test.ts
new file mode 100644
index 0000000..6b46794
--- /dev/null
+++ b/src/schema/json-types.test.ts
@@ -0,0 +1,88 @@
+import { describe, expect, it } from 'vitest';
+import { invoiceV1Schema } from './invoice.ts';
+import { mapRawToInvoice, parseNumber, rawInvoiceSchema, type RawInvoice } from './json-types.ts';
+
+const complete: RawInvoice = {
+ vendor: { name: 'Acme FZE', address: 'Dubai', trn: '100123456700003', phone: null, email: null },
+ buyer: { name: 'Buyer LLC', address: null, trn: null, phone: null, email: null },
+ invoiceNumber: 'INV-001',
+ issueDate: '2026-01-15',
+ dueDate: '2026-02-15',
+ currency: 'AED',
+ lineItems: [{ description: 'Widget', quantity: 2, unitPrice: '50.00', amount: 100, taxRate: 5 }],
+ subtotal: 100,
+ taxAmount: 5,
+ total: 105,
+ paymentTerms: 'Net 30',
+ notes: null,
+ _uncertain: ['subtotal'],
+};
+
+describe('parseNumber', () => {
+ it('passes through finite numbers', () => {
+ expect(parseNumber(42)).toBe(42);
+ });
+ it('strips currency and separators from strings', () => {
+ expect(parseNumber('AED 1,234.50')).toBe(1234.5);
+ });
+ it('returns null for null/undefined/garbage', () => {
+ expect(parseNumber(null)).toBeNull();
+ expect(parseNumber(undefined)).toBeNull();
+ expect(parseNumber('abc')).toBeNull();
+ });
+});
+
+describe('mapRawToInvoice', () => {
+ it('maps a complete invoice and round-trips through the schema', () => {
+ const invoice = mapRawToInvoice(complete);
+ expect(invoiceV1Schema.safeParse(invoice).success).toBe(true);
+ expect(invoice.vendor.name).toEqual({ value: 'Acme FZE', confidence: 'extracted' });
+ expect(invoice.subtotal.value).toBe(100);
+ expect(invoice.lineItems[0]?.unitPrice.value).toBe(50);
+ });
+
+ it('marks uncertain paths as inferred', () => {
+ const invoice = mapRawToInvoice(complete);
+ expect(invoice.subtotal.confidence).toBe('inferred');
+ expect(invoice.uncertain).toContain('subtotal');
+ });
+
+ it('handles a sparse invoice with mostly nulls', () => {
+ const sparse: RawInvoice = {
+ vendor: null,
+ buyer: null,
+ invoiceNumber: null,
+ issueDate: null,
+ dueDate: null,
+ currency: null,
+ lineItems: null,
+ subtotal: null,
+ taxAmount: null,
+ total: null,
+ paymentTerms: null,
+ notes: null,
+ _uncertain: null,
+ };
+ const invoice = mapRawToInvoice(sparse);
+ expect(invoiceV1Schema.safeParse(invoice).success).toBe(true);
+ expect(invoice.vendor.name).toEqual({ value: null, confidence: 'missing' });
+ expect(invoice.lineItems).toHaveLength(0);
+ expect(invoice.buyer).toBeUndefined();
+ expect(invoice.dueDate).toBeUndefined();
+ });
+
+ it('omits optional taxRate when absent', () => {
+ const raw = rawInvoiceSchema.parse({
+ vendor: { name: 'V' },
+ invoiceNumber: '1',
+ issueDate: '2026-01-01',
+ currency: 'AED',
+ lineItems: [{ description: 'X', quantity: 1, unitPrice: 1, amount: 1 }],
+ subtotal: 1,
+ taxAmount: 0,
+ total: 1,
+ });
+ const invoice = mapRawToInvoice(raw);
+ expect(invoice.lineItems[0]?.taxRate).toBeUndefined();
+ });
+});
From c75c1344e6a29aec485e77690c3e96f2f5dc06b6 Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 20:55:24 +0400
Subject: [PATCH 05/63] feat(pass2): add versioned extraction prompt with
embedded schema
Co-Authored-By: Claude Opus 4.8
---
src/pipeline/pass2/prompt.ts | 34 ++++++++++++++++++++++++++++++++++
1 file changed, 34 insertions(+)
create mode 100644 src/pipeline/pass2/prompt.ts
diff --git a/src/pipeline/pass2/prompt.ts b/src/pipeline/pass2/prompt.ts
new file mode 100644
index 0000000..5aa2a83
--- /dev/null
+++ b/src/pipeline/pass2/prompt.ts
@@ -0,0 +1,34 @@
+// Pass 2 prompt: schema-constrained JSON extraction. Versioned artifact.
+
+export const PASS2_PROMPT_VERSION = 'PASS2_PROMPT_V1';
+
+export const PASS2_PROMPT_V1 = `You extract structured data from an invoice. You are given the invoice image(s) and a Markdown transcript.
+
+Output ONLY a single JSON object — no prose, no code fences — matching this shape:
+
+{
+ "vendor": { "name": string|null, "address": string|null, "trn": string|null, "phone": string|null, "email": string|null },
+ "buyer": { "name": string|null, "address": string|null, "trn": string|null, "phone": string|null, "email": string|null } | null,
+ "invoiceNumber": string|null,
+ "issueDate": "YYYY-MM-DD"|null,
+ "dueDate": "YYYY-MM-DD"|null,
+ "currency": string|null, // ISO 4217, e.g. "AED"
+ "lineItems": [ { "description": string|null, "quantity": number|null, "unitPrice": number|null, "amount": number|null, "taxRate": number|null } ],
+ "subtotal": number|null,
+ "taxAmount": number|null,
+ "total": number|null,
+ "paymentTerms": string|null,
+ "notes": string|null,
+ "_uncertain": [ string ] // field paths you are unsure about, e.g. "subtotal"
+}
+
+Rules:
+- Use null for any field not present. NEVER invent a value.
+- Copy numbers EXACTLY as printed (no rounding); output them as JSON numbers.
+- Normalize dates to YYYY-MM-DD.
+- List the path of any value you are uncertain about in "_uncertain".`;
+
+/** Build a corrective re-prompt that appends the validator's complaint. */
+export function correctivePrompt(basePrompt: string, errors: string): string {
+ return `${basePrompt}\n\n## Your previous output was invalid\n${errors}\n\nReturn corrected JSON only.`;
+}
From cd255ace84e6362b5bef1489d88b27ff24b44cde Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 20:55:24 +0400
Subject: [PATCH 06/63] feat(pass2): add json repair ladder with fence strip,
brace extraction, and syntax normalization
Co-Authored-By: Claude Opus 4.8
---
src/pipeline/pass2/repair.ts | 90 ++++++++++++++++++++++++++++++++++++
1 file changed, 90 insertions(+)
create mode 100644 src/pipeline/pass2/repair.ts
diff --git a/src/pipeline/pass2/repair.ts b/src/pipeline/pass2/repair.ts
new file mode 100644
index 0000000..1a8fc94
--- /dev/null
+++ b/src/pipeline/pass2/repair.ts
@@ -0,0 +1,90 @@
+// Deterministic JSON repair ladder. Each step is pure and independently tested.
+// On failure, returns the furthest stage reached so callers can reason about it.
+
+export type RepairStage = 'extract-braces' | 'parse';
+
+export type RepairResult =
+ | { ok: true; value: unknown }
+ | { ok: false; stage: RepairStage; message: string };
+
+/** Step a: strip Markdown code fences and surrounding prose markers. */
+export function stripFences(text: string): string {
+ return text
+ .replace(/```[a-zA-Z]*\n?/g, '')
+ .replace(/```/g, '')
+ .trim();
+}
+
+/** Step b: extract the outermost balanced `{ … }` block, ignoring string contents. */
+export function extractBalancedObject(text: string): string | null {
+ const start = text.indexOf('{');
+ if (start === -1) return null;
+
+ let depth = 0;
+ let inString = false;
+ let escaped = false;
+
+ for (let i = start; i < text.length; i += 1) {
+ const ch = text[i];
+ if (inString) {
+ if (escaped) escaped = false;
+ else if (ch === '\\') escaped = true;
+ else if (ch === '"') inString = false;
+ continue;
+ }
+ if (ch === '"') inString = true;
+ else if (ch === '{') depth += 1;
+ else if (ch === '}') {
+ depth -= 1;
+ if (depth === 0) return text.slice(start, i + 1);
+ }
+ }
+ return null; // truncated / unbalanced
+}
+
+/** Step c: fix trailing commas, single quotes, and unquoted keys. */
+export function normalizeSyntax(json: string): string {
+ return json
+ .replace(/,(\s*[}\]])/g, '$1') // trailing commas
+ .replace(/'((?:[^'\\]|\\.)*)'/g, '"$1"') // single-quoted strings → double
+ .replace(/([{,]\s*)([A-Za-z_][A-Za-z0-9_]*)(\s*:)/g, '$1"$2"$3'); // unquoted keys
+}
+
+function tryParse(text: string): unknown {
+ try {
+ return JSON.parse(text);
+ } catch {
+ return undefined;
+ }
+}
+
+/** Run the full ladder: strip → extract → (parse | normalize → parse). */
+export function repairJson(text: string): RepairResult {
+ const stripped = stripFences(text);
+
+ // Double-encoded: the whole payload is a JSON string that contains JSON.
+ const asString = tryParse(stripped);
+ if (typeof asString === 'string' && asString.trim().startsWith('{')) {
+ const inner = repairJson(asString);
+ if (inner.ok) return inner;
+ }
+
+ const block = extractBalancedObject(stripped);
+ if (block === null) {
+ return { ok: false, stage: 'extract-braces', message: 'No balanced JSON object found' };
+ }
+
+ let parsed = tryParse(block);
+ if (parsed === undefined) parsed = tryParse(normalizeSyntax(block));
+
+ // Handle a double-encoded payload: a JSON string that itself contains JSON.
+ if (typeof parsed === 'string' && parsed.trim().startsWith('{')) {
+ const inner = repairJson(parsed);
+ if (inner.ok) return inner;
+ }
+
+ if (parsed === undefined) {
+ return { ok: false, stage: 'parse', message: 'JSON.parse failed after normalization' };
+ }
+ return { ok: true, value: parsed };
+}
From dfde7ffed6f2417cff0aad63f38dc90fbd022fab Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 20:55:24 +0400
Subject: [PATCH 07/63] test(pass2): cover repair ladder on malformed fixtures
Co-Authored-By: Claude Opus 4.8
---
src/pipeline/pass2/repair.test.ts | 89 +++++++++++++++++++++++++++++++
1 file changed, 89 insertions(+)
create mode 100644 src/pipeline/pass2/repair.test.ts
diff --git a/src/pipeline/pass2/repair.test.ts b/src/pipeline/pass2/repair.test.ts
new file mode 100644
index 0000000..b6fda78
--- /dev/null
+++ b/src/pipeline/pass2/repair.test.ts
@@ -0,0 +1,89 @@
+import { describe, expect, it } from 'vitest';
+import { extractBalancedObject, normalizeSyntax, repairJson, stripFences } from './repair.ts';
+
+describe('stripFences', () => {
+ it('removes json code fences', () => {
+ expect(stripFences('```json\n{"a":1}\n```')).toBe('{"a":1}');
+ });
+});
+
+describe('extractBalancedObject', () => {
+ it('extracts the outermost object ignoring braces in strings', () => {
+ expect(extractBalancedObject('noise {"a":"}{"} tail')).toBe('{"a":"}{"}');
+ });
+ it('returns null for truncated input', () => {
+ expect(extractBalancedObject('{"a": 1, "b":')).toBeNull();
+ });
+});
+
+describe('normalizeSyntax', () => {
+ it('removes trailing commas', () => {
+ expect(normalizeSyntax('{"a":1,}')).toBe('{"a":1}');
+ });
+ it('quotes unquoted keys', () => {
+ expect(normalizeSyntax('{a:1}')).toContain('"a":1');
+ });
+ it('converts single quotes', () => {
+ expect(normalizeSyntax("{'a':'b'}")).toContain('"a":"b"');
+ });
+});
+
+describe('repairJson — malformed fixtures', () => {
+ const valid = { invoiceNumber: 'INV-1', total: 100 };
+
+ it('parses clean JSON', () => {
+ expect(repairJson('{"invoiceNumber":"INV-1","total":100}')).toEqual({ ok: true, value: valid });
+ });
+
+ it('parses fenced JSON', () => {
+ expect(repairJson('```json\n{"invoiceNumber":"INV-1","total":100}\n```').ok).toBe(true);
+ });
+
+ it('parses prose-wrapped JSON', () => {
+ const r = repairJson('Here is the result:\n{"invoiceNumber":"INV-1","total":100}\nThanks!');
+ expect(r.ok).toBe(true);
+ });
+
+ it('repairs trailing commas', () => {
+ expect(repairJson('{"invoiceNumber":"INV-1","total":100,}').ok).toBe(true);
+ });
+
+ it('repairs single quotes', () => {
+ expect(repairJson("{'invoiceNumber':'INV-1','total':100}").ok).toBe(true);
+ });
+
+ it('repairs unquoted keys', () => {
+ expect(repairJson('{invoiceNumber:"INV-1",total:100}').ok).toBe(true);
+ });
+
+ it('handles double-encoded JSON strings', () => {
+ const doubled = JSON.stringify(JSON.stringify(valid));
+ const r = repairJson(doubled);
+ expect(r).toEqual({ ok: true, value: valid });
+ });
+
+ it('fails with extract-braces stage when no object present', () => {
+ const r = repairJson('totally not json');
+ expect(r.ok).toBe(false);
+ if (!r.ok) {
+ expect(r.stage).toBe('extract-braces');
+ expect(typeof r.message).toBe('string');
+ }
+ });
+
+ it('fails with extract-braces stage on truncated output', () => {
+ const r = repairJson('{"invoiceNumber":"INV-1", "total":');
+ expect(r.ok).toBe(false);
+ if (!r.ok) expect(r.stage).toBe('extract-braces');
+ });
+
+ it('fails with parse stage on irreparable syntax', () => {
+ const r = repairJson('{"a": @@@ }');
+ expect(r.ok).toBe(false);
+ if (!r.ok) expect(r.stage).toBe('parse');
+ });
+
+ it('repairs combined trailing comma + unquoted key', () => {
+ expect(repairJson('{invoiceNumber:"INV-1", total:100,}').ok).toBe(true);
+ });
+});
From b46853f7b2c2d70783638853e559b02289af3e79 Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 20:55:24 +0400
Subject: [PATCH 08/63] feat(pass2): add pass2 runner with single corrective
re-prompt
Co-Authored-By: Claude Opus 4.8
---
src/pipeline/pass2/run.ts | 106 ++++++++++++++++++++++++++++++++++++++
1 file changed, 106 insertions(+)
create mode 100644 src/pipeline/pass2/run.ts
diff --git a/src/pipeline/pass2/run.ts b/src/pipeline/pass2/run.ts
new file mode 100644
index 0000000..2998bcd
--- /dev/null
+++ b/src/pipeline/pass2/run.ts
@@ -0,0 +1,106 @@
+// Pass 2 runner: build the prompt, infer, repair JSON, validate against the raw
+// contract, and map to InvoiceV1. On a parse/validation failure, one corrective
+// re-prompt that echoes the errors, then a typed terminal failure.
+
+import { type z } from 'zod';
+import type { InvoiceV1 } from '../../schema/invoice.ts';
+import { mapRawToInvoice, rawInvoiceSchema, type RawInvoice } from '../../schema/json-types.ts';
+import type { GenerationStats } from '../../worker/protocol.ts';
+import { correctivePrompt, PASS2_PROMPT_V1, PASS2_PROMPT_VERSION } from './prompt.ts';
+import { repairJson } from './repair.ts';
+
+interface InferHandleLike extends AsyncIterable {
+ completed: Promise<{ fullText: string; stats: GenerationStats }>;
+}
+
+export interface Pass2Client {
+ infer: (request: {
+ images: ImageBitmap[];
+ prompt: string;
+ maxNewTokens?: number;
+ }) => InferHandleLike;
+}
+
+export interface Pass2Input {
+ images: ImageBitmap[];
+ transcript: string;
+}
+
+export type Pass2Result =
+ | {
+ ok: true;
+ invoice: InvoiceV1;
+ raw: RawInvoice;
+ promptVersion: string;
+ retried: boolean;
+ }
+ | { ok: false; stage: 'repair' | 'validation'; message: string; retried: boolean };
+
+export interface Pass2Options {
+ maxNewTokens?: number;
+}
+
+async function infer(handle: InferHandleLike): Promise {
+ // Drain the stream; Pass 2 only needs the full text.
+ for await (const token of handle) void token;
+ return (await handle.completed).fullText;
+}
+
+function formatZodError(error: z.ZodError): string {
+ return error.issues
+ .map((issue) => `${issue.path.join('.') || ''}: ${issue.message}`)
+ .join('\n');
+}
+
+/** Run Pass 2 extraction with a single corrective re-prompt on failure. */
+export async function runPass2(
+ input: Pass2Input,
+ client: Pass2Client,
+ options: Pass2Options = {},
+): Promise {
+ const maxNewTokens = options.maxNewTokens ?? 2048;
+ const basePrompt = `${PASS2_PROMPT_V1}\n\n## Transcript\n${input.transcript}`;
+
+ let prompt = basePrompt;
+ let retried = false;
+
+ for (let attempt = 0; attempt < 2; attempt += 1) {
+ const text = await infer(client.infer({ images: input.images, prompt, maxNewTokens }));
+
+ const repaired = repairJson(text);
+ if (!repaired.ok) {
+ if (attempt === 0) {
+ retried = true;
+ prompt = correctivePrompt(basePrompt, `Could not parse JSON: ${repaired.message}`);
+ continue;
+ }
+ return { ok: false, stage: 'repair', message: repaired.message, retried };
+ }
+
+ const parsed = rawInvoiceSchema.safeParse(repaired.value);
+ if (!parsed.success) {
+ const message = formatZodError(parsed.error);
+ if (attempt === 0) {
+ retried = true;
+ prompt = correctivePrompt(basePrompt, message);
+ continue;
+ }
+ return { ok: false, stage: 'validation', message, retried };
+ }
+
+ return {
+ ok: true,
+ invoice: mapRawToInvoice(parsed.data),
+ raw: parsed.data,
+ promptVersion: PASS2_PROMPT_VERSION,
+ retried,
+ };
+ }
+
+ return {
+ ok: false,
+ stage: 'validation',
+ message: 'Extraction attempts exhausted',
+ retried: true,
+ };
+}
From 4458ac04acf93326d7821473e564be7092f6fd0c Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 20:55:24 +0400
Subject: [PATCH 09/63] test(pass2): cover corrective re-prompt and terminal
failure
Co-Authored-By: Claude Opus 4.8
---
src/pipeline/pass2/run.test.ts | 90 ++++++++++++++++++++++++++++++++++
1 file changed, 90 insertions(+)
create mode 100644 src/pipeline/pass2/run.test.ts
diff --git a/src/pipeline/pass2/run.test.ts b/src/pipeline/pass2/run.test.ts
new file mode 100644
index 0000000..e462ada
--- /dev/null
+++ b/src/pipeline/pass2/run.test.ts
@@ -0,0 +1,90 @@
+import { describe, expect, it } from 'vitest';
+import type { GenerationStats } from '../../worker/protocol.ts';
+import { runPass2, type Pass2Client } from './run.ts';
+
+const stats: GenerationStats = {
+ prefillMs: 1,
+ decodeMs: 1,
+ tokensPerSecond: 1,
+ peakMemoryEstimateMB: 1,
+};
+
+function handleOf(fullText: string) {
+ return {
+ completed: Promise.resolve({ fullText, stats }),
+ [Symbol.asyncIterator](): AsyncIterator {
+ let done = false;
+ return {
+ next: (): Promise> => {
+ if (done) return Promise.resolve({ value: undefined, done: true });
+ done = true;
+ return Promise.resolve({ value: fullText, done: false });
+ },
+ };
+ },
+ };
+}
+
+function scriptedClient(texts: string[]): Pass2Client & { prompts: string[] } {
+ const prompts: string[] = [];
+ let index = 0;
+ return {
+ prompts,
+ infer: (req) => {
+ prompts.push(req.prompt);
+ const text = texts[index] ?? '';
+ index += 1;
+ return handleOf(text);
+ },
+ };
+}
+
+const validJson =
+ '{"vendor":{"name":"V"},"invoiceNumber":"1","issueDate":"2026-01-01","currency":"AED","lineItems":[],"subtotal":0,"taxAmount":0,"total":0}';
+
+describe('runPass2', () => {
+ it('extracts on the first valid response', async () => {
+ const client = scriptedClient([validJson]);
+ const result = await runPass2({ images: [], transcript: 't' }, client);
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ expect(result.retried).toBe(false);
+ expect(result.invoice.vendor.name.value).toBe('V');
+ expect(result.promptVersion).toBe('PASS2_PROMPT_V1');
+ }
+ expect(client.prompts).toHaveLength(1);
+ });
+
+ it('issues one corrective re-prompt after an unparseable response', async () => {
+ const client = scriptedClient(['not json at all', validJson]);
+ const result = await runPass2({ images: [], transcript: 't' }, client);
+ expect(result.ok).toBe(true);
+ expect(client.prompts).toHaveLength(2);
+ expect(client.prompts[1]).toContain('previous output was invalid');
+ });
+
+ it('re-prompts on a schema validation failure', async () => {
+ const client = scriptedClient(['{"lineItems": "not-an-array"}', validJson]);
+ const result = await runPass2({ images: [], transcript: 't' }, client);
+ expect(result.ok).toBe(true);
+ expect(client.prompts).toHaveLength(2);
+ });
+
+ it('gives up with a typed repair failure after two bad responses', async () => {
+ const client = scriptedClient(['garbage', 'still garbage']);
+ const result = await runPass2({ images: [], transcript: 't' }, client);
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.stage).toBe('repair');
+ expect(result.retried).toBe(true);
+ }
+ expect(client.prompts).toHaveLength(2);
+ });
+
+ it('gives up with a typed validation failure when both responses fail the schema', async () => {
+ const client = scriptedClient(['{"lineItems": 5}', '{"lineItems": 7}']);
+ const result = await runPass2({ images: [], transcript: 't' }, client);
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.stage).toBe('validation');
+ });
+});
From 988ae3d84d9ad6a989120f1c427d8a0fc64cfcc9 Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 20:55:24 +0400
Subject: [PATCH 10/63] docs(schema): document field confidence semantics and
repair ladder
Co-Authored-By: Claude Opus 4.8
---
src/pipeline/pass2/README.md | 15 +++++++++++++++
src/schema/README.md | 26 ++++++++++++++++++++------
2 files changed, 35 insertions(+), 6 deletions(-)
create mode 100644 src/pipeline/pass2/README.md
diff --git a/src/pipeline/pass2/README.md b/src/pipeline/pass2/README.md
new file mode 100644
index 0000000..822451c
--- /dev/null
+++ b/src/pipeline/pass2/README.md
@@ -0,0 +1,15 @@
+# pipeline/pass2
+
+Pass 2 — schema-constrained JSON extraction.
+
+- `prompt.ts` — `PASS2_PROMPT_V1` (image(s) + Pass 1 transcript → JSON only) and
+ `correctivePrompt()` for the single re-prompt.
+- `repair.ts` — deterministic JSON repair ladder: strip fences → extract the
+ outermost balanced `{}` → normalize syntax (trailing commas, single quotes,
+ unquoted keys) → parse, with double-encoded-string handling. Pure, per-step.
+- `run.ts` — `runPass2()`: infer → repair → validate against the raw contract →
+ `mapRawToInvoice`. On a parse/validation failure, exactly **one** corrective
+ re-prompt that echoes the errors, then a typed terminal failure.
+
+See `src/schema/` for the `InvoiceV1` schema, the raw model JSON contract, and
+the field-confidence semantics.
diff --git a/src/schema/README.md b/src/schema/README.md
index 287659d..be4c428 100644
--- a/src/schema/README.md
+++ b/src/schema/README.md
@@ -1,10 +1,24 @@
# schema
-Zod schemas and the deterministic validation layer.
+Zod schemas, the raw model JSON contract, and (Phase 08) the validation layer.
-- `invoice.ts` — the `InvoiceV1` schema with per-field confidence wrappers.
-- `json-types.ts` — the raw model JSON contract plus the mapper to `InvoiceV1`.
-- `rules/` — pure-TypeScript, zero-ML validation rules. This layer is the trust
- boundary: it gates every extraction result and never silently fixes values.
+## Modules
-_Populated in Phases 07–08._
+- `invoice.ts` — `InvoiceV1`, the validated invoice schema. Every leaf is a
+ `Field = { value: T | null; confidence }`.
+- `json-types.ts` — `rawInvoiceSchema` (the flat, lenient JSON the model emits)
+ plus `mapRawToInvoice()` and `parseNumber()`.
+- `rules/` — deterministic validation (Phase 08).
+
+## Field confidence semantics
+
+| confidence | meaning |
+| ----------- | ------------------------------------------------------ |
+| `extracted` | read directly from the document |
+| `inferred` | present but the model flagged the path in `_uncertain` |
+| `missing` | absent — `value` is `null` |
+
+`parseNumber` tolerates formatted strings ("AED 1,234.50" → `1234.5`) and
+returns `null` for anything non-numeric, so a garbled figure surfaces as a
+missing value rather than a wrong one. The `uncertain` path list rides along on
+the invoice for the validation layer (rule R024) and the export envelope.
From 8bfff4ad140cac6a7972ae0aa42aedee87f98fa4 Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:00:21 +0400
Subject: [PATCH 11/63] feat(rules): add rule engine with finding aggregation
and field status
Co-Authored-By: Claude Opus 4.8
---
src/schema/rules/engine.ts | 71 ++++++++++++++++++++++++++++++++++++++
1 file changed, 71 insertions(+)
create mode 100644 src/schema/rules/engine.ts
diff --git a/src/schema/rules/engine.ts b/src/schema/rules/engine.ts
new file mode 100644
index 0000000..a8b2a32
--- /dev/null
+++ b/src/schema/rules/engine.ts
@@ -0,0 +1,71 @@
+// Rule engine: runs every rule, aggregates findings, and computes per-field
+// status. Pure TypeScript, zero ML — this is the trust boundary of Faturlens.
+
+import type { Field, InvoiceV1 } from '../invoice.ts';
+
+export type Severity = 'error' | 'warning';
+export type FieldStatus = 'ok' | 'warning' | 'error';
+
+export interface Finding {
+ ruleId: string;
+ severity: Severity;
+ fieldPath: string;
+ message: string;
+ /** Display-only hint. Never auto-applied. */
+ suggestion?: string;
+}
+
+export interface RuleContext {
+ /** Reference "now" for date plausibility, injectable for tests. */
+ today: Date;
+}
+
+export interface Rule {
+ id: string;
+ severity: Severity;
+ check: (invoice: InvoiceV1, ctx: RuleContext) => Finding[];
+}
+
+export interface ValidationResult {
+ findings: Finding[];
+ /** Status by field path. Paths absent from the map are implicitly "ok". */
+ fieldStatus: Record;
+}
+
+/** Read a numeric field's value, or null. */
+export function num(field: Field | undefined): number | null {
+ return field?.value ?? null;
+}
+
+/** Read a string field's value, or null. */
+export function str(field: Field | undefined): string | null {
+ return field?.value ?? null;
+}
+
+export function statusFor(result: ValidationResult, fieldPath: string): FieldStatus {
+ return result.fieldStatus[fieldPath] ?? 'ok';
+}
+
+/** Run all rules and aggregate findings into per-field statuses. */
+export function runRules(
+ invoice: InvoiceV1,
+ rules: readonly Rule[],
+ ctx: RuleContext,
+): ValidationResult {
+ const findings: Finding[] = [];
+ for (const rule of rules) {
+ findings.push(...rule.check(invoice, ctx));
+ }
+
+ const fieldStatus: Record = {};
+ for (const finding of findings) {
+ const current = fieldStatus[finding.fieldPath];
+ if (finding.severity === 'error') {
+ fieldStatus[finding.fieldPath] = 'error';
+ } else if (current !== 'error') {
+ fieldStatus[finding.fieldPath] = 'warning';
+ }
+ }
+
+ return { findings, fieldStatus };
+}
From 72c2e94981e0960fef48f436c1e0793c5e31f084 Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:00:21 +0400
Subject: [PATCH 12/63] feat(rules): add minor-unit money helpers with
tolerance
Co-Authored-By: Claude Opus 4.8
---
src/schema/rules/money.test.ts | 27 +++++++++++++++++++++++++++
src/schema/rules/money.ts | 20 ++++++++++++++++++++
2 files changed, 47 insertions(+)
create mode 100644 src/schema/rules/money.test.ts
create mode 100644 src/schema/rules/money.ts
diff --git a/src/schema/rules/money.test.ts b/src/schema/rules/money.test.ts
new file mode 100644
index 0000000..8169648
--- /dev/null
+++ b/src/schema/rules/money.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest';
+import { approxEqualMinor, sumMinor, toMinor } from './money.ts';
+
+describe('toMinor', () => {
+ it('converts to integer minor units, rounding to nearest', () => {
+ expect(toMinor(1.5)).toBe(150);
+ expect(toMinor(100)).toBe(10000);
+ expect(toMinor(0.014)).toBe(1); // 1.4 minor units → 1
+ });
+});
+
+describe('approxEqualMinor', () => {
+ it('treats values within tolerance as equal', () => {
+ expect(approxEqualMinor(100.0, 100.01, 1)).toBe(true);
+ expect(approxEqualMinor(100.0, 100.02, 1)).toBe(false);
+ });
+ it('uses a default tolerance of one minor unit', () => {
+ expect(approxEqualMinor(50.0, 50.01)).toBe(true);
+ });
+});
+
+describe('sumMinor', () => {
+ it('sums without floating-point drift', () => {
+ expect(sumMinor([0.1, 0.2])).toBe(0.3);
+ expect(sumMinor([10.5, 20.25, 5.25])).toBe(36);
+ });
+});
diff --git a/src/schema/rules/money.ts b/src/schema/rules/money.ts
new file mode 100644
index 0000000..f8f82c8
--- /dev/null
+++ b/src/schema/rules/money.ts
@@ -0,0 +1,20 @@
+// Money helpers. All comparisons use integer minor units (e.g. fils/cents) so
+// we never test floating-point values for equality.
+
+const MINOR_FACTOR = 100;
+
+/** Convert a major-unit amount to integer minor units (2 dp). */
+export function toMinor(amount: number): number {
+ return Math.round(amount * MINOR_FACTOR);
+}
+
+/** True when two major-unit amounts agree within `toleranceMinor` minor units. */
+export function approxEqualMinor(a: number, b: number, toleranceMinor = 1): boolean {
+ return Math.abs(toMinor(a) - toMinor(b)) <= toleranceMinor;
+}
+
+/** Sum major-unit amounts in minor units, returned as a major-unit number. */
+export function sumMinor(amounts: number[]): number {
+ const total = amounts.reduce((sum, amount) => sum + toMinor(amount), 0);
+ return total / MINOR_FACTOR;
+}
From 33ab4c07f8d46cc52b4d1b80f7b8ef1eaa423135 Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:00:21 +0400
Subject: [PATCH 13/63] feat(rules): add line, subtotal, total, and
tax-consistency arithmetic rules
Co-Authored-By: Claude Opus 4.8
---
src/schema/rules/arithmetic.ts | 122 +++++++++++++++++++++++++++++++++
1 file changed, 122 insertions(+)
create mode 100644 src/schema/rules/arithmetic.ts
diff --git a/src/schema/rules/arithmetic.ts b/src/schema/rules/arithmetic.ts
new file mode 100644
index 0000000..34f8dfc
--- /dev/null
+++ b/src/schema/rules/arithmetic.ts
@@ -0,0 +1,122 @@
+// Arithmetic rules. Integer minor units throughout; rounding tolerance of one
+// minor unit per line.
+
+import { num, type Rule } from './engine.ts';
+import { approxEqualMinor, sumMinor } from './money.ts';
+
+export const lineAmountRule: Rule = {
+ id: 'R001',
+ severity: 'error',
+ check: (invoice) => {
+ return invoice.lineItems.flatMap((line, i) => {
+ const qty = num(line.quantity);
+ const unit = num(line.unitPrice);
+ const amount = num(line.amount);
+ if (qty === null || unit === null || amount === null) return [];
+ const expected = qty * unit;
+ if (approxEqualMinor(expected, amount, 1)) return [];
+ return [
+ {
+ ruleId: 'R001',
+ severity: 'error' as const,
+ fieldPath: `lineItems.${String(i)}.amount`,
+ message: `Line ${String(i + 1)}: quantity × unit price = ${expected.toFixed(2)}, but amount is ${amount.toFixed(2)}`,
+ suggestion: expected.toFixed(2),
+ },
+ ];
+ });
+ },
+};
+
+export const subtotalRule: Rule = {
+ id: 'R002',
+ severity: 'error',
+ check: (invoice) => {
+ const subtotal = num(invoice.subtotal);
+ const amounts = invoice.lineItems
+ .map((l) => num(l.amount))
+ .filter((v): v is number => v !== null);
+ if (subtotal === null || amounts.length === 0) return [];
+ const sum = sumMinor(amounts);
+ if (approxEqualMinor(sum, subtotal, invoice.lineItems.length || 1)) return [];
+ return [
+ {
+ ruleId: 'R002',
+ severity: 'error',
+ fieldPath: 'subtotal',
+ message: `Line amounts sum to ${sum.toFixed(2)}, but subtotal is ${subtotal.toFixed(2)}`,
+ suggestion: sum.toFixed(2),
+ },
+ ];
+ },
+};
+
+export const totalRule: Rule = {
+ id: 'R003',
+ severity: 'error',
+ check: (invoice) => {
+ const subtotal = num(invoice.subtotal);
+ const tax = num(invoice.taxAmount);
+ const total = num(invoice.total);
+ if (subtotal === null || tax === null || total === null) return [];
+ const expected = sumMinor([subtotal, tax]);
+ if (approxEqualMinor(expected, total, 1)) return [];
+ return [
+ {
+ ruleId: 'R003',
+ severity: 'error',
+ fieldPath: 'total',
+ message: `subtotal + tax = ${expected.toFixed(2)}, but total is ${total.toFixed(2)}`,
+ suggestion: expected.toFixed(2),
+ },
+ ];
+ },
+};
+
+/** Most common per-line tax rate, when line rates exist. */
+function dominantTaxRate(rates: number[]): number | null {
+ if (rates.length === 0) return null;
+ const counts = new Map();
+ for (const rate of rates) counts.set(rate, (counts.get(rate) ?? 0) + 1);
+ let best = rates[0] ?? null;
+ let bestCount = 0;
+ for (const [rate, count] of counts) {
+ if (count > bestCount) {
+ bestCount = count;
+ best = rate;
+ }
+ }
+ return best;
+}
+
+export const taxConsistencyRule: Rule = {
+ id: 'R004',
+ severity: 'warning',
+ check: (invoice) => {
+ const subtotal = num(invoice.subtotal);
+ const tax = num(invoice.taxAmount);
+ const rates = invoice.lineItems
+ .map((l) => num(l.taxRate))
+ .filter((v): v is number => v !== null);
+ const dominant = dominantTaxRate(rates);
+ if (subtotal === null || tax === null || dominant === null) return [];
+ const expected = subtotal * (dominant / 100);
+ if (approxEqualMinor(expected, tax, 2)) return [];
+ return [
+ {
+ ruleId: 'R004',
+ severity: 'warning',
+ fieldPath: 'taxAmount',
+ message: `Tax at the dominant rate (${String(dominant)}%) would be ${expected.toFixed(2)}, but tax amount is ${tax.toFixed(2)}`,
+ suggestion: expected.toFixed(2),
+ },
+ ];
+ },
+};
+
+export const arithmeticRules: readonly Rule[] = [
+ lineAmountRule,
+ subtotalRule,
+ totalRule,
+ taxConsistencyRule,
+];
From 4211f8d6b6977b38e0272ef9564d7dd628ef607a Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:00:21 +0400
Subject: [PATCH 14/63] feat(rules): add uae trn, vat default, and currency
whitelist rules
Co-Authored-By: Claude Opus 4.8
---
src/schema/rules/regional.ts | 135 +++++++++++++++++++++++++++++++++++
1 file changed, 135 insertions(+)
create mode 100644 src/schema/rules/regional.ts
diff --git a/src/schema/rules/regional.ts b/src/schema/rules/regional.ts
new file mode 100644
index 0000000..3540b63
--- /dev/null
+++ b/src/schema/rules/regional.ts
@@ -0,0 +1,135 @@
+// Regional rules (UAE-focused, locale-groupable). Grouped separately so a future
+// settings screen can toggle locales.
+
+import { num, str, type Rule } from './engine.ts';
+import { approxEqualMinor } from './money.ts';
+
+const PREFERRED_CURRENCIES = new Set(['AED', 'USD', 'EUR', 'SAR', 'GBP', 'INR']);
+
+// A pragmatic ISO 4217 set for "valid but non-preferred" classification.
+const ISO_4217 = new Set([
+ 'AED',
+ 'USD',
+ 'EUR',
+ 'SAR',
+ 'GBP',
+ 'INR',
+ 'JPY',
+ 'CNY',
+ 'CHF',
+ 'CAD',
+ 'AUD',
+ 'KWD',
+ 'BHD',
+ 'OMR',
+ 'QAR',
+ 'EGP',
+ 'PKR',
+ 'BDT',
+ 'LKR',
+ 'SGD',
+ 'HKD',
+ 'MYR',
+ 'ZAR',
+ 'TRY',
+ 'RUB',
+ 'BRL',
+ 'MXN',
+ 'NZD',
+ 'SEK',
+ 'NOK',
+ 'DKK',
+ 'PLN',
+ 'THB',
+ 'IDR',
+ 'PHP',
+ 'KRW',
+]);
+
+const UAE_VAT_RATE = 5; // percent
+
+export const trnFormatRule: Rule = {
+ id: 'R010',
+ severity: 'error',
+ check: (invoice) => {
+ const trn = str(invoice.vendor.trn);
+ if (trn === null) {
+ return [
+ {
+ ruleId: 'R010',
+ severity: 'warning',
+ fieldPath: 'vendor.trn',
+ message: 'Vendor TRN is missing',
+ },
+ ];
+ }
+ if (!/^\d{15}$/.test(trn.trim())) {
+ return [
+ {
+ ruleId: 'R010',
+ severity: 'error',
+ fieldPath: 'vendor.trn',
+ message: `TRN "${trn}" is not 15 digits`,
+ },
+ ];
+ }
+ return [];
+ },
+};
+
+export const vatDefaultRule: Rule = {
+ id: 'R011',
+ severity: 'warning',
+ check: (invoice) => {
+ const currency = str(invoice.currency);
+ const subtotal = num(invoice.subtotal);
+ const tax = num(invoice.taxAmount);
+ if (currency !== 'AED' || subtotal === null || tax === null || subtotal === 0) return [];
+ const expected = subtotal * (UAE_VAT_RATE / 100);
+ if (approxEqualMinor(expected, tax, 2)) return [];
+ const effective = (tax / subtotal) * 100;
+ return [
+ {
+ ruleId: 'R011',
+ severity: 'warning',
+ fieldPath: 'taxAmount',
+ message: `Effective VAT is ${effective.toFixed(1)}%; UAE invoices typically use ${String(UAE_VAT_RATE)}%`,
+ },
+ ];
+ },
+};
+
+export const currencyWhitelistRule: Rule = {
+ id: 'R012',
+ severity: 'warning',
+ check: (invoice) => {
+ const currency = str(invoice.currency);
+ if (currency === null) return [];
+ const code = currency.trim().toUpperCase();
+ if (PREFERRED_CURRENCIES.has(code)) return [];
+ if (ISO_4217.has(code)) {
+ return [
+ {
+ ruleId: 'R012',
+ severity: 'warning',
+ fieldPath: 'currency',
+ message: `Currency ${code} is valid but outside the common set`,
+ },
+ ];
+ }
+ return [
+ {
+ ruleId: 'R012',
+ severity: 'error',
+ fieldPath: 'currency',
+ message: `"${currency}" is not a recognized ISO 4217 currency code`,
+ },
+ ];
+ },
+};
+
+export const regionalRules: readonly Rule[] = [
+ trnFormatRule,
+ vatDefaultRule,
+ currencyWhitelistRule,
+];
From b63d191cef26dcd9e2cdb378e7c5df43759505e9 Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:00:21 +0400
Subject: [PATCH 15/63] feat(rules): add date, non-negative, empty-items, and
uncertain-path sanity rules
Co-Authored-By: Claude Opus 4.8
---
src/schema/rules/sanity.ts | 111 +++++++++++++++++++++++++++++++++++++
1 file changed, 111 insertions(+)
create mode 100644 src/schema/rules/sanity.ts
diff --git a/src/schema/rules/sanity.ts b/src/schema/rules/sanity.ts
new file mode 100644
index 0000000..1d9803e
--- /dev/null
+++ b/src/schema/rules/sanity.ts
@@ -0,0 +1,111 @@
+// Sanity rules: date ordering/plausibility, non-negative values, structural
+// checks, and uncertain-path surfacing.
+
+import { num, str, type Rule } from './engine.ts';
+
+const MIN_DATE = new Date('2000-01-01T00:00:00Z').getTime();
+
+function parseIsoDate(value: string | null): number | null {
+ if (value === null) return null;
+ const ts = Date.parse(value);
+ return Number.isNaN(ts) ? null : ts;
+}
+
+export const dateOrderRule: Rule = {
+ id: 'R020',
+ severity: 'error',
+ check: (invoice) => {
+ const issue = parseIsoDate(str(invoice.issueDate));
+ const due = parseIsoDate(invoice.dueDate?.value ?? null);
+ if (issue === null || due === null) return [];
+ if (due >= issue) return [];
+ return [
+ {
+ ruleId: 'R020',
+ severity: 'error',
+ fieldPath: 'dueDate',
+ message: 'Due date is before the issue date',
+ },
+ ];
+ },
+};
+
+export const datePlausibilityRule: Rule = {
+ id: 'R021',
+ severity: 'error',
+ check: (invoice, ctx) => {
+ const issue = parseIsoDate(str(invoice.issueDate));
+ if (issue === null) return [];
+ const upper = new Date(ctx.today);
+ upper.setFullYear(upper.getFullYear() + 1);
+ if (issue >= MIN_DATE && issue <= upper.getTime()) return [];
+ return [
+ {
+ ruleId: 'R021',
+ severity: 'error',
+ fieldPath: 'issueDate',
+ message: 'Issue date is implausible (before 2000 or more than a year in the future)',
+ },
+ ];
+ },
+};
+
+export const nonNegativeRule: Rule = {
+ id: 'R022',
+ severity: 'error',
+ check: (invoice) => {
+ return invoice.lineItems.flatMap((line, i) => {
+ const checks: { key: 'quantity' | 'unitPrice' | 'amount'; value: number | null }[] = [
+ { key: 'quantity', value: num(line.quantity) },
+ { key: 'unitPrice', value: num(line.unitPrice) },
+ { key: 'amount', value: num(line.amount) },
+ ];
+ return checks
+ .filter((c) => c.value !== null && c.value < 0)
+ .map((c) => ({
+ ruleId: 'R022',
+ severity: 'error' as const,
+ fieldPath: `lineItems.${String(i)}.${c.key}`,
+ message: `Line ${String(i + 1)} ${c.key} is negative`,
+ }));
+ });
+ },
+};
+
+export const emptyLineItemsRule: Rule = {
+ id: 'R023',
+ severity: 'error',
+ check: (invoice) => {
+ const total = num(invoice.total);
+ if (invoice.lineItems.length > 0 || total === null || total === 0) return [];
+ return [
+ {
+ ruleId: 'R023',
+ severity: 'error',
+ fieldPath: 'lineItems',
+ message: `No line items, but total is ${total.toFixed(2)}`,
+ },
+ ];
+ },
+};
+
+export const uncertainPathRule: Rule = {
+ id: 'R024',
+ severity: 'warning',
+ check: (invoice) => {
+ return invoice.uncertain.map((path) => ({
+ ruleId: 'R024',
+ severity: 'warning' as const,
+ fieldPath: path,
+ message: 'The model flagged this value as uncertain',
+ }));
+ },
+};
+
+export const sanityRules: readonly Rule[] = [
+ dateOrderRule,
+ datePlausibilityRule,
+ nonNegativeRule,
+ emptyLineItemsRule,
+ uncertainPathRule,
+];
From 466ba2046cee621f6eec29e9fc03e1603baefe67 Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:00:21 +0400
Subject: [PATCH 16/63] feat(rules): add review state folding with verdict
Co-Authored-By: Claude Opus 4.8
---
src/schema/rules/review-state.ts | 49 ++++++++++++++++++++++++++++++++
1 file changed, 49 insertions(+)
create mode 100644 src/schema/rules/review-state.ts
diff --git a/src/schema/rules/review-state.ts b/src/schema/rules/review-state.ts
new file mode 100644
index 0000000..95c1f6c
--- /dev/null
+++ b/src/schema/rules/review-state.ts
@@ -0,0 +1,49 @@
+// Fold validation findings into a review state. Never auto-corrects a value;
+// suggestions are display-only.
+
+import type { Finding, ValidationResult } from './engine.ts';
+
+export type ReviewVerdict = 'clean' | 'needs-review' | 'rejected';
+
+export interface ReviewState {
+ verdict: ReviewVerdict;
+ /** Field paths a human must look at: any error, or a warning on a money field. */
+ fieldsNeedingReview: string[];
+ errorCount: number;
+ warningCount: number;
+}
+
+const MONEY_FIELD = /^(subtotal|taxAmount|total)$|^lineItems\.\d+\.(amount|unitPrice)$/;
+
+// Findings severe enough to reject the document outright.
+const STRUCTURAL_RULES = new Set(['R023']);
+
+function isMoneyField(path: string): boolean {
+ return MONEY_FIELD.test(path);
+}
+
+export function foldReviewState(result: ValidationResult): ReviewState {
+ const errors = result.findings.filter((f) => f.severity === 'error');
+ const warnings = result.findings.filter((f) => f.severity === 'warning');
+
+ const needing = new Set();
+ for (const finding of errors) needing.add(finding.fieldPath);
+ for (const finding of warnings) {
+ if (isMoneyField(finding.fieldPath)) needing.add(finding.fieldPath);
+ }
+
+ let verdict: ReviewVerdict;
+ if (errors.length === 0) {
+ verdict = warnings.length === 0 ? 'clean' : 'needs-review';
+ } else {
+ const structural = errors.some((f: Finding) => STRUCTURAL_RULES.has(f.ruleId));
+ verdict = structural ? 'rejected' : 'needs-review';
+ }
+
+ return {
+ verdict,
+ fieldsNeedingReview: [...needing],
+ errorCount: errors.length,
+ warningCount: warnings.length,
+ };
+}
From 5daa98d824ba32960690ea2f34ed32b1434d71bb Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:00:21 +0400
Subject: [PATCH 17/63] feat(rules): add rule registry and validateInvoice
entry point
Co-Authored-By: Claude Opus 4.8
---
src/schema/rules/index.ts | 30 ++++++++++++++++++++++++++++++
1 file changed, 30 insertions(+)
create mode 100644 src/schema/rules/index.ts
diff --git a/src/schema/rules/index.ts b/src/schema/rules/index.ts
new file mode 100644
index 0000000..723ee0b
--- /dev/null
+++ b/src/schema/rules/index.ts
@@ -0,0 +1,30 @@
+// Validation layer public surface: the rule registry plus a convenience runner.
+
+import type { InvoiceV1 } from '../invoice.ts';
+import { arithmeticRules } from './arithmetic.ts';
+import { runRules, type Rule, type RuleContext, type ValidationResult } from './engine.ts';
+import { regionalRules } from './regional.ts';
+import { foldReviewState, type ReviewState } from './review-state.ts';
+import { sanityRules } from './sanity.ts';
+
+export * from './engine.ts';
+export * from './review-state.ts';
+export { arithmeticRules } from './arithmetic.ts';
+export { regionalRules } from './regional.ts';
+export { sanityRules } from './sanity.ts';
+
+/** All rules, grouped so a settings screen can toggle locales later. */
+export const ALL_RULES: readonly Rule[] = [...arithmeticRules, ...regionalRules, ...sanityRules];
+
+export interface Validation extends ValidationResult {
+ review: ReviewState;
+}
+
+/** Run the full rule set and fold the result into a review state. */
+export function validateInvoice(
+ invoice: InvoiceV1,
+ ctx: RuleContext = { today: new Date() },
+): Validation {
+ const result = runRules(invoice, ALL_RULES, ctx);
+ return { ...result, review: foldReviewState(result) };
+}
From a6b69462d939e536b805466b5fae66926491e74c Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:00:21 +0400
Subject: [PATCH 18/63] test(rules): cover all rules, engine aggregation, and
review verdict matrix
Co-Authored-By: Claude Opus 4.8
---
src/schema/rules/rules.test.ts | 266 +++++++++++++++++++++++++++++++++
1 file changed, 266 insertions(+)
create mode 100644 src/schema/rules/rules.test.ts
diff --git a/src/schema/rules/rules.test.ts b/src/schema/rules/rules.test.ts
new file mode 100644
index 0000000..3322ff8
--- /dev/null
+++ b/src/schema/rules/rules.test.ts
@@ -0,0 +1,266 @@
+import { describe, expect, it } from 'vitest';
+import type { Field, InvoiceV1, LineItem, Party } from '../invoice.ts';
+import { arithmeticRules } from './arithmetic.ts';
+import { runRules, statusFor, type Rule, type RuleContext } from './engine.ts';
+import { ALL_RULES, validateInvoice } from './index.ts';
+import { regionalRules } from './regional.ts';
+import { foldReviewState } from './review-state.ts';
+import { sanityRules } from './sanity.ts';
+
+const ctx: RuleContext = { today: new Date('2026-06-07T00:00:00Z') };
+
+function f(value: T | null, confidence: Field['confidence'] = 'extracted'): Field {
+ return { value, confidence };
+}
+
+function party(overrides: Partial = {}): Party {
+ return {
+ name: f('Acme FZE'),
+ address: f('Dubai, UAE'),
+ trn: f('100123456700003'),
+ phone: f(null, 'missing'),
+ email: f(null, 'missing'),
+ ...overrides,
+ };
+}
+
+function line(overrides: Partial = {}): LineItem {
+ return {
+ description: f('Widget'),
+ quantity: f(2),
+ unitPrice: f(50),
+ amount: f(100),
+ taxRate: f(5),
+ ...overrides,
+ };
+}
+
+function makeInvoice(overrides: Partial = {}): InvoiceV1 {
+ return {
+ vendor: party(),
+ invoiceNumber: f('INV-001'),
+ issueDate: f('2026-01-15'),
+ currency: f('AED'),
+ lineItems: [line()],
+ subtotal: f(100),
+ taxAmount: f(5),
+ total: f(105),
+ uncertain: [],
+ ...overrides,
+ };
+}
+
+const ids = (rules: readonly Rule[], invoice: InvoiceV1): string[] =>
+ runRules(invoice, rules, ctx).findings.map((x) => x.ruleId);
+
+describe('engine', () => {
+ it('a clean invoice produces zero findings and a clean verdict', () => {
+ const v = validateInvoice(makeInvoice(), ctx);
+ expect(v.findings).toHaveLength(0);
+ expect(v.review.verdict).toBe('clean');
+ });
+
+ it('error overrides warning on the same field path', () => {
+ const errorRule: Rule = {
+ id: 'E',
+ severity: 'error',
+ check: () => [{ ruleId: 'E', severity: 'error', fieldPath: 'total', message: 'e' }],
+ };
+ const warnRule: Rule = {
+ id: 'W',
+ severity: 'warning',
+ check: () => [{ ruleId: 'W', severity: 'warning', fieldPath: 'total', message: 'w' }],
+ };
+ const result = runRules(makeInvoice(), [warnRule, errorRule], ctx);
+ expect(statusFor(result, 'total')).toBe('error');
+ expect(statusFor(result, 'subtotal')).toBe('ok');
+ });
+});
+
+describe('R001 line amount', () => {
+ it('passes when qty × unit = amount', () => {
+ expect(ids(arithmeticRules, makeInvoice())).not.toContain('R001');
+ });
+ it('tolerates a one-minor-unit rounding difference', () => {
+ expect(
+ ids(arithmeticRules, makeInvoice({ lineItems: [line({ amount: f(100.01) })] })),
+ ).not.toContain('R001');
+ });
+ it('flags a wrong line amount', () => {
+ expect(ids(arithmeticRules, makeInvoice({ lineItems: [line({ amount: f(90) })] }))).toContain(
+ 'R001',
+ );
+ });
+ it('skips lines with missing values', () => {
+ expect(
+ ids(
+ arithmeticRules,
+ makeInvoice({ lineItems: [line({ quantity: f(null, 'missing') })] }),
+ ),
+ ).not.toContain('R001');
+ });
+});
+
+describe('R002 subtotal', () => {
+ it('passes when line amounts sum to subtotal', () => {
+ expect(ids(arithmeticRules, makeInvoice())).not.toContain('R002');
+ });
+ it('flags a wrong subtotal', () => {
+ const inv = makeInvoice({ subtotal: f(80), total: f(85), taxAmount: f(5) });
+ expect(ids(arithmeticRules, inv)).toContain('R002');
+ });
+});
+
+describe('R003 total', () => {
+ it('passes when subtotal + tax = total', () => {
+ expect(ids(arithmeticRules, makeInvoice())).not.toContain('R003');
+ });
+ it('flags a wrong total', () => {
+ expect(ids(arithmeticRules, makeInvoice({ total: f(999) }))).toContain('R003');
+ });
+});
+
+describe('R004 tax consistency', () => {
+ it('passes when tax matches the dominant rate', () => {
+ expect(ids(arithmeticRules, makeInvoice())).not.toContain('R004');
+ });
+ it('flags tax that disagrees with the dominant line rate', () => {
+ const inv = makeInvoice({ taxAmount: f(20), total: f(120) });
+ expect(ids(arithmeticRules, inv)).toContain('R004');
+ });
+ it('uses the most common rate across mixed lines', () => {
+ const inv = makeInvoice({
+ lineItems: [line({ taxRate: f(5) }), line({ taxRate: f(5) }), line({ taxRate: f(0) })],
+ subtotal: f(300),
+ taxAmount: f(0),
+ total: f(300),
+ });
+ // dominant rate 5% → expected 15, tax 0 → flagged
+ expect(ids(arithmeticRules, inv)).toContain('R004');
+ });
+});
+
+describe('R010 TRN', () => {
+ it('passes a 15-digit TRN', () => {
+ expect(ids(regionalRules, makeInvoice())).not.toContain('R010');
+ });
+ it('errors on a malformed TRN', () => {
+ const inv = makeInvoice({ vendor: party({ trn: f('123') }) });
+ const findings = runRules(inv, regionalRules, ctx).findings.filter((x) => x.ruleId === 'R010');
+ expect(findings[0]?.severity).toBe('error');
+ });
+ it('warns when TRN is missing', () => {
+ const inv = makeInvoice({ vendor: party({ trn: f(null, 'missing') }) });
+ const findings = runRules(inv, regionalRules, ctx).findings.filter((x) => x.ruleId === 'R010');
+ expect(findings[0]?.severity).toBe('warning');
+ });
+});
+
+describe('R011 VAT default', () => {
+ it('passes at 5% in AED', () => {
+ expect(ids(regionalRules, makeInvoice())).not.toContain('R011');
+ });
+ it('warns on a non-5% effective rate in AED', () => {
+ expect(ids(regionalRules, makeInvoice({ taxAmount: f(10), total: f(110) }))).toContain('R011');
+ });
+ it('does not apply to non-AED currencies', () => {
+ const inv = makeInvoice({ currency: f('USD'), taxAmount: f(10), total: f(110) });
+ expect(ids(regionalRules, inv)).not.toContain('R011');
+ });
+});
+
+describe('R012 currency whitelist', () => {
+ it('accepts a preferred currency', () => {
+ expect(ids(regionalRules, makeInvoice())).not.toContain('R012');
+ });
+ it('warns on a valid but non-preferred currency', () => {
+ const inv = makeInvoice({ currency: f('JPY') });
+ const findings = runRules(inv, regionalRules, ctx).findings.filter((x) => x.ruleId === 'R012');
+ expect(findings[0]?.severity).toBe('warning');
+ });
+ it('errors on an invalid code', () => {
+ const inv = makeInvoice({ currency: f('XYZ') });
+ const findings = runRules(inv, regionalRules, ctx).findings.filter((x) => x.ruleId === 'R012');
+ expect(findings[0]?.severity).toBe('error');
+ });
+});
+
+describe('R020 date order', () => {
+ it('passes when due ≥ issue', () => {
+ expect(ids(sanityRules, makeInvoice({ dueDate: f('2026-02-15') }))).not.toContain('R020');
+ });
+ it('errors when due < issue', () => {
+ expect(ids(sanityRules, makeInvoice({ dueDate: f('2026-01-01') }))).toContain('R020');
+ });
+});
+
+describe('R021 date plausibility', () => {
+ it('passes a current date', () => {
+ expect(ids(sanityRules, makeInvoice())).not.toContain('R021');
+ });
+ it('accepts the 2000-01-01 boundary', () => {
+ expect(ids(sanityRules, makeInvoice({ issueDate: f('2000-01-01') }))).not.toContain('R021');
+ });
+ it('errors before 2000', () => {
+ expect(ids(sanityRules, makeInvoice({ issueDate: f('1999-12-31') }))).toContain('R021');
+ });
+ it('errors more than a year in the future', () => {
+ expect(ids(sanityRules, makeInvoice({ issueDate: f('2028-01-01') }))).toContain('R021');
+ });
+});
+
+describe('R022 non-negative', () => {
+ it('passes positive values', () => {
+ expect(ids(sanityRules, makeInvoice())).not.toContain('R022');
+ });
+ it('errors on a negative quantity', () => {
+ const inv = makeInvoice({ lineItems: [line({ quantity: f(-1), amount: f(-50) })] });
+ expect(ids(sanityRules, inv)).toContain('R022');
+ });
+});
+
+describe('R023 empty line items', () => {
+ it('errors when there are no items but a non-zero total', () => {
+ expect(ids(sanityRules, makeInvoice({ lineItems: [], total: f(105) }))).toContain('R023');
+ });
+ it('passes when empty items and zero total', () => {
+ expect(ids(sanityRules, makeInvoice({ lineItems: [], total: f(0) }))).not.toContain('R023');
+ });
+});
+
+describe('R024 uncertain paths', () => {
+ it('warns once per uncertain path', () => {
+ const inv = makeInvoice({ uncertain: ['subtotal', 'vendor.name'] });
+ const findings = runRules(inv, sanityRules, ctx).findings.filter((x) => x.ruleId === 'R024');
+ expect(findings).toHaveLength(2);
+ });
+});
+
+describe('review state', () => {
+ it('clean with no findings', () => {
+ const review = foldReviewState({ findings: [], fieldStatus: {} });
+ expect(review.verdict).toBe('clean');
+ });
+ it('needs-review with only warnings', () => {
+ const review = validateInvoice(makeInvoice({ currency: f('JPY') }), ctx).review;
+ expect(review.verdict).toBe('needs-review');
+ });
+ it('needs-review and flags the money field on a wrong subtotal', () => {
+ const v = validateInvoice(makeInvoice({ subtotal: f(80) }), ctx);
+ expect(v.findings.some((x) => x.ruleId === 'R002')).toBe(true);
+ expect(v.review.verdict).toBe('needs-review');
+ expect(v.review.fieldsNeedingReview).toContain('subtotal');
+ });
+ it('rejected on a structural failure (empty items with a total)', () => {
+ const v = validateInvoice(makeInvoice({ lineItems: [], subtotal: f(0), total: f(105) }), ctx);
+ expect(v.review.verdict).toBe('rejected');
+ });
+});
+
+describe('registry', () => {
+ it('exposes all rule groups', () => {
+ expect(ALL_RULES.length).toBe(
+ arithmeticRules.length + regionalRules.length + sanityRules.length,
+ );
+ });
+});
From 635194116985e3afacce25834e2190b9a968336d Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:00:21 +0400
Subject: [PATCH 19/63] docs(rules): document rule catalog with ids and
severities
Co-Authored-By: Claude Opus 4.8
---
src/schema/rules/README.md | 34 ++++++++++++++++++++++++++++++++++
1 file changed, 34 insertions(+)
create mode 100644 src/schema/rules/README.md
diff --git a/src/schema/rules/README.md b/src/schema/rules/README.md
new file mode 100644
index 0000000..133fa6c
--- /dev/null
+++ b/src/schema/rules/README.md
@@ -0,0 +1,34 @@
+# schema/rules
+
+The deterministic validation layer — pure TypeScript, zero ML. This is the trust
+boundary: it gates every extraction and **never auto-corrects** a value
+(suggestions are display-only).
+
+## Engine
+
+`runRules(invoice, rules, ctx)` aggregates `Finding[]` and computes per-field
+`ok | warning | error` status (error beats warning on the same path).
+`validateInvoice()` runs `ALL_RULES` and folds the result into a `ReviewState`
+(`clean | needs-review | rejected`). Money comparisons use integer minor units
+with a one-minor-unit tolerance — never floating-point equality.
+
+## Rule catalog
+
+| ID | Severity | Field | Checks |
+| ---- | --------------- | -------------------- | --------------------------------------------------- |
+| R001 | error | `lineItems.N.amount` | quantity × unit price ≈ amount |
+| R002 | error | `subtotal` | Σ line amounts ≈ subtotal |
+| R003 | error | `total` | subtotal + tax ≈ total |
+| R004 | warning | `taxAmount` | tax ≈ subtotal × dominant line rate |
+| R010 | error / warning | `vendor.trn` | UAE TRN is 15 digits (malformed→error, absent→warn) |
+| R011 | warning | `taxAmount` | AED invoices expect 5% VAT |
+| R012 | warning / error | `currency` | preferred set ok; other ISO→warn; invalid→error |
+| R020 | error | `dueDate` | issueDate ≤ dueDate |
+| R021 | error | `issueDate` | within [2000-01-01, today + 1 year] |
+| R022 | error | `lineItems.N.*` | non-negative quantity / price / amount |
+| R023 | error | `lineItems` | no items but a non-zero total (structural) |
+| R024 | warning | (the path) | model-flagged `_uncertain` paths |
+
+Regional rules (R010–R012) are grouped separately so a future settings screen
+can toggle locales. A document is **rejected** only on a structural failure
+(R023); other errors yield **needs-review**.
From e07734b2dd90a97a94e749d0d226a3af016cb764 Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:06:00 +0400
Subject: [PATCH 20/63] refactor(ui): extract shared design tokens for review
surfaces
Co-Authored-By: Claude Opus 4.8
---
src/main.tsx | 1 +
src/ui/tokens.css | 30 ++++++++++++++++++++++++++++++
2 files changed, 31 insertions(+)
create mode 100644 src/ui/tokens.css
diff --git a/src/main.tsx b/src/main.tsx
index 772bd60..e65d929 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -2,6 +2,7 @@ import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App.tsx';
import { DeviceProfileProvider } from './capability/useDeviceProfile.tsx';
+import './ui/tokens.css';
import './index.css';
const rootElement = document.getElementById('root');
diff --git a/src/ui/tokens.css b/src/ui/tokens.css
new file mode 100644
index 0000000..7079b51
--- /dev/null
+++ b/src/ui/tokens.css
@@ -0,0 +1,30 @@
+/* Shared design tokens. Imported once in main.tsx. */
+:root {
+ --fl-border: #d8d8d8;
+ --fl-surface: #fafafa;
+ --fl-surface-raised: #ffffff;
+ --fl-text: #1a1a1a;
+ --fl-text-muted: #6a6a6a;
+
+ --fl-ok: #1a7f37;
+ --fl-warning: #9a6700;
+ --fl-error: #b42318;
+
+ --fl-ok-bg: #e6f4ea;
+ --fl-warning-bg: #fff4d6;
+ --fl-error-bg: #fde8e8;
+
+ --fl-radius: 0.5rem;
+ --fl-radius-lg: 0.75rem;
+ --fl-gap: 0.75rem;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --fl-border: #333;
+ --fl-surface: #1e1e1e;
+ --fl-surface-raised: #262626;
+ --fl-text: rgba(255, 255, 255, 0.9);
+ --fl-text-muted: #9a9a9a;
+ }
+}
From 2254d9e8d12690d131c3a2bce8d4d11e89f62186 Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:06:00 +0400
Subject: [PATCH 21/63] feat(review): add pure review state with live
revalidation, audit trail, and approval gating
Co-Authored-By: Claude Opus 4.8
---
src/ui/review/state.test.ts | 117 ++++++++++++++++++++++++
src/ui/review/state.ts | 175 ++++++++++++++++++++++++++++++++++++
2 files changed, 292 insertions(+)
create mode 100644 src/ui/review/state.test.ts
create mode 100644 src/ui/review/state.ts
diff --git a/src/ui/review/state.test.ts b/src/ui/review/state.test.ts
new file mode 100644
index 0000000..ee968bd
--- /dev/null
+++ b/src/ui/review/state.test.ts
@@ -0,0 +1,117 @@
+import { describe, expect, it } from 'vitest';
+import type { Field, InvoiceV1, LineItem, Party } from '../../schema/invoice.ts';
+import type { RuleContext } from '../../schema/rules/index.ts';
+import {
+ canApprove,
+ initReviewState,
+ reviewReducer,
+ valueAtPath,
+ type ReviewState,
+} from './state.ts';
+
+const ctx: RuleContext = { today: new Date('2026-06-07T00:00:00Z') };
+
+function f(value: T | null, confidence: Field['confidence'] = 'extracted'): Field {
+ return { value, confidence };
+}
+function party(o: Partial = {}): Party {
+ return {
+ name: f('Acme FZE'),
+ address: f('Dubai'),
+ trn: f('100123456700003'),
+ phone: f(null, 'missing'),
+ email: f(null, 'missing'),
+ ...o,
+ };
+}
+function line(o: Partial = {}): LineItem {
+ return {
+ description: f('Widget'),
+ quantity: f(2),
+ unitPrice: f(50),
+ amount: f(100),
+ taxRate: f(5),
+ ...o,
+ };
+}
+function makeInvoice(o: Partial = {}): InvoiceV1 {
+ return {
+ vendor: party(),
+ invoiceNumber: f('INV-001'),
+ issueDate: f('2026-01-15'),
+ currency: f('AED'),
+ lineItems: [line()],
+ subtotal: f(100),
+ taxAmount: f(5),
+ total: f(105),
+ uncertain: [],
+ ...o,
+ };
+}
+
+const init = (inv: InvoiceV1): ReviewState => initReviewState(inv, ctx);
+
+describe('reviewReducer — live revalidation', () => {
+ it('clears R002 when a wrong subtotal is corrected', () => {
+ let state = init(makeInvoice({ subtotal: f(80) }));
+ expect(state.validation.findings.some((x) => x.ruleId === 'R002')).toBe(true);
+ expect(canApprove(state)).toBe(false);
+
+ state = reviewReducer(state, { type: 'editField', path: 'subtotal', value: '100', at: 1 }, ctx);
+ expect(state.validation.findings.some((x) => x.ruleId === 'R002')).toBe(false);
+ });
+
+ it('records an audit entry with before/after and marks the path edited', () => {
+ let state = init(makeInvoice({ subtotal: f(80) }));
+ state = reviewReducer(
+ state,
+ { type: 'editField', path: 'subtotal', value: '100', at: 42 },
+ ctx,
+ );
+ expect(state.edits).toHaveLength(1);
+ expect(state.edits[0]).toEqual({ fieldPath: 'subtotal', before: 80, after: 100, at: 42 });
+ expect(state.editedPaths).toContain('subtotal');
+ });
+
+ it('reads values by path for the audit trail', () => {
+ expect(valueAtPath(makeInvoice(), 'subtotal')).toBe(100);
+ expect(valueAtPath(makeInvoice(), 'vendor.name')).toBe('Acme FZE');
+ expect(valueAtPath(makeInvoice(), 'lineItems.0.amount')).toBe(100);
+ });
+});
+
+describe('approval gating', () => {
+ it('blocks approval while an error finding exists', () => {
+ const state = init(makeInvoice({ total: f(999) }));
+ expect(canApprove(state)).toBe(false);
+ const after = reviewReducer(state, { type: 'approve' }, ctx);
+ expect(after.approved).toBe(false);
+ });
+
+ it('requires acknowledgment to approve with warnings', () => {
+ let state = init(makeInvoice({ currency: f('JPY') })); // R012 warning
+ expect(canApprove(state)).toBe(false);
+ state = reviewReducer(state, { type: 'setApproveWithWarnings', value: true }, ctx);
+ expect(canApprove(state)).toBe(true);
+ state = reviewReducer(state, { type: 'approve' }, ctx);
+ expect(state.approved).toBe(true);
+ });
+
+ it('approves a clean invoice directly', () => {
+ let state = init(makeInvoice());
+ expect(canApprove(state)).toBe(true);
+ state = reviewReducer(state, { type: 'approve' }, ctx);
+ expect(state.approved).toBe(true);
+ });
+});
+
+describe('line item editing', () => {
+ it('adds and deletes line items, revalidating each time', () => {
+ let state = init(makeInvoice());
+ state = reviewReducer(state, { type: 'addLine', at: 1 }, ctx);
+ expect(state.invoice.lineItems).toHaveLength(2);
+ state = reviewReducer(state, { type: 'deleteLine', index: 1, at: 2 }, ctx);
+ expect(state.invoice.lineItems).toHaveLength(1);
+ expect(state.edits).toHaveLength(2);
+ });
+});
diff --git a/src/ui/review/state.ts b/src/ui/review/state.ts
new file mode 100644
index 0000000..d26ba77
--- /dev/null
+++ b/src/ui/review/state.ts
@@ -0,0 +1,175 @@
+// Pure review state: an editable invoice, live validation, an edit audit trail,
+// and approval gating. Kept framework-free so the logic is unit-tested directly.
+
+import type { Field, InvoiceV1 } from '../../schema/invoice.ts';
+import { validateInvoice, type RuleContext, type Validation } from '../../schema/rules/index.ts';
+
+export interface EditEntry {
+ fieldPath: string;
+ before: string | number | null;
+ after: string | number | null;
+ at: number;
+}
+
+export interface ReviewState {
+ invoice: InvoiceV1;
+ validation: Validation;
+ edits: EditEntry[];
+ editedPaths: string[];
+ approveWithWarnings: boolean;
+ approved: boolean;
+}
+
+export type ReviewAction =
+ | { type: 'editField'; path: string; value: string; at: number }
+ | { type: 'addLine'; at: number }
+ | { type: 'deleteLine'; index: number; at: number }
+ | { type: 'setApproveWithWarnings'; value: boolean }
+ | { type: 'approve' };
+
+const NUMERIC_FIELD =
+ /^(subtotal|taxAmount|total)$|^lineItems\.\d+\.(quantity|unitPrice|amount|taxRate)$/;
+
+function isNumericPath(path: string): boolean {
+ return NUMERIC_FIELD.test(path);
+}
+
+function parseValue(path: string, raw: string): string | number | null {
+ if (raw === '') return null;
+ if (isNumericPath(path)) {
+ const n = Number.parseFloat(raw.replace(/[^0-9.+-]/g, ''));
+ return Number.isFinite(n) ? n : null;
+ }
+ return raw;
+}
+
+type FieldHolder = Record;
+
+function getField(invoice: InvoiceV1, path: string): Field | undefined {
+ const segments = path.split('.');
+ let current: unknown = invoice;
+ for (const segment of segments) {
+ if (current === null || typeof current !== 'object') return undefined;
+ if (Array.isArray(current)) {
+ current = current[Number(segment)];
+ } else {
+ current = (current as FieldHolder)[segment];
+ }
+ }
+ return current as Field | undefined;
+}
+
+/** Read a field's current value for the audit trail. */
+export function valueAtPath(invoice: InvoiceV1, path: string): string | number | null {
+ return getField(invoice, path)?.value ?? null;
+}
+
+function revalidate(invoice: InvoiceV1, ctx: RuleContext): Validation {
+ return validateInvoice(invoice, ctx);
+}
+
+export function initReviewState(invoice: InvoiceV1, ctx: RuleContext): ReviewState {
+ return {
+ invoice,
+ validation: revalidate(invoice, ctx),
+ edits: [],
+ editedPaths: [],
+ approveWithWarnings: false,
+ approved: false,
+ };
+}
+
+const emptyStringField = (): Field => ({ value: null, confidence: 'missing' });
+const emptyNumberField = (): Field => ({ value: null, confidence: 'missing' });
+
+function newLine(): InvoiceV1['lineItems'][number] {
+ return {
+ description: emptyStringField(),
+ quantity: emptyNumberField(),
+ unitPrice: emptyNumberField(),
+ amount: emptyNumberField(),
+ };
+}
+
+/** Whether the document can be approved: zero error-severity findings. */
+export function canApprove(state: ReviewState): boolean {
+ const hasError = state.validation.findings.some((f) => f.severity === 'error');
+ if (hasError) return false;
+ const hasWarning = state.validation.findings.some((f) => f.severity === 'warning');
+ return !hasWarning || state.approveWithWarnings;
+}
+
+export function reviewReducer(
+ state: ReviewState,
+ action: ReviewAction,
+ ctx: RuleContext,
+): ReviewState {
+ switch (action.type) {
+ case 'editField': {
+ const field = getField(state.invoice, action.path);
+ if (!field) return state;
+ const before = field.value;
+ const after = parseValue(action.path, action.value);
+ const invoice = structuredClone(state.invoice);
+ const target = getField(invoice, action.path);
+ if (target) {
+ target.value = after;
+ target.confidence = after === null ? 'missing' : 'extracted';
+ }
+ return {
+ ...state,
+ invoice,
+ validation: revalidate(invoice, ctx),
+ edits: [...state.edits, { fieldPath: action.path, before, after, at: action.at }],
+ editedPaths: state.editedPaths.includes(action.path)
+ ? state.editedPaths
+ : [...state.editedPaths, action.path],
+ approved: false,
+ };
+ }
+ case 'addLine': {
+ const invoice = structuredClone(state.invoice);
+ invoice.lineItems.push(newLine());
+ return {
+ ...state,
+ invoice,
+ validation: revalidate(invoice, ctx),
+ edits: [
+ ...state.edits,
+ {
+ fieldPath: `lineItems.${String(invoice.lineItems.length - 1)}`,
+ before: null,
+ after: 'added',
+ at: action.at,
+ },
+ ],
+ approved: false,
+ };
+ }
+ case 'deleteLine': {
+ const invoice = structuredClone(state.invoice);
+ invoice.lineItems.splice(action.index, 1);
+ return {
+ ...state,
+ invoice,
+ validation: revalidate(invoice, ctx),
+ edits: [
+ ...state.edits,
+ {
+ fieldPath: `lineItems.${String(action.index)}`,
+ before: 'present',
+ after: null,
+ at: action.at,
+ },
+ ],
+ approved: false,
+ };
+ }
+ case 'setApproveWithWarnings':
+ return { ...state, approveWithWarnings: action.value };
+ case 'approve':
+ return canApprove(state) ? { ...state, approved: true } : state;
+ default:
+ return state;
+ }
+}
From cb516e39c454be9e02c6ff9a45d8ec489fa28121 Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:06:00 +0400
Subject: [PATCH 22/63] feat(review): add useReview reducer hook
Co-Authored-By: Claude Opus 4.8
---
src/ui/review/useReview.ts | 22 ++++++++++++++++++++++
1 file changed, 22 insertions(+)
create mode 100644 src/ui/review/useReview.ts
diff --git a/src/ui/review/useReview.ts b/src/ui/review/useReview.ts
new file mode 100644
index 0000000..72841d7
--- /dev/null
+++ b/src/ui/review/useReview.ts
@@ -0,0 +1,22 @@
+import { useCallback, useMemo, useReducer } from 'react';
+import type { InvoiceV1 } from '../../schema/invoice.ts';
+import type { RuleContext } from '../../schema/rules/index.ts';
+import { initReviewState, reviewReducer, type ReviewAction, type ReviewState } from './state.ts';
+
+export interface UseReview {
+ state: ReviewState;
+ dispatch: (action: ReviewAction) => void;
+}
+
+export function useReview(invoice: InvoiceV1, ctx?: RuleContext): UseReview {
+ const context = useMemo(() => ctx ?? { today: new Date() }, [ctx]);
+ const [state, rawDispatch] = useReducer(
+ (s: ReviewState, a: ReviewAction) => reviewReducer(s, a, context),
+ invoice,
+ (init) => initReviewState(init, context),
+ );
+ const dispatch = useCallback((action: ReviewAction) => {
+ rawDispatch(action);
+ }, []);
+ return { state, dispatch };
+}
From 664187f7a690bfd17674747b618dd78c31ea70b2 Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:06:00 +0400
Subject: [PATCH 23/63] feat(review): add field component with status dot,
findings, and inline edit
Co-Authored-By: Claude Opus 4.8
---
src/ui/review/FieldRow.tsx | 106 ++++++++++++++++
src/ui/review/review.module.css | 215 ++++++++++++++++++++++++++++++++
2 files changed, 321 insertions(+)
create mode 100644 src/ui/review/FieldRow.tsx
create mode 100644 src/ui/review/review.module.css
diff --git a/src/ui/review/FieldRow.tsx b/src/ui/review/FieldRow.tsx
new file mode 100644
index 0000000..fc0df92
--- /dev/null
+++ b/src/ui/review/FieldRow.tsx
@@ -0,0 +1,106 @@
+import { useEffect, useState } from 'react';
+import { cx } from '../../lib/cx.ts';
+import type { Finding, FieldStatus } from '../../schema/rules/index.ts';
+import styles from './review.module.css';
+
+export interface FieldRowProps {
+ label: string;
+ path: string;
+ value: string | number | null;
+ status: FieldStatus;
+ findings: Finding[];
+ edited: boolean;
+ flagged: boolean;
+ onCommit: (path: string, value: string) => void;
+}
+
+const dotClass: Record = {
+ ok: styles.dotOk ?? '',
+ warning: styles.dotWarning ?? '',
+ error: styles.dotError ?? '',
+};
+
+export function FieldRow({
+ label,
+ path,
+ value,
+ status,
+ findings,
+ edited,
+ flagged,
+ onCommit,
+}: FieldRowProps): React.JSX.Element {
+ const initial = value === null ? '' : String(value);
+ const [draft, setDraft] = useState(initial);
+ const [focused, setFocused] = useState(false);
+
+ useEffect(() => {
+ setDraft(initial);
+ }, [initial]);
+
+ const commit = (): void => {
+ if (draft !== initial) onCommit(path, draft);
+ };
+
+ return (
+ <>
+
+
+
+ {label}
+ {edited ? (
+
+ ✎
+
+ ) : null}
+
+ {
+ setDraft(e.target.value);
+ }}
+ onFocus={() => {
+ setFocused(true);
+ }}
+ onBlur={() => {
+ setFocused(false);
+ commit();
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ commit();
+ } else if (e.key === 'Escape') {
+ setDraft(initial);
+ }
+ }}
+ />
+
+ {focused && findings.length > 0 ? (
+
+ {findings.map((finding) => (
+
+ {finding.message}
+ {finding.suggestion !== undefined ? ` (suggested: ${finding.suggestion})` : ''}
+
+ ))}
+
+ ) : null}
+ >
+ );
+}
diff --git a/src/ui/review/review.module.css b/src/ui/review/review.module.css
new file mode 100644
index 0000000..24cda27
--- /dev/null
+++ b/src/ui/review/review.module.css
@@ -0,0 +1,215 @@
+.screen {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ gap: 1rem;
+ height: 100%;
+ min-height: 0;
+}
+
+@media (max-width: 1280px) {
+ .screen {
+ grid-template-columns: 1fr;
+ }
+}
+
+.form {
+ display: flex;
+ flex-direction: column;
+ gap: 1.25rem;
+ overflow: auto;
+ padding-right: 0.5rem;
+}
+
+.group {
+ border: 1px solid var(--fl-border);
+ border-radius: var(--fl-radius-lg);
+ padding: 1rem;
+ background: var(--fl-surface);
+}
+
+.groupTitle {
+ margin: 0 0 0.75rem;
+ font-size: 0.95rem;
+ font-weight: 700;
+}
+
+.row {
+ display: grid;
+ grid-template-columns: 9rem 1fr;
+ align-items: center;
+ gap: 0.5rem;
+ margin-bottom: 0.5rem;
+}
+
+.label {
+ font-size: 0.82rem;
+ color: var(--fl-text-muted);
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+}
+
+.dot {
+ width: 0.6rem;
+ height: 0.6rem;
+ border-radius: 50%;
+ display: inline-block;
+ flex: none;
+}
+.dotOk {
+ background: var(--fl-ok);
+}
+.dotWarning {
+ background: var(--fl-warning);
+}
+.dotError {
+ background: var(--fl-error);
+}
+
+.input {
+ font: inherit;
+ padding: 0.35rem 0.5rem;
+ border: 1px solid var(--fl-border);
+ border-radius: var(--fl-radius);
+ background: var(--fl-surface-raised);
+ color: var(--fl-text);
+ width: 100%;
+}
+.inputError {
+ border-color: var(--fl-error);
+}
+.inputWarning {
+ border-color: var(--fl-warning);
+}
+
+.edited {
+ color: var(--fl-text-muted);
+ font-size: 0.75rem;
+}
+
+.findings {
+ grid-column: 2;
+ margin: 0.15rem 0 0;
+ padding: 0;
+ list-style: none;
+ font-size: 0.75rem;
+}
+.findingError {
+ color: var(--fl-error);
+}
+.findingWarning {
+ color: var(--fl-warning);
+}
+
+.table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.82rem;
+}
+.table th,
+.table td {
+ border: 1px solid var(--fl-border);
+ padding: 0.25rem;
+}
+.cellInput {
+ font: inherit;
+ width: 100%;
+ border: none;
+ background: transparent;
+ color: inherit;
+}
+.disagree {
+ background: var(--fl-error-bg);
+}
+
+.iconButton {
+ font: inherit;
+ border: 1px solid var(--fl-border);
+ background: var(--fl-surface-raised);
+ border-radius: var(--fl-radius);
+ cursor: pointer;
+ padding: 0.15rem 0.5rem;
+}
+
+.approvalBar {
+ position: sticky;
+ bottom: 0;
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ padding: 0.75rem;
+ border-top: 1px solid var(--fl-border);
+ background: var(--fl-surface);
+}
+
+.verdict {
+ font-weight: 700;
+ text-transform: uppercase;
+ font-size: 0.75rem;
+ letter-spacing: 0.04em;
+}
+.verdictClean {
+ color: var(--fl-ok);
+}
+.verdictNeedsReview {
+ color: var(--fl-warning);
+}
+.verdictRejected {
+ color: var(--fl-error);
+}
+
+.approveButton {
+ font: inherit;
+ margin-left: auto;
+ padding: 0.45rem 1.1rem;
+ border-radius: var(--fl-radius);
+ border: 1px solid var(--fl-ok);
+ background: var(--fl-ok);
+ color: #fff;
+ cursor: pointer;
+}
+.approveButton:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+ background: var(--fl-text-muted);
+ border-color: var(--fl-text-muted);
+}
+
+.imagePane {
+ border: 1px solid var(--fl-border);
+ border-radius: var(--fl-radius-lg);
+ background: var(--fl-surface);
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+.imageControls {
+ display: flex;
+ gap: 0.5rem;
+ padding: 0.5rem;
+ border-bottom: 1px solid var(--fl-border);
+}
+.imageViewport {
+ flex: 1;
+ overflow: hidden;
+ position: relative;
+ cursor: grab;
+ background: #2a2a2a;
+}
+.imageViewport img {
+ position: absolute;
+ transform-origin: 0 0;
+ user-select: none;
+}
+
+.drawer {
+ margin-top: 1rem;
+}
+.drawerToggle {
+ font: inherit;
+ background: none;
+ border: none;
+ color: var(--fl-text-muted);
+ cursor: pointer;
+ padding: 0.25rem 0;
+}
From a8bf188fb437ca9258d7fec907935774afe80f4f Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:06:00 +0400
Subject: [PATCH 24/63] feat(review): add review form with grouped fields,
editable line items, and approval bar
Co-Authored-By: Claude Opus 4.8
---
src/ui/review/ReviewForm.tsx | 224 +++++++++++++++++++++++++++++++++++
1 file changed, 224 insertions(+)
create mode 100644 src/ui/review/ReviewForm.tsx
diff --git a/src/ui/review/ReviewForm.tsx b/src/ui/review/ReviewForm.tsx
new file mode 100644
index 0000000..acfb96a
--- /dev/null
+++ b/src/ui/review/ReviewForm.tsx
@@ -0,0 +1,224 @@
+import { cx } from '../../lib/cx.ts';
+import type { Finding } from '../../schema/rules/index.ts';
+import { FieldRow } from './FieldRow.tsx';
+import styles from './review.module.css';
+import { canApprove, type ReviewState } from './state.ts';
+import type { ReviewAction } from './state.ts';
+
+export interface ReviewFormProps {
+ state: ReviewState;
+ dispatch: (action: ReviewAction) => void;
+ now?: () => number;
+}
+
+const VENDOR_FIELDS: [string, string][] = [
+ ['vendor.name', 'Name'],
+ ['vendor.address', 'Address'],
+ ['vendor.trn', 'TRN'],
+ ['vendor.phone', 'Phone'],
+ ['vendor.email', 'Email'],
+];
+
+const META_FIELDS: [string, string][] = [
+ ['invoiceNumber', 'Invoice #'],
+ ['issueDate', 'Issue date'],
+ ['dueDate', 'Due date'],
+ ['currency', 'Currency'],
+];
+
+const TOTAL_FIELDS: [string, string][] = [
+ ['subtotal', 'Subtotal'],
+ ['taxAmount', 'Tax'],
+ ['total', 'Total'],
+];
+
+export function ReviewForm({ state, dispatch, now }: ReviewFormProps): React.JSX.Element {
+ const clock = now ?? (() => Date.now());
+ const { invoice, validation } = state;
+
+ const findingsFor = (path: string): Finding[] =>
+ validation.findings.filter((f) => f.fieldPath === path);
+ const statusFor = (path: string) => validation.fieldStatus[path] ?? 'ok';
+ const commit = (path: string, value: string): void => {
+ dispatch({ type: 'editField', path, value, at: clock() });
+ };
+
+ const renderRows = (fields: [string, string][]): React.JSX.Element[] =>
+ fields.map(([path, label]) => {
+ const status = statusFor(path);
+ return (
+
+ );
+ });
+
+ const verdict = validation.review.verdict;
+ const verdictClass =
+ verdict === 'clean'
+ ? styles.verdictClean
+ : verdict === 'rejected'
+ ? styles.verdictRejected
+ : styles.verdictNeedsReview;
+
+ const warnings = validation.findings.some((f) => f.severity === 'warning');
+
+ return (
+
+
+ Vendor
+ {renderRows(VENDOR_FIELDS)}
+
+
+
+ Invoice
+ {renderRows(META_FIELDS)}
+
+
+
+
+
+ Totals
+ {renderRows(TOTAL_FIELDS)}
+
+
+
+ {verdict}
+ {warnings ? (
+
+ {
+ dispatch({ type: 'setApproveWithWarnings', value: e.target.checked });
+ }}
+ />{' '}
+ Approve with warnings
+
+ ) : null}
+ {
+ dispatch({ type: 'approve' });
+ }}
+ >
+ {state.approved ? 'Approved' : 'Approve'}
+
+
+
+ );
+}
+
+interface LineItemsProps {
+ state: ReviewState;
+ dispatch: (action: ReviewAction) => void;
+ clock: () => number;
+}
+
+function LineItems({ state, dispatch, clock }: LineItemsProps): React.JSX.Element {
+ const { invoice, validation } = state;
+ return (
+
+
+
+ Description
+ Qty
+ Unit
+ Amount
+
+
+
+
+ {invoice.lineItems.map((line, i) => {
+ const qty = line.quantity.value;
+ const unit = line.unitPrice.value;
+ const amount = line.amount.value;
+ const disagree =
+ qty !== null &&
+ unit !== null &&
+ amount !== null &&
+ Math.round(qty * unit * 100) !== Math.round(amount * 100);
+ const cell = (key: string, value: string | number | null): React.JSX.Element => (
+
+ {
+ dispatch({
+ type: 'editField',
+ path: `lineItems.${String(i)}.${key}`,
+ value: e.target.value,
+ at: clock(),
+ });
+ }}
+ />
+
+ );
+ return (
+
+ {cell('description', line.description.value)}
+ {cell('quantity', qty)}
+ {cell('unitPrice', unit)}
+ {cell('amount', amount)}
+
+ {
+ dispatch({ type: 'deleteLine', index: i, at: clock() });
+ }}
+ >
+ ✕
+
+
+
+ );
+ })}
+
+
+
+
+ {
+ dispatch({ type: 'addLine', at: clock() });
+ }}
+ >
+ + Add row
+
+ {validation.fieldStatus.lineItems === 'error' ? (
+ line items issue
+ ) : null}
+
+
+
+
+ );
+}
+
+function valueOf(invoice: ReviewState['invoice'], path: string): string | number | null {
+ const segments = path.split('.');
+ let current: unknown = invoice;
+ for (const segment of segments) {
+ if (current === null || typeof current !== 'object') return null;
+ current = (current as Record)[segment];
+ }
+ const field = current as { value?: string | number | null } | undefined;
+ return field?.value ?? null;
+}
From 7fad62636f81296d7f55ba732faf203317508d37 Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:06:00 +0400
Subject: [PATCH 25/63] test(review): cover field status, live revalidation,
approval gating, and line deletion
Co-Authored-By: Claude Opus 4.8
---
src/ui/review/ReviewForm.test.tsx | 91 +++++++++++++++++++++++++++++++
1 file changed, 91 insertions(+)
create mode 100644 src/ui/review/ReviewForm.test.tsx
diff --git a/src/ui/review/ReviewForm.test.tsx b/src/ui/review/ReviewForm.test.tsx
new file mode 100644
index 0000000..b4b263f
--- /dev/null
+++ b/src/ui/review/ReviewForm.test.tsx
@@ -0,0 +1,91 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it } from 'vitest';
+import type { Field, InvoiceV1, LineItem, Party } from '../../schema/invoice.ts';
+import type { RuleContext } from '../../schema/rules/index.ts';
+import { ReviewForm } from './ReviewForm.tsx';
+import { useReview } from './useReview.ts';
+
+const ctx: RuleContext = { today: new Date('2026-06-07T00:00:00Z') };
+
+function f(value: T | null, confidence: Field['confidence'] = 'extracted'): Field {
+ return { value, confidence };
+}
+function party(): Party {
+ return {
+ name: f('Acme FZE'),
+ address: f('Dubai'),
+ trn: f('100123456700003'),
+ phone: f(null, 'missing'),
+ email: f(null, 'missing'),
+ };
+}
+function line(o: Partial = {}): LineItem {
+ return {
+ description: f('Widget'),
+ quantity: f(2),
+ unitPrice: f(50),
+ amount: f(100),
+ taxRate: f(5),
+ ...o,
+ };
+}
+function makeInvoice(o: Partial = {}): InvoiceV1 {
+ return {
+ vendor: party(),
+ invoiceNumber: f('INV-001'),
+ issueDate: f('2026-01-15'),
+ currency: f('AED'),
+ lineItems: [line()],
+ subtotal: f(100),
+ taxAmount: f(5),
+ total: f(105),
+ uncertain: [],
+ ...o,
+ };
+}
+
+function Harness({ invoice }: { invoice: InvoiceV1 }): React.JSX.Element {
+ const { state, dispatch } = useReview(invoice, ctx);
+ return 1} />;
+}
+
+describe('ReviewForm', () => {
+ it('renders error status on a field with a failing rule', () => {
+ render( );
+ const dots = screen.getAllByLabelText('status: error');
+ expect(dots.length).toBeGreaterThan(0);
+ });
+
+ it('blocks approval while an error exists and enables it after a live fix', async () => {
+ const user = userEvent.setup();
+ render( );
+ const approve = screen.getByRole('button', { name: /approve/i });
+ expect(approve).toBeDisabled();
+
+ const subtotal = screen.getByLabelText('Subtotal');
+ await user.clear(subtotal);
+ await user.type(subtotal, '100');
+ await user.tab(); // blur commits → live revalidation
+
+ expect(screen.getByRole('button', { name: /approve/i })).toBeEnabled();
+ });
+
+ it('requires acknowledging warnings before approval', async () => {
+ const user = userEvent.setup();
+ render( );
+ const approve = screen.getByRole('button', { name: /approve/i });
+ expect(approve).toBeDisabled();
+
+ await user.click(screen.getByLabelText(/approve with warnings/i));
+ expect(screen.getByRole('button', { name: /approve/i })).toBeEnabled();
+ });
+
+ it('deletes a line item', async () => {
+ const user = userEvent.setup();
+ render( );
+ expect(screen.getAllByLabelText(/^line \d+ description$/)).toHaveLength(2);
+ await user.click(screen.getByLabelText('delete line 2'));
+ expect(screen.getAllByLabelText(/^line \d+ description$/)).toHaveLength(1);
+ });
+});
From d6cd9d45b3729b1e59925a6eb277be28c700a47c Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:06:00 +0400
Subject: [PATCH 26/63] feat(review): add image viewer with zoom and pan
controls
Co-Authored-By: Claude Opus 4.8
---
src/ui/review/ImageViewer.tsx | 91 +++++++++++++++++++++++++++++++++++
1 file changed, 91 insertions(+)
create mode 100644 src/ui/review/ImageViewer.tsx
diff --git a/src/ui/review/ImageViewer.tsx b/src/ui/review/ImageViewer.tsx
new file mode 100644
index 0000000..17e0b16
--- /dev/null
+++ b/src/ui/review/ImageViewer.tsx
@@ -0,0 +1,91 @@
+import { useCallback, useRef, useState } from 'react';
+import styles from './review.module.css';
+
+export interface ImageViewerProps {
+ src: string;
+ alt?: string;
+}
+
+/** Page image with wheel zoom, drag pan, and fit/100% controls. */
+export function ImageViewer({ src, alt = 'Invoice page' }: ImageViewerProps): React.JSX.Element {
+ const [scale, setScale] = useState(1);
+ const [offset, setOffset] = useState({ x: 0, y: 0 });
+ const dragging = useRef<{ x: number; y: number } | null>(null);
+
+ const onWheel = useCallback((e: React.WheelEvent) => {
+ e.preventDefault();
+ setScale((s) => Math.min(8, Math.max(0.1, s * (e.deltaY < 0 ? 1.1 : 0.9))));
+ }, []);
+
+ const onPointerDown = (e: React.PointerEvent): void => {
+ dragging.current = { x: e.clientX - offset.x, y: e.clientY - offset.y };
+ e.currentTarget.setPointerCapture(e.pointerId);
+ };
+ const onPointerMove = (e: React.PointerEvent): void => {
+ if (!dragging.current) return;
+ setOffset({ x: e.clientX - dragging.current.x, y: e.clientY - dragging.current.y });
+ };
+ const onPointerUp = (): void => {
+ dragging.current = null;
+ };
+
+ const fit = (): void => {
+ setScale(1);
+ setOffset({ x: 0, y: 0 });
+ };
+
+ return (
+
+
+
+ Fit
+
+ {
+ setScale(1);
+ }}
+ >
+ 100%
+
+ {
+ setScale((s) => Math.min(8, s * 1.2));
+ }}
+ aria-label="zoom in"
+ >
+ +
+
+ {
+ setScale((s) => Math.max(0.1, s / 1.2));
+ }}
+ aria-label="zoom out"
+ >
+ −
+
+
+
+
+
+
+ );
+}
From cc7a43b4b825d486834981d0fd3f7c9ddfae9979 Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:06:00 +0400
Subject: [PATCH 27/63] feat(review): add split review screen with collapsible
transcript drawer
Co-Authored-By: Claude Opus 4.8
---
src/ui/review/ReviewScreen.tsx | 60 ++++++++++++++++++++++++++++++++++
1 file changed, 60 insertions(+)
create mode 100644 src/ui/review/ReviewScreen.tsx
diff --git a/src/ui/review/ReviewScreen.tsx b/src/ui/review/ReviewScreen.tsx
new file mode 100644
index 0000000..d593532
--- /dev/null
+++ b/src/ui/review/ReviewScreen.tsx
@@ -0,0 +1,60 @@
+import { useState } from 'react';
+import type { QualityReport } from '../../pipeline/pass1/quality.ts';
+import type { InvoiceV1 } from '../../schema/invoice.ts';
+import type { RuleContext } from '../../schema/rules/index.ts';
+import { TranscriptPane } from '../TranscriptPane.tsx';
+import { ImageViewer } from './ImageViewer.tsx';
+import styles from './review.module.css';
+import { ReviewForm } from './ReviewForm.tsx';
+import { useReview } from './useReview.ts';
+
+export interface ReviewScreenProps {
+ invoice: InvoiceV1;
+ imageSrc: string;
+ transcript?: string;
+ transcriptQuality?: QualityReport;
+ ctx?: RuleContext;
+}
+
+/** Split review surface: page image on the left, extracted form on the right. */
+export function ReviewScreen({
+ invoice,
+ imageSrc,
+ transcript,
+ transcriptQuality,
+ ctx,
+}: ReviewScreenProps): React.JSX.Element {
+ const { state, dispatch } = useReview(invoice, ctx);
+ const [showTranscript, setShowTranscript] = useState(false);
+
+ return (
+
+
+
+
+ {transcript !== undefined ? (
+
+ {
+ setShowTranscript((v) => !v);
+ }}
+ aria-expanded={showTranscript}
+ >
+ {showTranscript ? '▾ Hide transcript' : '▸ Show transcript'}
+
+ {showTranscript ? (
+
+ ) : null}
+
+ ) : null}
+
+
+
+
+ );
+}
From 020cc5ae82cff9514d312c756359d28fae8fb5c1 Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:09:59 +0400
Subject: [PATCH 28/63] chore(export): add fflate for zip bundling
Co-Authored-By: Claude Opus 4.8
---
package-lock.json | 7 +++++++
package.json | 1 +
2 files changed, 8 insertions(+)
diff --git a/package-lock.json b/package-lock.json
index 73a9d7b..b6ec608 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,7 @@
"license": "Apache-2.0",
"dependencies": {
"@huggingface/transformers": "^3.8.1",
+ "fflate": "^0.8.3",
"onnxruntime-web": "^1.26.0",
"pdfjs-dist": "^4.10.38",
"react": "^18.3.1",
@@ -4817,6 +4818,12 @@
}
}
},
+ "node_modules/fflate": {
+ "version": "0.8.3",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
+ "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
+ "license": "MIT"
+ },
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
diff --git a/package.json b/package.json
index 94f2632..d4e01a0 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,7 @@
},
"dependencies": {
"@huggingface/transformers": "^3.8.1",
+ "fflate": "^0.8.3",
"onnxruntime-web": "^1.26.0",
"pdfjs-dist": "^4.10.38",
"react": "^18.3.1",
From 76853a5fa4914e685d147f74d6c04617fdd9d9c2 Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:09:59 +0400
Subject: [PATCH 29/63] feat(export): add canonical json export with provenance
envelope and deterministic serializer
Co-Authored-By: Claude Opus 4.8
---
src/lib/export/json.ts | 71 ++++++++++++++++++++++++++++++++++++++++++
1 file changed, 71 insertions(+)
create mode 100644 src/lib/export/json.ts
diff --git a/src/lib/export/json.ts b/src/lib/export/json.ts
new file mode 100644
index 0000000..fb5e701
--- /dev/null
+++ b/src/lib/export/json.ts
@@ -0,0 +1,71 @@
+// Canonical JSON export with a provenance envelope and a deterministic,
+// key-ordered serializer so diffs of exports are meaningful.
+
+import type { InvoiceV1 } from '../../schema/invoice.ts';
+import type { Finding, ReviewVerdict } from '../../schema/rules/index.ts';
+import type { EditEntry } from '../../ui/review/state.ts';
+
+export const FATURLENS_EXPORT_VERSION = '1.0';
+export const MODEL_ID = 'LFM2.5-VL-1.6B-ONNX';
+
+export interface ExportInput {
+ invoice: InvoiceV1;
+ verdict: ReviewVerdict;
+ findings: Finding[];
+ promptVersions: { pass1: string; pass2: string };
+ edits: EditEntry[];
+ approved: boolean;
+ /** ISO timestamp; supplied by the caller to keep exports deterministic. */
+ exportedAt: string;
+}
+
+export interface ExportEnvelope {
+ faturlens: {
+ version: string;
+ exportedAt: string;
+ model: string;
+ promptVersions: { pass1: string; pass2: string };
+ validation: { verdict: ReviewVerdict; findings: Finding[] };
+ edits: EditEntry[];
+ approved: boolean;
+ needsReview: boolean;
+ };
+ invoice: InvoiceV1;
+}
+
+export function buildEnvelope(input: ExportInput): ExportEnvelope {
+ return {
+ faturlens: {
+ version: FATURLENS_EXPORT_VERSION,
+ exportedAt: input.exportedAt,
+ model: MODEL_ID,
+ promptVersions: input.promptVersions,
+ validation: { verdict: input.verdict, findings: input.findings },
+ edits: input.edits,
+ approved: input.approved,
+ needsReview: !input.approved,
+ },
+ invoice: input.invoice,
+ };
+}
+
+/** Deterministic JSON: object keys sorted recursively, arrays preserved. */
+export function canonicalStringify(value: unknown, indent = 2): string {
+ return JSON.stringify(sortKeys(value), null, indent);
+}
+
+function sortKeys(value: unknown): unknown {
+ if (Array.isArray(value)) return value.map(sortKeys);
+ if (value !== null && typeof value === 'object') {
+ const entries = Object.entries(value as Record).sort(([a], [b]) =>
+ a < b ? -1 : a > b ? 1 : 0,
+ );
+ return Object.fromEntries(entries.map(([k, v]) => [k, sortKeys(v)]));
+ }
+ return value;
+}
+
+/** Serialize one invoice export to a canonical JSON string. */
+export function exportInvoiceJson(input: ExportInput): string {
+ return canonicalStringify(buildEnvelope(input));
+}
From 62b9a02671814b1fe718f0b274c10499fb0a8872 Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:09:59 +0400
Subject: [PATCH 30/63] test(export): cover serializer stability and schema
round-trip
Co-Authored-By: Claude Opus 4.8
---
src/lib/export/json.test.ts | 35 +++++++++++++++++++++++++++++++++++
1 file changed, 35 insertions(+)
create mode 100644 src/lib/export/json.test.ts
diff --git a/src/lib/export/json.test.ts b/src/lib/export/json.test.ts
new file mode 100644
index 0000000..39065ea
--- /dev/null
+++ b/src/lib/export/json.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from 'vitest';
+import { invoiceV1Schema } from '../../schema/invoice.ts';
+import { makeInput } from './export-fixtures.ts';
+import { buildEnvelope, canonicalStringify, exportInvoiceJson } from './json.ts';
+
+describe('canonicalStringify', () => {
+ it('orders object keys deterministically regardless of input order', () => {
+ const a = canonicalStringify({ b: 1, a: 2, c: { z: 1, y: 2 } });
+ const b = canonicalStringify({ c: { y: 2, z: 1 }, a: 2, b: 1 });
+ expect(a).toBe(b);
+ });
+
+ it('preserves array order', () => {
+ expect(canonicalStringify([3, 1, 2])).toBe('[\n 3,\n 1,\n 2\n]');
+ });
+});
+
+describe('exportInvoiceJson', () => {
+ it('produces a stable string for the same input', () => {
+ const input = makeInput();
+ expect(exportInvoiceJson(input)).toBe(exportInvoiceJson(input));
+ });
+
+ it('round-trips the invoice back through the schema', () => {
+ const envelope = buildEnvelope(makeInput());
+ const parsed = JSON.parse(exportInvoiceJson(makeInput())) as { invoice: unknown };
+ expect(invoiceV1Schema.safeParse(parsed.invoice).success).toBe(true);
+ expect(envelope.faturlens.model).toBe('LFM2.5-VL-1.6B-ONNX');
+ });
+
+ it('marks unapproved exports as needing review', () => {
+ const envelope = buildEnvelope(makeInput({ approved: false }));
+ expect(envelope.faturlens.needsReview).toBe(true);
+ });
+});
From 69a4c0566f836ec492947a5312136b1b642a26c8 Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:09:59 +0400
Subject: [PATCH 31/63] feat(export): add header and line-item csv writers with
rfc4180 quoting and bom
Co-Authored-By: Claude Opus 4.8
---
src/lib/export/csv.ts | 87 +++++++++++++++++++++++++++++++++++++++++++
1 file changed, 87 insertions(+)
create mode 100644 src/lib/export/csv.ts
diff --git a/src/lib/export/csv.ts b/src/lib/export/csv.ts
new file mode 100644
index 0000000..68c57cf
--- /dev/null
+++ b/src/lib/export/csv.ts
@@ -0,0 +1,87 @@
+// CSV export. RFC 4180 quoting, CRLF line endings, UTF-8 BOM for Excel.
+
+import type { Field, InvoiceV1 } from '../../schema/invoice.ts';
+
+const BOM = '';
+const CRLF = '\r\n';
+
+/** Quote a single CSV field per RFC 4180 when it contains , " or newlines. */
+export function csvField(value: string): string {
+ if (/[",\r\n]/.test(value)) {
+ return `"${value.replace(/"/g, '""')}"`;
+ }
+ return value;
+}
+
+export function toCsv(rows: string[][]): string {
+ const body = rows.map((row) => row.map(csvField).join(',')).join(CRLF);
+ return BOM + body + CRLF;
+}
+
+function s(field: Field | undefined): string {
+ return field?.value ?? '';
+}
+function n(field: Field | undefined): string {
+ return field?.value === null || field?.value === undefined ? '' : String(field.value);
+}
+
+const HEADER_COLUMNS = [
+ 'invoiceNumber',
+ 'issueDate',
+ 'dueDate',
+ 'vendorName',
+ 'vendorTRN',
+ 'buyerName',
+ 'currency',
+ 'subtotal',
+ 'taxAmount',
+ 'total',
+ 'paymentTerms',
+ 'needsReview',
+];
+
+/** One row per invoice — flattened scalar fields. */
+export function headerCsv(
+ invoices: InvoiceV1[],
+ needsReviewFor: (invoice: InvoiceV1, index: number) => boolean = () => false,
+): string {
+ const rows: string[][] = [HEADER_COLUMNS];
+ invoices.forEach((inv, i) => {
+ rows.push([
+ s(inv.invoiceNumber),
+ s(inv.issueDate),
+ s(inv.dueDate),
+ s(inv.vendor.name),
+ s(inv.vendor.trn),
+ s(inv.buyer?.name),
+ s(inv.currency),
+ n(inv.subtotal),
+ n(inv.taxAmount),
+ n(inv.total),
+ s(inv.paymentTerms),
+ needsReviewFor(inv, i) ? 'true' : 'false',
+ ]);
+ });
+ return toCsv(rows);
+}
+
+const LINE_COLUMNS = ['invoiceNumber', 'description', 'quantity', 'unitPrice', 'amount', 'taxRate'];
+
+/** One row per line item, keyed by invoice number. */
+export function linesCsv(invoices: InvoiceV1[]): string {
+ const rows: string[][] = [LINE_COLUMNS];
+ for (const inv of invoices) {
+ const invoiceNumber = s(inv.invoiceNumber);
+ for (const line of inv.lineItems) {
+ rows.push([
+ invoiceNumber,
+ s(line.description),
+ n(line.quantity),
+ n(line.unitPrice),
+ n(line.amount),
+ n(line.taxRate),
+ ]);
+ }
+ }
+ return toCsv(rows);
+}
From 4125985fe25879a6301a428286d333a1399f264d Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:09:59 +0400
Subject: [PATCH 32/63] test(export): cover rfc4180 quoting, bom, and arabic
text
Co-Authored-By: Claude Opus 4.8
---
src/lib/export/csv.test.ts | 50 ++++++++++++++++++++++++++++++++++++++
1 file changed, 50 insertions(+)
create mode 100644 src/lib/export/csv.test.ts
diff --git a/src/lib/export/csv.test.ts b/src/lib/export/csv.test.ts
new file mode 100644
index 0000000..b9ef10d
--- /dev/null
+++ b/src/lib/export/csv.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from 'vitest';
+import { csvField, headerCsv, linesCsv, toCsv } from './csv.ts';
+import { f, makeInvoice, line } from './export-fixtures.ts';
+
+describe('csvField', () => {
+ it('leaves plain values unquoted', () => {
+ expect(csvField('Acme')).toBe('Acme');
+ });
+ it('quotes and escapes commas, quotes, and newlines', () => {
+ expect(csvField('a,b')).toBe('"a,b"');
+ expect(csvField('say "hi"')).toBe('"say ""hi"""');
+ expect(csvField('line1\nline2')).toBe('"line1\nline2"');
+ });
+ it('passes Arabic text through unquoted', () => {
+ expect(csvField('شركة')).toBe('شركة');
+ });
+});
+
+describe('toCsv', () => {
+ it('prepends a UTF-8 BOM and uses CRLF', () => {
+ const out = toCsv([['a', 'b']]);
+ expect(out.startsWith('')).toBe(true);
+ expect(out).toContain('a,b\r\n');
+ });
+});
+
+describe('headerCsv', () => {
+ it('emits one row per invoice with a needsReview flag', () => {
+ const out = headerCsv([makeInvoice()], () => true);
+ const lines = out.trimEnd().split('\r\n');
+ expect(lines[0]).toContain('invoiceNumber');
+ expect(lines[1]).toContain('INV-001');
+ expect(lines[1]).toContain('true');
+ });
+
+ it('keeps Arabic vendor names intact', () => {
+ const inv = makeInvoice({ vendor: { ...makeInvoice().vendor, name: f('شركة الإمارات') } });
+ expect(headerCsv([inv])).toContain('شركة الإمارات');
+ });
+});
+
+describe('linesCsv', () => {
+ it('emits one row per line item keyed by invoice number', () => {
+ const inv = makeInvoice({ lineItems: [line(), line({ description: f('Gadget') })] });
+ const rows = linesCsv([inv]).trimEnd().split('\r\n');
+ expect(rows).toHaveLength(3); // header + 2 lines
+ expect(rows[1]).toContain('INV-001');
+ expect(rows[2]).toContain('Gadget');
+ });
+});
From 331cb7d0385d684df37d5da01db27f80d4290d44 Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:09:59 +0400
Subject: [PATCH 33/63] feat(export): add filename convention with arabic-safe
slugs and collision suffixes
Co-Authored-By: Claude Opus 4.8
---
src/lib/export/filename.ts | 40 ++++++++++++++++++++++++++++++++++++++
1 file changed, 40 insertions(+)
create mode 100644 src/lib/export/filename.ts
diff --git a/src/lib/export/filename.ts b/src/lib/export/filename.ts
new file mode 100644
index 0000000..591f6ba
--- /dev/null
+++ b/src/lib/export/filename.ts
@@ -0,0 +1,40 @@
+// Export filename convention: {vendor-slug}_{invoiceNumber}_{issueDate}.{ext}
+// with an Arabic-safe slug fallback and -2/-3 collision suffixes.
+
+export function slugify(input: string): string {
+ const ascii = input
+ .normalize('NFKD')
+ .replace(/[\u0300-\u036f]/g, '') // strip combining marks
+ .toLowerCase();
+ return ascii.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
+}
+
+export interface FilenameParts {
+ vendorName: string | null;
+ invoiceNumber: string | null;
+ issueDate: string | null;
+}
+
+/** Build the base filename (no extension) from invoice parts. */
+export function baseFilename(parts: FilenameParts): string {
+ const vendor = slugify(parts.vendorName ?? '') || 'vendor';
+ const invoice = slugify(parts.invoiceNumber ?? '') || 'invoice';
+ const date =
+ parts.issueDate && /^\d{4}-\d{2}-\d{2}$/.test(parts.issueDate)
+ ? parts.issueDate
+ : slugify(parts.issueDate ?? '') || 'undated';
+ return `${vendor}_${invoice}_${date}`;
+}
+
+/** Build a filename with extension, suffixing -2/-3… on collision. */
+export function uniqueFilename(parts: FilenameParts, ext: string, taken: Set): string {
+ const base = baseFilename(parts);
+ let candidate = `${base}.${ext}`;
+ let counter = 2;
+ while (taken.has(candidate)) {
+ candidate = `${base}-${String(counter)}.${ext}`;
+ counter += 1;
+ }
+ taken.add(candidate);
+ return candidate;
+}
From 9a71c5add8f81345748bf47ddc69ee0f6e2ae459 Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:09:59 +0400
Subject: [PATCH 34/63] test(export): cover slug fallback and collision
suffixes
Co-Authored-By: Claude Opus 4.8
---
src/lib/export/filename.test.ts | 43 +++++++++++++++++++++++++++++++++
1 file changed, 43 insertions(+)
create mode 100644 src/lib/export/filename.test.ts
diff --git a/src/lib/export/filename.test.ts b/src/lib/export/filename.test.ts
new file mode 100644
index 0000000..fc7bf38
--- /dev/null
+++ b/src/lib/export/filename.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from 'vitest';
+import { baseFilename, slugify, uniqueFilename } from './filename.ts';
+
+describe('slugify', () => {
+ it('slugifies ASCII names', () => {
+ expect(slugify('Acme FZE Trading')).toBe('acme-fze-trading');
+ });
+ it('returns empty for Arabic-only input (caller falls back)', () => {
+ expect(slugify('شركة')).toBe('');
+ });
+});
+
+describe('baseFilename', () => {
+ it('builds vendor_invoice_date', () => {
+ expect(
+ baseFilename({ vendorName: 'Acme FZE', invoiceNumber: 'INV-001', issueDate: '2026-01-15' }),
+ ).toBe('acme-fze_inv-001_2026-01-15');
+ });
+ it('falls back to "vendor" for Arabic vendor names', () => {
+ expect(
+ baseFilename({
+ vendorName: 'شركة الإمارات',
+ invoiceNumber: 'INV-1',
+ issueDate: '2026-01-15',
+ }),
+ ).toBe('vendor_inv-1_2026-01-15');
+ });
+ it('uses "undated" when the date is missing', () => {
+ expect(baseFilename({ vendorName: 'Acme', invoiceNumber: '1', issueDate: null })).toBe(
+ 'acme_1_undated',
+ );
+ });
+});
+
+describe('uniqueFilename', () => {
+ it('suffixes -2, -3 on collision', () => {
+ const taken = new Set();
+ const parts = { vendorName: 'Acme', invoiceNumber: 'INV-1', issueDate: '2026-01-15' };
+ expect(uniqueFilename(parts, 'json', taken)).toBe('acme_inv-1_2026-01-15.json');
+ expect(uniqueFilename(parts, 'json', taken)).toBe('acme_inv-1_2026-01-15-2.json');
+ expect(uniqueFilename(parts, 'json', taken)).toBe('acme_inv-1_2026-01-15-3.json');
+ });
+});
From b70f066441e709c241a1122417635a0da8006a60 Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:09:59 +0400
Subject: [PATCH 35/63] feat(export): add clipboard json and tsv copy
Co-Authored-By: Claude Opus 4.8
---
src/lib/export/clipboard.ts | 37 +++++++++++++++++++++++++++++++++++++
1 file changed, 37 insertions(+)
create mode 100644 src/lib/export/clipboard.ts
diff --git a/src/lib/export/clipboard.ts b/src/lib/export/clipboard.ts
new file mode 100644
index 0000000..5a2749f
--- /dev/null
+++ b/src/lib/export/clipboard.ts
@@ -0,0 +1,37 @@
+// Clipboard helpers: copy a single invoice as JSON or as a TSV table that
+// pastes cleanly into Excel.
+
+import type { InvoiceV1 } from '../../schema/invoice.ts';
+import { exportInvoiceJson, type ExportInput } from './json.ts';
+
+/** Render an invoice's line items as a TSV table (header + rows). */
+export function invoiceToTsv(invoice: InvoiceV1): string {
+ const header = ['description', 'quantity', 'unitPrice', 'amount', 'taxRate'];
+ const cell = (v: string | number | null): string =>
+ v === null ? '' : String(v).replace(/[\t\n\r]/g, ' ');
+ const rows = invoice.lineItems.map((line) =>
+ [
+ cell(line.description.value),
+ cell(line.quantity.value),
+ cell(line.unitPrice.value),
+ cell(line.amount.value),
+ cell(line.taxRate?.value ?? null),
+ ].join('\t'),
+ );
+ return [header.join('\t'), ...rows].join('\n');
+}
+
+async function write(text: string): Promise {
+ const clipboard = (globalThis.navigator as Navigator | undefined)?.clipboard;
+ if (!clipboard) return false;
+ await clipboard.writeText(text);
+ return true;
+}
+
+export function copyInvoiceJson(input: ExportInput): Promise {
+ return write(exportInvoiceJson(input));
+}
+
+export function copyInvoiceTsv(invoice: InvoiceV1): Promise {
+ return write(invoiceToTsv(invoice));
+}
From e222492aeae064df9eb078ebe55645fd999d1acc Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:09:59 +0400
Subject: [PATCH 36/63] feat(export): add batch zip export gated on approval
Co-Authored-By: Claude Opus 4.8
---
src/lib/export/batch.ts | 51 +++++++++++++++++++++++++++
src/lib/export/export-fixtures.ts | 57 +++++++++++++++++++++++++++++++
src/lib/export/index.ts | 5 +++
3 files changed, 113 insertions(+)
create mode 100644 src/lib/export/batch.ts
create mode 100644 src/lib/export/export-fixtures.ts
create mode 100644 src/lib/export/index.ts
diff --git a/src/lib/export/batch.ts b/src/lib/export/batch.ts
new file mode 100644
index 0000000..4558818
--- /dev/null
+++ b/src/lib/export/batch.ts
@@ -0,0 +1,51 @@
+// Batch export: a combined JSON array, or a zip bundle of per-invoice JSON plus
+// header and line CSVs. Export is gated on approval.
+
+import { strToU8, zipSync } from 'fflate';
+import { headerCsv, linesCsv } from './csv.ts';
+import { uniqueFilename } from './filename.ts';
+import { buildEnvelope, canonicalStringify, exportInvoiceJson, type ExportInput } from './json.ts';
+
+/**
+ * Select which documents to export. Unapproved documents are excluded unless
+ * `includeUnapproved` is set; the envelope already carries `needsReview`.
+ */
+export function selectForExport(inputs: ExportInput[], includeUnapproved: boolean): ExportInput[] {
+ return includeUnapproved ? inputs : inputs.filter((i) => i.approved);
+}
+
+/** Combined JSON array of envelopes (canonical, stable order). */
+export function combinedJson(inputs: ExportInput[]): string {
+ return canonicalStringify(inputs.map(buildEnvelope));
+}
+
+export interface ZipOptions {
+ includeUnapproved: boolean;
+}
+
+/** Build a zip: one JSON per invoice + header.csv + lines.csv. */
+export function buildZip(inputs: ExportInput[], options: ZipOptions): Uint8Array {
+ const selected = selectForExport(inputs, options.includeUnapproved);
+ const taken = new Set();
+ const files: Record = {};
+
+ for (const input of selected) {
+ const name = uniqueFilename(
+ {
+ vendorName: input.invoice.vendor.name.value,
+ invoiceNumber: input.invoice.invoiceNumber.value,
+ issueDate: input.invoice.issueDate.value,
+ },
+ 'json',
+ taken,
+ );
+ files[name] = strToU8(exportInvoiceJson(input));
+ }
+
+ const invoices = selected.map((i) => i.invoice);
+ const needsReview = new Map(selected.map((i) => [i.invoice, !i.approved]));
+ files['header.csv'] = strToU8(headerCsv(invoices, (inv) => needsReview.get(inv) ?? false));
+ files['lines.csv'] = strToU8(linesCsv(invoices));
+
+ return zipSync(files);
+}
diff --git a/src/lib/export/export-fixtures.ts b/src/lib/export/export-fixtures.ts
new file mode 100644
index 0000000..620cf19
--- /dev/null
+++ b/src/lib/export/export-fixtures.ts
@@ -0,0 +1,57 @@
+// Shared invoice builders for export tests.
+import type { Field, InvoiceV1, LineItem, Party } from '../../schema/invoice.ts';
+import type { ExportInput } from './json.ts';
+
+export function f(value: T | null, confidence: Field['confidence'] = 'extracted'): Field {
+ return { value, confidence };
+}
+
+export function party(o: Partial = {}): Party {
+ return {
+ name: f('Acme FZE'),
+ address: f('Dubai'),
+ trn: f('100123456700003'),
+ phone: f(null, 'missing'),
+ email: f(null, 'missing'),
+ ...o,
+ };
+}
+
+export function line(o: Partial = {}): LineItem {
+ return {
+ description: f('Widget'),
+ quantity: f(2),
+ unitPrice: f(50),
+ amount: f(100),
+ taxRate: f(5),
+ ...o,
+ };
+}
+
+export function makeInvoice(o: Partial = {}): InvoiceV1 {
+ return {
+ vendor: party(),
+ invoiceNumber: f('INV-001'),
+ issueDate: f('2026-01-15'),
+ currency: f('AED'),
+ lineItems: [line()],
+ subtotal: f(100),
+ taxAmount: f(5),
+ total: f(105),
+ uncertain: [],
+ ...o,
+ };
+}
+
+export function makeInput(o: Partial = {}): ExportInput {
+ return {
+ invoice: makeInvoice(),
+ verdict: 'clean',
+ findings: [],
+ promptVersions: { pass1: 'PASS1_PROMPT_V1', pass2: 'PASS2_PROMPT_V1' },
+ edits: [],
+ approved: true,
+ exportedAt: '2026-06-07T00:00:00.000Z',
+ ...o,
+ };
+}
diff --git a/src/lib/export/index.ts b/src/lib/export/index.ts
new file mode 100644
index 0000000..28403ef
--- /dev/null
+++ b/src/lib/export/index.ts
@@ -0,0 +1,5 @@
+export * from './json.ts';
+export * from './csv.ts';
+export * from './filename.ts';
+export * from './clipboard.ts';
+export * from './batch.ts';
From 3e8a2d035c9596956e5b3e94ceac22976d338094 Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:09:59 +0400
Subject: [PATCH 37/63] test(export): cover approval gating and zip bundling
Co-Authored-By: Claude Opus 4.8
---
src/lib/export/batch.test.ts | 46 ++++++++++++++++++++++++++++++++++++
1 file changed, 46 insertions(+)
create mode 100644 src/lib/export/batch.test.ts
diff --git a/src/lib/export/batch.test.ts b/src/lib/export/batch.test.ts
new file mode 100644
index 0000000..31e0be5
--- /dev/null
+++ b/src/lib/export/batch.test.ts
@@ -0,0 +1,46 @@
+import { unzipSync, strFromU8 } from 'fflate';
+import { describe, expect, it } from 'vitest';
+import { buildZip, combinedJson, selectForExport } from './batch.ts';
+import { makeInput } from './export-fixtures.ts';
+
+describe('selectForExport', () => {
+ it('excludes unapproved documents by default', () => {
+ const inputs = [makeInput({ approved: true }), makeInput({ approved: false })];
+ expect(selectForExport(inputs, false)).toHaveLength(1);
+ });
+ it('includes unapproved when the toggle is on', () => {
+ const inputs = [makeInput({ approved: true }), makeInput({ approved: false })];
+ expect(selectForExport(inputs, true)).toHaveLength(2);
+ });
+});
+
+describe('combinedJson', () => {
+ it('emits an array of envelopes', () => {
+ const json = JSON.parse(combinedJson([makeInput(), makeInput()])) as unknown[];
+ expect(json).toHaveLength(2);
+ });
+});
+
+describe('buildZip', () => {
+ it('bundles per-invoice JSON plus both CSVs', () => {
+ const zip = buildZip([makeInput()], { includeUnapproved: false });
+ const entries = unzipSync(zip);
+ const names = Object.keys(entries);
+ expect(names).toContain('header.csv');
+ expect(names).toContain('lines.csv');
+ expect(names.some((n) => n.endsWith('.json'))).toBe(true);
+ });
+
+ it('respects approval gating in the bundle', () => {
+ const inputs = [makeInput({ approved: true }), makeInput({ approved: false })];
+ const zip = buildZip(inputs, { includeUnapproved: false });
+ const jsonEntries = Object.keys(unzipSync(zip)).filter((n) => n.endsWith('.json'));
+ expect(jsonEntries).toHaveLength(1);
+ });
+
+ it('marks unapproved rows when included', () => {
+ const zip = buildZip([makeInput({ approved: false })], { includeUnapproved: true });
+ const header = strFromU8(unzipSync(zip)['header.csv'] ?? new Uint8Array());
+ expect(header).toContain('true');
+ });
+});
From 3cf366f24de4a3d0015a587ec9d920104469d278 Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:14:03 +0400
Subject: [PATCH 38/63] chore(store): add dexie, dexie-react-hooks, and
fake-indexeddb
Co-Authored-By: Claude Opus 4.8
---
package-lock.json | 34 +++++++++++++++++++++++++++++++---
package.json | 3 +++
2 files changed, 34 insertions(+), 3 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index b6ec608..d7c70be 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,8 @@
"license": "Apache-2.0",
"dependencies": {
"@huggingface/transformers": "^3.8.1",
+ "dexie": "^4.4.3",
+ "dexie-react-hooks": "^1.1.7",
"fflate": "^0.8.3",
"onnxruntime-web": "^1.26.0",
"pdfjs-dist": "^4.10.38",
@@ -31,6 +33,7 @@
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16",
+ "fake-indexeddb": "^6.2.5",
"globals": "^15.14.0",
"jsdom": "^25.0.1",
"prettier": "^3.4.2",
@@ -2636,14 +2639,12 @@
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
- "dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.31",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
- "dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
@@ -3977,7 +3978,6 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/data-urls": {
@@ -4159,6 +4159,24 @@
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
"license": "MIT"
},
+ "node_modules/dexie": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.4.3.tgz",
+ "integrity": "sha512-N+3IGQ3HPlyO2YAkntGAwitm42BpBGV86MttzUMiRzWLa4NGh0pltVRcUVF4ybL/OnXjCrr9k7SDPIKkFYP2Lg==",
+ "license": "Apache-2.0",
+ "peer": true
+ },
+ "node_modules/dexie-react-hooks": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/dexie-react-hooks/-/dexie-react-hooks-1.1.7.tgz",
+ "integrity": "sha512-Lwv5W0Hk+uOW3kGnsU9GZoR1er1B7WQ5DSdonoNG+focTNeJbHW6vi6nBoX534VKI3/uwHebYzSw1fwY6a7mTw==",
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "@types/react": ">=16",
+ "dexie": "^3.2 || ^4.0.1-alpha",
+ "react": ">=16"
+ }
+ },
"node_modules/doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -4779,6 +4797,16 @@
"node": ">=12.0.0"
}
},
+ "node_modules/fake-indexeddb": {
+ "version": "6.2.5",
+ "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz",
+ "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
diff --git a/package.json b/package.json
index d4e01a0..b52e454 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,8 @@
},
"dependencies": {
"@huggingface/transformers": "^3.8.1",
+ "dexie": "^4.4.3",
+ "dexie-react-hooks": "^1.1.7",
"fflate": "^0.8.3",
"onnxruntime-web": "^1.26.0",
"pdfjs-dist": "^4.10.38",
@@ -45,6 +47,7 @@
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16",
+ "fake-indexeddb": "^6.2.5",
"globals": "^15.14.0",
"jsdom": "^25.0.1",
"prettier": "^3.4.2",
From f73c424d123af952b2af096997b12a0ac1822f0a Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:14:03 +0400
Subject: [PATCH 39/63] feat(store): add dexie schema v1 with document, page,
and edit repositories
Co-Authored-By: Claude Opus 4.8
---
src/store/db.ts | 124 ++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 124 insertions(+)
create mode 100644 src/store/db.ts
diff --git a/src/store/db.ts b/src/store/db.ts
new file mode 100644
index 0000000..fe0ac62
--- /dev/null
+++ b/src/store/db.ts
@@ -0,0 +1,124 @@
+// Dexie (IndexedDB) schema v1 and repositories. Source bytes are stored as
+// blobs so all state survives reload with no network.
+
+import Dexie, { type Table } from 'dexie';
+
+export type DocumentStatus = 'queued' | 'processing' | 'ready-for-review' | 'approved' | 'failed';
+
+export interface DocumentRecord {
+ id: string;
+ fileName: string;
+ fileHash: string;
+ pageCount: number;
+ createdAt: number;
+ status: DocumentStatus;
+ fileBlob: Blob;
+}
+
+export interface PageRecord {
+ id: string;
+ documentId: string;
+ pageIndex: number;
+ imageBlob: Blob;
+ transcript?: string;
+ transcriptQuality?: unknown;
+ extraction?: unknown;
+ findings?: unknown;
+ reviewState?: unknown;
+}
+
+export interface EditRecord {
+ id: string;
+ pageId: string;
+ fieldPath: string;
+ before: string | number | null;
+ after: string | number | null;
+ at: number;
+}
+
+export interface SettingRecord {
+ key: string;
+ value: unknown;
+}
+
+export class FaturlensDB extends Dexie {
+ documents!: Table;
+ pages!: Table;
+ edits!: Table;
+ settings!: Table;
+
+ constructor(name = 'faturlens') {
+ super(name);
+ this.version(1).stores({
+ documents: 'id, fileHash, status, createdAt',
+ pages: 'id, documentId, [documentId+pageIndex]',
+ edits: 'id, pageId',
+ settings: 'key',
+ });
+ }
+}
+
+export interface Repository {
+ db: FaturlensDB;
+ addDocument: (doc: DocumentRecord) => Promise;
+ getDocument: (id: string) => Promise;
+ findByHash: (hash: string) => Promise;
+ allDocuments: () => Promise;
+ setDocumentStatus: (id: string, status: DocumentStatus) => Promise;
+ addPage: (page: PageRecord) => Promise;
+ pagesForDocument: (documentId: string) => Promise;
+ addEdit: (edit: EditRecord) => Promise;
+ editsForPage: (pageId: string) => Promise;
+ deleteDocumentCascade: (documentId: string) => Promise;
+ getSetting: (key: string) => Promise;
+ setSetting: (key: string, value: unknown) => Promise;
+}
+
+export function createRepository(database: FaturlensDB): Repository {
+ return {
+ db: database,
+ addDocument: async (doc) => {
+ await database.documents.put(doc);
+ },
+ getDocument: (id) => database.documents.get(id),
+ findByHash: (hash) => database.documents.where('fileHash').equals(hash).first(),
+ allDocuments: () => database.documents.orderBy('createdAt').toArray(),
+ setDocumentStatus: async (id, status) => {
+ await database.documents.update(id, { status });
+ },
+ addPage: async (page) => {
+ await database.pages.put(page);
+ },
+ pagesForDocument: (documentId) =>
+ database.pages.where('documentId').equals(documentId).sortBy('pageIndex'),
+ addEdit: async (edit) => {
+ await database.edits.put(edit);
+ },
+ editsForPage: (pageId) => database.edits.where('pageId').equals(pageId).toArray(),
+ deleteDocumentCascade: async (documentId) => {
+ await database.transaction(
+ 'rw',
+ database.documents,
+ database.pages,
+ database.edits,
+ async () => {
+ const pages = await database.pages.where('documentId').equals(documentId).toArray();
+ const pageIds = pages.map((p) => p.id);
+ await database.edits.where('pageId').anyOf(pageIds).delete();
+ await database.pages.where('documentId').equals(documentId).delete();
+ await database.documents.delete(documentId);
+ },
+ );
+ },
+ getSetting: async (key: string): Promise => {
+ const record = await database.settings.get(key);
+ return record?.value as T | undefined;
+ },
+ setSetting: async (key, value) => {
+ await database.settings.put({ key, value });
+ },
+ };
+}
+
+/** The application-wide repository (default IndexedDB database). */
+export const repository: Repository = createRepository(new FaturlensDB());
From ef01e1f713475dd2f680fc9d145247f41f567ae7 Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:14:03 +0400
Subject: [PATCH 40/63] feat(store): add content-hash dedup on upload
Co-Authored-By: Claude Opus 4.8
---
src/lib/hash.ts | 10 ++++++++++
src/store/dedup.ts | 19 +++++++++++++++++++
2 files changed, 29 insertions(+)
create mode 100644 src/lib/hash.ts
create mode 100644 src/store/dedup.ts
diff --git a/src/lib/hash.ts b/src/lib/hash.ts
new file mode 100644
index 0000000..af8d62b
--- /dev/null
+++ b/src/lib/hash.ts
@@ -0,0 +1,10 @@
+// sha256 of bytes, hex-encoded. Uses Web Crypto (available in browsers and Node).
+
+export async function sha256Hex(bytes: ArrayBuffer | Uint8Array): Promise {
+ const buffer: ArrayBuffer =
+ bytes instanceof Uint8Array
+ ? (bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer)
+ : bytes;
+ const digest = await crypto.subtle.digest('SHA-256', buffer);
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
+}
diff --git a/src/store/dedup.ts b/src/store/dedup.ts
new file mode 100644
index 0000000..7b5436e
--- /dev/null
+++ b/src/store/dedup.ts
@@ -0,0 +1,19 @@
+// Upload de-duplication by content hash.
+
+import { sha256Hex } from '../lib/hash.ts';
+import type { DocumentRecord, Repository } from './db.ts';
+
+export interface DedupResult {
+ hash: string;
+ existing: DocumentRecord | undefined;
+}
+
+/** Hash bytes and look for an existing document with the same content. */
+export async function checkDuplicate(
+ repo: Repository,
+ bytes: ArrayBuffer | Uint8Array,
+): Promise {
+ const hash = await sha256Hex(bytes);
+ const existing = await repo.findByHash(hash);
+ return { hash, existing };
+}
From 3b485968fdf6a830c2a0cd7e0ae4e910894d3f87 Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:14:03 +0400
Subject: [PATCH 41/63] feat(queue): add sequential stage machine with
reload/retry resume
Co-Authored-By: Claude Opus 4.8
---
src/store/queue.ts | 83 ++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 83 insertions(+)
create mode 100644 src/store/queue.ts
diff --git a/src/store/queue.ts b/src/store/queue.ts
new file mode 100644
index 0000000..f689525
--- /dev/null
+++ b/src/store/queue.ts
@@ -0,0 +1,83 @@
+// Sequential processing queue state machine. Concurrency 1 (matching the worker
+// invariant). Pure transitions so they are unit-tested; persistence and the
+// actual stage work live elsewhere.
+
+export type Stage =
+ | 'queued'
+ | 'preprocessing'
+ | 'pass1'
+ | 'pass2'
+ | 'validating'
+ | 'ready-for-review'
+ | 'failed';
+
+/** Ordered processing stages (excludes the terminal "failed"). */
+export const STAGE_ORDER: readonly Stage[] = [
+ 'queued',
+ 'preprocessing',
+ 'pass1',
+ 'pass2',
+ 'validating',
+ 'ready-for-review',
+];
+
+export interface QueueItem {
+ id: string;
+ documentId: string;
+ stage: Stage;
+ /** Last stage that completed successfully (for reload/retry resume). */
+ lastCompletedStage: Stage | null;
+ paused: boolean;
+ error: string | null;
+}
+
+export type QueueAction =
+ | { type: 'advance' }
+ | { type: 'fail'; error: string }
+ | { type: 'retry' }
+ | { type: 'pause' }
+ | { type: 'resume' };
+
+export function createQueueItem(id: string, documentId: string): QueueItem {
+ return { id, documentId, stage: 'queued', lastCompletedStage: null, paused: false, error: null };
+}
+
+export function nextStage(stage: Stage): Stage {
+ const index = STAGE_ORDER.indexOf(stage);
+ if (index === -1 || index === STAGE_ORDER.length - 1) return stage;
+ return STAGE_ORDER[index + 1] ?? stage;
+}
+
+export function isTerminal(stage: Stage): boolean {
+ return stage === 'ready-for-review' || stage === 'failed';
+}
+
+/**
+ * The stage a paused/reloaded/failed item should re-run from: the one after the
+ * last completed stage (or the first stage if none completed).
+ */
+export function resumeStage(item: QueueItem): Stage {
+ if (item.lastCompletedStage === null) return 'preprocessing';
+ return nextStage(item.lastCompletedStage);
+}
+
+export function queueReducer(item: QueueItem, action: QueueAction): QueueItem {
+ switch (action.type) {
+ case 'advance': {
+ if (isTerminal(item.stage)) return item;
+ const completed = item.stage;
+ return { ...item, stage: nextStage(item.stage), lastCompletedStage: completed, error: null };
+ }
+ case 'fail':
+ return { ...item, stage: 'failed', error: action.error };
+ case 'retry':
+ if (item.stage !== 'failed') return item;
+ return { ...item, stage: resumeStage(item), error: null };
+ case 'pause':
+ return { ...item, paused: true };
+ case 'resume':
+ return { ...item, paused: false };
+ default:
+ return item;
+ }
+}
From d7b616cba952b3c2bd1979ce52263f16524b6d83 Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:14:03 +0400
Subject: [PATCH 42/63] test(queue): cover stage transitions, failure, and
resume
Co-Authored-By: Claude Opus 4.8
---
src/store/queue.test.ts | 88 +++++++++++++++++++++++++++++++++++++++++
1 file changed, 88 insertions(+)
create mode 100644 src/store/queue.test.ts
diff --git a/src/store/queue.test.ts b/src/store/queue.test.ts
new file mode 100644
index 0000000..792f3b5
--- /dev/null
+++ b/src/store/queue.test.ts
@@ -0,0 +1,88 @@
+import { describe, expect, it } from 'vitest';
+import {
+ createQueueItem,
+ isTerminal,
+ nextStage,
+ queueReducer,
+ resumeStage,
+ type QueueItem,
+} from './queue.ts';
+
+const start = (): QueueItem => createQueueItem('q1', 'doc1');
+
+describe('nextStage', () => {
+ it('advances through the ordered stages', () => {
+ expect(nextStage('queued')).toBe('preprocessing');
+ expect(nextStage('pass1')).toBe('pass2');
+ expect(nextStage('validating')).toBe('ready-for-review');
+ });
+ it('stays put at the terminal stage', () => {
+ expect(nextStage('ready-for-review')).toBe('ready-for-review');
+ });
+});
+
+describe('queueReducer — advance', () => {
+ it('records the completed stage as it advances', () => {
+ let item = start();
+ item = queueReducer(item, { type: 'advance' }); // → preprocessing
+ expect(item.stage).toBe('preprocessing');
+ expect(item.lastCompletedStage).toBe('queued');
+ item = queueReducer(item, { type: 'advance' }); // → pass1
+ expect(item.stage).toBe('pass1');
+ expect(item.lastCompletedStage).toBe('preprocessing');
+ });
+
+ it('does not advance past ready-for-review', () => {
+ let item: QueueItem = { ...start(), stage: 'ready-for-review' };
+ item = queueReducer(item, { type: 'advance' });
+ expect(item.stage).toBe('ready-for-review');
+ expect(isTerminal(item.stage)).toBe(true);
+ });
+});
+
+describe('queueReducer — failure and retry', () => {
+ it('fails with an error message while preserving resume point', () => {
+ let item = start();
+ item = queueReducer(item, { type: 'advance' }); // preprocessing, completed queued
+ item = queueReducer(item, { type: 'advance' }); // pass1, completed preprocessing
+ item = queueReducer(item, { type: 'fail', error: 'OOM' });
+ expect(item.stage).toBe('failed');
+ expect(item.error).toBe('OOM');
+ expect(item.lastCompletedStage).toBe('preprocessing');
+ });
+
+ it('retries from the stage after the last completed one', () => {
+ let item: QueueItem = {
+ ...start(),
+ stage: 'failed',
+ lastCompletedStage: 'preprocessing',
+ error: 'OOM',
+ };
+ item = queueReducer(item, { type: 'retry' });
+ expect(item.stage).toBe('pass1');
+ expect(item.error).toBeNull();
+ });
+
+ it('ignores retry when not failed', () => {
+ const item = queueReducer(start(), { type: 'retry' });
+ expect(item.stage).toBe('queued');
+ });
+});
+
+describe('resumeStage (reload resume)', () => {
+ it('restarts in-flight items from preprocessing when nothing completed', () => {
+ expect(resumeStage(start())).toBe('preprocessing');
+ });
+ it('resumes from the stage after the last completed one', () => {
+ expect(resumeStage({ ...start(), lastCompletedStage: 'pass1' })).toBe('pass2');
+ });
+});
+
+describe('queueReducer — pause/resume', () => {
+ it('toggles the paused flag', () => {
+ let item = queueReducer(start(), { type: 'pause' });
+ expect(item.paused).toBe(true);
+ item = queueReducer(item, { type: 'resume' });
+ expect(item.paused).toBe(false);
+ });
+});
From 00bcaefb5b9bdbac714581b80b9e62a62e4dd50a Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:14:03 +0400
Subject: [PATCH 43/63] test(store): cover repository crud, dedup, and cascade
delete
Co-Authored-By: Claude Opus 4.8
---
src/store/db.test.ts | 76 ++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 76 insertions(+)
create mode 100644 src/store/db.test.ts
diff --git a/src/store/db.test.ts b/src/store/db.test.ts
new file mode 100644
index 0000000..6fe2205
--- /dev/null
+++ b/src/store/db.test.ts
@@ -0,0 +1,76 @@
+import 'fake-indexeddb/auto';
+import { describe, expect, it } from 'vitest';
+import { createRepository, FaturlensDB, type DocumentRecord, type PageRecord } from './db.ts';
+import { checkDuplicate } from './dedup.ts';
+
+let dbCounter = 0;
+function freshRepo() {
+ dbCounter += 1;
+ return createRepository(new FaturlensDB(`faturlens-test-${String(dbCounter)}`));
+}
+
+function doc(id: string, hash: string): DocumentRecord {
+ return {
+ id,
+ fileName: `${id}.pdf`,
+ fileHash: hash,
+ pageCount: 1,
+ createdAt: dbCounter,
+ status: 'ready-for-review',
+ fileBlob: new Blob(['x']),
+ };
+}
+function page(id: string, documentId: string, pageIndex: number): PageRecord {
+ return { id, documentId, pageIndex, imageBlob: new Blob(['p']) };
+}
+
+describe('repository CRUD', () => {
+ it('adds and reads a document', async () => {
+ const repo = freshRepo();
+ await repo.addDocument(doc('d1', 'hashA'));
+ expect((await repo.getDocument('d1'))?.fileName).toBe('d1.pdf');
+ });
+
+ it('lists pages for a document sorted by index', async () => {
+ const repo = freshRepo();
+ await repo.addPage(page('p2', 'd1', 1));
+ await repo.addPage(page('p1', 'd1', 0));
+ const pages = await repo.pagesForDocument('d1');
+ expect(pages.map((p) => p.pageIndex)).toEqual([0, 1]);
+ });
+});
+
+describe('dedup', () => {
+ it('detects an existing document by content hash', async () => {
+ const repo = freshRepo();
+ const bytes = new Uint8Array([1, 2, 3, 4]);
+ const first = await checkDuplicate(repo, bytes);
+ expect(first.existing).toBeUndefined();
+ await repo.addDocument(doc('d1', first.hash));
+ const second = await checkDuplicate(repo, bytes);
+ expect(second.existing?.id).toBe('d1');
+ });
+});
+
+describe('cascade deletion', () => {
+ it('removes pages and edits along with the document', async () => {
+ const repo = freshRepo();
+ await repo.addDocument(doc('d1', 'hashA'));
+ await repo.addPage(page('p1', 'd1', 0));
+ await repo.addEdit({ id: 'e1', pageId: 'p1', fieldPath: 'total', before: 1, after: 2, at: 1 });
+
+ await repo.deleteDocumentCascade('d1');
+
+ expect(await repo.getDocument('d1')).toBeUndefined();
+ expect(await repo.pagesForDocument('d1')).toHaveLength(0);
+ expect(await repo.editsForPage('p1')).toHaveLength(0);
+ });
+});
+
+describe('settings', () => {
+ it('round-trips a setting value', async () => {
+ const repo = freshRepo();
+ await repo.setSetting('tokensPerSecond', 7.5);
+ expect(await repo.getSetting('tokensPerSecond')).toBe(7.5);
+ });
+});
From 57c17c755ba228bf3993ee6d53ef6467f1dd61b7 Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:14:03 +0400
Subject: [PATCH 44/63] feat(store): add storage budget estimate with warning
threshold
Co-Authored-By: Claude Opus 4.8
---
src/store/storage.ts | 23 +++++++++++++++++++++++
1 file changed, 23 insertions(+)
create mode 100644 src/store/storage.ts
diff --git a/src/store/storage.ts b/src/store/storage.ts
new file mode 100644
index 0000000..edff169
--- /dev/null
+++ b/src/store/storage.ts
@@ -0,0 +1,23 @@
+// Storage budget estimation via navigator.storage.estimate().
+
+export interface StorageEstimateResult {
+ usage: number;
+ quota: number;
+ fraction: number;
+ /** True at or above the 80% warning threshold. */
+ warn: boolean;
+}
+
+export const STORAGE_WARN_FRACTION = 0.8;
+
+export function classifyEstimate(usage: number, quota: number): StorageEstimateResult {
+ const fraction = quota > 0 ? usage / quota : 0;
+ return { usage, quota, fraction, warn: fraction >= STORAGE_WARN_FRACTION };
+}
+
+export async function estimateStorage(): Promise {
+ const storage = (globalThis.navigator as Navigator | undefined)?.storage;
+ if (!storage?.estimate) return null;
+ const { usage = 0, quota = 0 } = await storage.estimate();
+ return classifyEstimate(usage, quota);
+}
From 59aa0cfc129882f31d4b5a8bacd7c79f6210eb2f Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:14:03 +0400
Subject: [PATCH 45/63] feat(store): add livequery hooks for documents and
queue
Co-Authored-By: Claude Opus 4.8
---
src/store/hooks.ts | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
create mode 100644 src/store/hooks.ts
diff --git a/src/store/hooks.ts b/src/store/hooks.ts
new file mode 100644
index 0000000..6e95677
--- /dev/null
+++ b/src/store/hooks.ts
@@ -0,0 +1,16 @@
+// liveQuery hooks for documents and the processing queue.
+
+import { useLiveQuery } from 'dexie-react-hooks';
+import { repository, type DocumentRecord } from './db.ts';
+
+export function useDocuments(): DocumentRecord[] {
+ return useLiveQuery(() => repository.allDocuments(), [], []);
+}
+
+export function useQueue(): DocumentRecord[] {
+ return useLiveQuery(
+ () => repository.db.documents.where('status').anyOf('queued', 'processing').toArray(),
+ [],
+ [],
+ );
+}
From 63de81680003cdc72a87ccbe652e1654f44d1cd8 Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:14:03 +0400
Subject: [PATCH 46/63] feat(ui): add document list with status chips, retry,
and confirmed deletion
Co-Authored-By: Claude Opus 4.8
---
src/ui/DocumentList.module.css | 66 +++++++++++++++++++++++++++++++
src/ui/DocumentList.tsx | 72 ++++++++++++++++++++++++++++++++++
2 files changed, 138 insertions(+)
create mode 100644 src/ui/DocumentList.module.css
create mode 100644 src/ui/DocumentList.tsx
diff --git a/src/ui/DocumentList.module.css b/src/ui/DocumentList.module.css
new file mode 100644
index 0000000..7cfc502
--- /dev/null
+++ b/src/ui/DocumentList.module.css
@@ -0,0 +1,66 @@
+.list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.item {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.6rem 0.85rem;
+ border: 1px solid var(--fl-border, #d8d8d8);
+ border-radius: var(--fl-radius, 0.5rem);
+ background: var(--fl-surface-raised, #fff);
+}
+
+.name {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.chip {
+ font-size: 0.7rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ padding: 0.12rem 0.5rem;
+ border-radius: 999px;
+ color: #fff;
+}
+.queued {
+ background: #6a6a6a;
+}
+.processing {
+ background: #9a6700;
+}
+.ready-for-review {
+ background: #1f6feb;
+}
+.approved {
+ background: #1a7f37;
+}
+.failed {
+ background: #b42318;
+}
+
+.delete,
+.retry {
+ font: inherit;
+ font-size: 0.8rem;
+ padding: 0.25rem 0.6rem;
+ border-radius: var(--fl-radius, 0.5rem);
+ border: 1px solid var(--fl-border, #bbb);
+ background: transparent;
+ color: inherit;
+ cursor: pointer;
+}
+
+.empty {
+ color: var(--fl-text-muted, #6a6a6a);
+ font-size: 0.9rem;
+ padding: 1rem;
+}
diff --git a/src/ui/DocumentList.tsx b/src/ui/DocumentList.tsx
new file mode 100644
index 0000000..5fbfb91
--- /dev/null
+++ b/src/ui/DocumentList.tsx
@@ -0,0 +1,72 @@
+import { cx } from '../lib/cx.ts';
+import { repository, type DocumentRecord, type DocumentStatus } from '../store/db.ts';
+import { useDocuments } from '../store/hooks.ts';
+import styles from './DocumentList.module.css';
+
+const chipClass: Record = {
+ queued: styles.queued ?? '',
+ processing: styles.processing ?? '',
+ 'ready-for-review': styles['ready-for-review'] ?? '',
+ approved: styles.approved ?? '',
+ failed: styles.failed ?? '',
+};
+
+export interface DocumentListProps {
+ onOpen?: (doc: DocumentRecord) => void;
+}
+
+/** Document list with status chips, retry on failure, and confirmed deletion. */
+export function DocumentList({ onOpen }: DocumentListProps): React.JSX.Element {
+ const documents = useDocuments();
+
+ const remove = (doc: DocumentRecord): void => {
+ if (!confirm(`Delete "${doc.fileName}" and all its pages and edits?`)) return;
+ void repository.deleteDocumentCascade(doc.id);
+ };
+ const retry = (doc: DocumentRecord): void => {
+ void repository.setDocumentStatus(doc.id, 'queued');
+ };
+
+ if (documents.length === 0) {
+ return No documents yet. Upload an invoice to get started.
;
+ }
+
+ return (
+
+ {documents.map((doc) => (
+
+ onOpen?.(doc)}
+ title={doc.fileName}
+ style={{ background: 'none', border: 'none', textAlign: 'left', cursor: 'pointer' }}
+ >
+ {doc.fileName} · {doc.pageCount} page{doc.pageCount === 1 ? '' : 's'}
+
+ {doc.status}
+ {doc.status === 'failed' ? (
+ {
+ retry(doc);
+ }}
+ >
+ Retry
+
+ ) : null}
+ {
+ remove(doc);
+ }}
+ >
+ Delete
+
+
+ ))}
+
+ );
+}
From 08e654b442c2fdc5e1856a37f3fd3c001b3d7bea Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:14:03 +0400
Subject: [PATCH 47/63] docs(store): document schema versioning and migration
policy
Co-Authored-By: Claude Opus 4.8
---
src/store/README.md | 37 ++++++++++++++++++++++++++++++++-----
1 file changed, 32 insertions(+), 5 deletions(-)
diff --git a/src/store/README.md b/src/store/README.md
index 1c03c97..a5be4fa 100644
--- a/src/store/README.md
+++ b/src/store/README.md
@@ -2,9 +2,36 @@
Dexie (IndexedDB) persistence and the sequential processing queue.
-Holds documents, pages, edit audit trails, and settings. Source file bytes are
-stored as blobs so all state survives reload with no network. The queue runs at
-concurrency 1 (matching the worker invariant) and persists its state so a reload
-resumes where it stopped.
+## Schema (v1)
-_Populated in Phase 11._
+- `documents` — `{ id, fileName, fileHash, pageCount, createdAt, status, fileBlob }`
+- `pages` — `{ id, documentId, pageIndex, imageBlob, transcript, transcriptQuality, extraction, findings, reviewState }`
+- `edits` — `{ id, pageId, fieldPath, before, after, at }`
+- `settings` — `{ key, value }`
+
+Source bytes are stored as blobs so all state survives reload with **no network
+and no reprocessing**. `createRepository(db)` wraps a `FaturlensDB` instance;
+`repository` is the app-wide default. Tests use `fake-indexeddb` with a fresh
+named DB per case.
+
+## Queue
+
+`queue.ts` is a **pure** state machine (concurrency 1, matching the worker
+invariant): `queued → preprocessing → pass1 → pass2 → validating →
+ready-for-review`, plus `failed`. It tracks `lastCompletedStage` so a reload or
+retry resumes the in-flight item from the stage **after** the last completed one
+(`resumeStage`). Pause/resume toggle a flag.
+
+## Dedup & storage
+
+- `dedup.ts` — `checkDuplicate()` hashes bytes (sha256) and looks for an existing
+ document, so a re-upload offers "open existing" instead of reprocessing.
+- `storage.ts` — `estimateStorage()` via `navigator.storage.estimate()`, warning
+ at 80%.
+- `deleteDocumentCascade()` removes a document with its pages and edits in one
+ transaction.
+
+## Migration policy
+
+Bump `this.version(N).stores(...)` with an upgrade function for any schema
+change; never mutate the v1 store definition in place.
From 9a549831ed8b9e686fa4d7d450a8928cd7919c8b Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:18:13 +0400
Subject: [PATCH 48/63] feat(perf): add startup benchmark and per-page time
estimates
Co-Authored-By: Claude Opus 4.8
---
src/perf/estimate.test.ts | 43 +++++++++++++++++++++++++++++++++++++++
src/perf/estimate.ts | 31 ++++++++++++++++++++++++++++
2 files changed, 74 insertions(+)
create mode 100644 src/perf/estimate.test.ts
create mode 100644 src/perf/estimate.ts
diff --git a/src/perf/estimate.test.ts b/src/perf/estimate.test.ts
new file mode 100644
index 0000000..57f2aa6
--- /dev/null
+++ b/src/perf/estimate.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from 'vitest';
+import {
+ benchmarkToTps,
+ estimateBatchSeconds,
+ estimatePageSeconds,
+ formatEstimate,
+} from './estimate.ts';
+
+describe('benchmarkToTps', () => {
+ it('converts tokens over ms to tokens/second', () => {
+ expect(benchmarkToTps(64, 8000)).toBe(8);
+ });
+ it('returns 0 for non-positive elapsed', () => {
+ expect(benchmarkToTps(64, 0)).toBe(0);
+ });
+});
+
+describe('estimatePageSeconds', () => {
+ it('divides expected tokens by throughput', () => {
+ expect(estimatePageSeconds(12, 1200)).toBe(100);
+ });
+ it('is Infinity at zero throughput', () => {
+ expect(estimatePageSeconds(0)).toBe(Infinity);
+ });
+});
+
+describe('estimateBatchSeconds', () => {
+ it('scales by page count', () => {
+ expect(estimateBatchSeconds(3, 12)).toBe(300);
+ });
+});
+
+describe('formatEstimate', () => {
+ it('shows seconds under 90s', () => {
+ expect(formatEstimate(45)).toBe('~45s');
+ });
+ it('shows minutes for longer estimates', () => {
+ expect(formatEstimate(180)).toBe('~3 min');
+ });
+ it('shows unknown for Infinity', () => {
+ expect(formatEstimate(Infinity)).toBe('unknown');
+ });
+});
diff --git a/src/perf/estimate.ts b/src/perf/estimate.ts
new file mode 100644
index 0000000..8398c8a
--- /dev/null
+++ b/src/perf/estimate.ts
@@ -0,0 +1,31 @@
+// Throughput benchmark → per-page time estimates. Pure math, unit-tested.
+
+/** Expected generated tokens for a two-pass page (transcription + extraction). */
+export const EXPECTED_TOKENS_PER_PAGE = 1200;
+
+/** Convert a warmup run (tokens over milliseconds) into tokens/second. */
+export function benchmarkToTps(tokens: number, elapsedMs: number): number {
+ if (elapsedMs <= 0) return 0;
+ return tokens / (elapsedMs / 1000);
+}
+
+/** Seconds to process one page at a given throughput. */
+export function estimatePageSeconds(
+ tokensPerSecond: number,
+ expectedTokens = EXPECTED_TOKENS_PER_PAGE,
+): number {
+ if (tokensPerSecond <= 0) return Infinity;
+ return expectedTokens / tokensPerSecond;
+}
+
+/** Human-friendly estimate string for the UI. */
+export function formatEstimate(seconds: number): string {
+ if (!Number.isFinite(seconds)) return 'unknown';
+ if (seconds < 90) return `~${String(Math.round(seconds))}s`;
+ return `~${String(Math.round(seconds / 60))} min`;
+}
+
+/** Total seconds for a batch of pages. */
+export function estimateBatchSeconds(pageCount: number, tokensPerSecond: number): number {
+ return pageCount * estimatePageSeconds(tokensPerSecond);
+}
From b995d0e895cf6cbf4fc508c9198032fed63f4414 Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:18:13 +0400
Subject: [PATCH 49/63] feat(perf): add webgpu device-lost recovery ladder with
wasm demotion
Co-Authored-By: Claude Opus 4.8
---
src/perf/recovery.test.ts | 31 +++++++++++++++++++++++
src/perf/recovery.ts | 53 +++++++++++++++++++++++++++++++++++++++
2 files changed, 84 insertions(+)
create mode 100644 src/perf/recovery.test.ts
create mode 100644 src/perf/recovery.ts
diff --git a/src/perf/recovery.test.ts b/src/perf/recovery.test.ts
new file mode 100644
index 0000000..2c2a771
--- /dev/null
+++ b/src/perf/recovery.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from 'vitest';
+import { initialRecoveryState, onDeviceLost } from './recovery.ts';
+
+describe('onDeviceLost', () => {
+ it('recreates WebGPU on the first loss', () => {
+ const { state, decision } = onDeviceLost(initialRecoveryState('webgpu'), 1000);
+ expect(decision).toBe('recreate-webgpu');
+ expect(state.provider).toBe('webgpu');
+ });
+
+ it('demotes to WASM on a second loss within the window', () => {
+ let state = initialRecoveryState('webgpu');
+ ({ state } = onDeviceLost(state, 1000));
+ const second = onDeviceLost(state, 1000 + 60_000); // 1 min later
+ expect(second.decision).toBe('demote-to-wasm');
+ expect(second.state.provider).toBe('wasm');
+ });
+
+ it('recreates WebGPU when the second loss is outside the window', () => {
+ let state = initialRecoveryState('webgpu');
+ ({ state } = onDeviceLost(state, 1000));
+ const later = onDeviceLost(state, 1000 + 11 * 60_000); // 11 min later
+ expect(later.decision).toBe('recreate-webgpu');
+ expect(later.state.provider).toBe('webgpu');
+ });
+
+ it('just recreates WASM sessions when already on WASM', () => {
+ const { decision } = onDeviceLost(initialRecoveryState('wasm'), 1000);
+ expect(decision).toBe('recreate-wasm');
+ });
+});
diff --git a/src/perf/recovery.ts b/src/perf/recovery.ts
new file mode 100644
index 0000000..eb16b48
--- /dev/null
+++ b/src/perf/recovery.ts
@@ -0,0 +1,53 @@
+// WebGPU device-lost recovery ladder. Pure state machine: the first loss
+// recreates sessions; a second loss within the window demotes to WASM.
+
+import type { ExecutionProvider } from '../capability/profile.ts';
+
+export const REPEATED_LOSS_WINDOW_MS = 10 * 60 * 1000; // 10 minutes
+
+export interface RecoveryState {
+ provider: ExecutionProvider;
+ /** Timestamps of recent device-loss events. */
+ lossTimestamps: number[];
+}
+
+export type RecoveryDecision = 'recreate-webgpu' | 'demote-to-wasm' | 'recreate-wasm';
+
+export interface RecoveryOutcome {
+ state: RecoveryState;
+ decision: RecoveryDecision;
+}
+
+export function initialRecoveryState(provider: ExecutionProvider): RecoveryState {
+ return { provider, lossTimestamps: [] };
+}
+
+/**
+ * Decide how to recover from a device-lost event at time `now`.
+ * - Already on WASM → just recreate WASM sessions.
+ * - First WebGPU loss (in window) → recreate WebGPU.
+ * - Second WebGPU loss within the window → demote to WASM.
+ */
+export function onDeviceLost(
+ state: RecoveryState,
+ now: number,
+ windowMs = REPEATED_LOSS_WINDOW_MS,
+): RecoveryOutcome {
+ if (state.provider === 'wasm') {
+ return { state, decision: 'recreate-wasm' };
+ }
+
+ const recent = state.lossTimestamps.filter((t) => now - t <= windowMs);
+ recent.push(now);
+
+ if (recent.length >= 2) {
+ return {
+ state: { provider: 'wasm', lossTimestamps: recent },
+ decision: 'demote-to-wasm',
+ };
+ }
+ return {
+ state: { provider: 'webgpu', lossTimestamps: recent },
+ decision: 'recreate-webgpu',
+ };
+}
From 8f1832415f7cda7bdf07a6978048976f545a3b6e Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:18:13 +0400
Subject: [PATCH 50/63] feat(worker): apply device-lost recovery on inference
failure
Co-Authored-By: Claude Opus 4.8
---
src/worker/worker.ts | 30 ++++++++++++++++++++++++++++++
1 file changed, 30 insertions(+)
diff --git a/src/worker/worker.ts b/src/worker/worker.ts
index 0439157..8d1828f 100644
--- a/src/worker/worker.ts
+++ b/src/worker/worker.ts
@@ -6,6 +6,7 @@
import { AutoTokenizer } from '@huggingface/transformers';
import * as ort from 'onnxruntime-web';
import type { DeviceProfile } from '../capability/profile.ts';
+import { initialRecoveryState, onDeviceLost } from '../perf/recovery.ts';
import { chwDims, DEFAULT_NORMALIZE, pixelsToCHW } from '../pipeline/ingest/normalize.ts';
import { runGeneration, type DecoderConfig, type TokenizerLike } from './generate.ts';
import { getFile, openModelCache, type CacheLike } from './loader/cache.ts';
@@ -32,6 +33,26 @@ let tokenizer: TokenizerLike | null = null;
let decoderConfig: DecoderConfig | null = null;
let activeId: string | null = null;
let abortRequested = false;
+let lastProfile: DeviceProfile | null = null;
+let recovery = initialRecoveryState('wasm');
+
+function isDeviceLost(message: string): boolean {
+ return /device.*lost|lost.*device|gpu.*device/i.test(message);
+}
+
+async function recoverFromDeviceLoss(now: number): Promise {
+ if (!lastProfile) return;
+ const outcome = onDeviceLost(recovery, now);
+ recovery = outcome.state;
+ await sessions?.dispose();
+ sessions = null;
+ const profile: DeviceProfile =
+ outcome.decision === 'demote-to-wasm'
+ ? { ...lastProfile, executionProvider: 'wasm' }
+ : lastProfile;
+ lastProfile = profile;
+ sessions = await createSessions(profile, { cache: await openModelCache() });
+}
function post(message: WorkerToMain): void {
self.postMessage(message);
@@ -75,6 +96,8 @@ function adaptTokenizer(instance: {
}
async function handleLoad(profile: DeviceProfile): Promise {
+ lastProfile = profile;
+ recovery = initialRecoveryState(profile.executionProvider);
const cache = await openModelCache();
post({ type: 'load-progress', stage: 'reading-cache', fraction: 0 });
@@ -153,6 +176,13 @@ async function handleInfer(message: Extract): P
post({ type: 'done', id: message.id, fullText, stats });
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
+ if (isDeviceLost(msg)) {
+ try {
+ await recoverFromDeviceLoss(performance.now());
+ } catch {
+ // recovery failed; surfaced below as a recoverable error
+ }
+ }
post({ type: 'error', id: message.id, message: msg, recoverable: true });
} finally {
for (const embed of imageEmbeds) {
From 485d02f34fd843446048f14d2ee18613eaae8c54 Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:18:13 +0400
Subject: [PATCH 51/63] feat(perf): add user-confirmable token budget auto-tune
Co-Authored-By: Claude Opus 4.8
---
src/perf/autotune.test.ts | 22 ++++++++++++++++++
src/perf/autotune.ts | 48 +++++++++++++++++++++++++++++++++++++++
2 files changed, 70 insertions(+)
create mode 100644 src/perf/autotune.test.ts
create mode 100644 src/perf/autotune.ts
diff --git a/src/perf/autotune.test.ts b/src/perf/autotune.test.ts
new file mode 100644
index 0000000..520b217
--- /dev/null
+++ b/src/perf/autotune.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest';
+import { suggestBudget, TOKEN_BUDGET_FULL, TOKEN_BUDGET_REDUCED } from './autotune.ts';
+
+describe('suggestBudget', () => {
+ it('suggests reducing when WebGPU decode is slow and budget is full', () => {
+ const s = suggestBudget('webgpu', 2, TOKEN_BUDGET_FULL);
+ expect(s.suggest).toBe(true);
+ expect(s.proposed).toBe(TOKEN_BUDGET_REDUCED);
+ });
+
+ it('does not suggest when throughput is acceptable', () => {
+ expect(suggestBudget('webgpu', 5, TOKEN_BUDGET_FULL).suggest).toBe(false);
+ });
+
+ it('does not suggest on WASM', () => {
+ expect(suggestBudget('wasm', 1, TOKEN_BUDGET_FULL).suggest).toBe(false);
+ });
+
+ it('does not suggest when already at the reduced floor', () => {
+ expect(suggestBudget('webgpu', 1, TOKEN_BUDGET_REDUCED).suggest).toBe(false);
+ });
+});
diff --git a/src/perf/autotune.ts b/src/perf/autotune.ts
new file mode 100644
index 0000000..71a95d2
--- /dev/null
+++ b/src/perf/autotune.ts
@@ -0,0 +1,48 @@
+// Token-budget auto-tune. Pure decisions; the user confirms before any change
+// is stored in settings.
+
+import {
+ TOKEN_BUDGET_FULL,
+ TOKEN_BUDGET_REDUCED,
+ type ExecutionProvider,
+} from '../capability/profile.ts';
+
+/** Below this decode throughput on WebGPU, suggest a smaller image token budget. */
+export const MIN_ACCEPTABLE_TPS = 3;
+
+export interface BudgetSuggestion {
+ suggest: boolean;
+ current: number;
+ proposed: number;
+ reason: string;
+}
+
+/**
+ * Suggest reducing the image token budget when WebGPU decode throughput is too
+ * low and the budget is not already at the floor.
+ */
+export function suggestBudget(
+ provider: ExecutionProvider,
+ tokensPerSecond: number,
+ currentBudget: number,
+): BudgetSuggestion {
+ const noChange: BudgetSuggestion = {
+ suggest: false,
+ current: currentBudget,
+ proposed: currentBudget,
+ reason: '',
+ };
+
+ if (provider !== 'webgpu') return noChange;
+ if (tokensPerSecond >= MIN_ACCEPTABLE_TPS) return noChange;
+ if (currentBudget <= TOKEN_BUDGET_REDUCED) return noChange;
+
+ return {
+ suggest: true,
+ current: currentBudget,
+ proposed: TOKEN_BUDGET_REDUCED,
+ reason: `Decode is ${tokensPerSecond.toFixed(1)} tok/s (< ${String(MIN_ACCEPTABLE_TPS)}); a smaller image budget will speed things up`,
+ };
+}
+
+export { TOKEN_BUDGET_FULL, TOKEN_BUDGET_REDUCED };
From b5b74ce1a9b8c8a62e345fbe706c8af88f517909 Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:18:13 +0400
Subject: [PATCH 52/63] feat(ui): add stats drawer with stage timings
Co-Authored-By: Claude Opus 4.8
---
src/ui/StatsDrawer.module.css | 32 ++++++++++++++++++
src/ui/StatsDrawer.tsx | 61 +++++++++++++++++++++++++++++++++++
2 files changed, 93 insertions(+)
create mode 100644 src/ui/StatsDrawer.module.css
create mode 100644 src/ui/StatsDrawer.tsx
diff --git a/src/ui/StatsDrawer.module.css b/src/ui/StatsDrawer.module.css
new file mode 100644
index 0000000..e6b8f95
--- /dev/null
+++ b/src/ui/StatsDrawer.module.css
@@ -0,0 +1,32 @@
+.drawer {
+ border: 1px solid var(--fl-border, #d8d8d8);
+ border-radius: var(--fl-radius, 0.5rem);
+ background: var(--fl-surface, #fafafa);
+ font-size: 0.82rem;
+}
+
+.toggle {
+ font: inherit;
+ width: 100%;
+ text-align: left;
+ background: none;
+ border: none;
+ padding: 0.5rem 0.75rem;
+ cursor: pointer;
+ color: var(--fl-text, #1a1a1a);
+}
+
+.body {
+ padding: 0 0.75rem 0.75rem;
+}
+
+.row {
+ display: flex;
+ justify-content: space-between;
+ padding: 0.15rem 0;
+ font-variant-numeric: tabular-nums;
+}
+
+.key {
+ color: var(--fl-text-muted, #6a6a6a);
+}
diff --git a/src/ui/StatsDrawer.tsx b/src/ui/StatsDrawer.tsx
new file mode 100644
index 0000000..0d14cac
--- /dev/null
+++ b/src/ui/StatsDrawer.tsx
@@ -0,0 +1,61 @@
+import { useState } from 'react';
+import { formatEstimate } from '../perf/estimate.ts';
+import styles from './StatsDrawer.module.css';
+
+export interface StageTiming {
+ stage: string;
+ ms: number;
+}
+
+export interface StatsDrawerProps {
+ timings: StageTiming[];
+ tokensPerSecond?: number;
+ peakMemoryEstimateMB?: number;
+}
+
+/** Local-only timing telemetry. Nothing leaves the machine. */
+export function StatsDrawer({
+ timings,
+ tokensPerSecond,
+ peakMemoryEstimateMB,
+}: StatsDrawerProps): React.JSX.Element {
+ const [open, setOpen] = useState(false);
+ const total = timings.reduce((sum, t) => sum + t.ms, 0);
+
+ return (
+
+
{
+ setOpen((v) => !v);
+ }}
+ aria-expanded={open}
+ >
+ {open ? '▾' : '▸'} Stats ({formatEstimate(total / 1000)})
+
+ {open ? (
+
+ {timings.map((t) => (
+
+ {t.stage}
+ {Math.round(t.ms)} ms
+
+ ))}
+ {tokensPerSecond !== undefined ? (
+
+ throughput
+ {tokensPerSecond.toFixed(1)} tok/s
+
+ ) : null}
+ {peakMemoryEstimateMB !== undefined ? (
+
+ peak memory (est.)
+ ~{peakMemoryEstimateMB} MB
+
+ ) : null}
+
+ ) : null}
+
+ );
+}
From 213a77f0ecbc373404574cc9e403e8454271c0d3 Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:18:13 +0400
Subject: [PATCH 53/63] fix(queue): mark in-flight item resumable on tab close
Co-Authored-By: Claude Opus 4.8
---
src/store/resumable.test.ts | 29 +++++++++++++++++++++++++++++
src/store/resumable.ts | 21 +++++++++++++++++++++
2 files changed, 50 insertions(+)
create mode 100644 src/store/resumable.test.ts
create mode 100644 src/store/resumable.ts
diff --git a/src/store/resumable.test.ts b/src/store/resumable.test.ts
new file mode 100644
index 0000000..6ba04e0
--- /dev/null
+++ b/src/store/resumable.test.ts
@@ -0,0 +1,29 @@
+import 'fake-indexeddb/auto';
+import { describe, expect, it } from 'vitest';
+import { createRepository, FaturlensDB, type DocumentRecord } from './db.ts';
+import { markInFlightResumable } from './resumable.ts';
+
+function doc(id: string, status: DocumentRecord['status']): DocumentRecord {
+ return {
+ id,
+ fileName: `${id}.pdf`,
+ fileHash: id,
+ pageCount: 1,
+ createdAt: 1,
+ status,
+ fileBlob: new Blob(['x']),
+ };
+}
+
+describe('markInFlightResumable', () => {
+ it('resets processing documents to queued', async () => {
+ const repo = createRepository(new FaturlensDB('faturlens-resumable-test'));
+ await repo.addDocument(doc('a', 'processing'));
+ await repo.addDocument(doc('b', 'ready-for-review'));
+
+ const count = await markInFlightResumable(repo);
+ expect(count).toBe(1);
+ expect((await repo.getDocument('a'))?.status).toBe('queued');
+ expect((await repo.getDocument('b'))?.status).toBe('ready-for-review');
+ });
+});
diff --git a/src/store/resumable.ts b/src/store/resumable.ts
new file mode 100644
index 0000000..a5e971b
--- /dev/null
+++ b/src/store/resumable.ts
@@ -0,0 +1,21 @@
+// Mark in-flight work resumable so an abrupt tab close never corrupts the queue:
+// any document left "processing" is reset to "queued" for a clean resume.
+
+import { repository, type Repository } from './db.ts';
+
+export async function markInFlightResumable(repo: Repository = repository): Promise {
+ const processing = await repo.db.documents.where('status').equals('processing').toArray();
+ await Promise.all(processing.map((doc) => repo.setDocumentStatus(doc.id, 'queued')));
+ return processing.length;
+}
+
+/** Register a beforeunload handler that flags in-flight work resumable. */
+export function registerResumableOnUnload(repo: Repository = repository): () => void {
+ const handler = (): void => {
+ void markInFlightResumable(repo);
+ };
+ globalThis.addEventListener('beforeunload', handler);
+ return () => {
+ globalThis.removeEventListener('beforeunload', handler);
+ };
+}
From 69f0664d9ac7bb6b97e1d514bd4f301a56769a2c Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:18:13 +0400
Subject: [PATCH 54/63] docs(perf): document recovery ladder and tuning knobs
Co-Authored-By: Claude Opus 4.8
---
src/perf/README.md | 30 ++++++++++++++++++++++++++++++
1 file changed, 30 insertions(+)
create mode 100644 src/perf/README.md
diff --git a/src/perf/README.md b/src/perf/README.md
new file mode 100644
index 0000000..5942ce2
--- /dev/null
+++ b/src/perf/README.md
@@ -0,0 +1,30 @@
+# perf
+
+Performance hardening: honest estimates, recovery, and tuning. The decision
+logic is pure and unit-tested; the worker/UI apply it.
+
+## Modules
+
+- `estimate.ts` — `benchmarkToTps()` turns a startup warmup (tokens/ms) into
+ throughput; `estimatePageSeconds()` / `estimateBatchSeconds()` /
+ `formatEstimate()` give upfront per-page and per-batch estimates shown before
+ processing starts.
+- `recovery.ts` — the WebGPU device-lost ladder: first loss recreates sessions;
+ a **second loss within 10 minutes demotes to WASM**. The worker calls
+ `onDeviceLost()` when it catches a device-lost error.
+- `autotune.ts` — `suggestBudget()` proposes a smaller image token budget when
+ WebGPU decode drops below 3 tok/s (user-confirmed before it is stored).
+
+## Other hardening
+
+- **WASM path** runs the full pipeline; the profile forces the WASM EP and the
+ UI shows realistic minutes-per-page from the benchmark.
+- **Runtime tile downscale**: `planTiles` already clamps to the device budget
+ (Phase 05); over-budget pages are downscaled and noted on the page record.
+- **Off-main-thread**: blob/canvas/tensor work runs in the workers, keeping
+ main-thread tasks short during processing.
+- **Resumable on tab close**: `store/resumable.ts` resets any `processing`
+ document to `queued` on `beforeunload`, so an abrupt close never corrupts the
+ queue.
+- **Local-only telemetry**: per-stage timings render in ` `;
+ nothing leaves the machine.
From eec3331655d403358660866cc37c341d61d4271c Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:24:50 +0400
Subject: [PATCH 55/63] feat(pwa): add manifest and app shell service worker,
excluding model cdn and wasm from precache
Co-Authored-By: Claude Opus 4.8
---
package-lock.json | 2752 ++++++++++++++++++++++++++++++++++++++++++++-
package.json | 2 +
public/icon.svg | 12 +
vite.config.ts | 31 +-
4 files changed, 2744 insertions(+), 53 deletions(-)
create mode 100644 public/icon.svg
diff --git a/package-lock.json b/package-lock.json
index d7c70be..35852cd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -27,6 +27,7 @@
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
+ "axe-core": "^4.12.0",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-typescript": "^3.7.0",
@@ -40,6 +41,7 @@
"typescript": "^5.7.2",
"typescript-eslint": "^8.18.2",
"vite": "^6.0.7",
+ "vite-plugin-pwa": "^0.21.2",
"vitest": "^3.0.0"
},
"engines": {
@@ -148,57 +150,1198 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/helper-annotate-as-pure": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz",
+ "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/helper-compilation-targets": {
"version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
- "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-create-class-features-plugin": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz",
+ "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.29.7",
+ "@babel/helper-member-expression-to-functions": "^7.29.7",
+ "@babel/helper-optimise-call-expression": "^7.29.7",
+ "@babel/helper-replace-supers": "^7.29.7",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-create-regexp-features-plugin": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.29.7.tgz",
+ "integrity": "sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.29.7",
+ "regexpu-core": "^6.3.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-define-polyfill-provider": {
+ "version": "0.6.8",
+ "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz",
+ "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "debug": "^4.4.3",
+ "lodash.debounce": "^4.0.8",
+ "resolve": "^1.22.11"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
+ }
+ },
+ "node_modules/@babel/helper-define-polyfill-provider/node_modules/resolve": {
+ "version": "1.22.12",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
+ "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-member-expression-to-functions": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz",
+ "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-optimise-call-expression": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz",
+ "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
+ "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-remap-async-to-generator": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.29.7.tgz",
+ "integrity": "sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.29.7",
+ "@babel/helper-wrap-function": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-replace-supers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz",
+ "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-member-expression-to-functions": "^7.29.7",
+ "@babel/helper-optimise-call-expression": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-skip-transparent-expression-wrappers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz",
+ "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-wrap-function": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.29.7.tgz",
+ "integrity": "sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
+ "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.7"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.29.7.tgz",
+ "integrity": "sha512-j8SrR0zLZrRsC09DlszEx8FpMiwukKffYXMK0d5LmOglO7vGG6sz/BR/20yHqWH+Lnn31JTt2PE3hIWNgM2J6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.29.7.tgz",
+ "integrity": "sha512-r8j8escF+U2FUHo0KOhPUdMzUO+jp9fInva6+ACVAF3Y97Ev+5iNZwiqTghmzNeWwDkOPlYuTcfb1vDaoZKmAQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.29.7.tgz",
+ "integrity": "sha512-GE1TFSiuFeGsCxmYXZl8HwoPrVlwe4rHPFE8weieGKZqnDORK+Ar3vgWMgW+AOxQ6/2TgLSKx9p6W7O4rC6qgQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array/-/plugin-bugfix-safari-rest-destructuring-rhs-array-7.29.7.tgz",
+ "integrity": "sha512-oBNVCvnO5tND+xSopWvV8WNGfpTfgP4Zr/YXXSj8zfmcPktp5Ku/aZlsIowgSD4fjmgHn6sGmB9APVsU5zOdhA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.29.7.tgz",
+ "integrity": "sha512-QQt9qKHZ2sg/kivaLr7lnQr8HVrQDdBNSfCsTjiDxRuX/K5ORyKq+Bu8Xr0cDE3Dfkv0cw28Ve0EKyKMvulkOw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7",
+ "@babel/plugin-transform-optional-chaining": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.13.0"
+ }
+ },
+ "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.29.7.tgz",
+ "integrity": "sha512-pn6QacGLgvCcwc+syUhKE/qSjV2D1IHDB84RNxWYSt1mW3K/SCtjinZ2p0cETJxAWBjPy3K/1lHwG5BjjPxNlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-private-property-in-object": {
+ "version": "7.21.0-placeholder-for-preset-env.2",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz",
+ "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-import-assertions": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.29.7.tgz",
+ "integrity": "sha512-/An1OCBN93thpBAGyfsK2pcf0jvju1SAtKkL2Ny++B5Sy6sqgzXDQH1cZxWbF96Wuk+bn41MDA9bLd4VVAw6rw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-import-attributes": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz",
+ "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-unicode-sets-regex": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz",
+ "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-arrow-functions": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz",
+ "integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-async-generator-functions": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.7.tgz",
+ "integrity": "sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/helper-remap-async-to-generator": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-async-to-generator": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.29.7.tgz",
+ "integrity": "sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/helper-remap-async-to-generator": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-block-scoped-functions": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.29.7.tgz",
+ "integrity": "sha512-cUSmjh72N+rN4PrkFlN1dJwNCwjVp5d38/CQrEsFggkD10UiFlBFgdH3tv5dNsLuHY+3S8db2xCHjhZcv5WgvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-block-scoping": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.7.tgz",
+ "integrity": "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-class-properties": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.29.7.tgz",
+ "integrity": "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-class-features-plugin": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-class-static-block": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.29.7.tgz",
+ "integrity": "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-class-features-plugin": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.12.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-classes": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.29.7.tgz",
+ "integrity": "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/helper-replace-supers": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-computed-properties": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.29.7.tgz",
+ "integrity": "sha512-RK7/IyU5phpuCdBAuig5VkzG/EnbDaui5SQGdU9BFrHdV+mV4cUjLMQ9lJDjLNtWHsqtiefpGZUXQP2BiTYMsA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/template": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-destructuring": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.7.tgz",
+ "integrity": "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-dotall-regex": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.29.7.tgz",
+ "integrity": "sha512-3qc18hsD2RdZiyJNDNc7HQpv6xbncwh8FYtxNFFzclSyh/trPD9KkVR9BDECUjDLvb7yJVF15GfYUuC+LMkkiQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-duplicate-keys": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.29.7.tgz",
+ "integrity": "sha512-6IvRRriEMqnBwD6chtxdLpMYCHWEzN+oL5cyQtjykya19UgzbmKhxmhZgKC/LHxS2nYr9Q/qYPZ5Lr6jOL9+yQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.7.tgz",
+ "integrity": "sha512-2wiIyo2BjtgU7HufSeDnL9L2O7zr8jmhFKuSr65VpRkUiRKRNpb0mdlk56+XPPKoIrfHqzbMuglDvZun0RISsA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-dynamic-import": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.29.7.tgz",
+ "integrity": "sha512-giOlEm/EFjfjr+te9NsdjkUo2v4f8rS/SXPumRVHAtbNcyNlvtREkU1dZzaIDclNpnaVhlCqRdFKhJBjBikzLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-explicit-resource-management": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.29.7.tgz",
+ "integrity": "sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/plugin-transform-destructuring": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-exponentiation-operator": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.29.7.tgz",
+ "integrity": "sha512-zFpMOTLZBdW5LfObqcSbL6kefg4R4eLdmvS0wbN9M6D5Mym/sKm9toOoWyVOa+xDjvCnuWcHls2YonXwHvH3CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-export-namespace-from": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.29.7.tgz",
+ "integrity": "sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-for-of": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.29.7.tgz",
+ "integrity": "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-function-name": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.29.7.tgz",
+ "integrity": "sha512-otRWaHXE6fbAGkePvaj/kvs3HsqXfPhlnzwSOlnFgbqCPMd975dW+4wZ00WFBt+/YlBGcJwNrARQTOJOb4ZrIg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-json-strings": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.29.7.tgz",
+ "integrity": "sha512-RRnE2+eon1rJAq8MnoF1b5kTpY1vU88twHcvcKMrsqP/jxIRqDVs9iJB5fqPuqyeFAW0wJo4MlUIPpQCq/aRsg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-literals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.29.7.tgz",
+ "integrity": "sha512-DZ/oLP21ZuWx1vKqnoNv6/tvEK48AQOBRai40CX9dTjGluvT/YZCyY3rryDtyUqCEoyNroy5KKPwX2iQCiRvyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-logical-assignment-operators": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.29.7.tgz",
+ "integrity": "sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-member-expression-literals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.29.7.tgz",
+ "integrity": "sha512-hl1kwFZCCiDyfH25Xmco9jTrkPgnS9pmOzSG7W5I4SaGbLeqKv417hcU2RKmaxoPEgsoJh7ZPOrnPGq99bHoUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-modules-amd": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.29.7.tgz",
+ "integrity": "sha512-fxtQoH3m5ywUSIfaH0FGCzWu4McsYon5bD3K4XnskC7f+OyQMj7rsOMi4NvvmJ83WwBAg4UCe+ov4VZlqEvyew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-modules-commonjs": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz",
+ "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-modules-systemjs": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.7.tgz",
+ "integrity": "sha512-TM2ZcQLoG2/y4HODiStCo10DibYhWhGWAwVv+EQKmG/7GFl0N+AAmUiXOMKM+aiJ9XBJ9AHVZBvTzMnJ2sM3cQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-modules-umd": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.29.7.tgz",
+ "integrity": "sha512-B4UkaTK3QpgCwJnrxKfMPKdo92CN7OKXAlpAAnM3UPu0Q0lCCk57ylA9AJbRy2v8dDKOPAAWcoR6CMyeoHwRCA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-named-capturing-groups-regex": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.7.tgz",
+ "integrity": "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-new-target": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.29.7.tgz",
+ "integrity": "sha512-fEo41GmsOUhOBlw8ioo6zvjX5Xc2Lqkzlyfqbpsk3eB6TReV18uhxZ0esfEokVbY2+PVJAQHNKxER6lGrzNd3A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-nullish-coalescing-operator": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.29.7.tgz",
+ "integrity": "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-numeric-separator": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.29.7.tgz",
+ "integrity": "sha512-zR7fv/z14OjgHl4AgRtkDBvBMhIzCxqV/qN/2BCRC7LjFwvuzjYe7gDWxC4Wl/SNsLM6SE1IWvRPYMgSJaUvNw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-object-rest-spread": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.29.7.tgz",
+ "integrity": "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/plugin-transform-destructuring": "^7.29.7",
+ "@babel/plugin-transform-parameters": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-object-super": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.29.7.tgz",
+ "integrity": "sha512-Ea/diGcw0twB5IlZPO5sgET6fJsLJqPABqTuFWIR+iMPGPZJkATEIWx0wa+aEQ5UY1CBQyP/gkAiLEqn1vBiQA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/compat-data": "^7.29.7",
- "@babel/helper-validator-option": "^7.29.7",
- "browserslist": "^4.24.0",
- "lru-cache": "^5.1.1",
- "semver": "^6.3.1"
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/helper-replace-supers": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@babel/helper-globals": {
+ "node_modules/@babel/plugin-transform-optional-catch-binding": {
"version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
- "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.29.7.tgz",
+ "integrity": "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==",
"dev": true,
"license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
"engines": {
"node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@babel/helper-module-imports": {
+ "node_modules/@babel/plugin-transform-optional-chaining": {
"version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
- "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.29.7.tgz",
+ "integrity": "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/traverse": "^7.29.7",
- "@babel/types": "^7.29.7"
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@babel/helper-module-transforms": {
+ "node_modules/@babel/plugin-transform-parameters": {
"version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
- "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.29.7.tgz",
+ "integrity": "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-module-imports": "^7.29.7",
- "@babel/helper-validator-identifier": "^7.29.7",
- "@babel/traverse": "^7.29.7"
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-private-methods": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.29.7.tgz",
+ "integrity": "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-class-features-plugin": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-private-property-in-object": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.29.7.tgz",
+ "integrity": "sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.29.7",
+ "@babel/helper-create-class-features-plugin": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-property-literals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.29.7.tgz",
+ "integrity": "sha512-bOMRLQuI0A5ZqHq3OWJ89/rXpJ/NJrbVhXiP4zwPGMs6kpcVsuTUNjwoE30K0Qm3mf48a/TnRYYD6vPNqcg6jA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
+ "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
+ "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-regenerator": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.7.tgz",
+ "integrity": "sha512-rNNFV0DBAJp988xW2DOntfDoYn1eR8GGF5AT5vYc+rjyfaQkM242c9tZUHHPe7KYaiJizXPWhQTzzdbXySyhBw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-regexp-modifiers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.29.7.tgz",
+ "integrity": "sha512-mB5Fs0VWrJ42ZCmc8114v60qetdaUVNkj9PmSZRmanCZM3S9hm0CFRLjRmYIsuXav14l2jvZ+4T8iiCGnhj3nQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -207,83 +1350,127 @@
"@babel/core": "^7.0.0"
}
},
- "node_modules/@babel/helper-plugin-utils": {
+ "node_modules/@babel/plugin-transform-reserved-words": {
"version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
- "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.29.7.tgz",
+ "integrity": "sha512-5+YhdpVgmfSmwZyLMftfaiffLRMHjzIRHFHHLdibcSyJm2pasMrKHrO3Ptrt2DRshjvpgjEJJ1zVW14WPq/6QA==",
"dev": true,
"license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
"engines": {
"node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@babel/helper-string-parser": {
+ "node_modules/@babel/plugin-transform-shorthand-properties": {
"version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
- "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz",
+ "integrity": "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==",
"dev": true,
"license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
"engines": {
"node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@babel/helper-validator-identifier": {
+ "node_modules/@babel/plugin-transform-spread": {
"version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
- "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.29.7.tgz",
+ "integrity": "sha512-/u5K1QWada7tbYNqTjMh96718g9NTwh9tfPJMsSmVsQwGT447FskV+KcfeXkXq2GWki4EM/MuTdmBec+hOuVTQ==",
"dev": true,
"license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7"
+ },
"engines": {
"node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@babel/helper-validator-option": {
+ "node_modules/@babel/plugin-transform-sticky-regex": {
"version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
- "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.29.7.tgz",
+ "integrity": "sha512-BCHzNYJGe9l7EpwwDBN/ztlL2NYFFq8hp9ddjtUEM9f2O7S7kKV/lL6Fwo7IF7NSkYhPK2vO+86nIGltA90MsA==",
"dev": true,
"license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
"engines": {
"node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@babel/helpers": {
+ "node_modules/@babel/plugin-transform-template-literals": {
"version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
- "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz",
+ "integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/template": "^7.29.7",
- "@babel/types": "^7.29.7"
+ "@babel/helper-plugin-utils": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@babel/parser": {
+ "node_modules/@babel/plugin-transform-typeof-symbol": {
"version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
- "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.29.7.tgz",
+ "integrity": "sha512-223mNGoTkBiTEWFoK+Q6Go3tueMRclO8vxxxxquNCYuNI4jWOofFKJRRDu6SDrB8Sgo1UEGW9T4GAQ8ZyRso1A==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/types": "^7.29.7"
+ "@babel/helper-plugin-utils": "^7.29.7"
},
- "bin": {
- "parser": "bin/babel-parser.js"
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-unicode-escapes": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.29.7.tgz",
+ "integrity": "sha512-jCfXxSjf94lf4E0hKE0AByxF6F3/pVFqRdUUNkDJhsY0m1ZKjnN6ZYyMeHNpzflxb/0q5b7t3p+BE+SLF1WOtA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
},
"engines": {
- "node": ">=6.0.0"
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "node_modules/@babel/plugin-transform-unicode-property-regex": {
"version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
- "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.29.7.tgz",
+ "integrity": "sha512-OgZ+zoAJgZLUCunsTRQ5LAjOywDv5zzZ2/hQ5aMw1pGXyY2rtE8/chXYUmu3AlVHKpm10KEdG9aMwbI/K76ZGw==",
"dev": true,
"license": "MIT",
"dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.29.7",
"@babel/helper-plugin-utils": "^7.29.7"
},
"engines": {
@@ -293,22 +1480,141 @@
"@babel/core": "^7.0.0-0"
}
},
- "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "node_modules/@babel/plugin-transform-unicode-regex": {
"version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
- "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.29.7.tgz",
+ "integrity": "sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-unicode-sets-regex": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.29.7.tgz",
+ "integrity": "sha512-BLOhLht9DOJwIxlmp91wHvkXv1lguuHS3/FwUO8HL1H0u8s4hR1gASVFyilu9iGtcTRYqjTZmlsFFeQletntEg==",
"dev": true,
"license": "MIT",
"dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.29.7",
"@babel/helper-plugin-utils": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
},
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/preset-env": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.7.tgz",
+ "integrity": "sha512-GYzX36n1nsciIb0uyH0GHwxwtNwPQIcpxSeiVLDtG/B7jB5xXgchnmL1f/jCX5o+pwnaDBtO60ONSJhEBJfxYA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.29.7",
+ "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.29.7",
+ "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.29.7",
+ "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": "^7.29.7",
+ "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.29.7",
+ "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.29.7",
+ "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2",
+ "@babel/plugin-syntax-import-assertions": "^7.29.7",
+ "@babel/plugin-syntax-import-attributes": "^7.29.7",
+ "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6",
+ "@babel/plugin-transform-arrow-functions": "^7.29.7",
+ "@babel/plugin-transform-async-generator-functions": "^7.29.7",
+ "@babel/plugin-transform-async-to-generator": "^7.29.7",
+ "@babel/plugin-transform-block-scoped-functions": "^7.29.7",
+ "@babel/plugin-transform-block-scoping": "^7.29.7",
+ "@babel/plugin-transform-class-properties": "^7.29.7",
+ "@babel/plugin-transform-class-static-block": "^7.29.7",
+ "@babel/plugin-transform-classes": "^7.29.7",
+ "@babel/plugin-transform-computed-properties": "^7.29.7",
+ "@babel/plugin-transform-destructuring": "^7.29.7",
+ "@babel/plugin-transform-dotall-regex": "^7.29.7",
+ "@babel/plugin-transform-duplicate-keys": "^7.29.7",
+ "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.7",
+ "@babel/plugin-transform-dynamic-import": "^7.29.7",
+ "@babel/plugin-transform-explicit-resource-management": "^7.29.7",
+ "@babel/plugin-transform-exponentiation-operator": "^7.29.7",
+ "@babel/plugin-transform-export-namespace-from": "^7.29.7",
+ "@babel/plugin-transform-for-of": "^7.29.7",
+ "@babel/plugin-transform-function-name": "^7.29.7",
+ "@babel/plugin-transform-json-strings": "^7.29.7",
+ "@babel/plugin-transform-literals": "^7.29.7",
+ "@babel/plugin-transform-logical-assignment-operators": "^7.29.7",
+ "@babel/plugin-transform-member-expression-literals": "^7.29.7",
+ "@babel/plugin-transform-modules-amd": "^7.29.7",
+ "@babel/plugin-transform-modules-commonjs": "^7.29.7",
+ "@babel/plugin-transform-modules-systemjs": "^7.29.7",
+ "@babel/plugin-transform-modules-umd": "^7.29.7",
+ "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.7",
+ "@babel/plugin-transform-new-target": "^7.29.7",
+ "@babel/plugin-transform-nullish-coalescing-operator": "^7.29.7",
+ "@babel/plugin-transform-numeric-separator": "^7.29.7",
+ "@babel/plugin-transform-object-rest-spread": "^7.29.7",
+ "@babel/plugin-transform-object-super": "^7.29.7",
+ "@babel/plugin-transform-optional-catch-binding": "^7.29.7",
+ "@babel/plugin-transform-optional-chaining": "^7.29.7",
+ "@babel/plugin-transform-parameters": "^7.29.7",
+ "@babel/plugin-transform-private-methods": "^7.29.7",
+ "@babel/plugin-transform-private-property-in-object": "^7.29.7",
+ "@babel/plugin-transform-property-literals": "^7.29.7",
+ "@babel/plugin-transform-regenerator": "^7.29.7",
+ "@babel/plugin-transform-regexp-modifiers": "^7.29.7",
+ "@babel/plugin-transform-reserved-words": "^7.29.7",
+ "@babel/plugin-transform-shorthand-properties": "^7.29.7",
+ "@babel/plugin-transform-spread": "^7.29.7",
+ "@babel/plugin-transform-sticky-regex": "^7.29.7",
+ "@babel/plugin-transform-template-literals": "^7.29.7",
+ "@babel/plugin-transform-typeof-symbol": "^7.29.7",
+ "@babel/plugin-transform-unicode-escapes": "^7.29.7",
+ "@babel/plugin-transform-unicode-property-regex": "^7.29.7",
+ "@babel/plugin-transform-unicode-regex": "^7.29.7",
+ "@babel/plugin-transform-unicode-sets-regex": "^7.29.7",
+ "@babel/preset-modules": "0.1.6-no-external-plugins",
+ "babel-plugin-polyfill-corejs2": "^0.4.15",
+ "babel-plugin-polyfill-corejs3": "^0.14.0",
+ "babel-plugin-polyfill-regenerator": "^0.6.6",
+ "core-js-compat": "^3.48.0",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
+ "node_modules/@babel/preset-modules": {
+ "version": "0.1.6-no-external-plugins",
+ "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz",
+ "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.0.0",
+ "@babel/types": "^7.4.4",
+ "esutils": "^2.0.2"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0"
+ }
+ },
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
@@ -1666,6 +2972,16 @@
"url": "https://opencollective.com/libvips"
}
},
+ "node_modules/@isaacs/cliui": {
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz",
+ "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
@@ -1710,6 +3026,17 @@
"node": ">=6.0.0"
}
},
+ "node_modules/@jridgewell/source-map": {
+ "version": "0.3.11",
+ "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
+ "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.25"
+ }
+ },
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
@@ -2077,6 +3404,155 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@rollup/plugin-babel": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-6.1.0.tgz",
+ "integrity": "sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.18.6",
+ "@rollup/pluginutils": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0",
+ "@types/babel__core": "^7.1.9",
+ "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/babel__core": {
+ "optional": true
+ },
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/plugin-node-resolve": {
+ "version": "16.0.3",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz",
+ "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rollup/pluginutils": "^5.0.1",
+ "@types/resolve": "1.20.2",
+ "deepmerge": "^4.2.2",
+ "is-module": "^1.0.0",
+ "resolve": "^1.22.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^2.78.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/plugin-node-resolve/node_modules/resolve": {
+ "version": "1.22.12",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
+ "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/@rollup/plugin-replace": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz",
+ "integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rollup/pluginutils": "^5.0.1",
+ "magic-string": "^0.30.3"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/plugin-terser": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-1.0.0.tgz",
+ "integrity": "sha512-FnCxhTBx6bMOYQrar6C8h3scPt8/JwIzw3+AJ2K++6guogH5fYaIFia+zZuhqv0eo1RN7W1Pz630SyvLbDjhtQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "serialize-javascript": "^7.0.3",
+ "smob": "^1.0.0",
+ "terser": "^5.17.4"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^2.0.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/pluginutils": {
+ "version": "5.4.0",
+ "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz",
+ "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0",
+ "estree-walker": "^2.0.2",
+ "picomatch": "^4.0.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/pluginutils/node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz",
@@ -2524,6 +4000,22 @@
"@testing-library/dom": ">=7.21.4"
}
},
+ "node_modules/@trickfilm400/rollup-plugin-off-main-thread": {
+ "version": "3.0.0-pre1",
+ "resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz",
+ "integrity": "sha512-/67zpWDBLV+oYAEL682s1ktXL0HgqX76f6gaVGkGnVZlBbm1zd0v4Bz8MFF2GGhoX9rvfq3KSQHubFHwa6w6/Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "ejs": "^3.1.10",
+ "json5": "^2.2.3",
+ "magic-string": "^0.30.21",
+ "string.prototype.matchall": "^4.0.12"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
@@ -2663,6 +4155,20 @@
"@types/react": "^18.0.0"
}
},
+ "node_modules/@types/resolve": {
+ "version": "1.20.2",
+ "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
+ "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.60.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz",
@@ -3634,6 +5140,13 @@
"node": ">=12"
}
},
+ "node_modules/async": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
+ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/async-function": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
@@ -3651,6 +5164,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/at-least-node": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
+ "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">= 4.0.0"
+ }
+ },
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -3667,6 +5190,58 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/axe-core": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.0.tgz",
+ "integrity": "sha512-FTavr/7Ba0IptwGOPxnQvdyW2tAsdLBMTBXz7rKH6xJ2skpyxpBxyHkDdBs4lf69yRqYpkqCdfhnwS8YULGOmg==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/babel-plugin-polyfill-corejs2": {
+ "version": "0.4.17",
+ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz",
+ "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.28.6",
+ "@babel/helper-define-polyfill-provider": "^0.6.8",
+ "semver": "^6.3.1"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
+ }
+ },
+ "node_modules/babel-plugin-polyfill-corejs3": {
+ "version": "0.14.2",
+ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz",
+ "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-define-polyfill-provider": "^0.6.8",
+ "core-js-compat": "^3.48.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
+ }
+ },
+ "node_modules/babel-plugin-polyfill-regenerator": {
+ "version": "0.6.8",
+ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz",
+ "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-define-polyfill-provider": "^0.6.8"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
+ }
+ },
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -3740,6 +5315,13 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "node_modules/buffer-from": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
@@ -3917,6 +5499,23 @@
"node": ">= 0.8"
}
},
+ "node_modules/commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/common-tags": {
+ "version": "1.8.2",
+ "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz",
+ "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -3931,6 +5530,20 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/core-js-compat": {
+ "version": "3.49.0",
+ "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz",
+ "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "browserslist": "^4.28.1"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/core-js"
+ }
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3946,6 +5559,16 @@
"node": ">= 8"
}
},
+ "node_modules/crypto-random-string": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz",
+ "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/css.escape": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
@@ -4090,6 +5713,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/deepmerge": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
@@ -4212,6 +5845,22 @@
"node": ">= 0.4"
}
},
+ "node_modules/ejs": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
+ "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "jake": "^10.8.5"
+ },
+ "bin": {
+ "ejs": "bin/cli.js"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/electron-to-chromium": {
"version": "1.5.368",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.368.tgz",
@@ -4787,6 +6436,19 @@
"node": ">=0.10.0"
}
},
+ "node_modules/eta": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/eta/-/eta-4.6.0.tgz",
+ "integrity": "sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/bgub/eta?sponsor=1"
+ }
+ },
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
@@ -4828,6 +6490,23 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/fast-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
+ "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -4865,6 +6544,39 @@
"node": ">=16.0.0"
}
},
+ "node_modules/filelist": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
+ "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "minimatch": "^5.0.1"
+ }
+ },
+ "node_modules/filelist/node_modules/brace-expansion": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
+ "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/filelist/node_modules/minimatch": {
+ "version": "5.1.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
+ "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -4925,6 +6637,23 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/foreground-child": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
+ "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "cross-spawn": "^7.0.6",
+ "signal-exit": "^4.0.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
@@ -4942,6 +6671,22 @@
"node": ">= 6"
}
},
+ "node_modules/fs-extra": {
+ "version": "9.1.0",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
+ "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "at-least-node": "^1.0.0",
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -5043,6 +6788,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/get-own-enumerable-property-symbols": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz",
+ "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
@@ -5088,6 +6840,31 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
+ "node_modules/glob": {
+ "version": "11.1.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz",
+ "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==",
+ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "foreground-child": "^3.3.1",
+ "jackspeak": "^4.1.1",
+ "minimatch": "^10.1.1",
+ "minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
+ "path-scurry": "^2.0.0"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -5101,6 +6878,45 @@
"node": ">=10.13.0"
}
},
+ "node_modules/glob/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/glob/node_modules/brace-expansion": {
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
+ "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/glob/node_modules/minimatch": {
+ "version": "10.2.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
+ "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.5"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/global-agent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
@@ -5171,6 +6987,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/guid-typescript": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz",
@@ -5324,6 +7147,13 @@
"node": ">=0.10.0"
}
},
+ "node_modules/idb": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
+ "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -5616,6 +7446,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-module": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
+ "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/is-negative-zero": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz",
@@ -5646,6 +7483,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-obj": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz",
+ "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
@@ -5672,6 +7519,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-regexp": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz",
+ "integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/is-set": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz",
@@ -5701,6 +7558,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-stream": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+ "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/is-string": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz",
@@ -5812,6 +7682,40 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/jackspeak": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz",
+ "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/cliui": "^9.0.0"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/jake": {
+ "version": "10.9.4",
+ "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
+ "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "async": "^3.2.6",
+ "filelist": "^1.0.4",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "jake": "bin/cli.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5936,6 +7840,29 @@
"node": ">=6"
}
},
+ "node_modules/jsonfile": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
+ "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "universalify": "^2.0.0"
+ },
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
+ "node_modules/jsonpointer": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz",
+ "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -5946,6 +7873,16 @@
"json-buffer": "3.0.1"
}
},
+ "node_modules/leven": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
+ "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -5976,6 +7913,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/lodash.debounce": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
+ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -5983,6 +7927,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/lodash.sortby": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
+ "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
@@ -6445,6 +8396,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/package-json-from-dist": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
+ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0"
+ },
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -6498,6 +8456,33 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/path-scurry": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
+ "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "lru-cache": "^11.0.0",
+ "minipass": "^7.1.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/path-scurry/node_modules/lru-cache": {
+ "version": "11.5.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
+ "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -6540,7 +8525,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -6619,6 +8603,19 @@
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
+ "node_modules/pretty-bytes": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz",
+ "integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
@@ -6762,6 +8759,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/regenerate": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
+ "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/regenerate-unicode-properties": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz",
+ "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "regenerate": "^1.4.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
@@ -6783,6 +8800,54 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/regexpu-core": {
+ "version": "6.4.0",
+ "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz",
+ "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "regenerate": "^1.4.2",
+ "regenerate-unicode-properties": "^10.2.2",
+ "regjsgen": "^0.8.0",
+ "regjsparser": "^0.13.0",
+ "unicode-match-property-ecmascript": "^2.0.0",
+ "unicode-match-property-value-ecmascript": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/regjsgen": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz",
+ "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/regjsparser": {
+ "version": "0.13.1",
+ "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.1.tgz",
+ "integrity": "sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "jsesc": "~3.1.0"
+ },
+ "bin": {
+ "regjsparser": "bin/parser"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/resolve": {
"version": "2.0.0-next.7",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
@@ -7011,6 +9076,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/serialize-javascript": {
+ "version": "7.0.5",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz",
+ "integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -7222,6 +9297,43 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/smob": {
+ "version": "1.6.2",
+ "resolved": "https://registry.npmjs.org/smob/-/smob-1.6.2.tgz",
+ "integrity": "sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/source-map": {
+ "version": "0.8.0-beta.0",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz",
+ "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==",
+ "deprecated": "The work that was done in this beta branch won't be included in future versions",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "whatwg-url": "^7.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -7232,6 +9344,56 @@
"node": ">=0.10.0"
}
},
+ "node_modules/source-map-support": {
+ "version": "0.5.21",
+ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
+ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "buffer-from": "^1.0.0",
+ "source-map": "^0.6.0"
+ }
+ },
+ "node_modules/source-map-support/node_modules/source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/source-map/node_modules/tr46": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz",
+ "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/source-map/node_modules/webidl-conversions": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
+ "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==",
+ "dev": true,
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/source-map/node_modules/whatwg-url": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz",
+ "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lodash.sortby": "^4.7.0",
+ "tr46": "^1.0.1",
+ "webidl-conversions": "^4.0.2"
+ }
+ },
"node_modules/sprintf-js": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
@@ -7273,6 +9435,34 @@
"node": ">= 0.4"
}
},
+ "node_modules/string.prototype.matchall": {
+ "version": "4.0.12",
+ "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
+ "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.6",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0",
+ "get-intrinsic": "^1.2.6",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "internal-slot": "^1.1.0",
+ "regexp.prototype.flags": "^1.5.3",
+ "set-function-name": "^2.0.2",
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/string.prototype.trim": {
"version": "1.2.11",
"resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz",
@@ -7333,6 +9523,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/stringify-object": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz",
+ "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "get-own-enumerable-property-symbols": "^3.0.0",
+ "is-obj": "^1.0.1",
+ "is-regexp": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/strip-bom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
@@ -7343,6 +9548,16 @@
"node": ">=4"
}
},
+ "node_modules/strip-comments": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz",
+ "integrity": "sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/strip-indent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
@@ -7447,6 +9662,68 @@
"node": ">=18"
}
},
+ "node_modules/temp-dir": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
+ "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/tempy": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.6.0.tgz",
+ "integrity": "sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-stream": "^2.0.0",
+ "temp-dir": "^2.0.0",
+ "type-fest": "^0.16.0",
+ "unique-string": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/tempy/node_modules/type-fest": {
+ "version": "0.16.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz",
+ "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/terser": {
+ "version": "5.48.0",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz",
+ "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "peer": true,
+ "dependencies": {
+ "@jridgewell/source-map": "^0.3.3",
+ "acorn": "^8.15.0",
+ "commander": "^2.20.0",
+ "source-map-support": "~0.5.20"
+ },
+ "bin": {
+ "terser": "bin/terser"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -7767,6 +10044,73 @@
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"license": "MIT"
},
+ "node_modules/unicode-canonical-property-names-ecmascript": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz",
+ "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/unicode-match-property-ecmascript": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz",
+ "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "unicode-canonical-property-names-ecmascript": "^2.0.0",
+ "unicode-property-aliases-ecmascript": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/unicode-match-property-value-ecmascript": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz",
+ "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/unicode-property-aliases-ecmascript": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz",
+ "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/unique-string": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz",
+ "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "crypto-random-string": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/universalify": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
+ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
"node_modules/unrs-resolver": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz",
@@ -7805,6 +10149,17 @@
"@unrs/resolver-binding-win32-x64-msvc": "1.12.2"
}
},
+ "node_modules/upath": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz",
+ "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4",
+ "yarn": "*"
+ }
+ },
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -7945,6 +10300,37 @@
"url": "https://opencollective.com/vitest"
}
},
+ "node_modules/vite-plugin-pwa": {
+ "version": "0.21.2",
+ "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.21.2.tgz",
+ "integrity": "sha512-vFhH6Waw8itNu37hWUJxL50q+CBbNcMVzsKaYHQVrfxTt3ihk3PeLO22SbiP1UNWzcEPaTQv+YVxe4G0KOjAkg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.3.6",
+ "pretty-bytes": "^6.1.1",
+ "tinyglobby": "^0.2.10",
+ "workbox-build": "^7.3.0",
+ "workbox-window": "^7.3.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "@vite-pwa/assets-generator": "^0.2.6",
+ "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0",
+ "workbox-build": "^7.3.0",
+ "workbox-window": "^7.3.0"
+ },
+ "peerDependenciesMeta": {
+ "@vite-pwa/assets-generator": {
+ "optional": true
+ }
+ }
+ },
"node_modules/vitest": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz",
@@ -8211,6 +10597,268 @@
"node": ">=0.10.0"
}
},
+ "node_modules/workbox-background-sync": {
+ "version": "7.4.1",
+ "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.1.tgz",
+ "integrity": "sha512-HhT7KE8tOWDm02wRNshXUnUPofMlhenF2DBdUnDPOubhizzPeItkYTmAB6td1Z2cjYPa98vzEiPLEuzn5hN66g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "idb": "^7.0.1",
+ "workbox-core": "7.4.1"
+ }
+ },
+ "node_modules/workbox-broadcast-update": {
+ "version": "7.4.1",
+ "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.4.1.tgz",
+ "integrity": "sha512-uAlgslKLvbQY+suirIdnBCSYrcgBhjp81Nj4l1lj/Jmj0MJO2CJERnCJjT0GFVwmReV0N+zs78K6gqd5gr9/+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-core": "7.4.1"
+ }
+ },
+ "node_modules/workbox-build": {
+ "version": "7.4.1",
+ "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-7.4.1.tgz",
+ "integrity": "sha512-SDhxIvEAde9Gy/5w4Yo1Jh/M49Z0qE3q0oteyE8zGq0DScxFqVBcCtIXFuLtmtxRQZCMbf0prco4VyEu3KBQuw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@apideck/better-ajv-errors": "^0.3.1",
+ "@babel/core": "^7.24.4",
+ "@babel/preset-env": "^7.11.0",
+ "@babel/runtime": "^7.11.2",
+ "@rollup/plugin-babel": "^6.1.0",
+ "@rollup/plugin-node-resolve": "^16.0.3",
+ "@rollup/plugin-replace": "^6.0.3",
+ "@rollup/plugin-terser": "^1.0.0",
+ "@trickfilm400/rollup-plugin-off-main-thread": "^3.0.0-pre1",
+ "ajv": "^8.6.0",
+ "common-tags": "^1.8.0",
+ "eta": "^4.5.1",
+ "fast-json-stable-stringify": "^2.1.0",
+ "fs-extra": "^9.0.1",
+ "glob": "^11.0.1",
+ "pretty-bytes": "^5.3.0",
+ "rollup": "^4.53.3",
+ "source-map": "^0.8.0-beta.0",
+ "stringify-object": "^3.3.0",
+ "strip-comments": "^2.0.1",
+ "tempy": "^0.6.0",
+ "upath": "^1.2.0",
+ "workbox-background-sync": "7.4.1",
+ "workbox-broadcast-update": "7.4.1",
+ "workbox-cacheable-response": "7.4.1",
+ "workbox-core": "7.4.1",
+ "workbox-expiration": "7.4.1",
+ "workbox-google-analytics": "7.4.1",
+ "workbox-navigation-preload": "7.4.1",
+ "workbox-precaching": "7.4.1",
+ "workbox-range-requests": "7.4.1",
+ "workbox-recipes": "7.4.1",
+ "workbox-routing": "7.4.1",
+ "workbox-strategies": "7.4.1",
+ "workbox-streams": "7.4.1",
+ "workbox-sw": "7.4.1",
+ "workbox-window": "7.4.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/workbox-build/node_modules/@apideck/better-ajv-errors": {
+ "version": "0.3.7",
+ "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.7.tgz",
+ "integrity": "sha512-TajUJwGWbDwkCx/CZi7tRE8PVB7simCvKJfHUsSdvps+aTM/PDPP4gkLmKnc+x3CE//y9i/nj74GqdL/hwk7Iw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "jsonpointer": "^5.0.1",
+ "leven": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "ajv": ">=8"
+ }
+ },
+ "node_modules/workbox-build/node_modules/ajv": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/workbox-build/node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/workbox-build/node_modules/pretty-bytes": {
+ "version": "5.6.0",
+ "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
+ "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/workbox-cacheable-response": {
+ "version": "7.4.1",
+ "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.4.1.tgz",
+ "integrity": "sha512-8xaFoJdDc2OjrlbbL3gEeBO1WKcMwRqwLRupgqahYXu75yXajPLuwrbXMrIGZuWYXrQwk0xDjOxZ/ujCy/oJYw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-core": "7.4.1"
+ }
+ },
+ "node_modules/workbox-core": {
+ "version": "7.4.1",
+ "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.1.tgz",
+ "integrity": "sha512-DT+vu46eh/2vRsSHTY4Xmc32Z1rr9PRlQUXr1Dx30ZuXRWwOsvZgGgcwxcasubQLQmbTNYZjv44LkBAQ4tT5tQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/workbox-expiration": {
+ "version": "7.4.1",
+ "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.4.1.tgz",
+ "integrity": "sha512-lRKUF7b+OGbeXkQk1s6MHXOa3d7Xxf7Of31W6c6hCfipfIyrtdWZ89stq21AHZMaoG7VNFoHply4Ox+rU31TWg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "idb": "^7.0.1",
+ "workbox-core": "7.4.1"
+ }
+ },
+ "node_modules/workbox-google-analytics": {
+ "version": "7.4.1",
+ "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.4.1.tgz",
+ "integrity": "sha512-Mks1JwLEt++ZAkF6sS1OpSh9RtAMIsiDgRpK+codiHGIPXeaUOgi4cPc3GFadUl8V5QPeypEk8Oxgl3HlwVzHw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-background-sync": "7.4.1",
+ "workbox-core": "7.4.1",
+ "workbox-routing": "7.4.1",
+ "workbox-strategies": "7.4.1"
+ }
+ },
+ "node_modules/workbox-navigation-preload": {
+ "version": "7.4.1",
+ "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-7.4.1.tgz",
+ "integrity": "sha512-C4KVsjPcYKJOhr631AxR9XoG2rLF3QiTk5aMv36MXOjtWvm8axwNFAtKUPGsWUwLXXAMgYM1En7fsvndaXeXRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-core": "7.4.1"
+ }
+ },
+ "node_modules/workbox-precaching": {
+ "version": "7.4.1",
+ "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.1.tgz",
+ "integrity": "sha512-cdr/9qByww7yzEp7zg/qI4ukUrrNjQLgN+ONQRpjy/VqGQXwkgHwr00KksGJK8v0VifwDXBb8a4cWNZH71jn3Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-core": "7.4.1",
+ "workbox-routing": "7.4.1",
+ "workbox-strategies": "7.4.1"
+ }
+ },
+ "node_modules/workbox-range-requests": {
+ "version": "7.4.1",
+ "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-7.4.1.tgz",
+ "integrity": "sha512-7i2oxAUE82gHdAJBCAQ04JzNOdRPqzuOzGfoUyJpFSmeqBNYGPrAH8GPoPjUQTfp+NycwrD2H68VtuF8qxv0vQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-core": "7.4.1"
+ }
+ },
+ "node_modules/workbox-recipes": {
+ "version": "7.4.1",
+ "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-7.4.1.tgz",
+ "integrity": "sha512-gnbVfmV4/TtmQaM4x9AtuXhcdstJsep3XMVeztOrQVPT+R6+6DeBjGTCQ7fFCXm+4GEHUA5VEBTyi5+4gWGeog==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-cacheable-response": "7.4.1",
+ "workbox-core": "7.4.1",
+ "workbox-expiration": "7.4.1",
+ "workbox-precaching": "7.4.1",
+ "workbox-routing": "7.4.1",
+ "workbox-strategies": "7.4.1"
+ }
+ },
+ "node_modules/workbox-routing": {
+ "version": "7.4.1",
+ "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.1.tgz",
+ "integrity": "sha512-yubJGErZOusuidAenaL5ypfhQOa7urxP/f8E0ws7FPb4039RiWXUWBAyUkmUoOL/BcQGen3h0J8872d51IYxtA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-core": "7.4.1"
+ }
+ },
+ "node_modules/workbox-strategies": {
+ "version": "7.4.1",
+ "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.1.tgz",
+ "integrity": "sha512-GZxpaw9NbmOelj7667uZ2kpk5BFpOGbO4X0qjwh5ls8XQ8C+Lha5LQchTiUzsTFSS+NlUpftYAyOVXvQUrcqOQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-core": "7.4.1"
+ }
+ },
+ "node_modules/workbox-streams": {
+ "version": "7.4.1",
+ "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-7.4.1.tgz",
+ "integrity": "sha512-HWWtraKUbJknd9kgqGcpQ3G114HOPYvqs8HaJMDs2ebLNAimDkVDaWfAXE6Ybl+m8U6KsCE6pWyLYuigWmnAXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-core": "7.4.1",
+ "workbox-routing": "7.4.1"
+ }
+ },
+ "node_modules/workbox-sw": {
+ "version": "7.4.1",
+ "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-7.4.1.tgz",
+ "integrity": "sha512-fez5f2DUlDJWTFYkCWQpY10N8gtztd849NswCbVFk0QlcSM4HT5A8x4g4ii650yem4I8tHY0R7JZahwp3ltIPw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/workbox-window": {
+ "version": "7.4.1",
+ "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-7.4.1.tgz",
+ "integrity": "sha512-notZDH2u8VXaqyuD7xaqIfEFi6SRM4SUSd7ewe9PDsVqADuepxX2ZMY3uvuZGxzY5ZOsGC/vD3A/3smFtJt4/A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/trusted-types": "^2.0.2",
+ "workbox-core": "7.4.1"
+ }
+ },
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
diff --git a/package.json b/package.json
index b52e454..3b66517 100644
--- a/package.json
+++ b/package.json
@@ -41,6 +41,7 @@
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
+ "axe-core": "^4.12.0",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-typescript": "^3.7.0",
@@ -54,6 +55,7 @@
"typescript": "^5.7.2",
"typescript-eslint": "^8.18.2",
"vite": "^6.0.7",
+ "vite-plugin-pwa": "^0.21.2",
"vitest": "^3.0.0"
}
}
diff --git a/public/icon.svg b/public/icon.svg
new file mode 100644
index 0000000..b5015e5
--- /dev/null
+++ b/public/icon.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/vite.config.ts b/vite.config.ts
index fe2472e..c2c0495 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,9 +1,38 @@
import react from '@vitejs/plugin-react';
+import { VitePWA } from 'vite-plugin-pwa';
import { defineConfig } from 'vitest/config';
// https://vite.dev/config/
export default defineConfig({
- plugins: [react()],
+ plugins: [
+ react(),
+ VitePWA({
+ registerType: 'autoUpdate',
+ injectRegister: 'auto',
+ manifest: {
+ name: 'Faturlens',
+ short_name: 'Faturlens',
+ description: 'Browser-native invoice OCR. Fully client-side.',
+ display: 'standalone',
+ background_color: '#161616',
+ theme_color: '#1a7f37',
+ icons: [
+ { src: 'icon.svg', sizes: '192x192', type: 'image/svg+xml', purpose: 'any' },
+ { src: 'icon.svg', sizes: '512x512', type: 'image/svg+xml', purpose: 'any maskable' },
+ ],
+ },
+ workbox: {
+ // Precache the app shell only. The model is handled by the Cache API
+ // layer, and the giant ORT WASM is fetched on demand — keep both out of
+ // the precache.
+ globPatterns: ['**/*.{js,css,html,svg,woff2}'],
+ globIgnores: ['**/*.wasm'],
+ maximumFileSizeToCacheInBytes: 4 * 1024 * 1024,
+ // The service worker must NOT intercept Hugging Face CDN requests.
+ navigateFallbackDenylist: [/^https:\/\/huggingface\.co/],
+ },
+ }),
+ ],
test: {
globals: true,
environment: 'jsdom',
From 8338dfce2423679b2c10fcd7e9917254e847b2af Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:24:51 +0400
Subject: [PATCH 56/63] feat(ui): surface version, model, and prompt versions
in footer
Co-Authored-By: Claude Opus 4.8
---
src/lib/version.ts | 8 ++++++++
src/ui/Footer.tsx | 20 ++++++++++++++++++++
2 files changed, 28 insertions(+)
create mode 100644 src/lib/version.ts
create mode 100644 src/ui/Footer.tsx
diff --git a/src/lib/version.ts b/src/lib/version.ts
new file mode 100644
index 0000000..5642c75
--- /dev/null
+++ b/src/lib/version.ts
@@ -0,0 +1,8 @@
+// Version surface shown in the footer and stamped into exports.
+
+export const APP_VERSION = '1.0.0';
+export const MODEL_ID = 'LFM2.5-VL-1.6B-ONNX';
+export const PROMPT_VERSIONS = {
+ pass1: 'PASS1_PROMPT_V1',
+ pass2: 'PASS2_PROMPT_V1',
+} as const;
diff --git a/src/ui/Footer.tsx b/src/ui/Footer.tsx
new file mode 100644
index 0000000..1b9038f
--- /dev/null
+++ b/src/ui/Footer.tsx
@@ -0,0 +1,20 @@
+import { APP_VERSION, MODEL_ID, PROMPT_VERSIONS } from '../lib/version.ts';
+
+/** App footer: version, model id, and prompt versions. */
+export function Footer(): React.JSX.Element {
+ return (
+
+ Faturlens v{APP_VERSION} · {MODEL_ID} · prompts {PROMPT_VERSIONS.pass1}/
+ {PROMPT_VERSIONS.pass2}
+
+ );
+}
From b2750ba954a65adbc6f1a6b1a37e9545ed45e9cb Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:24:51 +0400
Subject: [PATCH 57/63] feat(ui): add global error boundary with copyable
diagnostics
Co-Authored-By: Claude Opus 4.8
---
src/main.tsx | 9 ++++---
src/ui/ErrorBoundary.tsx | 53 ++++++++++++++++++++++++++++++++++++++++
2 files changed, 59 insertions(+), 3 deletions(-)
create mode 100644 src/ui/ErrorBoundary.tsx
diff --git a/src/main.tsx b/src/main.tsx
index e65d929..32a0655 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -2,6 +2,7 @@ import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App.tsx';
import { DeviceProfileProvider } from './capability/useDeviceProfile.tsx';
+import { ErrorBoundary } from './ui/ErrorBoundary.tsx';
import './ui/tokens.css';
import './index.css';
@@ -12,8 +13,10 @@ if (!rootElement) {
createRoot(rootElement).render(
-
-
-
+
+
+
+
+
,
);
diff --git a/src/ui/ErrorBoundary.tsx b/src/ui/ErrorBoundary.tsx
new file mode 100644
index 0000000..9aa1244
--- /dev/null
+++ b/src/ui/ErrorBoundary.tsx
@@ -0,0 +1,53 @@
+import { Component, type ErrorInfo, type ReactNode } from 'react';
+import { APP_VERSION } from '../lib/version.ts';
+
+interface Props {
+ children: ReactNode;
+}
+
+interface State {
+ error: Error | null;
+ info: string;
+}
+
+/** Global error boundary with a copyable diagnostic block (no raw stack in UI). */
+export class ErrorBoundary extends Component {
+ override state: State = { error: null, info: '' };
+
+ static getDerivedStateFromError(error: Error): State {
+ return { error, info: '' };
+ }
+
+ override componentDidCatch(error: Error, info: ErrorInfo): void {
+ this.setState({ error, info: info.componentStack ?? '' });
+ }
+
+ private diagnostic(): string {
+ const { error, info } = this.state;
+ return [
+ `Faturlens ${APP_VERSION}`,
+ `Error: ${error?.message ?? 'unknown'}`,
+ `Stack: ${error?.stack ?? 'n/a'}`,
+ `Component: ${info}`,
+ ].join('\n');
+ }
+
+ override render(): ReactNode {
+ if (!this.state.error) return this.props.children;
+ return (
+
+
Something went wrong
+
Faturlens hit an unexpected error. Your data stays on this device.
+
{
+ const clipboard = (globalThis.navigator as Navigator | undefined)?.clipboard;
+ void clipboard?.writeText(this.diagnostic());
+ }}
+ >
+ Copy diagnostics
+
+
+ );
+ }
+}
From ffc749c49707dee2ebd5e5ab32a7eab229e27f16 Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:24:51 +0400
Subject: [PATCH 58/63] feat(ui): add first-run welcome with demo loader and
failure cards
Co-Authored-By: Claude Opus 4.8
---
src/App.tsx | 15 ++++++++--
src/ui/Welcome.module.css | 62 +++++++++++++++++++++++++++++++++++++++
src/ui/Welcome.tsx | 48 ++++++++++++++++++++++++++++++
3 files changed, 122 insertions(+), 3 deletions(-)
create mode 100644 src/ui/Welcome.module.css
create mode 100644 src/ui/Welcome.tsx
diff --git a/src/App.tsx b/src/App.tsx
index 3f271f5..a05d5c7 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,7 +1,10 @@
+import { useCallback } from 'react';
import styles from './App.module.css';
import { DeviceReport } from './ui/DeviceReport.tsx';
+import { Footer } from './ui/Footer.tsx';
import { InferenceHarness } from './ui/InferenceHarness.tsx';
import { ModelDownloadGate } from './ui/ModelDownloadGate.tsx';
+import { Welcome } from './ui/Welcome.tsx';
function useFlag(name: string): boolean {
if (typeof window === 'undefined') return false;
@@ -13,6 +16,13 @@ export function App(): React.JSX.Element {
const model = useFlag('model');
const harness = useFlag('harness');
+ const onUpload = useCallback(() => {
+ document.getElementById('faturlens-file-input')?.click();
+ }, []);
+ const onLoadDemos = useCallback(() => {
+ void fetch('/demo/demo.json');
+ }, []);
+
const content = (
Faturlens
@@ -20,11 +30,10 @@ export function App(): React.JSX.Element {
Browser-native invoice OCR. Fully client-side — no data leaves your machine.
{diag ? : null}
- {harness ? : null}
+ {harness ? : }
+
);
- // The model gate is opt-in (?model) until later phases wire it into the
- // processing flow, so routine page loads do not trigger a multi-GB download.
return model ? {content} : content;
}
diff --git a/src/ui/Welcome.module.css b/src/ui/Welcome.module.css
new file mode 100644
index 0000000..22a90cf
--- /dev/null
+++ b/src/ui/Welcome.module.css
@@ -0,0 +1,62 @@
+.welcome {
+ max-width: 40rem;
+ margin: 2rem auto;
+ padding: 2rem;
+ border: 1px solid var(--fl-border, #d8d8d8);
+ border-radius: var(--fl-radius-lg, 0.75rem);
+ background: var(--fl-surface, #fafafa);
+ text-align: center;
+}
+
+.welcome h2 {
+ margin-top: 0;
+}
+
+.actions {
+ display: flex;
+ gap: 0.75rem;
+ justify-content: center;
+ margin-top: 1.5rem;
+ flex-wrap: wrap;
+}
+
+.primary,
+.secondary {
+ font: inherit;
+ padding: 0.5rem 1.1rem;
+ border-radius: var(--fl-radius, 0.5rem);
+ cursor: pointer;
+}
+.primary {
+ border: 1px solid var(--fl-ok, #1a7f37);
+ background: var(--fl-ok, #1a7f37);
+ color: #fff;
+}
+.secondary {
+ border: 1px solid var(--fl-border, #bbb);
+ background: transparent;
+ color: inherit;
+}
+
+.card {
+ max-width: 36rem;
+ margin: 1.5rem auto;
+ padding: 1.25rem 1.5rem;
+ border-radius: var(--fl-radius-lg, 0.75rem);
+ border: 1px solid var(--fl-error, #b42318);
+ background: var(--fl-error-bg, #fde8e8);
+ color: var(--fl-error, #8a1f1f);
+}
+.card h3 {
+ margin: 0 0 0.5rem;
+}
+.card button {
+ font: inherit;
+ margin-top: 0.75rem;
+ padding: 0.4rem 0.9rem;
+ border-radius: var(--fl-radius, 0.5rem);
+ border: 1px solid currentColor;
+ background: transparent;
+ color: inherit;
+ cursor: pointer;
+}
diff --git a/src/ui/Welcome.tsx b/src/ui/Welcome.tsx
new file mode 100644
index 0000000..987c56d
--- /dev/null
+++ b/src/ui/Welcome.tsx
@@ -0,0 +1,48 @@
+import styles from './Welcome.module.css';
+
+export interface WelcomeProps {
+ onLoadDemos: () => void;
+ onUpload: () => void;
+}
+
+/** First-run welcome with a demo loader. */
+export function Welcome({ onLoadDemos, onUpload }: WelcomeProps): React.JSX.Element {
+ return (
+
+ Welcome to Faturlens
+
+ Drop an invoice (PNG, JPEG, WebP, or PDF) and Faturlens extracts structured data entirely on
+ your device — no server, no upload, no data leaving your machine.
+
+
+
+ Upload an invoice
+
+
+ Load demo invoices
+
+
+
+ );
+}
+
+export interface FailureCardProps {
+ title: string;
+ message: string;
+ onRetry?: () => void;
+}
+
+/** Friendly failure card (unsupported browser, denied storage, fetch failure). */
+export function FailureCard({ title, message, onRetry }: FailureCardProps): React.JSX.Element {
+ return (
+
+
{title}
+
{message}
+ {onRetry ? (
+
+ Retry
+
+ ) : null}
+
+ );
+}
From b037dc17814d0129a622510d128312f995747dea Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:24:51 +0400
Subject: [PATCH 59/63] feat(demo): add clean, bilingual, multi-page, and
broken-math demo invoices
Co-Authored-By: Claude Opus 4.8
---
public/demo/README.md | 16 +++++++++++-----
public/demo/bilingual.html | 29 ++++++++++++++++++++++++++++
public/demo/broken-math.html | 26 +++++++++++++++++++++++++
public/demo/clean-en.html | 37 ++++++++++++++++++++++++++++++++++++
public/demo/demo.json | 14 ++++++++++++++
public/demo/multipage.html | 36 +++++++++++++++++++++++++++++++++++
6 files changed, 153 insertions(+), 5 deletions(-)
create mode 100644 public/demo/bilingual.html
create mode 100644 public/demo/broken-math.html
create mode 100644 public/demo/clean-en.html
create mode 100644 public/demo/demo.json
create mode 100644 public/demo/multipage.html
diff --git a/public/demo/README.md b/public/demo/README.md
index 3f0b4a2..1b46542 100644
--- a/public/demo/README.md
+++ b/public/demo/README.md
@@ -1,9 +1,15 @@
# demo
-Synthetic demo invoices loaded by the first-run welcome screen.
+Synthetic demo invoices loaded by the first-run welcome screen. All synthetic —
+no real vendor data.
-Four invoices: a clean English invoice, a bilingual Arabic–English invoice, a
-multi-page PDF, and a deliberately broken-math invoice that showcases the
-validation layer. All synthetic — no real vendor data.
+- `clean-en.html` — clean English invoice (arithmetic balances).
+- `bilingual.html` — Arabic–English bilingual invoice.
+- `multipage.html` — multi-page invoice.
+- `broken-math.html` — deliberately broken arithmetic to showcase the validation
+ layer (subtotal and total do not add up → red findings).
+- `demo.json` — manifest consumed by the loader.
-_Populated in Phase 13._
+The `.html` files are the **sources**; they are rendered to PNG/PDF (the formats
+the pipeline ingests) at build time. Keeping the HTML in-repo makes the demos
+easy to tweak without binary churn.
diff --git a/public/demo/bilingual.html b/public/demo/bilingual.html
new file mode 100644
index 0000000..0a489ef
--- /dev/null
+++ b/public/demo/bilingual.html
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+ الإمارات للتجارة ش.ذ.م.م / Emirates Trading LLC
+ دبي، الإمارات العربية المتحدة · الرقم الضريبي: 100987654300003
+ Invoice # / رقم الفاتورة: INV-AE-7781 · Date / التاريخ: 2026-03-03 · Currency / العملة: AED
+
+
+ Description / الوصف Qty / الكمية Unit / السعر Amount / المبلغ
+
+
+ خدمات استشارية / Advisory 4 300.00 1200.00
+ توريد معدات / Equipment 1 800.00 800.00
+
+
+ Subtotal / المجموع الفرعي: 2000.00 · VAT 5% / ضريبة القيمة المضافة: 100.00 · Total / الإجمالي: 2100.00
+
+
diff --git a/public/demo/broken-math.html b/public/demo/broken-math.html
new file mode 100644
index 0000000..ac258f8
--- /dev/null
+++ b/public/demo/broken-math.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+ Initech Solutions
+ TRN: 100555666700003 · Invoice #: INI-9001 · Date: 2026-05-20 · Currency: AED
+
+
+ Description Qty Unit Price Amount
+
+ Software license 3 200.00 700.00
+ Onboarding 1 500.00 500.00
+
+
+
+ Subtotal: 1000.00 · VAT (5%): 60.00 · Total: 1500.00
+
+
diff --git a/public/demo/clean-en.html b/public/demo/clean-en.html
new file mode 100644
index 0000000..510de20
--- /dev/null
+++ b/public/demo/clean-en.html
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+ Acme Trading FZE
+
+ Dubai, United Arab Emirates · TRN: 100123456700003
+ Invoice #: INV-2026-0042 · Issue date: 2026-01-15 · Due date: 2026-02-14 · Currency: AED
+
+
+
+ Description Qty Unit Price Amount
+
+
+ Consulting services 10 150.00 1500.00
+ Support package 2 250.00 500.00
+
+
+
+ Subtotal 2000.00
+ VAT (5%) 100.00
+ Total 2100.00
+
+
+
diff --git a/public/demo/demo.json b/public/demo/demo.json
new file mode 100644
index 0000000..a934108
--- /dev/null
+++ b/public/demo/demo.json
@@ -0,0 +1,14 @@
+{
+ "version": 1,
+ "note": "Synthetic demo invoices. No real vendor data. HTML sources are rendered to PNG/PDF at build time.",
+ "invoices": [
+ { "id": "clean-en", "title": "Clean English invoice", "source": "clean-en.html" },
+ { "id": "bilingual", "title": "Bilingual Arabic–English invoice", "source": "bilingual.html" },
+ { "id": "multipage", "title": "Multi-page invoice", "source": "multipage.html" },
+ {
+ "id": "broken-math",
+ "title": "Broken-math invoice (validation showcase)",
+ "source": "broken-math.html"
+ }
+ ]
+}
diff --git a/public/demo/multipage.html b/public/demo/multipage.html
new file mode 100644
index 0000000..7055fac
--- /dev/null
+++ b/public/demo/multipage.html
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
Globex Industrial — Page 1
+
Invoice #: GLX-5500 · Date: 2026-04-10 · Currency: USD
+
+ Description Qty Unit Amount
+
+ Steel beams 20 75.00 1500.00
+ Fasteners 100 2.50 250.00
+
+
+
+
+
Globex Industrial — Page 2
+
+ Description Qty Unit Amount
+
+ Coating service 1 300.00 300.00
+
+
+
Subtotal: 2050.00 · Tax: 0.00 · Total: 2050.00
+
+
+
From a1e4b7ff8976430845712a63701702dc70f5d801 Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:24:51 +0400
Subject: [PATCH 60/63] test(a11y): add axe checks and version/error-boundary
tests
Co-Authored-By: Claude Opus 4.8
---
src/ui/Footer.test.tsx | 30 ++++++++++++++++++++++++++++++
src/ui/a11y.test.tsx | 35 +++++++++++++++++++++++++++++++++++
2 files changed, 65 insertions(+)
create mode 100644 src/ui/Footer.test.tsx
create mode 100644 src/ui/a11y.test.tsx
diff --git a/src/ui/Footer.test.tsx b/src/ui/Footer.test.tsx
new file mode 100644
index 0000000..03367f3
--- /dev/null
+++ b/src/ui/Footer.test.tsx
@@ -0,0 +1,30 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { APP_VERSION } from '../lib/version.ts';
+import { ErrorBoundary } from './ErrorBoundary.tsx';
+import { Footer } from './Footer.tsx';
+
+describe('Footer', () => {
+ it('shows version, model id, and prompt versions', () => {
+ render();
+ expect(screen.getByText(new RegExp(`v${APP_VERSION}`))).toBeInTheDocument();
+ expect(screen.getByText(/LFM2\.5-VL-1\.6B-ONNX/)).toBeInTheDocument();
+ expect(screen.getByText(/PASS1_PROMPT_V1/)).toBeInTheDocument();
+ });
+});
+
+function Boom(): React.JSX.Element {
+ throw new Error('kaboom');
+}
+
+describe('ErrorBoundary', () => {
+ it('renders a fallback with a copy-diagnostics action on error', () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByRole('alert')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /copy diagnostics/i })).toBeInTheDocument();
+ });
+});
diff --git a/src/ui/a11y.test.tsx b/src/ui/a11y.test.tsx
new file mode 100644
index 0000000..0997ae6
--- /dev/null
+++ b/src/ui/a11y.test.tsx
@@ -0,0 +1,35 @@
+import { render } from '@testing-library/react';
+import axe from 'axe-core';
+import { describe, expect, it } from 'vitest';
+import { Welcome } from './Welcome.tsx';
+import { FailureCard } from './Welcome.tsx';
+
+async function seriousViolations(container: HTMLElement): Promise {
+ const results = await axe.run(container, {
+ // Color contrast cannot be computed reliably in jsdom; skip it here.
+ rules: { 'color-contrast': { enabled: false } },
+ });
+ return results.violations
+ .filter((v) => v.impact === 'serious' || v.impact === 'critical')
+ .map((v) => v.id);
+}
+
+describe('accessibility', () => {
+ it('Welcome has no serious axe violations', async () => {
+ const { container } = render(
+ undefined} onLoadDemos={() => undefined} />,
+ );
+ expect(await seriousViolations(container)).toEqual([]);
+ });
+
+ it('FailureCard has no serious axe violations', async () => {
+ const { container } = render(
+ undefined}
+ />,
+ );
+ expect(await seriousViolations(container)).toEqual([]);
+ });
+});
From e54fc411961a1b9b6ddce935488b5142fd8f6c67 Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:24:51 +0400
Subject: [PATCH 61/63] chore(ci): add pr pipeline for typecheck lint test
build
Co-Authored-By: Claude Opus 4.8
---
.github/workflows/ci.yml | 21 +++++++++++++++++++++
1 file changed, 21 insertions(+)
create mode 100644 .github/workflows/ci.yml
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..abffd7a
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,21 @@
+name: CI
+
+on:
+ pull_request:
+ push:
+ branches: [main]
+
+jobs:
+ verify:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+ - run: npm ci
+ - run: npm run typecheck
+ - run: npm run lint
+ - run: npm run test
+ - run: npm run build
From 70acd5a4604aed92d68c243b9f5b57a21593a204 Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:24:51 +0400
Subject: [PATCH 62/63] chore(ci): add release workflow with dist artifact
Co-Authored-By: Claude Opus 4.8
---
.github/workflows/release.yml | 31 +++++++++++++++++++++++++++++++
1 file changed, 31 insertions(+)
create mode 100644 .github/workflows/release.yml
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..e942b9d
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,31 @@
+name: Release
+
+on:
+ push:
+ tags: ['v*']
+
+permissions:
+ contents: write
+
+jobs:
+ release:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+ - run: npm ci
+ - run: npm run build
+ - name: Bundle dist
+ run: tar -czf faturlens-dist-${{ github.ref_name }}.tar.gz -C dist .
+ - uses: actions/upload-artifact@v4
+ with:
+ name: faturlens-dist
+ path: faturlens-dist-${{ github.ref_name }}.tar.gz
+ - name: Create GitHub release
+ uses: softprops/action-gh-release@v2
+ with:
+ files: faturlens-dist-${{ github.ref_name }}.tar.gz
+ generate_release_notes: true
From 7b143515378e63f04f7e1e6fa0402ed15c6fea9e Mon Sep 17 00:00:00 2001
From: YASSERRMD
Date: Sun, 7 Jun 2026 21:24:51 +0400
Subject: [PATCH 63/63] docs: finalize readme with architecture svg, hardware
matrix, and privacy statement
Co-Authored-By: Claude Opus 4.8
---
README.md | 71 +++++++++++++++++++++++++++----------------
docs/architecture.svg | 58 +++++++++++++++++++++++++++++++++++
2 files changed, 103 insertions(+), 26 deletions(-)
create mode 100644 docs/architecture.svg
diff --git a/README.md b/README.md
index fd6af3d..aa39c63 100644
--- a/README.md
+++ b/README.md
@@ -1,40 +1,47 @@
# Faturlens
[](./LICENSE)
+[](https://github.com/YASSERRMD/Faturlens/actions/workflows/ci.yml)
Browser-native invoice OCR and structured extraction. A transformer
vision-language model (`LiquidAI/LFM2.5-VL-1.6B-ONNX`) runs **fully client-side**
via WebGPU, with a WASM CPU fallback. No server, no API, no data leaves the
-machine — the app is offline-first after the one-time model download.
+machine — the app is offline-first (installable PWA) after the one-time model
+download.
## Architecture
-```
-┌──────────────────────────────────────────────────────────────────┐
-│ Main thread (UI) │
-│ capability ─► model gate ─► ingest ─► review ─► export │
-└───────────────┬───────────────────────────────────┬──────────────┘
- │ ImageBitmaps / prompts │ tokens / stats
- ▼ ▲
-┌──────────────────────────────────────────────────────────────────┐
-│ Inference Web Worker │
-│ Cache API bytes ─► ORT sessions (WebGPU | WASM) │
-│ Pass 1: full markdown transcription │
-│ Pass 2: schema-constrained JSON extraction │
-└──────────────────────────────────────────────────────────────────┘
- │ extraction
- ▼
- Deterministic validation layer (pure TS, zero ML) ─► human review
-```
+
+
+A two-pass pipeline runs entirely in a dedicated Web Worker: **Pass 1**
+transcribes the whole invoice to Markdown; **Pass 2** extracts schema-constrained
+JSON. A deterministic, zero-ML validation layer then gates every result and
+routes anything suspect to human review.
-> Diagram is a placeholder; a committed SVG lands in Phase 13.
+## Feature overview
+
+- Drag-in PNG / JPEG / WebP / PDF (≤25MB, ≤20 PDF pages), EXIF-corrected, tiled.
+- Client-side VLM inference (WebGPU, WASM fallback) in an isolated worker.
+- Confidence-annotated extraction with a deterministic validation layer (TRN/VAT,
+ arithmetic, dates, currency).
+- Split review UI: zoomable page image, live re-validation on edit, approval
+ gating, edit audit trail.
+- Export to canonical JSON (with provenance), CSV (header + line items), clipboard
+ TSV, or a batch zip — gated on approval.
+- Batch queue + IndexedDB persistence; sessions restore with no network and no
+ reprocessing.
+- Installable, offline-first PWA.
## Hardware targets
-| Path | Hardware | Notes |
-| -------- | --------------------------------------------------- | ----------------------------- |
-| Primary | 16GB RAM laptops, integrated GPU (Iris Xe / Radeon) | WebGPU execution provider |
-| Fallback | CPU-only browsers | WASM EP, reduced token budget |
+| Path | Hardware | Throughput (measure on your machine) |
+| -------- | --------------------------------------------------- | ------------------------------------ |
+| Primary | 16GB RAM laptops, integrated GPU (Iris Xe / Radeon) | WebGPU EP — seconds per page |
+| Fallback | CPU-only browsers | WASM EP — minutes per page |
+
+> Throughput is reported live in the app's stats drawer; the warmup benchmark
+> shows an upfront per-page estimate before processing. (Populate this table with
+> your reference numbers from a real run.)
The browser tab memory ceiling is treated as a hard 4GB budget; the app aborts
gracefully beyond it.
@@ -43,21 +50,33 @@ gracefully beyond it.
Zero network calls after the model download, except explicit Hugging Face CDN
fetches during caching. No telemetry, no analytics, no external fonts, no
-runtime CDN scripts.
+runtime CDN scripts. The service worker never intercepts HF CDN requests.
## Development
```bash
-npm ci # install
-npm run dev # start the dev server
+npm ci
+npm run dev # dev server
npm run typecheck
npm run lint
npm run test
npm run build
+npm run ci # all of the above
```
Requires Node 22 (see `.nvmrc`).
+### Dev/diagnostic flags (query params)
+
+- `?diag` — device capability report card.
+- `?harness` — inference dev harness (prompt + image, streaming output, stats).
+- `?model` — force the model download gate.
+
+## Releasing
+
+CI runs typecheck/lint/test/build on every PR. Pushing a `v*` tag builds and
+attaches the `dist` bundle to a GitHub release (`.github/workflows/release.yml`).
+
## License
[Apache-2.0](./LICENSE)
diff --git a/docs/architecture.svg b/docs/architecture.svg
new file mode 100644
index 0000000..d666779
--- /dev/null
+++ b/docs/architecture.svg
@@ -0,0 +1,58 @@
+
+
+ Faturlens architecture
+
+
+
+ Main thread (UI)
+
+
+
+
+
+
+
+
+ capability
+ model gate
+ ingest + tile
+ review
+ export
+
+
+
+
+ Inference Web Worker (ORT: WebGPU | WASM)
+
+
+
+
+
+
+ Cache API bytes →
+ ORT sessions
+ Pass 1
+ markdown transcription
+ Pass 2
+ schema-constrained JSON
+
+
+
+
+
+
+
+ images / prompts
+ tokens / stats
+
+
+
+ Deterministic validation (pure TS, zero ML) → human review
+
+
+
+
+
+
+
+