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