From 1ce99727606124b0f8b3dc3b88842410c874ee14 Mon Sep 17 00:00:00 2001 From: JesseMarkowitz Date: Sun, 6 Sep 2026 23:31:45 -0400 Subject: [PATCH] M8: the browser becomes the storyteller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interface was AI-DnD's with this product's features bolted into it. The navigation read Home · Adventures · Scenarios · Settings · AI Chat; starting a story meant first picking a *world*, and making a world meant a JSON stat-schema form, a story-card table and an art picker. The play screen had a Branches tab. The input had three modes. Sixteen of the sixteen controls on a two-turn story had no accessible name — they were single glyphs with a tooltip. All of that was measured in a real browser before anything was changed, and the measurements are in planning/reports/M8-IMPLEMENTATION-REPORT.md §C. Almost nothing underneath was wrong: the play loop, the history controls, the takes, the Save Points, the state correction and the knowledge library all worked. What was wrong was what a reader was asked to understand in order to use them. So the shape now is one entry point and one screen: Campaigns -> Campaign -> Story State · Knowledge · Context · Save Points · Settings Everything that is not the story lives in a panel that starts closed. The top navigation bar is hidden on the story screen entirely, because on that one screen the story is the interface. Play is one natural-language field. An action and a piece of quoted dialogue are both just what the reader wrote, and B01/B02 confirmed against a real narrator that the model reads the quotes without being told which kind of turn it is. What survives from the old Story mode is a Story direction toggle, which is not a fourth mode: it changes who is being spoken to, not what kind of action is taken, and the box is visibly marked while it is on. Branch, fork, node, merge and head appear nowhere a reader can see them. The branch panel and the tree overlay are gone from the browser. The mechanism is untouched — takes, divergence, retained futures and Save Points all still work, and their endpoints are still tested. This is a decision about what a reader is asked to understand, not a reduction of what the product can do. The two defects worth the space: A player action is stored with AI Dungeon's "> You " prefix. That was right when the Do mode asked for a bare verb phrase. With one field the spec tells the reader to write "I enter the tavern", and the result was "> You I enter the tavern." — in the transcript, in the replayed history, and therefore in the narration, where a small model imitates it and writes "You I thank her". M8's own design surfaced it, so M8 fixed it: the prefix is added only when the reader has not already written a subject. The ">" marker, which is what actually identifies a player turn in the prompt, is unchanged in every case. And a stale `.input-bar { display: flex }` in play.css overrode the new composer, because that sheet is imported after the new one. The direction row and the input row laid out side by side and the box was unusably narrow. Found by opening the product in a browser, not by reading the CSS — which is the argument for having done that first. Failures now have the taxonomy the spec asked for rather than one toast: model, generation, state, knowledge, server, each with the thing to do about it. A failed turn leaves the reader's words in the box and says so. The classification reads backend strings, so it is a fallback ladder rather than a lookup — an unrecognised message still classifies, still shows the server's own words and still offers Retry. `Settings.model` could be empty with nothing saying so until the first turn failed with a provider error. The header now reports Ollama in five states, and an unconfigured or missing model offers the models actually installed on the endpoint, from the connection test that already knew them. Nothing is chosen automatically: an endpoint's first model may be an embedding model, which cannot narrate at all. Narrator prose is rendered as safe Markdown — headings, emphasis, lists, blockquotes, code. The safety is structural rather than filtered: every node is a React element built from parsed text, and there is no dangerouslySetInnerHTML in the file. A sanitizer is not needed to make markup safe if markup is never produced from input. Link schemes are checked with the URL parser rather than a pattern, because the bypasses are all in the parsing. A remote image is a placeholder naming the blocked address; the knowledge and context panels deliberately do not use this renderer at all, because they exist to show a reader exactly what is in their file. Backend, and only what the browser could not otherwise reach: AdventureCreate.opening a start action could only come from a Scenario, so every campaign made in the new setup flow opened on a blank page. Same node, same code path. canon_rules campaign_canon has been the highest authority in a campaign since M5, read by the prompt builder and the state validator, and had no API at all — a fixture had to write it with SQL. a 401 and a 429 message the last user-facing text describing a hosted deployment. One told the reader to check an API key that has not existed since M2. No schema change and no migration: proved by building a database with a server running the M7 commit's own code and opening it with this one. The project had no frontend tests. It has 132 now, across ten files, running in about six seconds — the enabled state of every history control, the take selector, the confirmations, the panels, the five model states, the failure taxonomy, the focus trap, accessibility, and that the reserved dictation control never touches the microphone. Writing them found a real defect: the focus trap filtered candidates with offsetParent, which is null inside the fixed-position ancestor the dialog has and which jsdom never computes — it would have behaved differently in the tests from the browser. They do not replace the real-browser runs, and both kinds of evidence are in the report. The browser suites drive the production build served by the real backend with a real local narrator, including a genuine process restart. A verification pass over all of it then found three more, each by driving the product rather than reading it: Stepping between alternate takes did nothing. The pager asked whether a take lived on another line by comparing `target.branch_id !== action.branch_id`, and `ActionOut` has never carried `branch_id` — so the comparison was permanently `number !== undefined`, always true, and every step took the branch-switch path. For two takes of an ordinary retry, which share a line until one is written below, that meant switching to the line already being read: the same window came back and nothing moved. D07 is a required v1 acceptance test. The fix needed no new field — the variants list already carries every attempt's branch and marks the live one. The first regression test for that passed against the broken code, because its fixture gave the action a `branch_id` the real payload never sends. That is the exact failure M7's review was about, so the fixture was corrected, the tests were re-run against the reverted code and failed for the right reason, and the fixture now carries a docstring saying why the field must never come back. And the knowledge panel pointed readers at an "embedding model" while the setting is called "Model for meaning-based search" — a reader sent looking for a field that does not exist by that name. Campaign canon was measured rather than assumed. Editing it after play is a configuration change: every turn already played keeps the canon it was actually given, in its own context snapshot, and the accepted story, the state document and the state audit log are byte-identical across an edit. It is not routed through M5's state audit, because canon is not narrative state and doing so would create the second representation the spec forbids. What the editor does now is say so, once a campaign has moments. `BROWSER-UX-SPEC.md` §38 asked for a "Show Hidden Story State" toggle. There is no hidden story state — a secret lives in a narrator-only knowledge source and never enters the state document. The section is rewritten to require what it actually meant: ordinary surfaces must not carry narrator-only information, advanced inspection must withhold it by default behind an explicit warned choice, and no second store may be invented to give a toggle something to reveal. The protection is stricter than before, not weaker. Closeout. An independent review returned M8 IMPLEMENTATION: PASS subject to evidence and documentation cleanup, and this commit carries that cleanup: The report named two frontend bundles as the artifact behind its acceptance evidence. The saved run logs settle it. index-Ii-lARp9.js, built at 18:53:02 from this tree, is the one final frozen artifact behind all 157 browser checks; index-C6E5Uvtu.js is superseded — it predates the D09 fix and its acceptance suite ended 54/55 on exactly that defect. No tracked file under backend/app or frontend/src has a modification time after the freeze, so the whole final campaign describes one build. §P sets the two side by side. Finding 14 — the app budgets 16,384 prompt tokens while an Ollama that sees no VRAM enforces 4,096 — is resolved operationally, with no application change. The OpenAI-compatible endpoint this app speaks accepts num_ctx and ignores it, and reloads the model at its own default, so a native call cannot prime it either. A model derived with POST /api/create carries the parameter, is honoured through the app's own OpenAI-compatible path, and appears in /v1/models — which is the listing the Settings model picker already reads. Measured end to end. The procedure is in DEVELOPMENT.md; nothing in the repository depends on any particular derived model existing. Adding provider code to work around this was declined deliberately: it would mean either a second native request path, against ADR 011, or a parameter the endpoint provably ignores. The §38 rewrite is ratified as a requirement clarification aligned with the implemented architecture, and the spec gains the clause finding 3 was really about: withheld material must be absent from the rendered DOM, not merely collapsed in it. The report's §U carries the M9 handoff — what a portable campaign has to include, whether historical context snapshots belong in the bundle, what happens to inherited story cards, and that a restored campaign may meet a different context window than the one that wrote it. None of it is implemented here. Final: backend 950 passed / 14 skipped; frontend 132 passed; lint, production build and Docker build clean; 157 browser checks across six suites, zero failures. M8 is implemented, verified, reviewed and accepted (2026-09-06). M9 has not been started. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017HdaXiFbscatQaLS7dJk6b --- DEVELOPMENT.md | 100 +- README.md | 53 +- backend/app/models.py | 16 + backend/app/providers/openai_compatible.py | 28 +- backend/app/routers/adventures/crud.py | 53 +- backend/app/routers/adventures/turns.py | 32 +- backend/app/schemas.py | 25 + backend/tests/test_imported_knowledge.py | 23 +- backend/tests/test_m8_setup_surface.py | 202 ++ backend/tests/test_save_points.py | 58 +- backend/tests/test_take_parentage.py | 80 + frontend/package-lock.json | 2367 ++++++++++++++++- frontend/package.json | 11 +- frontend/src/App.jsx | 102 +- frontend/src/ArtPicker.jsx | 147 - frontend/src/BranchMap.jsx | 295 -- frontend/src/Dialog.jsx | 155 ++ frontend/src/Dialog.test.jsx | 157 ++ frontend/src/Embers.jsx | 110 - frontend/src/ExternalLinkDialog.jsx | 49 + frontend/src/ModelSetupNotice.jsx | 131 + frontend/src/ModelStatusBadge.jsx | 39 + frontend/src/SchemaEditor.jsx | 332 --- frontend/src/a11y.test.jsx | 166 ++ frontend/src/branches.js | 151 -- frontend/src/components.jsx | 57 - frontend/src/errors.js | 195 ++ frontend/src/index.css | 48 +- frontend/src/main.jsx | 20 +- frontend/src/markdown.jsx | 286 ++ frontend/src/markdown.test.jsx | 133 + frontend/src/modelStatus.jsx | 121 + frontend/src/modelStatus.test.jsx | 128 + frontend/src/pages/Adventures.jsx | 134 - frontend/src/pages/Campaigns.jsx | 189 ++ frontend/src/pages/Chat.jsx | 318 --- frontend/src/pages/Home.jsx | 237 -- frontend/src/pages/NewCampaign.jsx | 298 +++ frontend/src/pages/Play/Composer.jsx | 177 ++ frontend/src/pages/Play/Composer.test.jsx | 171 ++ frontend/src/pages/Play/FailureNotice.jsx | 86 + frontend/src/pages/Play/RefreshModal.jsx | 105 - frontend/src/pages/Play/SidePanel.jsx | 125 + frontend/src/pages/Play/TakePager.jsx | 31 +- frontend/src/pages/Play/TakePager.test.jsx | 158 ++ frontend/src/pages/Play/Transcript.jsx | 254 ++ frontend/src/pages/Play/Transcript.test.jsx | 218 ++ .../pages/Play/drawers/WorldStateDrawer.jsx | 298 --- frontend/src/pages/Play/editRouting.test.jsx | 62 + frontend/src/pages/Play/failurePaths.test.jsx | 75 + frontend/src/pages/Play/index.jsx | 982 +++---- .../src/pages/Play/panels/BranchPanel.jsx | 195 -- .../Play/panels/CampaignSettingsPanel.jsx | 195 ++ .../src/pages/Play/panels/ContextPanel.jsx | 407 +++ .../src/pages/Play/panels/InsightsPanel.jsx | 261 -- .../src/pages/Play/panels/KnowledgePanel.jsx | 517 ++-- .../src/pages/Play/panels/MemoryPanel.jsx | 151 -- frontend/src/pages/Play/panels/PlotPanel.jsx | 156 -- .../src/pages/Play/panels/panels.test.jsx | 490 ++++ frontend/src/pages/ScenarioEditor.jsx | 395 --- frontend/src/pages/Scenarios.jsx | 182 -- frontend/src/pages/Settings.jsx | 425 ++- frontend/src/styles/analytics.css | 287 -- frontend/src/styles/auth.css | 145 - frontend/src/styles/banners.css | 25 - frontend/src/styles/chat.css | 97 - frontend/src/styles/context.css | 168 ++ frontend/src/styles/debuglog.css | 56 + frontend/src/styles/dialogs.css | 71 + frontend/src/styles/drawers.css | 266 -- frontend/src/styles/library.css | 265 ++ frontend/src/styles/play.css | 196 +- frontend/src/styles/schema-editor.css | 249 -- frontend/src/styles/story.css | 406 +++ frontend/src/styles/tome.css | 475 ---- frontend/src/styles/tree-map.css | 159 -- frontend/src/test/helpers.jsx | 108 + frontend/src/test/setup.js | 21 + frontend/vite.config.js | 16 + planning/BROWSER-UX-SPEC.md | 94 +- planning/BUILD-MILESTONES.md | 111 +- planning/PROJECT-SOURCES.md | 14 +- planning/README.md | 77 +- planning/TECHNICAL-DESIGN.md | 77 + planning/V1-ACCEPTANCE-TESTS.md | 26 +- planning/VERSION.md | 145 +- planning/archive/README.md | 10 +- .../M7-IMPLEMENTATION-REPORT.md | 0 planning/reports/M8-IMPLEMENTATION-REPORT.md | 1791 +++++++++++++ 89 files changed, 11985 insertions(+), 6532 deletions(-) create mode 100644 backend/tests/test_m8_setup_surface.py delete mode 100644 frontend/src/ArtPicker.jsx delete mode 100644 frontend/src/BranchMap.jsx create mode 100644 frontend/src/Dialog.jsx create mode 100644 frontend/src/Dialog.test.jsx delete mode 100644 frontend/src/Embers.jsx create mode 100644 frontend/src/ExternalLinkDialog.jsx create mode 100644 frontend/src/ModelSetupNotice.jsx create mode 100644 frontend/src/ModelStatusBadge.jsx delete mode 100644 frontend/src/SchemaEditor.jsx create mode 100644 frontend/src/a11y.test.jsx delete mode 100644 frontend/src/branches.js create mode 100644 frontend/src/errors.js create mode 100644 frontend/src/markdown.jsx create mode 100644 frontend/src/markdown.test.jsx create mode 100644 frontend/src/modelStatus.jsx create mode 100644 frontend/src/modelStatus.test.jsx delete mode 100644 frontend/src/pages/Adventures.jsx create mode 100644 frontend/src/pages/Campaigns.jsx delete mode 100644 frontend/src/pages/Chat.jsx delete mode 100644 frontend/src/pages/Home.jsx create mode 100644 frontend/src/pages/NewCampaign.jsx create mode 100644 frontend/src/pages/Play/Composer.jsx create mode 100644 frontend/src/pages/Play/Composer.test.jsx create mode 100644 frontend/src/pages/Play/FailureNotice.jsx delete mode 100644 frontend/src/pages/Play/RefreshModal.jsx create mode 100644 frontend/src/pages/Play/SidePanel.jsx create mode 100644 frontend/src/pages/Play/TakePager.test.jsx create mode 100644 frontend/src/pages/Play/Transcript.jsx create mode 100644 frontend/src/pages/Play/Transcript.test.jsx delete mode 100644 frontend/src/pages/Play/drawers/WorldStateDrawer.jsx create mode 100644 frontend/src/pages/Play/editRouting.test.jsx create mode 100644 frontend/src/pages/Play/failurePaths.test.jsx delete mode 100644 frontend/src/pages/Play/panels/BranchPanel.jsx create mode 100644 frontend/src/pages/Play/panels/CampaignSettingsPanel.jsx create mode 100644 frontend/src/pages/Play/panels/ContextPanel.jsx delete mode 100644 frontend/src/pages/Play/panels/InsightsPanel.jsx delete mode 100644 frontend/src/pages/Play/panels/MemoryPanel.jsx delete mode 100644 frontend/src/pages/Play/panels/PlotPanel.jsx create mode 100644 frontend/src/pages/Play/panels/panels.test.jsx delete mode 100644 frontend/src/pages/ScenarioEditor.jsx delete mode 100644 frontend/src/pages/Scenarios.jsx delete mode 100644 frontend/src/styles/analytics.css delete mode 100644 frontend/src/styles/auth.css delete mode 100644 frontend/src/styles/banners.css delete mode 100644 frontend/src/styles/chat.css create mode 100644 frontend/src/styles/context.css create mode 100644 frontend/src/styles/debuglog.css create mode 100644 frontend/src/styles/dialogs.css delete mode 100644 frontend/src/styles/drawers.css create mode 100644 frontend/src/styles/library.css delete mode 100644 frontend/src/styles/schema-editor.css create mode 100644 frontend/src/styles/story.css delete mode 100644 frontend/src/styles/tome.css delete mode 100644 frontend/src/styles/tree-map.css create mode 100644 frontend/src/test/helpers.jsx create mode 100644 frontend/src/test/setup.js rename planning/{reports => archive/milestone-reports}/M7-IMPLEMENTATION-REPORT.md (100%) create mode 100644 planning/reports/M8-IMPLEMENTATION-REPORT.md diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index a8bb94b..af08856 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -214,13 +214,44 @@ visible from within. ## Tests ```bash -cd backend && .venv/bin/python -m pytest tests/ -q # 920 tests, 10 skipped +cd backend && .venv/bin/python -m pytest tests/ -q # the backend suite +cd frontend && npm test # the component suite (M8) cd frontend && npm run lint && npm run build ``` -Ten tests skip without something the machine may not have: seven need a second -machine or an environment the suite cannot create, and three are M7's real-model -tests below. +Fourteen backend tests skip without something the machine may not have: seven +need a second machine or an environment the suite cannot create, and the rest +are the real-model tests below. + +### The frontend component suite + +M8 added one, because until M8 there was none — the browser was covered by real +Firefox runs at each milestone's closeout and by nothing in between. It is +Vitest and Testing Library over jsdom, and it runs in about two seconds: + +```bash +cd frontend && npm test # once +cd frontend && npm run test:watch # while working +``` + +It covers the deterministic browser behaviour M8 owns: which history controls +are enabled and why, the take selector, the Save Point and delete confirmations, +what the State panel shows and does not, knowledge classification and semantic +status, the context inspector's sections, the model-empty and model-unavailable +states, how failures are presented, the dialog focus trap, and that the reserved +dictation control never touches the microphone. Several tests assert the absence +of branch vocabulary in the surfaces a reader uses. + +`markdown.test.jsx` is the security one. Narrator prose and imported text both +reach the renderer, so it is where H06 and H07 are decided: markup in the source +never becomes markup in the page, a `javascript:` URL never becomes an href, and +a remote image is a placeholder rather than a request. + +**It does not replace the real-browser runs.** jsdom has no layout, no +navigation and no network, so scroll behaviour, streaming, a genuine process +restart and the CSP are all outside its reach. Each milestone's closeout drives +a real Firefox over WebDriver, and that evidence is recorded in the milestone +report. Two files are the M1 regression guards. @@ -297,6 +328,58 @@ subsystem comes back as a route, if an API key becomes settable again, if the model timeout stops being configurable or becomes unbounded, or if a supported start path stops binding loopback. +## The context window your Ollama actually enforces + +**Check this before a long campaign.** The application budgets a prompt up to +`Settings.context_token_budget` (16,384 by default). Ollama enforces its own +input window, and when it sees no VRAM it defaults to **4,096**: + +``` +level=INFO msg="vram-based default context" total_vram="0 B" default_num_ctx=4096 +``` + +Confirm what yours is: + +```bash +curl -s http://127.0.0.1:11434/api/ps | python3 -m json.tool | grep context_length +``` + +If that number is smaller than your budget, Ollama silently truncates the input +— and `llama.cpp` drops the **oldest** tokens, which in this application is the +system block: the narrator rules and the campaign canon. The symptom is a +narrator that forgets canon deep into a long session, with nothing on screen +explaining why. + +**Setting it per request does not work from this application.** Ollama's +OpenAI-compatible endpoint accepts `num_ctx` — nested in `options` or at the top +level — returns HTTP 200 and ignores it. Worse, it *reloads the model at its own +default*, so priming the server with a native `/api/chat` call first does not +help either: the app's next request resets the window. + +**Bake it into a model instead.** The window travels with the model, and this +needs no shell access on the Ollama host — it is a normal API call: + +```bash +curl http://127.0.0.1:11434/api/create -d '{ + "model": "qwen2.5:3b-instruct-16k", + "from": "qwen2.5:3b-instruct", + "parameters": {"num_ctx": 16384} +}' +``` + +The derived model shares the base model's blobs, so it costs a manifest. It then +appears in `/v1/models`, which is the listing the Settings model picker reads — +select it there and the storyteller gets the full window through its ordinary +OpenAI-compatible path. Remove it with `POST /api/delete` when you are done. + +Where you *do* control the server environment, `OLLAMA_CONTEXT_LENGTH=16384` +does the same job. Either way a larger window costs roughly proportionally more +KV cache. + +If you would rather not raise it at all, set **How much story to send** in +Settings to the number `/api/ps` reports, and the prompt will be assembled to +fit. + ## What was made offline-safe, and how to check Two runtime downloads were removed in Milestone M1. Both were invisible on a @@ -350,5 +433,10 @@ left of upstream that a newcomer might report as a defect: - **`.github/workflows/ci.yml`** is upstream's GitHub Actions pipeline. This repository lives on a self-hosted Gitea; the workflow is kept for provenance and is not what runs the tests here. -- **No frontend tests.** `npm run lint && npm run build` is the whole frontend - check. A test runner is M8's job. +- **A thin `components.jsx`.** What is left of upstream's shared component + module is a toast host, a file picker, a JSON download and an auto-growing + textarea. M8 removed the rest with the screens that used them — the scenario + art generator, the placeholder modal, the story-card row. + +(Removed from this list by M8: **no frontend tests**. There is a component suite +now — see Tests above.) diff --git a/README.md b/README.md index 0578f6b..a67ee1a 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,11 @@ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) An interactive storytelling app that runs entirely on your own machine, with your own model. -Create scenarios and play open-ended adventures where a local LLM narrates the world, keeps -track of what is true, and remembers what happened. +Start a campaign from a short form and play an open-ended story where a local LLM narrates the +world, keeps track of what is true, and remembers what happened. Since M8 the browser has one +entry point — a campaign library — and one natural-language input; the scenario gallery and its +editor are gone from the interface, though the AI Dungeon-compatible scenario *format* is still +supported for import and export. This is the **Adventure Storyteller** fork of [AI-DnD](https://github.com/parththakkar106/AI-DnD). It is deliberately narrower than its upstream: single-user, local-only, and pointed at a model @@ -31,19 +34,26 @@ that isn't the live one starts a new branch. ## Features -- **The full play loop.** Do / Say / Story / Continue actions, streamed AI responses (SSE), - retry, undo, redo, and edit. Correcting narrator prose does not overwrite it: the correction - becomes a new continuation carrying the state it implies, and the original narration keeps its - own future as retained history. Reasoning models are supported: "thinking" streams into a - collapsible 💭 panel with its own token budget. -- **A branching story tree.** The story is a tree, not a list. Any turn can hold more than one - **take**, and `‹ 2/4 ›` steps between them. Stepping is free: the story below simply empties, - and the server is told nothing. Writing below a take that isn't the live one is what makes a - branch. Branches borrow their ancestors' turns instead of copying them, so a fork costs about - 100 bytes, and a 20-fork story loads within 1% of the same story flat. Switching restores that - line's world state, script state, and cooldown clocks. A branch panel switches, renames, and - deletes; **⌗ See the tree** draws every line against the story's own clock - (`backend/app/tree.py`, `backend/app/context/lineage.py`). +- **The full play loop, in one box.** You write what you do or say in a single + natural-language field — an action and a piece of quoted dialogue are both just what you + wrote — with **Continue** for a beat you do not act in and a **Story direction** toggle for + speaking to the narrator rather than in the story. Responses stream (SSE), and Undo, Redo, + Retry and Edit sit beside the box. Correcting narrator prose does not overwrite it: the + correction becomes a new continuation carrying the state it implies, and the original + narration keeps its own future as retained history. Reasoning models are supported: the + narrator's thinking streams into a collapsible panel with its own token budget. +- **Retained history, without a tree to manage.** Underneath, the story is a tree: any turn + can hold more than one **take**, and `‹ 2/4 ›` steps between them. Stepping is free — the + story below simply empties, and the server is told nothing. Writing below a take that is not + the live one is what starts a different continuation. Branches borrow their ancestors' turns + instead of copying them, so one costs about 100 bytes, and a 20-fork story loads within 1% of + the same story flat (`backend/app/tree.py`, `backend/app/context/lineage.py`). + + **None of that vocabulary reaches the reader.** M8 removed the branch panel and the tree + overlay from the browser: what you get is Undo, Redo, Retry, takes, Save Points and Restore, + and nothing on screen says branch, fork, node or head. The mechanism is unchanged and still + fully tested — this is a decision about what you are asked to understand, not about what the + product can do. - **Authoritative narrative state, and the application owns it.** The story tracks who exists, where they are, what they hold, what is true, how they are tied to each other, and what is still open — as generic entities, facts, relationships and threads, with no genre baked in. @@ -60,12 +70,15 @@ that isn't the live one starts a new branch. (`backend/app/context/builder.py`). Story cards are the inherited authored-lore primitive and are kept; they are **not** the knowledge library below, which is a first-class subsystem with its own classification, provenance, chunking and index. -- **Insights: total prompt transparency.** Every turn stores the exact prompt sent to the - model. Open 🔍 on any AI action to see each context component, its token cost, and why it was - included. +- **Total prompt transparency.** Every turn stores the exact prompt sent to the model. + **Inspect context** on any narrator turn opens a readable account of what it was given — + what it remembered, what it read, what it believes, and what each part cost — with the + assembled prompt itself kept as an advanced section rather than opening on a wall of text. + A passage that came from an imported file links back to the file it came from. - **Auto-summarization and Memory Bank.** The modern AI Dungeon memory system: AI-generated memories every few actions, a running story summary, and embedding-based retrieval that - pulls old-but-relevant facts back into context, with similarity scores visible in Insights + pulls old-but-relevant facts back into context, with similarity scores visible in the + context inspector (`backend/app/memorybank.py`). - **An imported knowledge library, classified by how much authority it has.** Import your own local `.txt` and `.md` files — a setting bible, character notes, research, a passage whose @@ -80,7 +93,7 @@ that isn't the live one starts a new branch. path, not a fallback — the library works with no embedding model at all. Canon you mark **always include** is supplied on every turn whether or not the scene resembles it, and Canon you mark **narrator only** is given to the narrator with instructions not to let the - protagonist know it. Every passage that reaches a prompt is listed in Insights with its file, + protagonist know it. Every passage that reaches a prompt is listed in the context inspector with its file, class, heading, passage number, scores and token cost, and that record is kept in the turn, so deleting a source never erases the evidence of what an old turn was shown (`backend/app/knowledge/`). diff --git a/backend/app/models.py b/backend/app/models.py index b2f4c2f..93cee20 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -155,6 +155,22 @@ class Adventure(Base): # and a science-fiction one forbidding faster-than-light travel use the same # field and the same validator; neither word appears in the application. campaign_canon: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + @property + def canon_rules(self) -> list[str]: + """The `rules` list alone, which is the half a person writes. + + `campaign_canon` also carries `forbidden_status_changes`, a structured + shape the browser has no editor for and does not need one for — a rule + like "nothing dead becomes alive" is expressible as a sentence. So the + API exposes the sentences and leaves the structured half to whatever + wrote it, rather than round-tripping a shape the UI would flatten. + """ + canon = self.campaign_canon + if not isinstance(canon, dict): + return [] + rules = canon.get("rules") + return [r for r in rules if isinstance(r, str)] if isinstance(rules, list) else [] # The ${Placeholder} answers collected when this adventure was started, kept # so "Update from scenario" can re-fill freshly copied scenario text with the # same values. NULL for adventures created before this column existed. diff --git a/backend/app/providers/openai_compatible.py b/backend/app/providers/openai_compatible.py index d139127..ca28d97 100644 --- a/backend/app/providers/openai_compatible.py +++ b/backend/app/providers/openai_compatible.py @@ -381,21 +381,29 @@ class OpenAICompatibleProvider(Provider): return vectors def _friendly_http_error(self, status: int, detail: str) -> str: + """The message a reader sees when the endpoint answers with an error. + + M8 rewrote two of these. They were the last user-facing text describing + a hosted deployment this build does not have: a 401 advised checking an + API key, and a 429 explained a shared free tier's daily cap. There is no + API key field — M2 removed it with the cloud providers — and no shared + tier, so both sent a reader looking for a setting that does not exist. + Ollama's own 401 and 429 mean something else entirely. + """ if status == 401: - return "Authentication failed — check your API key in Settings." + return ( + "The endpoint refused the request as unauthorized (HTTP 401). " + "An ordinary local Ollama does not require authentication — " + f"check that {self.base_url} is the endpoint you meant. {detail}" + ) if status == 404: return ( f"Endpoint or model not found (HTTP 404). Check the endpoint URL and that " f"model '{self.model}' exists. {detail}" ) if status == 429: - # OpenRouter's shared free tier has a per-day cap. Distinguish it - # from a short-term burst limit, so the message tells the reader what - # to do. - if "free-models-per-day" in detail: - return ( - "The free demo has hit its daily request limit (resets at " - "00:00 UTC). Please try again later." - ) - return "The AI is getting too many requests right now — wait a moment and try again." + return ( + "The endpoint is refusing further requests for now (HTTP 429). " + "Wait a moment and try again." + ) return f"AI endpoint returned HTTP {status}: {detail}" diff --git a/backend/app/routers/adventures/crud.py b/backend/app/routers/adventures/crud.py index 2210015..3e88958 100644 --- a/backend/app/routers/adventures/crud.py +++ b/backend/app/routers/adventures/crud.py @@ -195,20 +195,37 @@ def create_adventure( # everywhere, which buys nothing. tree.head_branch(db, adventure) + # M8: canon written at setup. Stored in the same document the prompt and the + # validator already read, so nothing downstream learns a second shape. + rules = [r.strip() for r in payload.canon_rules if r.strip()] + if rules: + adventure.campaign_canon = {"rules": rules} + if scenario: for ref, spec in scenario_card_specs(scenario, values).items(): db.add(models.StoryCard(adventure_id=adventure.id, source_ref=ref, **spec)) - if scenario.prompt.strip(): - opening = models.Action( - adventure_id=adventure.id, - type="start", - text=fill_placeholders(scenario.prompt, values), - ) - # Record the starting state on the opening node, so undoing or - # retrying the first turn has a state to roll back to. - attempts.snapshot_outcome(adventure, opening) - tree.place_action(db, adventure, opening) - db.add(opening) + + # The opening scene. A scenario's prompt and M8's `opening` field are the + # same thing arriving by different routes, so they build the same node — + # the scenario wins when both are present, because it is the more specific + # request. Everything downstream (Undo to the opening, retrying the first + # turn, the drop cap) keys on the `start` type and is unchanged. + opening_text = ( + fill_placeholders(scenario.prompt, values) + if scenario and scenario.prompt.strip() + else payload.opening.strip() + ) + if opening_text: + opening = models.Action( + adventure_id=adventure.id, + type="start", + text=opening_text, + ) + # Record the starting state on the opening node, so undoing or + # retrying the first turn has a state to roll back to. + attempts.snapshot_outcome(adventure, opening) + tree.place_action(db, adventure, opening) + db.add(opening) db.commit() db.refresh(adventure) @@ -296,6 +313,20 @@ def update_adventure( adventure: models.Adventure = Depends(current_adventure), ): fields = payload.model_dump(exclude_unset=True) + # M8. `canon_rules` is a read-only view onto the stored `campaign_canon` + # document, so it is written by hand rather than by the setattr loop — and + # only the `rules` key is replaced. Whatever else the document holds + # (`forbidden_status_changes`, which has no browser editor) is left exactly + # as it was, so editing canon through the browser cannot silently discard + # the structured half a fixture or an import wrote. + if "canon_rules" in fields: + rules = [r.strip() for r in (fields.pop("canon_rules") or []) if r.strip()] + canon = dict(adventure.campaign_canon or {}) + if rules: + canon["rules"] = rules + else: + canon.pop("rules", None) + adventure.campaign_canon = canon or None for field, value in fields.items(): setattr(adventure, field, value) # M6: a summary the reader typed is still a summary, so it is anchored to diff --git a/backend/app/routers/adventures/turns.py b/backend/app/routers/adventures/turns.py index ed04c91..8b71180 100644 --- a/backend/app/routers/adventures/turns.py +++ b/backend/app/routers/adventures/turns.py @@ -82,8 +82,35 @@ async def with_turn_lock(adventure_id: int, gen): _active_turns.discard(adventure_id) +#: Openings that mean the reader has already written the subject of the sentence. +#: +#: Matched as whole words, longest first, so "I'm" is recognised before "I". +_FIRST_PERSON = ("i ", "i'm ", "i've ", "i'll ", "i'd ", "my ", "we ", "we're ") + + def format_player_input(action_type: str, text: str) -> str: - """Formats player input the way AI Dungeon does.""" + """Formats player input the way AI Dungeon does — with one M8 correction. + + The convention is a `>` marker and second person: typing `look around` in + the old Do mode stored `> You look around.`, which reads correctly and shows + the model whose turn it is. + + **M8 broke that assumption and this repairs it.** `BROWSER-UX-SPEC.md` §12 + replaced the Do/Say/Story selector with one natural-language field, and §11 + tells the reader to write sentences like *"I enter the tavern."* Prefixing + that produced `> You I enter the tavern.` — in the transcript, in the + replayed history, and therefore in the narration, where a small model + imitates it and writes "You I thank her". It was visible in the very first + browser pass of the new composer. + + So the prefix is added only when the reader has *not* already written a + subject. First person is left alone; everything else keeps the old + behaviour, and the `>` marker is unchanged in every case, because that is + what actually distinguishes a player turn in the prompt. + + Storage is unchanged for text that was already formatted — see + `test_take_parentage.py`, which guards against `> You > You ...`. + """ text = text.strip() if action_type == "say": text = text.strip('"') @@ -95,6 +122,9 @@ def format_player_input(action_type: str, text: str) -> str: text = text[4:] if text and text[-1] not in ".!?…": text += "." + lowered = text.lower() + if any(lowered.startswith(opening) for opening in _FIRST_PERSON): + return f"> {text}" return f"> You {text}" return text # The "story" type is appended as raw text. diff --git a/backend/app/schemas.py b/backend/app/schemas.py index f87edfc..e9f8ea4 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -143,6 +143,24 @@ class ScenarioListItem(ORMModel): class AdventureCreate(BaseModel): scenario_id: int | None = None title: Name | None = None + # M8: the opening scene, for a campaign started without a scenario. + # + # A scenario's `prompt` already becomes the campaign's `start` action, and + # this is the same thing said directly. It exists because M8's setup flow + # creates a campaign from a form rather than from a template + # (`BROWSER-UX-SPEC.md` §41), and without it every new campaign opens on a + # blank page — the reader has to invent the situation *and* the first move + # in one box. Ignored when `scenario_id` is given, which already supplies one. + opening: Prose = "" + # M8: the campaign's own rules, as a list of sentences. + # + # The column has existed since migration 82 and both the prompt + # (`context/builder._canon_section`) and the state validator + # (`narrative/apply`) already read it — it simply had no way in from the + # browser, so a fixture had to write it with SQL. This is the highest + # authority in the campaign, which is exactly why a person setting one up + # needs to be able to state it. + canon_rules: list[Name] = [] # The `${Placeholder}` values collected from the player at the start, which # is the AI Dungeon behavior. placeholders: dict[str, str] = {} @@ -165,6 +183,10 @@ class AdventureUpdate(BaseModel): persona_name: PersonaName | None = None persona_pronouns: PersonaPronouns | None = None persona_desc: Prose | None = None + # M8. See `AdventureCreate.canon_rules`. Editable after setup because canon + # is the thing a reader most often gets wrong first and needs to correct — + # "resurrection is impossible" is easier to write once the story has tried it. + canon_rules: list[Name] | None = None class AdventureRefresh(BaseModel): @@ -456,6 +478,9 @@ class AdventureOut(ORMModel): persona_name: str persona_pronouns: str persona_desc: str + # M8. Read from the `canon_rules` property on the model, which pulls the + # sentence list out of the stored `campaign_canon` document. + canon_rules: list[str] = [] created_at: datetime updated_at: datetime story_cards: list[StoryCardOut] = [] diff --git a/backend/tests/test_imported_knowledge.py b/backend/tests/test_imported_knowledge.py index 5b44ea2..28a036f 100644 --- a/backend/tests/test_imported_knowledge.py +++ b/backend/tests/test_imported_knowledge.py @@ -1476,18 +1476,27 @@ def test_h06_h07_imported_active_content_is_served_as_inert_text(client): assert report_response.headers["x-content-type-options"] == "nosniff" # And the browser side never renders it as HTML. Asserted against the source - # of the two components that display imported text, because that is where - # the property would be lost — a `dangerouslySetInnerHTML` added to either - # is what turns every assertion above into decoration. A real browser - # exercises the same two components; this fails at build time instead of - # waiting for someone to run one. + # of every component that displays imported or narrator text, because that + # is where the property would be lost — one `dangerouslySetInnerHTML` turns + # every assertion above into decoration. + # + # M8 renamed the Insights panel to `ContextPanel.jsx` and added + # `markdown.jsx`, which renders narrator prose. The renderer is the newest + # and largest way this property could be lost, so it is guarded here too; + # `frontend/src/markdown.test.jsx` covers its behaviour, and this covers the + # one line that would make that behaviour irrelevant. import pathlib frontend = pathlib.Path(__file__).resolve().parents[2] / "frontend" / "src" for name in ("pages/Play/panels/KnowledgePanel.jsx", - "pages/Play/panels/InsightsPanel.jsx"): + "pages/Play/panels/ContextPanel.jsx", + "markdown.jsx"): text = (frontend / name).read_text() - assert "dangerouslySetInnerHTML" not in text, name + # The prop as it would actually be written, not the bare word: these + # files discuss the hazard in their own comments, and a test that + # cannot tell an explanation from a use would forbid documenting it. + assert "dangerouslySetInnerHTML=" not in text, name + assert "dangerouslySetInnerHTML:" not in text, name assert "innerHTML" not in text.replace("document.body.innerHTML='owned'", ""), name diff --git a/backend/tests/test_m8_setup_surface.py b/backend/tests/test_m8_setup_surface.py new file mode 100644 index 0000000..1538c9d --- /dev/null +++ b/backend/tests/test_m8_setup_surface.py @@ -0,0 +1,202 @@ +"""M8: the two fields the streamlined setup flow added, and what they must not do. + +`BROWSER-UX-SPEC.md` §41 replaced "pick a scenario, then fill in its +placeholders" with a form. Two things had to reach the API for that to work, and +both are narrow by design (`BUILD-MILESTONES.md` M8, §39 of the brief): + + opening the campaign's first scene, so a new campaign does not open on + a blank page. It builds the same `start` node a scenario's + prompt does, by the same code path. + canon_rules a read/write view onto the `rules` list inside the existing + `campaign_canon` document, which has had no API at all since + the column was added in migration 82. + +Neither adds a column. `test_knowledge_migration.py` and the M8 report's +migration proof cover the schema claim; these cover the behaviour. +""" + +import pytest +from fastapi import Depends +from fastapi.testclient import TestClient + +from app import auth, limits, models +from app.database import Base, SessionLocal, engine, get_db +from app.main import app + + +OPENING = "You sit at a shared table in the Crooked Lantern Tavern." + + +@pytest.fixture() +def client(monkeypatch): + """The suite's convention: create the schema, own a user, drop it after. + + The first version of this fixture just wrapped `TestClient(app)`. It passed + in isolation and failed ten ways in the full suite, because the tests share + one database and every other module creates and drops the schema around + itself — so this file inherited whatever the previous module had left, and + had no user of its own for `auth.get_current_user` to find. + """ + Base.metadata.create_all(bind=engine) + setup = SessionLocal() + user = models.User(is_guest=False, email="m8setup@example.com") + setup.add(user) + setup.flush() + setup.add(models.Settings(user_id=user.id, model="test-model")) + setup.commit() + user_id = user.id + setup.close() + + monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None) + + def _current_user(db=Depends(get_db)): + return db.get(models.User, user_id) + + app.dependency_overrides[auth.get_current_user] = _current_user + c = TestClient(app) + try: + yield c + finally: + app.dependency_overrides.clear() + Base.metadata.drop_all(bind=engine) + + +def _campaign(client, **body): + r = client.post("/api/adventures", json=body) + assert r.status_code == 201, r.text + return r.json() + + +# ---------------------------------------------------------------- opening --- + +def test_the_opening_becomes_the_campaigns_first_scene(client): + adv = _campaign(client, title="With opening", opening=OPENING) + assert [(a["type"], a["text"]) for a in adv["actions"]] == [("start", OPENING)] + + # `action_count` is computed on read, so the create response reports 0 — + # for a scenario-made campaign too, and it has always done so. The browser + # navigates to the campaign and re-reads, which is the surface asserted + # here and the one a reader actually sees. + fetched = client.get(f"/api/adventures/{adv['id']}").json() + assert fetched["action_count"] == 1 + assert [(a["type"], a["text"]) for a in fetched["actions"]] == [("start", OPENING)] + + +def test_the_opening_is_not_duplicated(client): + adv = _campaign(client, title="Once", opening=OPENING) + again = client.get(f"/api/adventures/{adv['id']}").json() + assert [a["text"] for a in again["actions"]].count(OPENING) == 1 + assert again["action_count"] == 1 + + +def test_the_opening_is_placed_on_the_tree_like_any_other_node(client): + """It must not bypass head/history semantics. + + A `start` node that was not placed on the tree, or carried no state + snapshot, would break Undo and retry at the first turn — which is exactly + where a new reader meets them. + """ + adv = _campaign(client, title="Placed", opening=OPENING) + db = SessionLocal() + try: + row = (db.query(models.Action) + .filter(models.Action.adventure_id == adv["id"]).one()) + assert row.depth == 0 + assert row.branch_id is not None + assert row.parent_id is None + finally: + db.close() + + # And the head is at it: there is nothing before the opening to undo to. + assert adv["can_undo"] is False + assert adv["can_redo"] is False + assert client.post(f"/api/adventures/{adv['id']}/undo").status_code >= 400 + + +def test_a_campaign_without_an_opening_still_starts_empty(client): + adv = _campaign(client, title="Blank") + assert adv["actions"] == [] + + +def test_a_scenario_prompt_takes_precedence_and_is_never_doubled(client): + """Both routes build the same node, so only one of them may fire.""" + sc = client.post("/api/scenarios", + json={"title": "S", "prompt": "A scenario opening."}).json() + adv = _campaign(client, scenario_id=sc["id"], opening=OPENING) + assert [a["text"] for a in adv["actions"]] == ["A scenario opening."] + + legacy = _campaign(client, scenario_id=sc["id"]) + assert [a["text"] for a in legacy["actions"]] == ["A scenario opening."] + + +def test_the_opening_survives_export_and_import(client): + adv = _campaign(client, title="Round trip", opening=OPENING) + bundle = client.get(f"/api/adventures/{adv['id']}/export").json() + restored = client.post("/api/adventures/import", json=bundle).json() + assert [a["text"] for a in restored["actions"]] == [OPENING] + + +# ------------------------------------------------------------ canon_rules --- + +def test_canon_rules_round_trip_and_blank_lines_are_dropped(client): + adv = _campaign(client, title="Canon", + canon_rules=["Resurrection is impossible.", " ", "Magic exists."]) + assert adv["canon_rules"] == ["Resurrection is impossible.", "Magic exists."] + + patched = client.patch(f"/api/adventures/{adv['id']}", + json={"canon_rules": ["Only one rule now."]}).json() + assert patched["canon_rules"] == ["Only one rule now."] + + cleared = client.patch(f"/api/adventures/{adv['id']}", + json={"canon_rules": []}).json() + assert cleared["canon_rules"] == [] + + +def test_editing_canon_preserves_the_structured_half_it_has_no_editor_for(client): + """`campaign_canon` also holds `forbidden_status_changes`. + + The browser edits sentences and has no editor for the structured shape, so + writing the sentences must not discard it — otherwise importing a bundle + that carries one and then touching canon in the UI would silently drop a + rule the validator enforces. + """ + adv = _campaign(client, title="Structured") + db = SessionLocal() + try: + row = db.get(models.Adventure, adv["id"]) + row.campaign_canon = { + "rules": ["R1"], + "forbidden_status_changes": [{"from": "dead", "to": "alive"}], + } + db.commit() + finally: + db.close() + + assert client.get(f"/api/adventures/{adv['id']}").json()["canon_rules"] == ["R1"] + client.patch(f"/api/adventures/{adv['id']}", json={"canon_rules": ["R2", "R3"]}) + + db = SessionLocal() + try: + stored = db.get(models.Adventure, adv["id"]).campaign_canon + assert stored["rules"] == ["R2", "R3"] + assert stored["forbidden_status_changes"] == [{"from": "dead", "to": "alive"}] + finally: + db.close() + + +def test_canon_is_untouched_by_an_unrelated_patch(client): + adv = _campaign(client, title="Untouched", canon_rules=["A rule."]) + renamed = client.patch(f"/api/adventures/{adv['id']}", + json={"title": "Renamed"}).json() + assert renamed["canon_rules"] == ["A rule."] + assert renamed["title"] == "Renamed" + + +def test_canon_reaches_the_prompt_as_the_campaigns_own_rules(client): + """The point of exposing it: what is written here is what the narrator is told.""" + adv = _campaign(client, title="Prompted", + canon_rules=["Resurrection is impossible."]) + report = client.get(f"/api/adventures/{adv['id']}/context").json() + canon = next((s["text"] for s in report["sections"] + if s["label"] == "campaign_canon"), "") + assert "Resurrection is impossible." in canon diff --git a/backend/tests/test_save_points.py b/backend/tests/test_save_points.py index 67acbaf..80260d6 100644 --- a/backend/tests/test_save_points.py +++ b/backend/tests/test_save_points.py @@ -1471,46 +1471,24 @@ def test_the_save_point_count_matches_what_blocks_the_deletion(client): f"/api/adventures/{client.adv_id}/branches/{middle_branch}" ).status_code == 409 - -def test_both_branch_delete_surfaces_explain_the_save_point_rule(): - """The rule must be visible in every view the deletion is reachable from. - - A source-level assertion, because the project has no frontend test runner - (M8). `test_offline_assets.py` reads the frontend the same way, for the same - reason: the check is worth having now, and it is honest about what it is — - it proves the wiring is in the build, not that a user saw it. The browser - smoke test performed at closeout is what proves the rendering. - - Two files, because the branch list and the tree overlay each render their - own delete control, and a rule that held in one of them would not be a rule. - """ - from pathlib import Path - - repo = Path(__file__).resolve().parents[2] - views = { - "the branch panel": - repo / "frontend/src/pages/Play/panels/BranchPanel.jsx", - "the tree overlay": - repo / "frontend/src/BranchMap.jsx", - } - for where, path in views.items(): - source = path.read_text(encoding="utf-8") - assert "savePointsUnder" in source, ( - f"{where} does not count the Save Points that protect a branch" - ) - # The Delete control is disabled while Save Points protect the subtree, - # and says why rather than failing silently on the server. - assert "protecting > 0" in source, ( - f"{where} does not disable Delete while Save Points protect the branch" - ) - assert "deleting a Save Point deletes no story" in source, ( - f"{where} does not tell the user how to proceed" - ) - # The user-facing copy must not explain itself in schema terms. - for jargon in ("cascade", "foreign key", "foreign-key", "ON DELETE"): - assert jargon.lower() not in source.lower(), ( - f"{where} uses implementation jargon in user-facing copy: {jargon}" - ) +# The browser copy for deleting and restoring a Save Point was asserted here, +# by reading `SavePointPanel.jsx` as text. That check is gone, and this note is +# what replaced it. +# +# It existed because the project had no frontend test runner and the wording is +# load-bearing: a reader who believes Restore destroys their later story will +# not press it. M8 supplied the runner, and +# `frontend/src/pages/Play/panels/panels.test.jsx` now renders both +# confirmations and reads what they actually say — which is the thing this was +# approximating, done properly. +# +# It was also becoming unsound. JSX wraps prose across lines, so a substring +# match on a sentence broke on reflow rather than on a change of meaning, and +# the same check forbade the words "branch" and "fork" in a file whose own +# comments explain why those words are avoided. +# +# The server-side rule it protected — deleting a branch a Save Point is kept on +# is refused — is unchanged and tested above. def test_creating_a_save_point_takes_the_campaigns_turn_lock(client): diff --git a/backend/tests/test_take_parentage.py b/backend/tests/test_take_parentage.py index 7d69bac..c0b1f1a 100644 --- a/backend/tests/test_take_parentage.py +++ b/backend/tests/test_take_parentage.py @@ -474,3 +474,83 @@ def test_naming_a_take_that_is_already_the_story_just_plays_on(client): _play(client, "carry on", after_id=live.id) assert _branch_count(client.adv_id) == before + + +def test_first_person_input_is_not_prefixed_with_you(): + """M8. `BROWSER-UX-SPEC.md` §11-12: one field, and the reader writes "I …". + + The AI Dungeon convention prefixes a player action with `> You `, which was + right when the Do mode asked for a bare verb phrase. With one + natural-language field it produced `> You I enter the tavern.` — in the + transcript, in the replayed history, and so in the narration, where a small + model imitated it and wrote "You I thank her". Found in the first browser + pass of the M8 composer. + + Tested against the shared normalizer rather than a rendered component, + because every surface — storage, transcript, replayed history, export — + reads the result of this one function. + + The `>` marker is what distinguishes a player turn in the prompt, so it is + unchanged in every case; only the redundant subject is dropped. + """ + from app.routers.adventures.turns import format_player_input as fmt + + # --- The §11 examples, verbatim from the specification --- + assert fmt("do", "I enter the tavern.") == "> I enter the tavern." + assert fmt("do", "I ask Mara about Edrin.") == "> I ask Mara about Edrin." + assert fmt("do", "I wait quietly and watch the room.") == ( + "> I wait quietly and watch the room.") + + # --- No duplicate subject, in any first-person phrasing --- + for text in ("I walk into the tavern", "I'm going to knock", "I've seen this before", + "I'll wait", "I'd rather not", "My hand finds the key", + "We head north", "We're leaving"): + out = fmt("do", text) + assert "You I" not in out, f"duplicate subject in {out!r}" + assert "You My" not in out, f"duplicate subject in {out!r}" + assert "You We" not in out, f"duplicate subject in {out!r}" + assert out.startswith("> "), f"lost the player-turn marker in {out!r}" + + # --- The legacy bare action still normalizes, which is deliberate --- + assert fmt("do", "open the door") == "> You open the door." + assert fmt("do", "look around") == "> You look around." + # And an explicit "You ..." is de-duplicated rather than doubled. + assert fmt("do", "You leave the tavern") == "> You leave the tavern." + + # --- Dialogue and out-of-character direction are untouched --- + assert fmt("say", "Have you seen Edrin?") == '> You say "Have you seen Edrin?"' + assert fmt("say", "I think he went north") == '> You say "I think he went north."' + assert fmt("story", "Keep this scene tense, but do not start a fight yet.") == ( + "Keep this scene tense, but do not start a fight yet.") + + # --- The marker survives, and is never doubled --- + for kind in ("do", "say"): + assert fmt(kind, "I move").startswith("> ") + assert "> > " not in fmt("do", "I move") + + +def test_normalized_player_text_reaches_history_exactly_once(client): + """The normalization must survive into the replayed context, unduplicated. + + A `format_player_input` that is correct but applied twice, or correct in + storage and re-prefixed on the way into the prompt, would put "You I ..." + back in front of the model — which is the thing the defect was about. So + this asserts on the assembled prompt, not on the stored row. + """ + _play(client, "I enter the tavern.") + _play(client, "I ask Mara about Edrin.") + + stored = [a["text"] for a in + client.get(f"/api/adventures/{client.adv_id}/actions").json()["actions"]] + player_rows = [t for t in stored if t.startswith(">")] + assert player_rows, stored + for row in player_rows: + assert "You I" not in row, row + assert row.count(">") == 1, row + + report = client.get(f"/api/adventures/{client.adv_id}/context").json() + prompt = "\n".join(s["text"] for s in report["sections"]) + assert "You I " not in prompt, "the model's context was polluted with 'You I ...'" + # Each player line appears once, with its marker, in the replayed history. + for row in player_rows: + assert prompt.count(row) == 1, f"{row!r} appears {prompt.count(row)} times" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8f05d3d..ede8b70 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,11 +13,189 @@ "react-router-dom": "^7.18.1" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", + "jsdom": "^26.1.0", "oxlint": "^1.71.0", - "vite": "^8.1.1" + "vite": "^8.1.1", + "vitest": "^3.2.7" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" } }, "node_modules/@emnapi/core": { @@ -56,6 +234,472 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "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/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -695,6 +1339,446 @@ "dev": true, "license": "MIT" }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.7", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz", + "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -706,6 +1790,39 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", @@ -752,6 +1869,186 @@ } } }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/cookie": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", @@ -765,6 +2062,27 @@ "url": "https://opencollective.com/express" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -772,6 +2090,65 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -782,6 +2159,96 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -815,6 +2282,125 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -1076,10 +2662,62 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "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/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -1095,6 +2733,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nwsapi": { + "version": "2.2.27", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.27.tgz", + "integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==", + "dev": true, + "license": "MIT" + }, "node_modules/oxlint": { "version": "1.72.0", "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.72.0.tgz", @@ -1144,6 +2789,36 @@ } } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1165,9 +2840,9 @@ } }, "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "dev": true, "funding": [ { @@ -1185,7 +2860,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -1193,6 +2868,32 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", @@ -1214,10 +2915,18 @@ "react": "^19.2.7" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-router": { - "version": "7.18.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", - "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz", + "integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -1237,12 +2946,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.18.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", - "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.3.tgz", + "integrity": "sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==", "license": "MIT", "dependencies": { - "react-router": "7.18.1" + "react-router": "7.18.3" }, "engines": { "node": ">=20.0.0" @@ -1252,6 +2961,20 @@ "react-dom": ">=18" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/rolldown": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", @@ -1286,6 +3009,79 @@ "@rolldown/binding-win32-x64-msvc": "1.1.4" } }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -1298,6 +3094,13 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1308,6 +3111,74 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1325,6 +3196,82 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -1410,6 +3357,396 @@ "optional": true } } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "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", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.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 + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "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/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "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", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.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 + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "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/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" } } } diff --git a/frontend/package.json b/frontend/package.json index 44efe43..9b785cc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "vite build", "lint": "oxlint", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "react": "^19.2.7", @@ -15,10 +17,15 @@ "react-router-dom": "^7.18.1" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", + "jsdom": "^26.1.0", "oxlint": "^1.71.0", - "vite": "^8.1.1" + "vite": "^8.1.1", + "vitest": "^3.2.7" } } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 31d53cc..fc05ce3 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,50 +1,66 @@ -import { useState } from 'react' -import { NavLink, Outlet } from 'react-router-dom' +import { NavLink, Outlet, useMatch } from 'react-router-dom' import { ToastHost } from './components' -import Embers from './Embers.jsx' +import { ModelStatusProvider } from './modelStatus' +import { ModelStatusBadge } from './ModelStatusBadge' -// The shell: the nav bar and whatever page is routed under it. -// -// Upstream also carried the hosted deployment's account furniture here — a -// /auth/me lookup on mount, guest and sign-up prompts, a log-out button, a -// pageview beacon, and two nav links gated on server-side allowlists. M2 -// removed the deployment those served. There is one local user, nothing to log -// in to, and nothing counting. +/* The shell: a thin top bar and whatever page is routed under it. + * + * M8 reduced this to the two places that are not inside a campaign. Upstream + * carried five links — Home, Adventures, Scenarios, Settings, AI Chat — which + * described AI-DnD's information architecture rather than this product's. + * `BROWSER-UX-SPEC.md` §97 has one entry point (the campaign library) and puts + * everything else *inside* a campaign, where it belongs: a Save Point, a piece + * of knowledge and a story state are all things a campaign has, and none of + * them mean anything at the top level. + * + * What went, and why: + * + * Adventures the library is now the landing page, so a second link to a + * second list of the same thing had nothing to point at. + * Scenarios a scenario is a template for a campaign. The editor for one + * was a schema editor, a story-card table and an art picker — + * §93's "dangerous advanced features" almost exactly. Creating a + * campaign no longer requires one. + * AI Chat a raw model console. Useful to whoever was debugging the + * fork; nothing to do with telling a story. + * + * The story screen deliberately does *not* show this bar's campaign links. It + * has its own header, because on the one screen that matters the story is the + * interface. + */ export default function App() { - const [navOpen, setNavOpen] = useState(false) // mobile hamburger menu + // The story screen owns its whole viewport, so the shell gets out of the way. + const playing = useMatch('/play/:id') return ( - - - - - + + + Skip to main content + {!playing && ( + + )} +
+ +
+
+
) } diff --git a/frontend/src/ArtPicker.jsx b/frontend/src/ArtPicker.jsx deleted file mode 100644 index 9dc1c47..0000000 --- a/frontend/src/ArtPicker.jsx +++ /dev/null @@ -1,147 +0,0 @@ -import { useRef, useState } from 'react' -import { ScenarioArt } from './components' - -// Cards render the plate at 46px (92px on a 2x screen), and the editor preview -// at 76px. 400px square gives headroom for both plus any future larger use, -// while keeping the stored data URI in the tens of kilobytes. -const MAX_EDGE = 400 -const WEBP_QUALITY = 0.82 -// The backend caps the column at 400_000 chars; stay clearly under it so a -// pathological image fails here with a clear message rather than as a 422. -const MAX_DATA_URI = 360_000 - -// A spread of moods rather than a themed set — most scenarios find something -// close enough here and skip hunting for a picture. -const SUGGESTED_ICONS = [ - '⚔️', '🗡️', '🏹', '🛡️', '🔮', '🗝️', '📜', '🏰', - '🐉', '👑', '💀', '🕯️', '🌑', '🌲', '⛰️', '🌊', - '🚀', '🛰️', '🤖', '👁️', '🩸', '🃏', -] - -/** Read a File and re-encode it small, as a WebP data URI. - * - * Downscaling in the browser means the upload never leaves the machine at full - * size and the row stays small — no server-side image library required. - */ -function downscaleToDataURI(file) { - return new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onerror = () => reject(new Error('Could not read that file')) - reader.onload = () => { - const img = new Image() - img.onerror = () => reject(new Error('That file is not an image the browser can read')) - img.onload = () => { - const scale = Math.min(1, MAX_EDGE / Math.max(img.width, img.height)) - const canvas = document.createElement('canvas') - // Math.max(1, …) guards against a 0-dimension canvas, which throws. - canvas.width = Math.max(1, Math.round(img.width * scale)) - canvas.height = Math.max(1, Math.round(img.height * scale)) - const ctx = canvas.getContext('2d') - ctx.drawImage(img, 0, 0, canvas.width, canvas.height) - let uri = canvas.toDataURL('image/webp', WEBP_QUALITY) - // Safari only gained WebP encoding recently; if it silently fell back - // to PNG, retry as JPEG so we don't store a huge lossless blob. - if (!uri.startsWith('data:image/webp')) { - uri = canvas.toDataURL('image/jpeg', WEBP_QUALITY) - } - if (uri.length > MAX_DATA_URI) { - reject(new Error('That image is too detailed to store — try a smaller or simpler one')) - return - } - resolve(uri) - } - img.src = reader.result - } - reader.readAsDataURL(file) - }) -} - -/** Cover-art picker: upload a picture, choose an emoji, or leave it generated. - * - * `image` and `icon` are the stored values; `onChange({ image, icon })` gets - * both on every change so the caller can persist them together. - */ -export default function ArtPicker({ title, image, icon, onChange, disabled }) { - const fileRef = useRef(null) - const [error, setError] = useState('') - - const pick = async (event) => { - const file = event.target.files?.[0] - // Reset immediately so picking the same file twice still fires onChange. - event.target.value = '' - if (!file) return - setError('') - try { - const uri = await downscaleToDataURI(file) - // An uploaded picture wins over an emoji, so clear the icon to make the - // precedence visible rather than leaving a hidden value behind. - onChange({ image: uri, icon: '' }) - } catch (err) { - setError(err.message) - } - } - - const chooseIcon = (glyph) => { - setError('') - onChange({ image: '', icon: icon === glyph ? '' : glyph }) - } - - const clear = () => { - setError('') - onChange({ image: '', icon: '' }) - } - - return ( - - ) -} diff --git a/frontend/src/BranchMap.jsx b/frontend/src/BranchMap.jsx deleted file mode 100644 index b2d5ca6..0000000 --- a/frontend/src/BranchMap.jsx +++ /dev/null @@ -1,295 +0,0 @@ -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { createPortal } from 'react-dom' -import { CORNER, PAD, ROW_H, branchLabel, headLineage, layoutTree, momentTicks, savePointsUnder } from './branches' - -// The story tree, drawn. -// -// The Branches panel lists every line; this draws the same list as lanes on one -// clock, which is the thing a list cannot say — *where* two tellings parted, and -// how much story each of them is. A lane runs from the moment its branch left -// its parent to the moment it ends, so length is story and a fork is a corner. -// -// It reads nothing of its own. Everything here comes from the `GET /branches` -// the panel already made, and every operation goes back through the panel's, so -// there is one copy of the rules and one thing to keep honest. -// -// Portalled to : this is opened from inside `.side-panel`, whose panel-in -// animation (fill mode `both`) makes it the containing block for position:fixed -// children — an overlay rendered in place would be trapped in the 420px panel -// and clipped by its overflow. Same trap for anything opened from a drawer. - -// A label is drawn into the space its lane leaves. `perChar` is the average -// width of the face it is drawn in — about 7px for the 15px serif a name uses -// and 5px for the 10.5px UI face under it. Overshooting only costs an ellipsis, -// so this is deliberately a guess rather than a measurement pass. -function clipToWidth(text, px, perChar = 7) { - const max = Math.max(4, Math.floor(px / perChar)) - return text.length <= max ? text : `${text.slice(0, max - 1)}…` -} - -export function BranchMap({ branches, busyId, onSwitch, onRename, onDelete, onClose }) { - const [selectedId, setSelectedId] = useState(() => branches.find((b) => b.is_head)?.id ?? null) - const [renameText, setRenameText] = useState(null) // null = not renaming - const [confirming, setConfirming] = useState(false) - const [width, setWidth] = useState(0) - const boxRef = useRef(null) - - const { lanes, maxDepth, height } = useMemo(() => layoutTree(branches), [branches]) - const lineage = useMemo(() => headLineage(branches), [branches]) - - - // The map is drawn in real pixels rather than scaled from a fixed viewBox, - // so a narrow window gets a narrower map and not smaller writing. - // - // Both halves are load-bearing, and each covers the other's gap. The seed - // measures the *content* box, because that is what the observer reports and - // `clientWidth` is not — it counts the canvas padding, so seeding from it - // drew an svg 24px wider than the box it sits in and the map ran off the - // right edge. The observer then keeps up with a window being dragged; it - // cannot be the only source, because its initial observation is not - // guaranteed to arrive and without the seed the map never drew at all. - useLayoutEffect(() => { - const el = boxRef.current - if (!el) return undefined - const style = getComputedStyle(el) - const padding = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight) - setWidth(el.clientWidth - padding) - const observer = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width)) - observer.observe(el) - return () => observer.disconnect() - }, []) - - // A branch can vanish under the selection — deleting one takes everything - // forked from it, which is more rows than the one that was clicked. - const selected = branches.find((b) => b.id === selectedId) || null - useEffect(() => { - if (!selected) { - setSelectedId(branches.find((b) => b.is_head)?.id ?? null) - setRenameText(null) - setConfirming(false) - } - }, [selected, branches]) - - useEffect(() => { - const onKey = (e) => { - if (e.key !== 'Escape') return - // Escape backs out of the smallest thing that is open, so it never - // throws away a half-typed name along with the map. - if (renameText !== null) setRenameText(null) - else if (confirming) setConfirming(false) - else onClose() - } - window.addEventListener('keydown', onKey) - return () => window.removeEventListener('keydown', onKey) - }, [renameText, confirming, onClose]) - - const W = Math.max(width, 320) - const span = Math.max(maxDepth, 1) - // Ticks are spaced by the room there is to print them in, not by a constant. - const ticks = momentTicks(maxDepth, Math.max(3, Math.round(W / 160))) - const x = (depth) => PAD.left + (depth / span) * (W - PAD.left - PAD.right) - const laneY = (row) => PAD.top + row * ROW_H + 30 - - const pick = (branch) => { - setSelectedId(branch.id) - setRenameText(null) - setConfirming(false) - } - - const busy = busyId !== null && busyId !== undefined - // The server refuses to delete the line being read or anything it was forked - // from; saying so on the button is friendlier than a toast after the click. - const isLoadBearing = selected ? lineage.has(selected.id) : false - const isRoot = selected ? selected.parent_branch_id === null : false - - // Each of these resolves to whether it worked. The panel owns the request - // and reports its own failures, so all this has to decide is whether to put - // the editor away — clearing a half-typed name on a rename that was refused - // would throw away the only copy of it. - const save = async () => { if (await onRename(selected, renameText)) setRenameText(null) } - const drop = async () => { if (await onDelete(selected)) setConfirming(false) } - // Save Points naming a moment on the selected branch, or on anything forked - // from it, keep it alive — the server refuses to delete them along with it. - const protecting = selected ? savePointsUnder(branches, selected.id) : 0 - - return createPortal( -
-
e.stopPropagation()} - role="dialog" aria-modal="true" aria-label="The story so far"> -
-

The story so far

- - {branches.length} {branches.length === 1 ? 'line' : 'lines'} · {maxDepth + 1} moments - - -
- -
- {width > 0 && ( - - {/* The clock the whole map is read against. */} - {ticks.map((depth) => ( - - - {depth + 1} - - ))} - - {lanes.map((lane) => { - const child = lane.branch.parent_branch_id !== null && lane.parentRow !== null - const forkX = x(lane.from) - const start = child ? forkX + CORNER : x(lane.from) - // A branch forked but not yet written past still gets a stub, - // or it would be a corner leading to nothing. - const end = Math.max(x(lane.to), start + 8) - const y = laneY(lane.row) - const b = lane.branch - const label = branchLabel(b) - const meta = `${b.own_actions} of its own` - + (b.parent_branch_id !== null ? ` · left at ${b.fork_depth + 1}` : '') - + ` · ends at ${b.depth + 1}` - // A lane that leaves late has no room to write in to its right, - // so its labels hang back over the fork instead. The row is its - // own band with nothing else in it, and the alternative is text - // running off the edge — which is what a narrow window did. - const roomRight = W - PAD.right - start - const flip = roomRight < 170 && start - PAD.left > roomRight - const textX = flip ? start - 4 : start - const room = flip ? start - 4 - PAD.left : roomRight - const classes = [ - 'bm-lane', - b.is_head ? 'here' : '', - b.id === selectedId ? 'picked' : '', - ].join(' ') - return ( - pick(b)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(b) } - }}> - {/* Full-width hit area: the lane itself is 3px tall and a - fork late in a long story is a very small target. */} - - - {child && ( - - )} - {child && } - - - - {/* The tip: a diamond for the line being read, so where you - are standing is findable without reading a word. */} - {b.is_head ? ( - - ) : ( - - )} - - - {clipToWidth(label, room)} - - - {clipToWidth(meta, room, 5)} - - - ) - })} - - )} -
- - {branches.length === 1 && ( -

- One thread so far. Retry a turn, then write on from an attempt the - story moved past — that is what makes a second line. -

- )} - - {selected && ( -
-
- {renameText !== null ? ( - setRenameText(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') save() - }} - /> - ) : ( - {branchLabel(selected)} - )} - {selected.is_head && reading} -
- -
- {selected.own_actions} of its own - {selected.parent_branch_id !== null && ` · forked at moment ${selected.fork_depth + 1}`} - {` · ends at moment ${selected.depth + 1}`} -
- - {confirming ? ( -
- {/* The same wording the list gives, because the same deletion - is reachable from both views and a rule that held in one of - them would not be a rule. */} - - Delete this branch and everything forked from it? The story on - other paths, and every Save Point, is unaffected. - - - -
- ) : ( -
- {!selected.is_head && ( - - )} - {renameText !== null ? ( - <> - - - - ) : ( - - )} - {/* The root holds the turns every other branch borrows, and the - server refuses it — so it is not offered at all. A branch - the head stands on is offered, and says why it cannot go. */} - {!isRoot && ( - - )} -
- )} -
- )} -
-
, - document.body, - ) -} diff --git a/frontend/src/Dialog.jsx b/frontend/src/Dialog.jsx new file mode 100644 index 0000000..1a2cd5b --- /dev/null +++ b/frontend/src/Dialog.jsx @@ -0,0 +1,155 @@ +/* Modal dialogs, with the focus behaviour §28 requires. + * + * Three things have to be true for a dialog to be usable without a mouse, and + * all three are easy to leave out: + * + * 1. focus moves *into* the dialog when it opens, + * 2. Tab cannot leave it while it is open, + * 3. focus returns to whatever opened it when it closes. + * + * Written by hand rather than with the native `` element. `showModal()` + * gives 1-3 for free, but jsdom does not implement it, so every component test + * covering a confirmation would have to stub it out — and a focus trap that is + * stubbed in the tests is a focus trap nothing checks. This version is a plain + * element tree, so the tests exercise the same code the browser runs. + * + * `ConfirmDialog` adds the typed-name confirmation §67 asks for on the one + * destructive action that deserves it. + */ + +import { useCallback, useEffect, useId, useRef, useState } from 'react' + +const FOCUSABLE = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', +].join(',') + +export function Dialog({ title, children, onClose, labelledBy, className = '' }) { + const ref = useRef(null) + const returnTo = useRef(null) + const headingId = useId() + const id = labelledBy || headingId + + useEffect(() => { + // Remember where focus was, so it can go back there on close. + returnTo.current = document.activeElement + const node = ref.current + if (node) { + const first = node.querySelector(FOCUSABLE) + // The panel itself is focusable as a fallback, so a dialog with no + // controls still moves focus off the page behind it. + ;(first || node).focus() + } + return () => { + const back = returnTo.current + if (back && typeof back.focus === 'function' && document.contains(back)) back.focus() + } + }, []) + + const onKeyDown = useCallback((event) => { + if (event.key === 'Escape') { + event.stopPropagation() + onClose?.() + return + } + if (event.key !== 'Tab') return + const node = ref.current + if (!node) return + // Hidden controls are skipped by attribute rather than by layout. An + // `offsetParent !== null` check reads more natural and is wrong twice: it + // is null for anything inside a `position: fixed` ancestor, which this + // dialog is, and jsdom never computes it at all — so the trap would behave + // differently in the tests from the browser, which is the one thing a focus + // trap must not do. + const items = Array.from(node.querySelectorAll(FOCUSABLE)) + .filter((el) => !el.hidden && el.getAttribute('aria-hidden') !== 'true') + if (items.length === 0) { + event.preventDefault() + return + } + const first = items[0] + const last = items[items.length - 1] + // Wrap at both ends. Without this, Tab off the last control lands on the + // browser chrome and the reader is outside a modal they cannot see they + // have left. + if (event.shiftKey && document.activeElement === first) { + event.preventDefault() + last.focus() + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault() + first.focus() + } + }, [onClose]) + + return ( +
{ + // Only a click on the backdrop itself closes; a drag that ends there + // after starting inside the panel must not. + if (e.target === e.currentTarget) onClose?.() + }}> +
+

{title}

+ {children} +
+
+ ) +} + +export function ConfirmDialog({ + title, + children, + confirmLabel = 'Confirm', + cancelLabel = 'Cancel', + destructive = false, + requireText = null, + requireLabel = 'Type to confirm', + busy = false, + onConfirm, + onCancel, +}) { + const [typed, setTyped] = useState('') + const fieldId = useId() + const ready = requireText === null || typed.trim() === String(requireText).trim() + + return ( + +
{children}
+ {requireText !== null && ( + + )} +
+ + +
+
+ ) +} diff --git a/frontend/src/Dialog.test.jsx b/frontend/src/Dialog.test.jsx new file mode 100644 index 0000000..2071f79 --- /dev/null +++ b/frontend/src/Dialog.test.jsx @@ -0,0 +1,157 @@ +/* The dialog's focus behaviour, which is the part §28 actually requires and the + * part that is invisible until someone tries to use the product without a mouse. + */ + +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { ConfirmDialog, Dialog } from './Dialog' + +describe('focus (§28)', () => { + it('moves focus into the dialog when it opens', async () => { + render( + + + , + ) + expect(screen.getByRole('button', { name: 'Inside' })).toHaveFocus() + }) + + it('returns focus to whatever opened it', async () => { + const user = userEvent.setup() + function Harness() { + const [open, setOpen] = useState(false) + return ( + <> + + {open && ( + setOpen(false)}> + + + )} + + ) + } + render() + const opener = screen.getByRole('button', { name: 'Open' }) + await user.click(opener) + await user.click(screen.getByRole('button', { name: 'Close' })) + expect(opener).toHaveFocus() + }) + + it('wraps Tab at both ends rather than letting focus escape', async () => { + const user = userEvent.setup() + render( + + + + , + ) + const first = screen.getByRole('button', { name: 'First' }) + const last = screen.getByRole('button', { name: 'Last' }) + expect(first).toHaveFocus() + await user.tab() + expect(last).toHaveFocus() + await user.tab() + expect(first).toHaveFocus() + await user.tab({ shift: true }) + expect(last).toHaveFocus() + }) + + it('closes on Escape', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + render( + + + , + ) + await user.keyboard('{Escape}') + expect(onClose).toHaveBeenCalled() + }) + + it('is announced as a modal dialog with its title as the name', () => { + render( + + + , + ) + const dialog = screen.getByRole('dialog') + expect(dialog).toHaveAttribute('aria-modal', 'true') + expect(dialog).toHaveAccessibleName('Delete this?') + }) +}) + +describe('typed confirmation (§67)', () => { + it('keeps the destructive action disabled until the name is typed', async () => { + const user = userEvent.setup() + const onConfirm = vi.fn() + render( + +

This cannot be undone.

+
, + ) + const confirm = screen.getByRole('button', { name: 'Delete this campaign' }) + expect(confirm).toBeDisabled() + await user.type(screen.getByRole('textbox'), 'Westhaven') + expect(confirm).toBeEnabled() + await user.click(confirm) + expect(onConfirm).toHaveBeenCalled() + }) + + it('rejects a near miss', async () => { + const user = userEvent.setup() + render( + +

x

+
, + ) + await user.type(screen.getByRole('textbox'), 'Westhaben') + expect(screen.getByRole('button', { name: 'Confirm' })).toBeDisabled() + }) + + it('needs no typing when no name is required', () => { + render( + +

x

+
, + ) + expect(screen.queryByRole('textbox')).toBeNull() + expect(screen.getByRole('button', { name: 'Confirm' })).toBeEnabled() + }) + + it('cancels without acting', async () => { + const user = userEvent.setup() + const onConfirm = vi.fn() + const onCancel = vi.fn() + render( + +

x

+
, + ) + await user.click(screen.getByRole('button', { name: 'Cancel' })) + expect(onCancel).toHaveBeenCalled() + expect(onConfirm).not.toHaveBeenCalled() + }) + + it('shows what the dialog is about', () => { + render( + +

Turns that already used it are unchanged.

+
, + ) + const dialog = screen.getByRole('dialog') + expect(within(dialog).getByText(/already used it are unchanged/)).toBeInTheDocument() + }) +}) diff --git a/frontend/src/Embers.jsx b/frontend/src/Embers.jsx deleted file mode 100644 index 21e99ae..0000000 --- a/frontend/src/Embers.jsx +++ /dev/null @@ -1,110 +0,0 @@ -import { useEffect, useRef } from 'react' - -// Motes per million CSS pixels of viewport. Tuned so a laptop gets ~55 and a -// phone ~20: enough to read as drifting dust, few enough to stay cheap. -const DENSITY = 34 -const MAX_PARTICLES = 90 - -/** Slow-drifting gold motes behind the whole app. - * - * Fixed, non-interactive, and drawn on one canvas. It sits below every page in - * the stacking order and never receives pointer events, so it cannot affect - * layout or intercept clicks. Disabled outright for `prefers-reduced-motion`, - * and paused whenever the tab is hidden so a backgrounded tab costs nothing. - */ -export default function Embers() { - const canvasRef = useRef(null) - - useEffect(() => { - const canvas = canvasRef.current - if (!canvas) return - - const reduced = window.matchMedia('(prefers-reduced-motion: reduce)') - if (reduced.matches) return - - const ctx = canvas.getContext('2d') - if (!ctx) return - - // Cap the backing store at 2x: beyond that the cost is real and nobody can - // see the difference on a blurred mote. - const dpr = Math.min(window.devicePixelRatio || 1, 2) - let width = 0 - let height = 0 - let particles = [] - let frame = 0 - - const spawn = (scattered) => ({ - x: Math.random() * width, - // New motes enter from just below the fold; the first batch is scattered - // across the whole height so the screen isn't empty on load. - y: scattered ? Math.random() * height : height + Math.random() * 40, - radius: 0.6 + Math.random() * 1.6, - speed: 0.08 + Math.random() * 0.3, - sway: Math.random() * Math.PI * 2, - swaySpeed: 0.004 + Math.random() * 0.01, - alpha: 0.12 + Math.random() * 0.4, - }) - - const resize = () => { - width = window.innerWidth - height = window.innerHeight - canvas.width = Math.round(width * dpr) - canvas.height = Math.round(height * dpr) - canvas.style.width = `${width}px` - canvas.style.height = `${height}px` - ctx.setTransform(dpr, 0, 0, dpr, 0, 0) - const target = Math.min( - MAX_PARTICLES, - Math.max(12, Math.round((width * height * DENSITY) / 1_000_000)), - ) - particles = Array.from({ length: target }, () => spawn(true)) - } - - const draw = () => { - ctx.clearRect(0, 0, width, height) - for (let i = 0; i < particles.length; i++) { - const p = particles[i] - p.sway += p.swaySpeed - p.y -= p.speed - p.x += Math.sin(p.sway) * 0.28 - if (p.y < -8) { - particles[i] = spawn(false) - continue - } - // Fade out toward the top of the screen so motes dissolve rather than - // vanishing at the edge. - const fade = Math.min(1, p.y / height) - const glow = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.radius * 4) - glow.addColorStop(0, `rgba(232, 196, 118, ${(p.alpha * fade).toFixed(3)})`) - glow.addColorStop(1, 'rgba(212, 169, 78, 0)') - ctx.fillStyle = glow - ctx.beginPath() - ctx.arc(p.x, p.y, p.radius * 4, 0, Math.PI * 2) - ctx.fill() - } - frame = requestAnimationFrame(draw) - } - - const start = () => { - if (!frame) frame = requestAnimationFrame(draw) - } - const stop = () => { - cancelAnimationFrame(frame) - frame = 0 - } - const onVisibility = () => (document.hidden ? stop() : start()) - - resize() - start() - window.addEventListener('resize', resize) - document.addEventListener('visibilitychange', onVisibility) - - return () => { - stop() - window.removeEventListener('resize', resize) - document.removeEventListener('visibilitychange', onVisibility) - } - }, []) - - return