diff --git a/rpa-dashboard-ai/SKILL.md b/rpa-dashboard-ai/SKILL.md new file mode 100644 index 0000000..084f5ed --- /dev/null +++ b/rpa-dashboard-ai/SKILL.md @@ -0,0 +1,171 @@ +--- +name: rpa-dashboard-ai +description: Builds a working dashboard for an RPA bot or service that uses AI (an LLM or OCR extraction call). It has everything from rpa-dashboard-no-ai (sidebar, top bar with dark toggle and status dot, KPI cards, batch donut, run panel with steps and live log, report table with search and export) plus confidence bands, a review split pane (source document beside the extracted fields, band filters, draft edits, Verify), an approve gate the server enforces, and a cost/ROI page (tokens and cost per run, total and monthly spend, priced coverage, an against-doing-it-by-hand panel). Web path is React + Vite + Tailwind with a sample-data server stub; desktop path (PyQt) is the no-AI page plus plain-HTML AI sections over the Qt bridge, gate enforced in Python. Use when the user asks for an "AI RPA dashboard", an "extraction dashboard", an "LLM/OCR review dashboard", or any RPA/bot dashboard in a project that calls an LLM or OCR to extract fields. Needs rpa-dashboard-no-ai installed next to it. +--- + +# RPA dashboard (AI) + +Everything this skill needs is bundled. Never read the original source projects. +Shared files live in the no-AI skill, installed next to this one: `` below +means `../rpa-dashboard-no-ai/templates/`. + +| File | What it is | +|---|---| +| `templates/web/` | The React + Vite + Tailwind starter: shell, KPI, donut, run panel, report, **band legend, review split pane, approve gate, cost page**, sample data. | +| `templates/web/pricing.py` | The pricing table (with its source and as-of date) and `cost_for_call`. The backend imports it. | +| `templates/web/server_stub.py` | Sample-data backend for runs, review rows and the approve gate. Standard library only. | +| `templates/web/bands.py` | The band cut points and rules. The one source of truth: the backend imports it. | +| `templates/desktop/ai_sections.html` | Desktop: the Review tab (band legend, review split pane, drafts, Verify, approve gate) and the Cost tab in plain HTML, pasted into `dashboard.html`. | +| `templates/desktop/ai_bridge.py` | Desktop: the no-AI `Bridge` plus the `ai_request` slot that hands every AI request to the Python review backend. | +| `templates/contract.md` | The AI contract: runs, review rows, endpoints and the gate, over HTTP or the Qt bridge. Extends `contract.md`. | +| `templates/reference/` | Superbill Review extracts, marked EXAMPLE ONLY. Use them to port extra polish, never copy their project names. | +| `frontend.md`, `detect_frontend.py` | Existing frontend: detect, report, keep or restyle. | +| `themes.md`, `palette.py` | Theme presets and the custom-colour steps. | +| `contract.md` | The no-AI contract the AI one extends. | + +The questions are the same as the no-AI skill's, in the same order. Ask one at a +time and wait for the answer. If the user's request already answers a question, +don't ask it again. + +## 1. Scan the project + +Before asking anything: +- **Frontend:** run `python detect_frontend.py ` and read + `frontend.md` section 1. +- **AI call:** find the LLM or OCR extraction (for example `genai`, `openai`, + `anthropic`, Textract or Tesseract calls): which fields it returns, which are + mandatory, and whether it returns per-word scores. Note the provider, the model + names, and whether the response's token usage (input, output, thinking) is kept. +- **Bot or backend:** the entry point, what it prints, and any web framework + (FastAPI, Flask, Django). + +If the project has no AI call at all, say so and suggest `rpa-dashboard-no-ai`. + +## 2. Report the frontend, then ask keep or restyle + +Follow `frontend.md` sections 2 to 5. +- **No frontend:** say "No existing frontend found." and go to step 3. +- **Frontend found:** say "Frontend found at ``, stack ``, colours + `
`." Then ask: **keep its look**, or **restyle** it? + +## 3. Ask for colours + +Skip this if the user chose keep. Offer: +1. **Tebra Sky**: blue glass cards on a sky gradient. +2. **Superbill Ember**: flat surfaces with a red-orange brand gradient. +3. **Custom**: the user gives one brand hex colour. + +## 4. Ask desktop or web + +Ask only if there is no frontend. If there is one, use its stack. If the project +uses PyQt, choose desktop and say so. +- **Web:** the React starter in `templates/web/`, served by the backend. +- **Desktop:** the no-AI PyQt page with `templates/desktop/ai_sections.html` pasted + in, hosted by `templates/desktop/ai_bridge.py`. No web server, no npm. + +## 5. Ask which optional sections to build + +Always built: the no-AI shell and sections, plus the band legend, the review split +pane, the approve gate and the cost page. Ask which extras to add (choose any, or none): +**checkpoint tracker**, **admin/roles screen**, **login page**, **model picker** (choose +the model per batch), **retry on another model** (for failed runs). Build only the +chosen ones at their `OPTIONAL:` markers, and delete the markers. +- **Model picker:** a `` (from `GET /models`, defaulting to the run's own model) and a Retry button + that sends `POST /runs/{job_id}/retry {model}`, then polls the run as usual. +Both need the endpoints in `templates/contract.md`; add them to the backend (and the stub). + +## 6. Build (web) + +1. Copy `templates/web/` into the project as `dashboard/` (an existing frontend: + see `frontend.md` section 3; in an existing React app, port the + `src/` components into it). Don't copy `node_modules/` or `dist/`. +2. Fill every `{{...}}` in `dashboard/index.html`'s CONFIG block: + + | Placeholder | Value | + |---|---| + | `APP_NAME`, `APP_SUBTITLE` | The project's name and a short tagline. | + | `LOGO_SRC` | The logo's path, or empty. | + | `STEPS_JSON` | The run steps as a JSON list, for example `["Initializing","Extract","Review","Completed"]`. | + | `REPORT_COLUMNS_JSON` | `[{"key":..., "label":...}]`, with a `status` column. | + | `API_BASE` | `/api` unless the backend mounts the dashboard API elsewhere. Keep it equal to `API` in `server_stub.py`. | + +3. Apply the theme in `dashboard/src/index.css` only: replace the `body{...}` and + `body.dark{...}` token blocks with the preset from `themes.md` (for Ember, + add its font `` to `index.html`'s ``), or with + `python palette.py ""` tokens for a custom colour, showing the user + its contrast table. For keep, fill them from the project's tokens + (`frontend.md` section 4). Never change the band colour block. +4. Search the output for `{{`. None may be left. + +## 6. Build (desktop) + +1. Build the no-AI desktop page and host first, exactly as steps 6 and 7 of + `../rpa-dashboard-no-ai/SKILL.md` say: `dashboard/dashboard.html` (theme, placeholders, + `SAMPLE_DATA = false` once the bot is wired), `bridge.py` and `bot_runner.py`. +2. Paste the whole of `templates/desktop/ai_sections.html` (Review and Cost tabs) into `dashboard/dashboard.html`, + just before ``. Change nothing in it: it uses the page's theme tokens, and its + band colours are fixed. +3. Copy `templates/desktop/ai_bridge.py`, `templates/web/server_stub.py`, + `templates/web/bands.py` and `templates/web/pricing.py` into `dashboard/`. Fill `{{APP_NAME}}` in `ai_bridge.py`. +4. The app now starts with `python dashboard/ai_bridge.py` (not `bridge.py`). +5. Search the output for `{{`. None may be left. + +## 7. Wire the data + +The contracts: `contract.md` for the shell, `templates/contract.md` for runs, +review rows and the gate. +- **Sample data (no backend for these endpoints yet):** keep `SAMPLE_DATA = true` in + `src/store.ts` and use `server_stub.py`. Replace its `SAMPLE_FIELDS` with the + fields the project's extraction really returns (name, a sample value, word scores, + mandatory). Tell the user which endpoints their backend must serve later. +- **Backend exists:** add the endpoints from `templates/contract.md` to it. Copy + `bands.py` next to it and band every field with `field_band(word_scores, value, + mandatory)`. The approve endpoint must refuse (409 with `detail`) while any flagged + field is unverified: the page never decides that. For the shell, set + `SAMPLE_DATA = false` and serve the no-AI endpoints too. +- **Cost page:** copy `pricing.py` next to the backend. Fill `PRICING` with the rates of + the models the project really calls, from the provider's pricing page, and set + `PRICING_SOURCE` and `PRICING_AS_OF`. If you can't read the rates, ask the user for + them. The backend stores each run's token usage and serves `GET /roi`, pricing each run + with `cost_for_call`. A run with no recorded usage gets `null`, never 0. If the + extraction doesn't keep token usage yet, wire it to (for example Gemini's + `usage_metadata`, OpenAI's and Anthropic's `usage`). In sample mode, replace the stub's + `ROI_RUNS` models with the project's models. +- **Desktop:** the same rules, over the bridge (`templates/contract.md`, "Desktop"). + The shell is wired in `bot_runner.py` as the no-AI skill says. For review data, keep + `server_stub.py` (replace its `SAMPLE_FIELDS` with the real fields, as above) until + the project stores its extraction results. Then write a module with the same + `dispatch(method, path, body)` over the project's store (same routes, bands from + `bands.py`, approve refused while flagged fields are unverified) and import it in + `ai_bridge.py` instead of `server_stub`. The page never decides approval. +- Never invent fields, model names or prices the project doesn't have. + +## 8. Verify + +``` +cd dashboard +npm install +npm run build +python server_stub.py # page + sample API on http://127.0.0.1:8000 +``` + +Open the page and check, in light, dark (the toggle) and a window 820px wide or less: +- the dashboard, Run and Report tabs show sample data; +- on Review: the band legend counts and the caveat, band filters, a typed edit marked + unsaved until Save, a refused Approve while flagged fields are unverified, and + "page X of Y" on a running document; +- on Cost: the coverage pill, total and this month's spend, the month table, a run + marked "not priced", the pricing source and as-of date, and the by-hand figure + changing when an assumption is edited; +- no `{{` is left and the console shows no script errors. + +Desktop: `python dashboard/bot_runner.py` (dry run: real steps, ending at 100), then +`python dashboard/ai_bridge.py`, and check the same list in the window. + +If you can't run npm (or open a window) here, say so and give the user these commands. Finish by +telling the user where the files are, which theme and sections were built, and +whether the review data is sample data or the backend's. diff --git a/rpa-dashboard-ai/templates/contract.md b/rpa-dashboard-ai/templates/contract.md new file mode 100644 index 0000000..88bd978 --- /dev/null +++ b/rpa-dashboard-ai/templates/contract.md @@ -0,0 +1,89 @@ +# Dashboard data contract (AI) + +Everything in the no-AI contract (`../rpa-dashboard-no-ai/templates/contract.md`) +applies unchanged: the `{event, data}` envelope, its events, its actions and its rules. +The AI dashboard adds extraction runs, review rows and the approve gate. These shapes +come from the Superbill prototype, trimmed. + +## Shapes + +``` +Run { job_id, document, status: pending|queued|running|done|failed, model, + pages_done, pages_total, summary: { green, amber, red, blank, unscored } | null, + approved, error } +ReviewRow { row_id, field, value, band: green|amber|red|blank|unscored, + score, reason, verified, corrected_value } +RoiRun { job_id, document, created_at, model, input_tokens, output_tokens, + thinking_tokens, total_cost } +``` + +- `summary` counts the run's rows per band. It is `null` until the run is `done`. +- `score` is the field's worst word score, or `null` when it has none. +- `corrected_value` is `null` until a reviewer saves a value different from `value`. +- `created_at` is ISO 8601 in UTC. Months on the cost page are UTC months. +- `RoiRun` token counts and `total_cost` (USD) are `null` when the run recorded no token + usage. The page shows it as "not priced" and counts it in the coverage pill, never as $0. + +## Endpoints (web: `{API_BASE}` + path, JSON in and out) + +| Method and path | What it does | +|---|---| +| `GET /runs` | `{runs: [Run]}`. The page opens the first one. | +| `GET /runs/{job_id}` | One `Run`. | +| `GET /runs/{job_id}/review` | `{rows: [ReviewRow]}`. Empty until the run is `done`. | +| `GET /runs/{job_id}/document` | The source document (PDF or image) shown beside the fields. | +| `PATCH /review/{row_id}` | Body `{corrected_value?, verified}`. Patches one row, returns it. | +| `POST /runs/{job_id}/verify-remaining` | Verifies every flagged row not yet verified. Returns `{verified: n}`. | +| `GET /roi` | **Fetch ROI.** `{runs: [RoiRun], pricing: {source, as_of}}`, every run, newest first. The page sums spend, groups months and computes the by-hand panel itself. | +| `POST /runs/{job_id}/approve` | **The approve gate.** `409 {detail}` while the run isn't done or any flagged row is unverified, else `200 {ok: true}`. | + +Optional, only when the user chose those sections: + +| Method and path | What it does | +|---|---| +| `GET /models` | `{models: [name]}`: the keys of the pricing table, so only priced models can be picked. | +| `POST /runs/{job_id}/retry` | Body `{model}`. Re-queues a `failed` run on that model, returns the `Run`. | + +Errors answer with a non-2xx status and `{detail: ""}`. The page shows `detail`. + +## Rules + +- **Bands** come from `templates/web/bands.py` and nowhere else: green ≥0.95, amber + 0.90–0.95, red <0.90. A field takes its worst word score. A blank mandatory field + is `blank`, whatever its score. No scores is `unscored`. +- **Flagged** = every band but green. Each flagged row must be verified before approval. +- **The server enforces the gate.** The page disables nothing that matters for safety: + it asks, and shows the server's refusal. +- **Drafts:** the page sends nothing while a reviewer types. Save sends one PATCH per + edited row (with `verified: true`). Leaving with unsaved edits asks first. +- **Polling:** while a run is `queued` or `running`, the page polls `GET /runs/{job_id}` + every 1.5s and shows "page X of Y". It stops when the run settles. +- **Cost** comes from `templates/web/pricing.py` and nowhere else: per million tokens, + input × input rate + (output + thinking) × output rate, long-context rates above the + model's threshold. An unknown model is an error naming the known models, never $0. + The table records its source URL and as-of date; `GET /roi` returns both. +- **Assumptions** (minutes per document, hourly rate) live only on the page, typed by the + reader and labelled as assumptions. The backend never stores or guesses them. +- **Caveat:** the legend always shows that colour reflects print clarity, not + correctness. + +`templates/web/server_stub.py` implements all of this on sample data, standard library +only. A real backend serves the same endpoints with the same rules. + +## Desktop (PyQt, no web server) + +The same endpoints and rules travel over the Qt bridge instead of HTTP: + +- Page to Python: one slot, `ai_request(id, method, path, body)`. `path` is the endpoint + path above without `{API_BASE}`, `body` is a JSON string (`"{}"` when there is none). +- Python to page: one envelope, `{event: "ai_response", data: {id, status, body}}`, with the + same `status` and JSON `body` the web endpoint would answer. +- Every endpoint above uses this pair, the cost page's `GET /roi` included (the page sends + `ai_request(id, "GET", "/roi", "{}")` and gets `{runs: [RoiRun], pricing: {source, as_of}}` + back as the `body`). No other AI events or slots exist. +- `GET /runs/{job_id}/document` answers an SVG string or a URL the page can load (for + example a `file:///` path to the PDF). + +`templates/desktop/ai_bridge.py` adds that slot to the no-AI `Bridge`. It hands each +request to the review backend's `dispatch(method, path, body)` (`server_stub.py` until the +project has its own), so the approve gate is enforced in Python, never in the page. diff --git a/rpa-dashboard-ai/templates/desktop/ai_bridge.py b/rpa-dashboard-ai/templates/desktop/ai_bridge.py new file mode 100644 index 0000000..ab5b017 --- /dev/null +++ b/rpa-dashboard-ai/templates/desktop/ai_bridge.py @@ -0,0 +1,37 @@ +"""PyQt6 host for the AI desktop dashboard: the no-AI Bridge plus one slot for the AI contract. + +Copy next to the no-AI bridge.py and bot_runner.py, with server_stub.py and bands.py. +Run it with: python dashboard/ai_bridge.py + +The page sends each AI request (templates/contract.md: runs, review rows, the approve gate) +as ai_request(id, method, path, body) and gets one `ai_response` envelope back. The review +backend answers it in Python, so the approve gate holds whatever the page does. +""" +import json, sys, threading +from PyQt6.QtCore import QUrl, pyqtSlot +from PyQt6.QtWidgets import QApplication, QMainWindow +from PyQt6.QtWebEngineWidgets import QWebEngineView +from PyQt6.QtWebChannel import QWebChannel + +import bridge +import server_stub as review # sample data. Once the project has its own review backend, import that (same dispatch()). + + +class AiBridge(bridge.Bridge): + @pyqtSlot(int, str, str, str) + def ai_request(self, req_id, method, path, body): + # ponytail: answered on the GUI thread; move to a worker if the backend gets slow. + status, payload = review.dispatch(method, path, json.loads(body or "{}")) + self.send_to_js("ai_response", {"id": req_id, "status": status, "body": payload}) + + +if __name__ == "__main__": + threading.Thread(target=review.simulate_extraction, daemon=True).start() # sample data only: delete with server_stub + app = QApplication(sys.argv) + win, view = QMainWindow(), QWebEngineView() + host, channel = AiBridge(view), QWebChannel() + channel.registerObject("pyBridge", host) + view.page().setWebChannel(channel) + view.setUrl(QUrl.fromLocalFile(str(bridge.HTML_PATH))) + win.setCentralWidget(view); win.resize(1280, 820); win.setWindowTitle("{{APP_NAME}}"); win.show() + sys.exit(app.exec()) diff --git a/rpa-dashboard-ai/templates/desktop/ai_sections.html b/rpa-dashboard-ai/templates/desktop/ai_sections.html new file mode 100644 index 0000000..7cda176 --- /dev/null +++ b/rpa-dashboard-ai/templates/desktop/ai_sections.html @@ -0,0 +1,264 @@ + + + + + + + + diff --git a/rpa-dashboard-ai/templates/reference/backend/bands.py b/rpa-dashboard-ai/templates/reference/backend/bands.py deleted file mode 100644 index 5e447e3..0000000 --- a/rpa-dashboard-ai/templates/reference/backend/bands.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Confidence-band classification — single source of truth for the cut points. - -The workbook colours and the review queue MUST agree about what counts as red. -If each applied its own threshold they would drift the first time one is tuned, -and a reviewer would see a red cell the queue never asked them to check. -Everything that needs a band imports from here. -""" -from __future__ import annotations - -from typing import Optional - -# --------------------------------------------------------------------------- -# Cut points -# --------------------------------------------------------------------------- -# Calibrated against the measured distribution over all 225 extracted rows of -# the six sample documents: -# -# 1.00 69.8% crisp print -# 0.90-0.99 18.2% crisp print -# 0.80-0.89 11.0% almost entirely handwritten CPT and modifiers -# 0.70-0.79 0.0% <- empty; the natural gap in this data -# <0.70 1.1% handwritten modifiers -# -# 0.80 left only 1.1% of cells red so nearly everything went green and the -# colour carried no information. 0.90 sits in the empty band and separates -# the handwriting cluster from printed text. -CONFIDENCE_THRESHOLD: float = 0.90 - -# Above CONFIDENCE_THRESHOLD the 0.90-0.99 band is still 18.2% of cells, and -# routing treats it differently from a clean 1.00: reviewers sample it rather -# than either trusting it outright or checking every one. A second cut at -# 0.95 names that band so the sheet shows what the routing rule already assumes. -# -# GREEN >= 0.95 clean print, straight through -# AMBER 0.90-0.95 slightly unclear, sampled -# RED < 0.90 handwriting cluster, always reviewed -GREEN_THRESHOLD: float = 0.95 - -# --------------------------------------------------------------------------- -# Band name constants -# --------------------------------------------------------------------------- -BAND_GREEN = "green" -BAND_AMBER = "amber" -BAND_RED = "red" - - -def band_of(score: Optional[float]) -> Optional[str]: - """Return the confidence band name, or ``None`` when the value was never scored. - - WHAT THE BAND CLAIMS: green means the source text this value came from was - transcribed cleanly. It does NOT mean the value is correct. The score is - the model's own legibility rating, so a confident misread comes back scored - high. Verifying correctness needs a second, independent read. Red is the - useful direction: it reliably surfaces handwriting. - - Amber does not mean "probably fine". It means the reading was good enough to - escape the handwriting cluster but not clean enough to trust unsampled, which - is a routing decision, not a quality claim. - """ - if score is None: - return None - if score >= GREEN_THRESHOLD: - return BAND_GREEN - return BAND_AMBER if score >= CONFIDENCE_THRESHOLD else BAND_RED diff --git a/rpa-dashboard-ai/templates/reference/backend/gemini_cost.py b/rpa-dashboard-ai/templates/reference/backend/gemini_cost.py index 7780a4a..e7c5b6b 100644 --- a/rpa-dashboard-ai/templates/reference/backend/gemini_cost.py +++ b/rpa-dashboard-ai/templates/reference/backend/gemini_cost.py @@ -1,3 +1,4 @@ +# EXAMPLE ONLY: an extract from the Superbill Review prototype, kept as reference for porting. ECW/eClinicalWorks, Tebra, 'Example Client', SPB_* tables and patient/ticket wording are that project's examples. Don't copy them into a new project. INCLUDE_THINKING_IN_COST = True # thinking tokens bill at output rate # ═════════════════════════════════════════════════════════════════════════════ diff --git a/rpa-dashboard-ai/templates/reference/web/DESIGN.md b/rpa-dashboard-ai/templates/reference/web/DESIGN.md index a20252d..1541088 100644 --- a/rpa-dashboard-ai/templates/reference/web/DESIGN.md +++ b/rpa-dashboard-ai/templates/reference/web/DESIGN.md @@ -1,3 +1,4 @@ + --- name: Superbill Extraction and Automation Tool description: Extract billing fields from a scan, verify them against the page, post to eClinicalWorks. diff --git a/rpa-dashboard-ai/templates/reference/web/api.ts b/rpa-dashboard-ai/templates/reference/web/api.ts index 737a20c..cd1caec 100644 --- a/rpa-dashboard-ai/templates/reference/web/api.ts +++ b/rpa-dashboard-ai/templates/reference/web/api.ts @@ -1,3 +1,4 @@ +/* EXAMPLE ONLY: an extract from the Superbill Review prototype, kept as reference for porting. ECW/eClinicalWorks, Tebra, 'Example Client', SPB_* tables and patient/ticket wording are that project's examples. Don't copy them into a new project. */ import type { AuditEntry, Batch, BatchStage, DemographyRecord, DemographyRun, InsuranceMasterRow, LastRun, ManagedUser, @@ -84,7 +85,7 @@ export const api = { for (const f of files) body.append('files', f) // `project` is which board received the drop. Nothing has detected a // template yet, and the board a batch appears on is derived from that, so - // without it a Gulf Coast upload surfaces under ECW and reads as a failed + // without it a Example Client upload surfaces under ECW and reads as a failed // upload on the Tebra board. return req(`/api/batches?${new URLSearchParams({ model, project })}`, { method: 'POST', diff --git a/rpa-dashboard-ai/templates/reference/web/bands.ts b/rpa-dashboard-ai/templates/reference/web/bands.ts index 0208684..16735ae 100644 --- a/rpa-dashboard-ai/templates/reference/web/bands.ts +++ b/rpa-dashboard-ai/templates/reference/web/bands.ts @@ -1,3 +1,4 @@ +/* EXAMPLE ONLY: an extract from the Superbill Review prototype, kept as reference for porting. ECW/eClinicalWorks, Tebra, 'Example Client', SPB_* tables and patient/ticket wording are that project's examples. Don't copy them into a new project. */ /** * The confidence bands, and the exact colours the workbook paints. * diff --git a/rpa-dashboard-ai/templates/reference/web/components/BrandHeader.tsx b/rpa-dashboard-ai/templates/reference/web/components/BrandHeader.tsx index a8459e6..44c5277 100644 --- a/rpa-dashboard-ai/templates/reference/web/components/BrandHeader.tsx +++ b/rpa-dashboard-ai/templates/reference/web/components/BrandHeader.tsx @@ -1,3 +1,4 @@ +/* EXAMPLE ONLY: an extract from the Superbill Review prototype, kept as reference for porting. ECW/eClinicalWorks, Tebra, 'Example Client', SPB_* tables and patient/ticket wording are that project's examples. Don't copy them into a new project. */ import { ThemeSwitcher } from '../theme' import { ROLE_LABEL } from '../auth' diff --git a/rpa-dashboard-ai/templates/reference/web/components/Donut.tsx b/rpa-dashboard-ai/templates/reference/web/components/Donut.tsx index 51f8e8e..d8ef52c 100644 --- a/rpa-dashboard-ai/templates/reference/web/components/Donut.tsx +++ b/rpa-dashboard-ai/templates/reference/web/components/Donut.tsx @@ -1,3 +1,4 @@ +/* EXAMPLE ONLY: an extract from the Superbill Review prototype, kept as reference for porting. ECW/eClinicalWorks, Tebra, 'Example Client', SPB_* tables and patient/ticket wording are that project's examples. Don't copy them into a new project. */ /** * A proportional ring. * diff --git a/rpa-dashboard-ai/templates/reference/web/components/PdfPane.tsx b/rpa-dashboard-ai/templates/reference/web/components/PdfPane.tsx index ea6373e..7e7a611 100644 --- a/rpa-dashboard-ai/templates/reference/web/components/PdfPane.tsx +++ b/rpa-dashboard-ai/templates/reference/web/components/PdfPane.tsx @@ -1,3 +1,4 @@ +/* EXAMPLE ONLY: an extract from the Superbill Review prototype, kept as reference for porting. ECW/eClinicalWorks, Tebra, 'Example Client', SPB_* tables and patient/ticket wording are that project's examples. Don't copy them into a new project. */ import { useCallback, useEffect, useRef, useState } from 'react' import { useQuery } from '@tanstack/react-query' import { api } from '../api' diff --git a/rpa-dashboard-ai/templates/reference/web/components/ReviewPane.tsx b/rpa-dashboard-ai/templates/reference/web/components/ReviewPane.tsx index 2a7bcc6..ab1dfa1 100644 --- a/rpa-dashboard-ai/templates/reference/web/components/ReviewPane.tsx +++ b/rpa-dashboard-ai/templates/reference/web/components/ReviewPane.tsx @@ -1,3 +1,4 @@ +/* EXAMPLE ONLY: an extract from the Superbill Review prototype, kept as reference for porting. ECW/eClinicalWorks, Tebra, 'Example Client', SPB_* tables and patient/ticket wording are that project's examples. Don't copy them into a new project. */ import { useEffect, useMemo, useRef, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { api } from '../api' @@ -12,7 +13,7 @@ const isOfficeLocation = (t: string | null) => // One review card per record on a page. An ECW roster can list the same // person twice (two Sleep Study encounters for one patient) — line_index -// splits those into separate cards. A Gulf Coast ticket spreads its charges +// splits those into separate cards. A Example Client ticket spreads its charges // over several line_index values and must stay ONE card, so it is excluded. // The name stays in the key so runs stored before line_index existed (every // row LineIndex 0) still group exactly as before. @@ -100,7 +101,7 @@ type Patient = { type SaveArgs = { row: ReviewRow; draft: string; verified: boolean } // ── Office Location ──────────────────────────────────────────────────────── -// A Gulf Coast ticket whose yellow note the Office Location rules read. The +// A Example Client ticket whose yellow note the Office Location rules read. The // backend stores the parsed note on the ticket's first line under this field // (src/superbill/extraction/office_location.py, ParsedNote); a ticket without // it is a run from before the rules existed and keeps the flat layout. @@ -191,11 +192,11 @@ export function ReviewPane({ }) { const [index, setIndex] = useState(0) // Drafts live here, not in FieldRow — a correction is no longer committed on - // blur (that is exactly how four ARMSTRONG fields got saved mid-keystroke: - // "ARMSTRONG, CHARLES T" as "\ONG, CHARLES T", "99291" as "992"). Text typed + // blur (that is exactly how four fields of one record got saved mid-keystroke: + // "Doe, Jane A" as "\e, Jane A", "99291" as "992"). Text typed // into a field is held until an explicit Save, keyed by row_id rather than // bare field name — a bare name repeats once a page can carry more than one - // row per field (Gulf Coast's repeated CPT lines), and DemographyTab.tsx + // row per field (Example Client's repeated CPT lines), and DemographyTab.tsx // hit exactly this before switching its own drafts to row_id. const [drafts, setDrafts] = useState>({}) const qc = useQueryClient() @@ -238,7 +239,7 @@ export function ReviewPane({ // PDF order: page, then where the patient sits on it. line_index is each // record's position on its page, set by build_field_rows. Sorting by name - // here used to put ANDERSON above the first patient on the sheet, so the + // here used to put a later surname above the first patient on the sheet, so the // list no longer matched the document the reviewer had open beside it. // Name is only the tie-break, for runs stored before line_index existed. return [...byKey.values()].sort( @@ -254,7 +255,7 @@ export function ReviewPane({ map.get(key)!.push(row) } for (const rows of map.values()) { - // Gulf Coast (Template 7) records keep the order the API sent — the flat + // Example Client (Template 7) records keep the order the API sent — the flat // 20-column superbill layout, Ticket# through Units. Re-sorting by band // would scatter it (that is what put ICD11 above ICD2 and CPT at the // bottom). Every other template keeps worst-band-last, which is what the @@ -280,7 +281,7 @@ export function ReviewPane({ // A field that was never queued (needs_review === false) has no review // row until this moment on most templates — build_field_rows only emits // a row when a value exists or the field is billing-critical, so a - // genuinely blank optional field (e.g. Gulf Coast's From DOS / To DOS / + // genuinely blank optional field (e.g. Example Client's From DOS / To DOS / // Units, always null today) is addressed by the cell it occupies, // line_index included, rather than by row_id. if (row.needs_review === false) { @@ -363,7 +364,7 @@ export function ReviewPane({ const discardAll = () => setDrafts(settledMap) // Leaving a patient with typed-but-unsaved edits used to be silent — the old - // blur handler saved automatically, which is what produced the ARMSTRONG + // blur handler saved automatically, which is what produced the truncated-name // corruption. Now nothing commits without Save, so leaving instead has to // ask, or the edit is just lost with no signal either way. const guardLeave = (): boolean => { @@ -711,7 +712,7 @@ function PatientView({
{/* Keyed on row_id — a real database identity from /all-fields (SPB_Extraction.RowId), unique across every field on every page. - A page-and-field key would collide on Gulf Coast's repeated CPT + A page-and-field key would collide on Example Client's repeated CPT lines, where two rows share the same (page, field). */} {/* No entry animation. Rows are keyed by row_id, so every Next remounts them all, and the old staggered fade (opacity 0 over @@ -802,7 +803,7 @@ function PatientView({ * lost by making it editable. * * Nothing here commits on its own. An earlier version saved on blur, which is - * exactly how "ARMSTRONG, CHARLES T" got recorded as "\ONG, CHARLES T" — the + * exactly how "Doe, Jane A" got recorded as "\e, Jane A" — the * moment focus left the box mid-keystroke, whatever half-typed text was there * became the correction. Typing only updates the shared draft in ReviewPane; * the patient-level Save bar (here and in PdfPane) is the one place an edit is @@ -835,7 +836,7 @@ function FieldRow({ const corrected = row.corrected_value != null && row.corrected_value !== row.value // While being edited the value takes its own full-width line. A DX field is // five comma-separated codes and a provider name runs past twenty - // characters; in the shared row they were truncated to "ARMSTRONG, C", so a + // characters; in the shared row they were truncated to "Doe, J", so a // reviewer was correcting a value they could not fully see. Expanding only // on focus keeps the scan-many-fields-fast layout for reading. const expanded = focused || dirty @@ -938,7 +939,7 @@ function FieldRow({ /** - * A Gulf Coast ticket laid out the way its yellow note is: the note itself, + * A Example Client ticket laid out the way its yellow note is: the note itself, * the page-level rules it fired, the visit, then one tab per encounter. * * Hovering a field lights the note line it came from. The page preview is an diff --git a/rpa-dashboard-ai/templates/reference/web/components/RoiTab.tsx b/rpa-dashboard-ai/templates/reference/web/components/RoiTab.tsx index e21a72d..bffa1c2 100644 --- a/rpa-dashboard-ai/templates/reference/web/components/RoiTab.tsx +++ b/rpa-dashboard-ai/templates/reference/web/components/RoiTab.tsx @@ -1,3 +1,4 @@ +/* EXAMPLE ONLY: an extract from the Superbill Review prototype, kept as reference for porting. ECW/eClinicalWorks, Tebra, 'Example Client', SPB_* tables and patient/ticket wording are that project's examples. Don't copy them into a new project. */ import { useMemo, useState } from 'react' import { useQuery } from '@tanstack/react-query' import { api } from '../api' @@ -95,7 +96,7 @@ type Filter = 'all' | 'priced' | 'unmeasured' | 'failed' /** * api.roi() has no project filter — it totals every document. The ROI board - * is per-project (Gulf Coast's spend is not eClinicalWorks' spend), so the + * is per-project (Example Client's spend is not eClinicalWorks' spend), so the * totals are recomputed here from the already-fetched runs instead of a new * query param. Every field is a plain sum or count over RoiRun — nothing * server-side is being second-guessed, just re-scoped to fewer rows. @@ -138,7 +139,7 @@ export function RoiTab({ }: { /** Absent on the demography board: its runs have no charge review to open. */ onOpenRun?: (jobId: string) => void - /** Which project's spend this board reports — Gulf Coast tickets and + /** Which project's spend this board reports — Example Client tickets and * eClinicalWorks documents never share a total. */ project: Project /** The Demography ROI (ticket 05): its own payload, one Gemini call per PDF diff --git a/rpa-dashboard-ai/templates/reference/web/components/RunPanel.tsx b/rpa-dashboard-ai/templates/reference/web/components/RunPanel.tsx index 1c885a2..5474c97 100644 --- a/rpa-dashboard-ai/templates/reference/web/components/RunPanel.tsx +++ b/rpa-dashboard-ai/templates/reference/web/components/RunPanel.tsx @@ -1,3 +1,4 @@ +/* EXAMPLE ONLY: an extract from the Superbill Review prototype, kept as reference for porting. ECW/eClinicalWorks, Tebra, 'Example Client', SPB_* tables and patient/ticket wording are that project's examples. Don't copy them into a new project. */ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { api } from '../api' import { IconDownload } from './icons' diff --git a/rpa-dashboard-ai/templates/reference/web/components/Sidebar.tsx b/rpa-dashboard-ai/templates/reference/web/components/Sidebar.tsx index d5df1bd..7700b6b 100644 --- a/rpa-dashboard-ai/templates/reference/web/components/Sidebar.tsx +++ b/rpa-dashboard-ai/templates/reference/web/components/Sidebar.tsx @@ -1,3 +1,4 @@ +/* EXAMPLE ONLY: an extract from the Superbill Review prototype, kept as reference for porting. ECW/eClinicalWorks, Tebra, 'Example Client', SPB_* tables and patient/ticket wording are that project's examples. Don't copy them into a new project. */ import { useState } from 'react' import type { ReactElement } from 'react' import type { BatchStage, BoardTab } from '../types' @@ -14,7 +15,7 @@ import { IconSpend, IconPage, IconLoop, IconIdCard, IconPatients } from './icons * icons are" step from every navigation. * * Two projects, one rail: ECW's boards (templates 1-6) and Tebra's (template 7 - * / Gulf Coast), each under its own group header. See project.ts for how a + * / Example Client), each under its own group header. See project.ts for how a * batch or run is assigned to one or the other. * * Selection and drag-and-drop-to-move-stage behaviour are unchanged — only the @@ -136,7 +137,7 @@ export function Sidebar({ tab="tebra-roi" icon={IconSpend} label="ROI Dashboard" - hint="What the Gulf Coast pipeline has cost, and what that bought" + hint="What the Example Client pipeline has cost, and what that bought" active={tab === 'tebra-roi'} onSelect={onTab} /> @@ -146,8 +147,8 @@ export function Sidebar({ dropStage="extraction" dropTab="tebra-extraction" icon={IconPage} - label="Gulf Coast Charge Extraction" - hint="Gulf Coast tickets: extract and clear the flagged fields" + label="Example Client Charge Extraction" + hint="Example Client tickets: extract and clear the flagged fields" active={tab === 'tebra-extraction'} n={counts.tebra.extraction} onSelect={onTab} @@ -159,8 +160,8 @@ export function Sidebar({ dropStage="automation" dropTab="tebra-automation" icon={IconLoop} - label="Gulf Coast Charge Automation" - hint="Gulf Coast batches that finished extraction and are ready to post" + label="Example Client Charge Automation" + hint="Example Client batches that finished extraction and are ready to post" active={tab === 'tebra-automation'} n={counts.tebra.automation} onSelect={onTab} diff --git a/rpa-dashboard-ai/templates/reference/web/components/SplitPane.tsx b/rpa-dashboard-ai/templates/reference/web/components/SplitPane.tsx index 8f0ecdf..3f10dbf 100644 --- a/rpa-dashboard-ai/templates/reference/web/components/SplitPane.tsx +++ b/rpa-dashboard-ai/templates/reference/web/components/SplitPane.tsx @@ -1,3 +1,4 @@ +/* EXAMPLE ONLY: an extract from the Superbill Review prototype, kept as reference for porting. ECW/eClinicalWorks, Tebra, 'Example Client', SPB_* tables and patient/ticket wording are that project's examples. Don't copy them into a new project. */ import { useCallback, useEffect, useRef, useState } from 'react' const KEY = 'superbill.split' diff --git a/rpa-dashboard-ai/templates/reference/web/components/StatsBar.tsx b/rpa-dashboard-ai/templates/reference/web/components/StatsBar.tsx index cd23f21..daf2873 100644 --- a/rpa-dashboard-ai/templates/reference/web/components/StatsBar.tsx +++ b/rpa-dashboard-ai/templates/reference/web/components/StatsBar.tsx @@ -1,3 +1,4 @@ +/* EXAMPLE ONLY: an extract from the Superbill Review prototype, kept as reference for porting. ECW/eClinicalWorks, Tebra, 'Example Client', SPB_* tables and patient/ticket wording are that project's examples. Don't copy them into a new project. */ import { BAND_VAR } from '../bands' import type { Run } from '../types' diff --git a/rpa-dashboard-ai/templates/reference/web/components/Tooltip.tsx b/rpa-dashboard-ai/templates/reference/web/components/Tooltip.tsx index dff3fab..270d9ea 100644 --- a/rpa-dashboard-ai/templates/reference/web/components/Tooltip.tsx +++ b/rpa-dashboard-ai/templates/reference/web/components/Tooltip.tsx @@ -1,3 +1,4 @@ +/* EXAMPLE ONLY: an extract from the Superbill Review prototype, kept as reference for porting. ECW/eClinicalWorks, Tebra, 'Example Client', SPB_* tables and patient/ticket wording are that project's examples. Don't copy them into a new project. */ import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' diff --git a/rpa-dashboard-ai/templates/reference/web/components/icons.tsx b/rpa-dashboard-ai/templates/reference/web/components/icons.tsx index 0911718..7ac3df5 100644 --- a/rpa-dashboard-ai/templates/reference/web/components/icons.tsx +++ b/rpa-dashboard-ai/templates/reference/web/components/icons.tsx @@ -1,3 +1,4 @@ +/* EXAMPLE ONLY: an extract from the Superbill Review prototype, kept as reference for porting. ECW/eClinicalWorks, Tebra, 'Example Client', SPB_* tables and patient/ticket wording are that project's examples. Don't copy them into a new project. */ /** * The icon set. * diff --git a/rpa-dashboard-ai/templates/reference/web/index.css b/rpa-dashboard-ai/templates/reference/web/index.css index 9e83a7f..a79076d 100644 --- a/rpa-dashboard-ai/templates/reference/web/index.css +++ b/rpa-dashboard-ai/templates/reference/web/index.css @@ -1,3 +1,4 @@ +/* EXAMPLE ONLY: an extract from the Superbill Review prototype, kept as reference for porting. ECW/eClinicalWorks, Tebra, 'Example Client', SPB_* tables and patient/ticket wording are that project's examples. Don't copy them into a new project. */ @import 'tailwindcss'; /* Manual dark mode: Tailwind v4 defaults `dark:` to the OS preference, but this @@ -1662,7 +1663,7 @@ textarea, .sb-brand-name { background-position: 0 0; } } -/* ── Office Location (a Gulf Coast ticket's yellow note) ────────────────────── +/* ── Office Location (a Example Client ticket's yellow note) ────────────────────── The note is amber because it IS amber on the page; everything else stays on the neutral surfaces. No brand ramp here -- it is reserved for its six things (DESIGN.md, "The One Loud Colour Rule"). */ diff --git a/rpa-dashboard-ai/templates/reference/web/index.html b/rpa-dashboard-ai/templates/reference/web/index.html index a78f364..c21e562 100644 --- a/rpa-dashboard-ai/templates/reference/web/index.html +++ b/rpa-dashboard-ai/templates/reference/web/index.html @@ -1,4 +1,5 @@ + diff --git a/rpa-dashboard-ai/templates/reference/web/theme.tsx b/rpa-dashboard-ai/templates/reference/web/theme.tsx index 859ef4a..2f1f026 100644 --- a/rpa-dashboard-ai/templates/reference/web/theme.tsx +++ b/rpa-dashboard-ai/templates/reference/web/theme.tsx @@ -1,3 +1,4 @@ +/* EXAMPLE ONLY: an extract from the Superbill Review prototype, kept as reference for porting. ECW/eClinicalWorks, Tebra, 'Example Client', SPB_* tables and patient/ticket wording are that project's examples. Don't copy them into a new project. */ import { createContext, useContext, useEffect, useState } from 'react' /** diff --git a/rpa-dashboard-ai/templates/reference/web/types.ts b/rpa-dashboard-ai/templates/reference/web/types.ts index e459668..36b492a 100644 --- a/rpa-dashboard-ai/templates/reference/web/types.ts +++ b/rpa-dashboard-ai/templates/reference/web/types.ts @@ -1,3 +1,4 @@ +/* EXAMPLE ONLY: an extract from the Superbill Review prototype, kept as reference for porting. ECW/eClinicalWorks, Tebra, 'Example Client', SPB_* tables and patient/ticket wording are that project's examples. Don't copy them into a new project. */ import type { Band } from './bands' /** @@ -21,7 +22,7 @@ export type Project = 'ecw' | 'tebra' * stage a card can be dropped into. * * Two projects live in one rail: the ECW boards (templates 1-6) and the Tebra - * boards (template 7 / Gulf Coast). Tebra ids are prefixed because a bare + * boards (template 7 / Example Client). Tebra ids are prefixed because a bare * 'extraction' can no longer identify a board on its own — see project.ts for * the tab -> (project, stage) mapping. */ @@ -118,7 +119,7 @@ export interface ReviewRow { patient: string | null /** * 0 for every template except one that can carry more than one row per - * (page, patient, field) — Gulf Coast's repeated CPT lines, one per coder + * (page, patient, field) — Example Client's repeated CPT lines, one per coder * annotation on the same ticket. */ line_index: number diff --git a/rpa-dashboard-ai/templates/web/bands.py b/rpa-dashboard-ai/templates/web/bands.py new file mode 100644 index 0000000..364f09e --- /dev/null +++ b/rpa-dashboard-ai/templates/web/bands.py @@ -0,0 +1,47 @@ +"""Confidence bands: the single source of truth for the cut points. + +Copy this file into the backend. Everything that needs a band (the review queue, +an exported sheet, the approve gate) imports from here, so they never disagree +about what counts as red. + + GREEN >= 0.95 clean print, straight through + AMBER 0.90-0.95 slightly unclear, sampled + RED < 0.90 handwriting or a messy scan, always reviewed + BLANK a mandatory field with no value, always flagged + UNSCORED no word scores came back for the value + +Calibration (from the prototype these rules came from): crisp print scored 0.90 and +up, handwriting clustered in 0.80-0.89, and 0.70-0.79 was empty. 0.90 sits in that +gap. 0.95 splits off the 0.90-0.99 band that reviewers sample rather than trust. + +What a band claims: the score is the model's own legibility rating. Green means the +text was read cleanly, NOT that the value is correct; a confident misread scores +green. Red is the useful direction: it reliably surfaces handwriting. +""" +from __future__ import annotations + +from typing import Iterable, Optional + +GREEN_THRESHOLD = 0.95 +CONFIDENCE_THRESHOLD = 0.90 +BANDS = ("green", "amber", "red", "blank", "unscored") + + +def band_of(score: float) -> str: + if score >= GREEN_THRESHOLD: + return "green" + return "amber" if score >= CONFIDENCE_THRESHOLD else "red" + + +def field_band(word_scores: Optional[Iterable[float]], value: Optional[str], mandatory: bool = False) -> str: + """A field's band. It takes its WORST word score, so one shaky word can't hide in + a clean-looking field. A blank mandatory field is flagged whatever its score.""" + if mandatory and not (value or "").strip(): + return "blank" + scores = list(word_scores or []) + return band_of(min(scores)) if scores else "unscored" + + +def needs_review(band: str) -> bool: + """Every band but green must be verified by a person before the run can be approved.""" + return band != "green" diff --git a/rpa-dashboard-ai/templates/web/index.html b/rpa-dashboard-ai/templates/web/index.html new file mode 100644 index 0000000..1398304 --- /dev/null +++ b/rpa-dashboard-ai/templates/web/index.html @@ -0,0 +1,27 @@ + + + + + + +{{APP_NAME}} + + + +
+ + + diff --git a/rpa-dashboard-ai/templates/web/package-lock.json b/rpa-dashboard-ai/templates/web/package-lock.json new file mode 100644 index 0000000..98ed2d5 --- /dev/null +++ b/rpa-dashboard-ai/templates/web/package-lock.json @@ -0,0 +1,2030 @@ +{ + "name": "rpa-ai-dashboard", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rpa-ai-dashboard", + "dependencies": { + "react": "^19.3.1", + "react-dom": "^19.3.1" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^6.1.2", + "tailwindcss": "^4.3.3", + "typescript": "^7.0.2", + "vite": "^8.3.4" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.154.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz", + "integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz", + "integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz", + "integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz", + "integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz", + "integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz", + "integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz", + "integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz", + "integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz", + "integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz", + "integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz", + "integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz", + "integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz", + "integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz", + "integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz", + "integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz", + "integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz", + "integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.152.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", + "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/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/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz", + "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz", + "integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.1" + } + }, + "node_modules/rolldown": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz", + "integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.154.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.14", + "@rolldown/binding-android-arm64": "1.2.14", + "@rolldown/binding-darwin-arm64": "1.2.14", + "@rolldown/binding-darwin-x64": "1.2.14", + "@rolldown/binding-freebsd-x64": "1.2.14", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.14", + "@rolldown/binding-linux-arm64-gnu": "1.2.14", + "@rolldown/binding-linux-arm64-musl": "1.2.14", + "@rolldown/binding-linux-ppc64-gnu": "1.2.14", + "@rolldown/binding-linux-s390x-gnu": "1.2.14", + "@rolldown/binding-linux-x64-gnu": "1.2.14", + "@rolldown/binding-linux-x64-musl": "1.2.14", + "@rolldown/binding-openharmony-arm64": "1.2.14", + "@rolldown/binding-win32-arm64-msvc": "1.2.14", + "@rolldown/binding-win32-x64-msvc": "1.2.14" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/vite": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + } + } +} diff --git a/rpa-dashboard-ai/templates/web/package.json b/rpa-dashboard-ai/templates/web/package.json new file mode 100644 index 0000000..5907075 --- /dev/null +++ b/rpa-dashboard-ai/templates/web/package.json @@ -0,0 +1,22 @@ +{ + "name": "rpa-ai-dashboard", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build" + }, + "dependencies": { + "react": "^19.3.1", + "react-dom": "^19.3.1" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^6.1.2", + "tailwindcss": "^4.3.3", + "typescript": "^7.0.2", + "vite": "^8.3.4" + } +} diff --git a/rpa-dashboard-ai/templates/web/pricing.py b/rpa-dashboard-ai/templates/web/pricing.py new file mode 100644 index 0000000..320741f --- /dev/null +++ b/rpa-dashboard-ai/templates/web/pricing.py @@ -0,0 +1,32 @@ +"""Pricing table and cost per call for the cost page. The backend imports it. + +Rates are USD per million tokens. Thinking tokens bill at the output rate. A model +with "long_threshold" switches both rates to its *_long rates when a call's input +tokens go above that threshold. + +Replace the table with the rates of the provider and models the project really uses, +copied from the provider's pricing page, and update PRICING_SOURCE and PRICING_AS_OF. +Never guess a rate: a model missing here is an error, never a cost of zero. +""" + +PRICING_SOURCE = "https://ai.google.dev/gemini-api/docs/pricing" +PRICING_AS_OF = "2026-08-11" + +PRICING = { + "gemini-2.5-pro": {"input": 1.25, "output": 10.00, + "long_threshold": 200_000, "input_long": 2.50, "output_long": 15.00}, + "gemini-2.5-flash": {"input": 0.30, "output": 2.50}, + "gemini-2.5-flash-lite": {"input": 0.10, "output": 0.40}, +} + + +def cost_for_call(model, input_tokens, output_tokens, thinking_tokens=0): + """USD for one call. Raises ValueError, naming the known models, for a model not in PRICING.""" + if model not in PRICING: + raise ValueError(f"No pricing for model '{model}'. Add it to PRICING from {PRICING_SOURCE}. " + f"Known models: {', '.join(PRICING)}") + p = PRICING[model] + in_rate, out_rate = p["input"], p["output"] + if "long_threshold" in p and input_tokens > p["long_threshold"]: + in_rate, out_rate = p["input_long"], p["output_long"] + return (input_tokens * in_rate + (output_tokens + thinking_tokens) * out_rate) / 1_000_000 diff --git a/rpa-dashboard-ai/templates/web/server_stub.py b/rpa-dashboard-ai/templates/web/server_stub.py new file mode 100644 index 0000000..435fba4 --- /dev/null +++ b/rpa-dashboard-ai/templates/web/server_stub.py @@ -0,0 +1,219 @@ +"""Sample-data backend for the AI review dashboard: runs, review rows and the approve gate. + + npm run build && python server_stub.py # page + API on http://127.0.0.1:8000 + python server_stub.py & npm run dev # or Vite on :5173, proxying /api here + +It stands in for the project's backend until that exists. A real backend serves the +same endpoints (templates/contract.md) with the same rules: bands from bands.py, and +approval refused while any flagged field is unverified. The page never decides that. +Standard library only. +""" +import json +import re +import threading +import time +from html import escape +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import urlparse + +from bands import BANDS, field_band, needs_review +from pricing import PRICING_AS_OF, PRICING_SOURCE, cost_for_call + +HERE = Path(__file__).resolve().parent +API = "/api" # same as API_BASE in index.html +REASON = {"green": None, "amber": "Slightly unclear, sample check", "red": "Unclear print, check every word", + "blank": "Required field is empty", "unscored": "Not found in the transcription"} +# (field, value, word scores, mandatory). Swap in the fields the project's extraction returns. +SAMPLE_FIELDS = [ + ("Invoice number", "INV-1001", [1.0], True), + ("Invoice date", "2026-10-01", [0.99, 0.97], True), + ("Vendor", "Contoso Supplies", [0.98, 0.92], True), + ("PO number", "PO-4471", [0.86], False), + ("Due date", "", [], True), + ("Total", "2,450.00", [0.99], True), + ("Tax", "196.00", [], False), +] +RUNS, ROWS = {}, {} +ROI_RUNS = [] # [{job_id, document, created_at, model, input_tokens, output_tokens, thinking_tokens}] +_next_row = [1] + + +def put_run(job_id, document, fields, status="done", pages_done=1, pages_total=1, model="sample-model"): + """Add or replace a run. fields: [(field, value, word scores, mandatory)].""" + RUNS[job_id] = {"job_id": job_id, "document": document, "status": status, "model": model, + "pages_done": pages_done, "pages_total": pages_total, "approved": False, "error": None} + ROWS[job_id] = [] + for field, value, words, mandatory in fields: + band = field_band(words, value, mandatory) + ROWS[job_id].append({"row_id": _next_row[0], "field": field, "value": value, "band": band, + "score": min(words) if words else None, "reason": REASON[band], + "verified": False, "corrected_value": None}) + _next_row[0] += 1 + return RUNS[job_id] + + +def reset(): + RUNS.clear() + ROWS.clear() + put_run("job-1002", "invoice-1002.pdf", SAMPLE_FIELDS, status="running", pages_done=1, pages_total=3) + put_run("job-1001", "invoice-1001.pdf", SAMPLE_FIELDS, pages_total=2, pages_done=2) + ROI_RUNS.clear() + now = time.time() + for i, (model, tokens) in enumerate([("gemini-2.5-flash", (21_000, 1_800, 900)), + ("gemini-2.5-flash", (18_500, 1_600, 0)), + ("gemini-2.5-pro", (240_000, 3_000, 2_500)), + ("gemini-2.5-flash-lite", (22_000, 1_700, 0)), + ("gemini-2.5-flash", (None, None, None))]): # usage not recorded + ROI_RUNS.append({"job_id": f"job-{900 + i}", "document": f"invoice-{900 + i}.pdf", + "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now - i * 12 * 864e2)), + "model": model, **dict(zip(("input_tokens", "output_tokens", "thinking_tokens"), tokens))}) + + +reset() # sample data from import on: the desktop bridge imports this module without a server + + +def run_view(job_id): + run, rows = RUNS[job_id], ROWS[job_id] + done = run["status"] == "done" + return {**run, "summary": {b: sum(r["band"] == b for r in rows) for b in BANDS} if done else None} + + +def unverified(job_id): + return [r for r in ROWS[job_id] if needs_review(r["band"]) and not r["verified"]] + + +# ---- routes: (method, path regex, handler(match groups..., body) -> (status, payload)) ---- +def list_runs(body): + return 200, {"runs": [run_view(j) for j in RUNS]} + + +def get_run(job_id, body): + return 200, run_view(job_id) + + +def review(job_id, body): + return 200, {"rows": ROWS[job_id] if RUNS[job_id]["status"] == "done" else []} + + +def patch_row(row_id, body): + row = next(r for rows in ROWS.values() for r in rows if r["row_id"] == int(row_id)) + row.update({k: body[k] for k in ("corrected_value", "verified") if k in body}) + return 200, row + + +def verify_remaining(job_id, body): + left = unverified(job_id) + for r in left: + r["verified"] = True + return 200, {"verified": len(left)} + + +def approve(job_id, body): + left = unverified(job_id) + if RUNS[job_id]["status"] != "done": + return 409, {"detail": "The run hasn't finished extracting."} + if left: # the approve gate + return 409, {"detail": f"{len(left)} flagged field(s) still need verifying."} + RUNS[job_id]["approved"] = True + return 200, {"ok": True} + + +def roi(body): + """Cost per run. total_cost is null when the run recorded no token usage: unpriced, never zero. + An unknown model is an error (500 naming the known models), never a cost of zero.""" + try: + runs = [{**r, "total_cost": None if r["input_tokens"] is None else + cost_for_call(r["model"], r["input_tokens"], r["output_tokens"], r["thinking_tokens"] or 0)} + for r in ROI_RUNS] + except ValueError as e: + return 500, {"detail": str(e)} + return 200, {"runs": runs, "pricing": {"source": PRICING_SOURCE, "as_of": PRICING_AS_OF}} + + +def document(job_id, body): + """A stand-in for the source document. A real backend returns the PDF or page image.""" + lines = "".join(f'{escape(f)}: {escape(v)}' + for i, (f, v, *_) in enumerate(SAMPLE_FIELDS)) + svg = (f'' + f'' + f'{escape(RUNS[job_id]["document"])} (sample){lines}') + return 200, svg + + +ROUTES = [ + ("GET", r"/runs", list_runs), + ("GET", r"/runs/([\w-]+)", get_run), + ("GET", r"/runs/([\w-]+)/review", review), + ("GET", r"/runs/([\w-]+)/document", document), + ("PATCH", r"/review/(\d+)", patch_row), + ("POST", r"/runs/([\w-]+)/verify-remaining", verify_remaining), + ("POST", r"/runs/([\w-]+)/approve", approve), + ("GET", r"/roi", roi), + ("POST", r"/set-dark", lambda body: (200, {})), +] + + +def dispatch(method, path, body): + """One API request -> (status, payload). The web handler and the desktop bridge both call this.""" + for m, pattern, fn in ROUTES: + match = re.fullmatch(pattern, path) + if m == method and match: + try: + return fn(*match.groups(), body) + except (KeyError, StopIteration): + return 404, {"detail": "Not found"} + return 404, {"detail": f"No route for {method} {path}"} + + +class Handler(SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=str(HERE / "dist"), **kwargs) + + def log_message(self, *args): + pass + + def do_GET(self): + self.api("GET") if self.path.startswith(API + "/") else super().do_GET() + + def do_POST(self): + self.api("POST") + + def do_PATCH(self): + self.api("PATCH") + + def api(self, method): + path = urlparse(self.path).path[len(API):] + size = int(self.headers.get("Content-Length") or 0) + body = json.loads(self.rfile.read(size) or b"{}") if size else {} + status, payload = dispatch(method, path, body) + svg = isinstance(payload, str) + data = payload.encode() if svg else json.dumps(payload).encode() + self.send_response(status) + self.send_header("Content-Type", "image/svg+xml" if svg else "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + +def make_server(host="127.0.0.1", port=8000): + reset() + return ThreadingHTTPServer((host, port), Handler) + + +def simulate_extraction(): + """Demo only: the running sample run reads one page every 3 seconds, then finishes.""" + while True: + time.sleep(3) + for run in RUNS.values(): + if run["status"] == "running": + run["pages_done"] += 1 + if run["pages_done"] >= run["pages_total"]: + run["status"] = "done" + + +if __name__ == "__main__": + server = make_server() + threading.Thread(target=simulate_extraction, daemon=True).start() + print("Sample backend on http://127.0.0.1:8000 (Ctrl+C to stop)") + server.serve_forever() diff --git a/rpa-dashboard-ai/templates/web/src/App.tsx b/rpa-dashboard-ai/templates/web/src/App.tsx new file mode 100644 index 0000000..64f4946 --- /dev/null +++ b/rpa-dashboard-ai/templates/web/src/App.tsx @@ -0,0 +1,108 @@ +import { useState } from 'react' +import { act, clearRun, toggleDark, useStore } from './store' +import { Overview } from './components/Overview' +import { RunPanel } from './components/RunPanel' +import { Report } from './components/Report' +import { Review } from './components/Review' +import { Cost } from './components/Cost' + +const TABS = { + dashboard: ['Dashboard', 'Overview of every batch', <>], + run: ['Run', 'Live progress and log', ], + review: ['Review', 'Check extracted fields against the document', <>], + report: ['Report', 'Result of every processed item', <>], + cost: ['Cost', 'Tokens, spend and the case against doing it by hand', <>], + // OPTIONAL: admin/roles tab goes here +} as const +type Tab = keyof typeof TABS + +const DOT = { ready: 'bg-ok', done: 'bg-ok', running: 'bg-warn animate-pulse', error: 'bg-bad' } + +export function App() { + const { status, user, dark } = useStore() + const [tab, setTab] = useState('dashboard') + const [unsaved, setUnsaved] = useState(0) // review edits not saved yet + + const show = (t: Tab) => { + if (t === tab) return + if (unsaved && !window.confirm(`${unsaved} unsaved change(s) on this document. Discard them and leave?`)) return + setUnsaved(0) + setTab(t) + if (t === 'dashboard') act('load_dashboard') + if (t === 'report') act('load_latest_report') + } + const run = () => { + show('run') + clearRun() + act('start_automation') + } + + return ( +
+ + +
+
+
+
{TABS[tab][0]}
+
{TABS[tab][1]}
+
+
+ + +
+ + {status.msg || status.state} +
+
+
+ +
+ {tab === 'dashboard' && } + {tab === 'run' && } + {tab === 'review' && } + {tab === 'report' && } + {tab === 'cost' && } +
+
+
+ ) +} diff --git a/rpa-dashboard-ai/templates/web/src/ai.ts b/rpa-dashboard-ai/templates/web/src/ai.ts new file mode 100644 index 0000000..cfbdd9f --- /dev/null +++ b/rpa-dashboard-ai/templates/web/src/ai.ts @@ -0,0 +1,71 @@ +/** The AI contract additions (see contract.md): runs, review rows, the approve gate. */ +export type Band = 'green' | 'amber' | 'red' | 'blank' | 'unscored' +export type RunStatus = 'pending' | 'queued' | 'running' | 'done' | 'failed' +export type Run = { + job_id: string + document: string + status: RunStatus + model: string + pages_done: number + pages_total: number + summary: Record | null // null until the run is done + approved: boolean + error: string | null +} +export type ReviewRow = { + row_id: number + field: string + value: string | null + band: Band + score: number | null + reason: string | null + verified: boolean + corrected_value: string | null +} + +/** One run on the cost page. total_cost is null when the run recorded no token usage (unpriced). */ +export type RoiRun = { + job_id: string + document: string + created_at: string // ISO, UTC + model: string + input_tokens: number | null + output_tokens: number | null + thinking_tokens: number | null + total_cost: number | null // USD +} +export type Pricing = { source: string; as_of: string } + +export const BUSY: RunStatus[] = ['queued', 'running'] // polled every 1.5s while in one of these + +/** Legend order, label, and the colour token. Band colours are semantic: never on buttons or headings. */ +export const BANDS: { band: Band; label: string; colour: string }[] = [ + { band: 'green', label: 'Clean ≥0.95', colour: 'var(--clean)' }, + { band: 'amber', label: 'Check 0.90–0.95', colour: 'var(--verify)' }, + { band: 'red', label: 'Review <0.90', colour: 'var(--review)' }, + { band: 'blank', label: 'Missing (required)', colour: 'var(--absent)' }, + { band: 'unscored', label: 'Unscored', colour: 'var(--absent)' }, +] +export const CAVEAT = 'Colour reflects print clarity, not correctness. A cleanly printed value can still be misread.' + +async function req(path: string, init?: RequestInit): Promise { + const res = await fetch(API_BASE + path, { ...init, headers: { 'Content-Type': 'application/json' } }) + if (!res.ok) { + // The server says why it refused (the approve gate answers 409 with the count). + const body = await res.json().catch(() => null) + throw new Error(body?.detail ?? `${res.status} ${res.statusText}`) + } + return res.json() +} + +export const ai = { + runs: () => req<{ runs: Run[] }>('/runs'), + run: (id: string) => req(`/runs/${id}`), + rows: (id: string) => req<{ rows: ReviewRow[] }>(`/runs/${id}/review`), + patchRow: (rowId: number, body: { corrected_value?: string | null; verified: boolean }) => + req(`/review/${rowId}`, { method: 'PATCH', body: JSON.stringify(body) }), + verifyRemaining: (id: string) => req<{ verified: number }>(`/runs/${id}/verify-remaining`, { method: 'POST' }), + approve: (id: string) => req<{ ok: boolean }>(`/runs/${id}/approve`, { method: 'POST' }), + roi: () => req<{ runs: RoiRun[]; pricing: Pricing }>('/roi'), + documentUrl: (id: string) => `${API_BASE}/runs/${id}/document`, +} diff --git a/rpa-dashboard-ai/templates/web/src/components/Cost.tsx b/rpa-dashboard-ai/templates/web/src/components/Cost.tsx new file mode 100644 index 0000000..036bcb2 --- /dev/null +++ b/rpa-dashboard-ai/templates/web/src/components/Cost.tsx @@ -0,0 +1,133 @@ +import { useEffect, useState } from 'react' +import { ai, type Pricing, type RoiRun } from '../ai' + +const money = (n: number) => `$${n.toFixed(n > 0 && n < 0.01 ? 4 : 2)}` +const num = (n: number | null) => (n == null ? '—' : n.toLocaleString('en-US')) +const monthOf = (iso: string) => iso.slice(0, 7) // 'YYYY-MM', UTC +const monthLabel = (ym: string) => + new Date(`${ym}-01T00:00:00Z`).toLocaleString('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' }) + +/** + * The cost page. Two kinds of number, kept apart: + * measured: tokens and cost per run, priced by the backend's pricing table; + * assumed: minutes per document and an hourly rate, typed by the reader and labelled so. + * A run with no recorded token usage is "not priced", never $0, and the coverage pill says how many. + */ +export function Cost() { + const [runs, setRuns] = useState([]) + const [pricing, setPricing] = useState() + const [error, setError] = useState() + const [minutes, setMinutes] = useState(5) // assumption: minutes to key one document by hand + const [rate, setRate] = useState(20) // assumption: USD per hour of that person's time + + useEffect(() => { + ai.roi().then((d) => { setRuns(d.runs); setPricing(d.pricing) }).catch((e) => setError(e.message)) + }, []) + + const priced = runs.filter((r) => r.total_cost != null) + const sum = (rs: RoiRun[]) => rs.reduce((n, r) => n + (r.total_cost ?? 0), 0) + const thisMonth = new Date().toISOString().slice(0, 7) + const months = [...new Set(runs.map((r) => monthOf(r.created_at)))].sort().reverse() + .map((m) => { + const rs = runs.filter((r) => monthOf(r.created_at) === m) + return { m, docs: rs.length, priced: rs.filter((r) => r.total_cost != null).length, cost: sum(rs) } + }) + const tokens = (k: 'input_tokens' | 'output_tokens' | 'thinking_tokens') => priced.reduce((n, r) => n + (r[k] ?? 0), 0) + const byHand = (priced.length * minutes / 60) * rate + + if (error) return
Can't load costs from {API_BASE}/roi: {error}
+ + return ( + <> +
+ + {priced.length} of {runs.length} documents priced + + {pricing && ( + + Rates from {pricing.source}, as of {pricing.as_of} + + )} +
+ +
+ + monthOf(r.created_at) === thisMonth)))} note={monthLabel(thisMonth)} /> + + +
+ +
+
Month by month
+
+ + + + {months.map((x) => ( + + + + + + ))} + +
MonthDocuments pricedSpend
{monthLabel(x.m)}{x.m === thisMonth && this month}{x.priced} of {x.docs}{x.priced ? money(x.cost) : '—'}
+
+
+ +
+
Against doing it by hand
+

These are your assumptions, not a measurement. Change them to match the team.

+
+ + +
+

+ By hand, {priced.length} documents would take {num(Math.round(priced.length * minutes / 6) / 10)} h, about {money(byHand)}. + The AI spent {money(sum(priced))} on them. +

+
+ + {/* OPTIONAL: model picker (per batch) goes here */} + +
+
Cost per run
+
+ + + + + + {runs.map((r) => ( + + + + + + ))} + +
DocumentDateModelInputOutputThinkingCost
{r.document}{r.created_at.slice(0, 10)}{r.model}{num(r.input_tokens)}{num(r.output_tokens)}{num(r.thinking_tokens)}{r.total_cost == null ? not priced : money(r.total_cost)}
+
+
+ + ) +} + +function Tile({ name, value, note }: { name: string; value: string; note: string }) { + return ( +
+
{name}
+
{value}
+
{note}
+
+ ) +} +const Th = ({ children, right }: { children: React.ReactNode; right?: boolean }) => + {children} +const Td = ({ children, right }: { children: React.ReactNode; right?: boolean }) => + {children} diff --git a/rpa-dashboard-ai/templates/web/src/components/Overview.tsx b/rpa-dashboard-ai/templates/web/src/components/Overview.tsx new file mode 100644 index 0000000..861d340 --- /dev/null +++ b/rpa-dashboard-ai/templates/web/src/components/Overview.tsx @@ -0,0 +1,81 @@ +import { useStore } from '../store' + +// batch state -> [label, colour token, pill colour] +export const STATES: Record = { + completed: ['Completed', 'var(--ok)', 'bg-ok'], + inprogress: ['In progress', 'var(--warn)', 'bg-warn'], + notcompleted: ['Not completed', 'var(--bad)', 'bg-bad'], + cleared: ['Cleared', 'var(--muted)', 'bg-muted'], +} + +function Kpi({ label, n, colour, live }: { label: string; n: number; colour: string; live?: boolean }) { + return ( +
+
+
+
{n}
+
{label}
+
+
+ ) +} + +export function Overview() { + const { batches } = useStore() + const count = Object.fromEntries(Object.keys(STATES).map((k) => [k, batches.filter((b) => b.state === k).length])) + const total = Object.values(count).reduce((a, x) => a + x, 0) + let acc = 0 + const stops = Object.keys(STATES).flatMap((k) => { + if (!count[k]) return [] + const from = (acc / total) * 100 + acc += count[k] + return [`${STATES[k][1]} ${from}% ${(acc / total) * 100}%`] + }) + + return ( +
+
+ + + 0} /> + +
+
+
+
Batch status split
+
+
`${STATES[k][0]} ${count[k]}`).join(', ') : 'no batches yet')} + style={{ background: total ? `conic-gradient(${stops.join(',')})` : 'conic-gradient(var(--border) 0 100%)' }}> +
+
{batches.length}
+
batches
+
+
+
    + {Object.keys(STATES).map((k) => ( +
  • + + {STATES[k][0]} · {count[k]} +
  • + ))} +
+
+
+
+
Recent batches
+ {batches.length === 0 &&
No batches yet
} + {batches.slice(0, 6).map((b) => { + const s = STATES[b.state] ?? [b.state, '', 'bg-warn'] + return ( +
+ #{b.batch_id} · {b.date} + {s[0]} +
+ ) + })} +
+
+
+ ) +} diff --git a/rpa-dashboard-ai/templates/web/src/components/Report.tsx b/rpa-dashboard-ai/templates/web/src/components/Report.tsx new file mode 100644 index 0000000..d411ea5 --- /dev/null +++ b/rpa-dashboard-ai/templates/web/src/components/Report.tsx @@ -0,0 +1,50 @@ +import { useState } from 'react' +import { useStore, type ReportRow } from '../store' + +const PILL: Record = { success: 'bg-ok', attention: 'bg-warn', failed: 'bg-bad' } + +// ponytail: a CSV that Excel opens. Have the backend send a real .xlsx if the sheet needs formatting. +function exportCsv(rows: ReportRow[]) { + const cell = (v: unknown) => `"${String(v ?? '').replace(/"/g, '""')}"` + const csv = [REPORT_COLUMNS.map((c) => cell(c.label)), ...rows.map((r) => REPORT_COLUMNS.map((c) => cell(r[c.key])))] + .map((line) => line.join(',')).join('\r\n') + const a = document.createElement('a') + a.href = URL.createObjectURL(new Blob(['' + csv], { type: 'text/csv' })) + a.download = 'report.csv' + a.click() + URL.revokeObjectURL(a.href) +} + +export function Report() { + const { reportRows } = useStore() + const [q, setQ] = useState('') + const rows = reportRows.filter((r) => !q || Object.values(r).join(' ').toLowerCase().includes(q.toLowerCase())) + + return ( +
+
+ setQ(e.target.value)} /> + +
+
+ + + {REPORT_COLUMNS.map((c) => )} + + + {rows.length === 0 && } + {rows.map((r, i) => ( + + {REPORT_COLUMNS.map((c) => ( + + ))} + + ))} + +
{c.label}
No rows yet
+ {c.key === 'status' ? {r.status} : String(r[c.key] ?? '')} +
+
+
+ ) +} diff --git a/rpa-dashboard-ai/templates/web/src/components/Review.tsx b/rpa-dashboard-ai/templates/web/src/components/Review.tsx new file mode 100644 index 0000000..c1cbd53 --- /dev/null +++ b/rpa-dashboard-ai/templates/web/src/components/Review.tsx @@ -0,0 +1,184 @@ +import { useEffect, useState } from 'react' +import { ai, BANDS, BUSY, CAVEAT, type Band, type ReviewRow, type Run } from '../ai' + +const FILTERS: { label: string; band: Band | 'all' }[] = [ + { label: 'All', band: 'all' }, { label: 'Red', band: 'red' }, { label: 'Amber', band: 'amber' }, + { label: 'Missing', band: 'blank' }, { label: 'Unscored', band: 'unscored' }, +] +const colourOf = (b: Band) => BANDS.find((x) => x.band === b)!.colour +const settled = (r: ReviewRow) => r.corrected_value ?? r.value ?? '' + +/** + * The review split pane: the source document beside its extracted fields. + * Typing makes a draft; nothing is written until Save. Verify signs off one field + * as it reads. Approve asks the server, which refuses while flagged fields are unverified. + */ +export function Review({ onUnsaved }: { onUnsaved: (n: number) => void }) { + const [runs, setRuns] = useState() + const [jobId, setJobId] = useState() + const [run, setRun] = useState() + const [rows, setRows] = useState([]) + const [drafts, setDrafts] = useState>({}) + const [filter, setFilter] = useState('all') + const [refusal, setRefusal] = useState() + const [error, setError] = useState() + const [saving, setSaving] = useState(false) + + const dirty = rows.filter((r) => r.row_id in drafts && drafts[r.row_id] !== settled(r)) + useEffect(() => onUnsaved(dirty.length), [dirty.length, onUnsaved]) + useEffect(() => { + if (!dirty.length) return + const warn = (e: BeforeUnloadEvent) => e.preventDefault() + window.addEventListener('beforeunload', warn) + return () => window.removeEventListener('beforeunload', warn) + }, [dirty.length]) + + useEffect(() => { + ai.runs().then((d) => { setRuns(d.runs); setJobId(d.runs[0]?.job_id) }) + .catch((e) => setError(`Can't load runs from ${API_BASE}: ${e.message}. Is the backend (or server_stub.py) running?`)) + }, []) + + // Load the chosen run, and poll it every 1.5s while it is still extracting. + useEffect(() => { + if (!jobId) return + let alive = true + let timer: ReturnType + setRun(undefined); setRows([]); setDrafts({}); setRefusal(undefined) + const tick = async () => { + try { + const r = await ai.run(jobId) + if (!alive) return + setRun(r) + if (BUSY.includes(r.status)) timer = setTimeout(tick, 1500) + else { const d = await ai.rows(jobId); if (alive) setRows(d.rows) } + } catch (e) { if (alive) setError((e as Error).message) } + } + tick() + return () => { alive = false; clearTimeout(timer) } + }, [jobId]) + + const reload = async () => { + if (!jobId) return + const [r, d] = await Promise.all([ai.run(jobId), ai.rows(jobId)]) + setRun(r); setRows(d.rows) + } + const pick = (id: string) => { + if (dirty.length && !window.confirm(`${dirty.length} unsaved change(s) on this document. Discard them and leave?`)) return + setJobId(id) + } + const save = async () => { + setSaving(true) + try { + await Promise.all(dirty.map((r) => { + const text = drafts[r.row_id].trim() + // Same as extracted (or emptied) means "the extracted value was right", not an edit. + return ai.patchRow(r.row_id, { corrected_value: text === '' || text === (r.value ?? '') ? null : text, verified: true }) + })) + setDrafts({}) + await reload() + } finally { setSaving(false) } + } + const verify = async (r: ReviewRow) => { await ai.patchRow(r.row_id, { verified: !r.verified }); await reload() } + const verifyRemaining = async () => { if (jobId) { await ai.verifyRemaining(jobId); await reload() } } + const approve = async () => { + if (!jobId) return + setRefusal(undefined) + try { await ai.approve(jobId); await reload() } catch (e) { setRefusal((e as Error).message) } + } + + if (error) return
{error}
+ if (!runs) return
Loading runs…
+ if (!runs.length) return
No documents extracted yet.
+ + const shown = filter === 'all' ? rows : rows.filter((r) => r.band === filter) + const left = rows.filter((r) => r.band !== 'green' && !r.verified).length + + return ( +
+
+ + {run && {run.status}} + {run && BUSY.includes(run.status) && page {run.pages_done} of {run.pages_total || '?'}} + {run?.error && {run.error}} + {/* OPTIONAL: retry on another model (failed runs) goes here */} + {run?.status === 'done' && ( +
+ {left} to verify + + {run.approved + ? ✓ Approved + : } +
+ )} + {refusal &&

Not approved: {refusal}

} +
+ +
+
    + {BANDS.map((b) => ( +
  • + + {b.label} · {run?.summary?.[b.band] ?? 0} +
  • + ))} +
+ {CAVEAT} +
+ {FILTERS.map((f) => ( + + ))} +
+
+ +
+