diff --git a/.binary-allowlist b/.binary-allowlist new file mode 100644 index 0000000..719e5af --- /dev/null +++ b/.binary-allowlist @@ -0,0 +1,4 @@ +# The management page's only non-text asset. An SVG so it stays reviewable +# in a diff: the gate blocks the extension, not the content, and a PNG would +# need a negation in .gitignore as well as an entry here. +web/public/*.svg diff --git a/.env.sample b/.env.sample index 3729ca7..99d83e3 100644 --- a/.env.sample +++ b/.env.sample @@ -74,7 +74,7 @@ SELFTEST_GROUPS= # Optional, and private. The album the per-person photo commands draw # from: a directory holding people.json, tags.json and media/. Everything # identifying lives in there and nothing about it is in this repository -- -# see DOCS-photo-pack.md, and tool/tagger.py for how it gets filled in. +# see DOCS-photo-pack.md, and ./web.sh for how it gets filled in. # # Leave it empty and the bot looks in $STATE_DIRECTORY/pack (systemd) and # then $XDG_DATA_HOME/signal-bot/pack. Not finding one is normal: the diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb76331..dd736ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,7 +54,7 @@ jobs: - name: shellcheck # --severity=style is the floor CI enforces; the local pre-commit hook # uses the same flag so a version skew cannot pass locally and fail here. - run: shellcheck -x --severity=style run.sh scripts/*.sh + run: shellcheck -x --severity=style ./*.sh scripts/*.sh - name: pytest (100% branch coverage) run: .venv/bin/python -m pytest -q @@ -68,3 +68,24 @@ jobs: # for eight hours on 2026-08-30. - name: cold start run: ./scripts/cold_start.sh + + # The management page. Its own job rather than more steps on `check`: it + # needs a Node toolchain that the Python job has no use for, and the two + # halves fail for unrelated reasons. + web: + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "24.20.0" + - run: corepack enable + - name: install (exact pins, from the lockfile) + run: pnpm install --frozen-lockfile + - name: tsc --noEmit and eslint + run: pnpm lint + - name: vitest (100% coverage, no excludes) + run: pnpm coverage diff --git a/.gitignore b/.gitignore index 9441140..19de4bb 100644 --- a/.gitignore +++ b/.gitignore @@ -194,3 +194,9 @@ adds.json # Wine/Bottles scratch dir, never part of the bot. bottles/ + +# The management page's build inputs and reports. dist/ is already covered by +# the bare entry above, which matches at any depth. +node_modules/ +web/coverage/ +*.tsbuildinfo diff --git a/DOCS-add-photo.md b/DOCS-add-photo.md index 75d88e5..ef4293f 100644 --- a/DOCS-add-photo.md +++ b/DOCS-add-photo.md @@ -104,7 +104,7 @@ answers that directly. Do not collapse the two jobs back together. still reporting one has a metadata track the re-mux should have removed, and refusing it is the correct outcome. -**Both entry points obey this.** `tool/tagger.py --import` used to be a +**Both entry points obey this.** `./web.sh --import` used to be a verbatim `shutil.copy2`; since 2026-08-31 it runs the same stripper, so the guarantee on this page covers the whole album and not just the chat path. diff --git a/DOCS-photo-pack.md b/DOCS-photo-pack.md index 640104d..0e787bd 100644 --- a/DOCS-photo-pack.md +++ b/DOCS-photo-pack.md @@ -175,13 +175,13 @@ and retrievable immediately. That path has its own document, because the metadata handling and the privacy invariants it has to keep are longer than the album format itself: **`DOCS-add-photo.md`**. -## Filling it in: tool/tagger.py +## Filling it in: ./web.sh ```sh -./tag.sh --import ~/photos # dedup, strip, import, then tag -./tag.sh --import ~/photos --dry-run # the report only -./tag.sh --untagged # only items with no tags yet -./tag.sh # carry on where you left off +./web.sh --import ~/photos # dedup, strip, import, then tag +./web.sh --import ~/photos --dry-run # the report only +./web.sh --untagged # only items with no tags yet +./web.sh # carry on where you left off ``` `tag.sh` sets up `.venv` on first use, so this works on a fresh clone with diff --git a/DOCS-web-ui.md b/DOCS-web-ui.md new file mode 100644 index 0000000..5415c0e --- /dev/null +++ b/DOCS-web-ui.md @@ -0,0 +1,152 @@ +# The management page + +`./web.sh` serves one page on `127.0.0.1:8140`: whether the bot is alive, what +it is configured with, and the photo album's tagger. It replaces +`tool/tagger.py`, which served the album half of this on the same port. + +```sh +./web.sh # the configured pack +./web.sh --dir ~/pack --port 8140 +./web.sh --import ~/photos # dedup, strip and import, then serve +./web.sh --import ~/photos --dry-run # the report, changing nothing +./web.sh --untagged # only items with no tags yet +``` + +## Why it is started by hand + +A systemd unit would be more convenient and is the wrong shape. This page can +rewrite `.env` — the file that decides which groups the bot answers in and who +may add it to one — and a unit would keep that writable on loopback all day +for the sake of a bookmark. Started by hand, the window is the few minutes +somebody is actually looking at it. + +It is also a **second process**, not more routes on the bot. The bot's own +port already serves `POST /_selftest`, which posts for real into a real group; +putting a configuration writer beside it would put both behind one mistake. + +## Why loopback is not the whole defence + +Binding to `127.0.0.1` stops the network. It does not stop the browser already +running on this machine, and two attacks reach a loopback port from an +ordinary web page. `signal_bot/manage/guard.py` closes both, in about forty +lines, with nothing to configure and no session to keep: + +- **Cross-origin form posts.** Any site can POST a `
` at + `127.0.0.1:8140` without reading the reply. A form can only send + `application/x-www-form-urlencoded`, `multipart/form-data` or `text/plain`, + so every mutating request must declare `application/json` — which a form + cannot. Anything else is **415**. +- **DNS rebinding.** A hostname the attacker controls is re-pointed at + `127.0.0.1` after the page loads, which makes the browser treat this app as + same-origin. The rebound request still carries the attacker's hostname, so + a `Host` that is not loopback is **421**. + +Neither is authentication, and neither pretends to be. They are the two +cheapest things that make "only this machine" mean it. + +## Editing .env + +Three refusals, in this order, and the order is the point. + +1. **A key that may not be written is refused before anything is parsed**, so + a read-only key cannot be smuggled past by making the rest of the file + invalid. `403`, naming the key. +2. **The whole candidate file is validated by the function startup uses** — + `load_config(candidate, use_dotenv=False)`. An edit this bot would refuse + at boot is refused at the form instead, with the journal's own sentence. + `400`. +3. **Only then is anything written.** A rejected edit leaves `.env` + byte-identical. + +The write itself is line-by-line: only the `KEY=` line of a key that actually +changed is replaced, so the paragraphs explaining each setting survive an edit +made in a browser. A key the file never had is appended under a marker rather +than dropped among comments that explain something else. Temp-then-replace +with mode `0600` reapplied before the swap, because this file holds the +account's number and is edited from a tab that can be closed mid-save. + +### What is read-only, and why + +| Key | Why the page will not write it | +|---|---| +| `PHONE_NUMBER` | Identifies the account; the container holds the matching registration. | +| `API_BASE_URL` | Repointing it sends every outgoing message and attachment fetch elsewhere at the next restart. Strictly worse than changing the number. | +| `DATABASE_PATH` | Repointing it silently orphans the whole statistical history. | +| `PHOTO_PACK_DIR` | **Cannot be validated.** `_photo_pack_dirs()` reads `os.environ` directly rather than the mapping it is handed — deliberately, since that is what keeps the test suite away from a real album. | +| `BIND_HOST` / `BIND_PORT` | Moving the bot's port from a page that proxies to it. | + +### Group settings need a group listing + +`GROUP_IDS` and `SELFTEST_GROUPS` are editable, but only while `/api/groups` +has actually returned something. `parse_group_ids` checks *syntax*; startup +additionally requires each listed group to be one the bot really joined, +judged against a listing that was really read. So with the container down, a +syntactically valid write can name a group the next startup rejects — turning +a scoping mistake into a unit that will not start. The page says so and leaves +the other keys editable. + +Group ids are **picked, never typed**, for the same reason the tagger picked +them: an id is 44 characters of base64. + +> `SELFTEST_GROUPS` is editable and worth understanding before you widen it. +> It is what keeps `POST /_selftest` fail-closed; every group named in it is a +> group the bot can be made to post arbitrary text into. + +## Routes + +Everything under `/api`. The page itself is served from `web/dist` at every +other path, so a reload on a sub-view works. + +| Method | Path | What it does | +|---|---|---| +| GET | `/api/status` | Proxies the bot's `/healthz`. Unreachable is a state, never an error. | +| GET | `/api/groups` | The live listing, or `[]` when the container is down. | +| GET | `/api/config` | The resolved config. The number is cut to its last four digits. | +| GET / PUT | `/api/env` | Read and write `.env`, per the rules above. | +| POST | `/api/selftest` | Proxied to the bot verbatim, including its 403. | +| POST | `/api/restart-container` | `docker restart`, through `signal_bot/external.py`. | +| GET | `/api/album/state` | The album, the roster and the groups to pick from. | +| POST | `/api/album/tag` | Record who is in one file. | +| GET | `/api/album/summary` | What each command would answer with. | +| POST | `/api/album/setup` | Write the roster, then prove the bot could load it. | +| POST | `/api/album/person` | Add one person mid-session. | +| GET | `/api/album/media/{index}` | One item, **by position**. | + +That last one is not a style choice. A filename supplied by a client is a +path, and this directory holds private photographs; the route takes an `int`, +so FastAPI refuses a name with **422** before any handler code runs. + +## Building it + +`dist/` is a build output and this repository does not commit those. +`scripts/install_service.sh` builds it, and verifies the artefact rather than +trusting the exit code. Until it has been built the page answers **503** with +the command to run, because `StaticFiles` raises at construction on a missing +directory — mounting it unconditionally would take the app down on a fresh +clone, and in CI, where pytest never runs `pnpm build`. + +```sh +pnpm --dir web install --frozen-lockfile +pnpm --dir web build +pnpm --dir web lint # tsc --noEmit + eslint strictTypeChecked +pnpm --dir web coverage # vitest, 100% with no exclude list +``` + +React 19, Vite 8, TypeScript **6.0.3**, ESLint 10 with `strictTypeChecked` + +`stylisticTypeChecked` + unicorn + sonarjs + perfectionist, Vitest 4 at 100% +on all four metrics. TypeScript stays on 6.0.3 because +`typescript-eslint@8.68.0` declares `typescript: ">=4.8.4 <6.1.0"`; the pin is +fleet-wide in `~/utils/dependency-freshness.allowlist.yaml` and clears itself +when the linter catches up. The compiler waits for the linter. + +`web/tsconfig.json` is strict JSON rather than the usual JSONC: this +repository's `check-json` hook is generic and tsconfig being commentable is a +convention, not a requirement. The two things the comments used to say: `lib` +is `ESNext` while `target` stays `ES2023`, so iterator helpers are declared +without being emitted for; and every strictness flag is on, including +`noUncheckedIndexedAccess` and `exactOptionalPropertyTypes`. + +There is **no coverage exclude list**, including for `src/main.tsx`. A +coverage omit is a suppression, and "the DOM bootstrap is awkward to assert" +is the same sentence as "this code is untested" — so the bootstrap is a shim +thin enough to test in jsdom instead. diff --git a/README.md b/README.md index c242b87..086b078 100644 --- a/README.md +++ b/README.md @@ -170,18 +170,12 @@ its own number and no access to your chats at all. ### Why not Shelter / a second Signal app on the phone -Two independent reasons, both verified rather than assumed: - -1. **A second app instance is not a second account.** Every Signal account - needs its own phone number, so Shelter never removed the actual blocker. - Signal's phone-number-free "Signal Login" is announced but not shipped. -2. **It cannot be installed anyway** on a phone with a Device Owner. Setting a - Device Owner makes Android enable `no_add_managed_profile`, - `no_add_private_profile` and `no_add_clone_profile` on every user — killing - Shelter, Island, Private Space and app cloning alike. Verified on-device: - `pm create-user --profileOf 0 --managed` returns - `Cannot add user: no_add_managed_profile is enabled`. This is the Android 11 - COMP deprecation and is unchanged through Android 16. +Two reasons, both verified rather than assumed. **A second app instance is not +a second account** — every Signal account needs its own phone number, so +Shelter never removed the actual blocker. And **it cannot be installed anyway** +on a phone with a Device Owner: Android then enables `no_add_managed_profile` +and its two siblings on every user, killing Shelter, Island, Private Space and +cloning alike. Unchanged through Android 16. ## When it cannot hear, and who may add it @@ -231,10 +225,16 @@ at 0, up to three highlights. Sundays close a week, month ends a month. Ask it things: `!staty` `!wczoraj` `!tydzień` `!miesiąc` `!rok` `!rekordy` `!korona` `!dni` `!ja`. Full behaviour: `DOCS-statistics.md`. +## Managing it + +`./web.sh` serves one page on `127.0.0.1:8140`: health, the configuration +(editable) and the album's tagger. Started by hand and stopped again, because +it can rewrite `.env`: [DOCS-web-ui.md](DOCS-web-ui.md). + ## Develop ```sh -pytest # 708 tests, 100% branch coverage, no network +pytest # 100% branch coverage, no network pre-commit run --all-files # ruff ALL + mypy strict + 250-line cap + gates ./scripts/e2e.sh # the real bot against the fake server ``` @@ -243,7 +243,7 @@ No `# noqa`, no `# type: ignore`, no coverage omits. Never bump `SCHEMA_VERSION` read `signal_bot/schema.py` first; a bump DROPs every table and throws the live tallies away, while `CREATE TABLE IF NOT EXISTS` is additive and safe. Wire format: [DOCS-signal-envelopes.md](DOCS-signal-envelopes.md). Releases are named after the popes, in order and in Latin — 0.1.4 is «Eleutherius» — and a bump publishes to the group as the pope's portrait, name and article: [DOCS-changelog.md](DOCS-changelog.md). Latency: [DOCS-latency.md](DOCS-latency.md). -Signal never delivers an account its own sends: `scripts/selftest.sh testbot '!papież' 3` drives the live dispatcher instead, [DOCS-selftest.md](DOCS-selftest.md). +Signal never delivers an account its own sends: `scripts/selftest.sh testbot '!papież' 3` drives the live dispatcher instead, [DOCS-selftest.md](DOCS-selftest.md). The page has its own gates: `pnpm --dir web lint` and `pnpm --dir web coverage`, both at 100% with no excludes. ## Licence diff --git a/scripts/install_service.sh b/scripts/install_service.sh index 37178c8..21b2204 100755 --- a/scripts/install_service.sh +++ b/scripts/install_service.sh @@ -87,10 +87,30 @@ ensure_ffmpeg() { fi } +# The management page is built, not committed: dist/ is a build output and +# this repository does not carry those. Verified by looking at the artefact +# rather than trusting the exit code, the same way exiftool is. +build_web() { + local web="${REPO_ROOT}/web" + if ! command -v pnpm >/dev/null; then + echo "Installing pnpm (the management page is built with it)..." + if ! sudo pacman -S --needed --noconfirm pnpm; then + die "could not install pnpm; run: sudo pacman -S pnpm" + fi + fi + command -v node >/dev/null || die "node not found; the page needs node >= 24" + echo "Building the management page..." + (cd "$web" && pnpm install --frozen-lockfile && pnpm build) || + die "pnpm build failed in ${web}" + [[ -f "${web}/dist/index.html" ]] || + die "pnpm build reported success but ${web}/dist/index.html is not there" +} + validate_requirements() { command -v systemctl >/dev/null || die "systemctl not found; this needs systemd" ensure_exiftool ensure_ffmpeg + build_web [[ -f "${REPO_ROOT}/.env" ]] || die "no ${REPO_ROOT}/.env -- run scripts/register_bot.sh first" [[ -x "${REPO_ROOT}/run.sh" ]] || die "no executable ${REPO_ROOT}/run.sh" diff --git a/signal_bot/pack_custom.py b/signal_bot/pack_custom.py index 32ff07f..f9640f9 100644 --- a/signal_bot/pack_custom.py +++ b/signal_bot/pack_custom.py @@ -8,7 +8,7 @@ people and custom tags in different files is what makes that guarantee cheap: ``people.json`` is edited by the tagger and defines commands, ``custom.json`` is appended to from the chat and defines no commands at all. -Written atomically, temp-then-replace, exactly as ``tool/tagger.py::Album.write`` +Written atomically, temp-then-replace, exactly as ``pack_album.Album.write`` already does, because the alternative is a half-written manifest that fails the next load and takes startup down. diff --git a/signal_bot/pack_holder.py b/signal_bot/pack_holder.py index 9c0730b..9141ce6 100644 --- a/signal_bot/pack_holder.py +++ b/signal_bot/pack_holder.py @@ -2,7 +2,7 @@ """The one mutable box the pack lives in, so a new photo is visible at once. ``pack_command.pack_handler`` used to close over a frozen ``Pack``. That was -right while the only writer was ``tool/tagger.py`` and a restart followed +right while the only writer was the tagging page and a restart followed every change; with ``!add_photo`` it means a photo somebody just added stays invisible to their own command until the unit is restarted. Every pack handler therefore reads ``holder.pack`` at call time instead of capturing it. diff --git a/signal_bot/pack_import.py b/signal_bot/pack_import.py index b00e979..39d6571 100644 --- a/signal_bot/pack_import.py +++ b/signal_bot/pack_import.py @@ -1,7 +1,7 @@ # Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. """Importing a folder of photos into the album without importing it twice. -``tool/tagger.py --import`` used to be a filename check and ``shutil.copy2``. +``./web.sh --import`` used to be a filename check and ``shutil.copy2``. Pointed at a phone export that overlaps what is already tagged, that copies the same photo in again under whatever name the exporter gave it that time, and copies the metadata in with it. Both are fixed here: diff --git a/signal_bot/pack_load.py b/signal_bot/pack_load.py index 86bb03c..b3f1c42 100644 --- a/signal_bot/pack_load.py +++ b/signal_bot/pack_load.py @@ -225,7 +225,7 @@ def _tags(directory: Path, people: set[str]) -> dict[str, frozenset[str]]: """ path = directory / TAGS_FILE if not path.exists(): - logger.warning("photo pack %s has no %s yet; run tool/tagger.py", directory, TAGS_FILE) + logger.warning("photo pack %s has no %s yet; run ./web.sh", directory, TAGS_FILE) return {} document = _document(path) tags: dict[str, frozenset[str]] = {} diff --git a/signal_bot/pack_store.py b/signal_bot/pack_store.py index 580b4d9..47d4c29 100644 --- a/signal_bot/pack_store.py +++ b/signal_bot/pack_store.py @@ -2,7 +2,7 @@ """Putting a file into the album, and taking it back out when the swap fails. Every write here is temp-then-``Path.replace``, the pattern -``tool/tagger.py::Album.write`` already uses, because a manifest that is half +``pack_album.Album.write`` already uses, because a manifest that is half written is a manifest that fails the next load -- and with ``Restart=always`` a startup-time ConfigError is a loop, not an error message. diff --git a/tests/test_pack_load.py b/tests/test_pack_load.py index e29124a..1513286 100644 --- a/tests/test_pack_load.py +++ b/tests/test_pack_load.py @@ -151,10 +151,11 @@ def test_one_entry_may_spell_itself_two_ways_that_fold_the_same(tmp_path: Path) def test_an_untagged_pack_loads_and_warns(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: - """Not an error: that is an album waiting for the tagger, not a broken one.""" + """Not an error: that is an album waiting to be tagged, not a broken one.""" pack = _load(tmp_path, write_tags=False) assert pack.tags == {} - assert "tagger" in caplog.text + # The warning names the command to run, so it has to move with it. + assert "./web.sh" in caplog.text def test_a_tag_naming_a_file_that_is_gone_is_refused(tmp_path: Path) -> None: diff --git a/tests/test_tool_imports.py b/tests/test_tool_imports.py index 5973de2..804333a 100644 --- a/tests/test_tool_imports.py +++ b/tests/test_tool_imports.py @@ -1,5 +1,5 @@ # Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. -"""Every script under tool/ imports, and the tagger answers --help. +"""Every script under tool/ imports, and the launcher answers --help. This exists because ``tool/tagger.py`` shipped an unconditional ImportError and survived two releases with it: commit 48957a1 renamed @@ -66,11 +66,11 @@ def test_the_module_imports(path: Path) -> None: sys.modules.pop(spec.name, None) -def test_the_tagger_answers_help() -> None: +def test_the_launcher_answers_help() -> None: # Through signal_bot.external, like every other subprocess here: S603 is # suppressed for exactly one file and widening it is what that entry # exists to prevent. - result = run([sys.executable, str(TOOL / "tagger.py"), "--help"]) + result = run([sys.executable, str(TOOL / "web.py"), "--help"]) assert result.ok, result.stderr for flag in ("--import", "--untagged", "--dry-run"): assert flag in result.stdout diff --git a/tool/tagger.html b/tool/tagger.html deleted file mode 100644 index 62bde74..0000000 --- a/tool/tagger.html +++ /dev/null @@ -1,327 +0,0 @@ - - - - - -Tagowanie zdjęć - - - -
- - - - - -
- - - diff --git a/tool/tagger.py b/tool/tagger.py deleted file mode 100755 index 25e2677..0000000 --- a/tool/tagger.py +++ /dev/null @@ -1,201 +0,0 @@ -#!/usr/bin/env python3 -# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. -"""Tag a private photo album, in a browser, on this machine only. - -Sixty-odd photos and seven people is not a hand-edited JSON file. This serves -one page on 127.0.0.1 that shows each item in turn with a numbered checkbox -per person, saves on every keystroke, and never leaves the machine. - -Names are typed *into the page*, never as arguments: an argument lands in the -shell history, and the whole design of the pack is that the names exist in -one directory and nowhere else. - -Either entry point sets up ``.venv`` on first use -- the dependencies are not -in the system interpreter, and a script that tells you to go and install them -yourself is a script that does not own its dependencies. - - python tool/tagger.py --untagged # bootstraps, then runs - ./tag.sh # the configured pack - ./tag.sh --dir ~/pack --port 8140 - ./tag.sh --import ~/photos # dedup, strip and import, then tag - ./tag.sh --import ~/photos --dry-run # the report, changing nothing - ./tag.sh --untagged # only items with no tags yet - -It prints a summary when it stops: how many items are untagged, and how many -photos each command would have. A command with zero is dead on arrival, and -this is where that shows up rather than in the group. -""" - -from __future__ import annotations - -# Importing this sets up .venv if the interpreter running us cannot import -# what follows. It has to come first, and it is an import rather than a call -# so that nothing here needs an E402 suppression. "python tool/tagger.py" -# puts tool/ on sys.path, which is what makes the plain name resolve. -from venv_bootstrap import REPO_ROOT - -import argparse -import sys -import webbrowser -from pathlib import Path -from typing import TYPE_CHECKING, Any, Final - -import httpx -import uvicorn -from fastapi import FastAPI, HTTPException -from fastapi.responses import FileResponse, HTMLResponse - -from signal_bot.config_env import load_config -from signal_bot.groups import ConfigError -from signal_bot.media_strip import ExiftoolStripper -from signal_bot.pack import MEDIA_DIR, default_dirs -from signal_bot.pack_album import Album -from signal_bot.pack_import import plan_import, run_import - -if TYPE_CHECKING: - from signal_bot.pack_report import ImportReport - -PAGE: Final = REPO_ROOT / "tool" / "tagger.html" -DEFAULT_PORT: Final = 8140 - - -def live_groups() -> list[dict[str, str]]: - """The groups this account is in, so an id is picked and never typed.""" - try: - config = load_config() - response = httpx.get(config.groups_url, timeout=5.0) - response.raise_for_status() - listing = response.json() - except ConfigError, httpx.HTTPError, ValueError: - # No container, no .env, no network: none of them stop the tagging, - # they only mean the group id has to be pasted instead of picked. - return [] - return [ - {"id": str(entry.get("id", "")), "name": str(entry.get("name", ""))} - for entry in listing - if entry.get("id") - ] - - -def roster_routes(app: FastAPI, album: Album) -> None: - """The two endpoints that write people.json. - - Split from build_app to keep it under the complexity gate, along the - seam that matters: everything here can refuse, and refusing has to reach - the page rather than the next restart. - """ - - @app.post("/api/setup") - async def setup(payload: dict[str, Any]) -> dict[str, str]: - try: - album.setup( - list(payload.get("groups", [])), - list(payload.get("people", [])), - list(payload.get("combos", [])), - ) - except ConfigError as error: - # Straight into the page: a manifest the bot would refuse should - # be fixed here, in the form, not found at the next restart. - raise HTTPException(status_code=400, detail=str(error)) from error - return {"status": "ok"} - - @app.post("/api/person") - async def person(payload: dict[str, Any]) -> dict[str, str]: - # Mid-session, not only at setup: the reason to add somebody is - # usually a photo of them already on the screen. - try: - key = album.add_person([str(t) for t in payload.get("triggers", [])]) - except ConfigError as error: - raise HTTPException(status_code=400, detail=str(error)) from error - return {"status": "ok", "key": key} - - -def build_app(album: Album) -> FastAPI: - """The one-page application, bound to one album.""" - app = FastAPI() - roster_routes(app, album) - - @app.get("/", response_class=HTMLResponse) - async def page() -> str: - return PAGE.read_text(encoding="utf-8") - - @app.get("/api/state") - async def state() -> dict[str, Any]: - return {**album.state(), "known_groups": live_groups()} - - @app.post("/api/tag") - async def tag(payload: dict[str, Any]) -> dict[str, str]: - try: - album.tag(str(payload["file"]), [str(key) for key in payload.get("keys", [])]) - except KeyError as error: - raise HTTPException(status_code=404, detail=str(error)) from error - return {"status": "ok"} - - @app.get("/api/summary") - async def summary() -> dict[str, list[str]]: - # The same lines the tagger prints when it stops. Shown in the page - # too, because "I tagged everything, did it save?" is a question the - # last photo used to answer by doing nothing at all. - return {"lines": album.summary()} - - @app.get("/media/{index}") - async def media(index: int) -> FileResponse: - # By index, never by a name the page sends back: a filename from a - # client is a path, and portraits.path_for exists for the same reason. - files = album.files - if not 0 <= index < len(files): - raise HTTPException(status_code=404, detail="no such item") - return FileResponse(album.directory / MEDIA_DIR / files[index]) - - return app - - -def main() -> int: - """Serve the tagger until it is stopped, then print the summary.""" - parser = argparse.ArgumentParser( - description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter - ) - parser.add_argument("--dir", type=Path, default=None, help="the pack directory") - parser.add_argument("--port", type=int, default=DEFAULT_PORT) - parser.add_argument( - "--import", dest="source", type=Path, default=None, help="import media from here first" - ) - parser.add_argument( - "--dry-run", action="store_true", help="report what --import would do, change nothing" - ) - parser.add_argument( - "--untagged", action="store_true", help="present only items with no tags yet" - ) - parser.add_argument("--no-open", action="store_true", help="do not open a browser") - arguments = parser.parse_args() - - directory = arguments.dir or default_dirs()[0][0] - if arguments.source is not None: - report = _import(arguments.source, directory, dry_run=arguments.dry_run) - sys.stdout.write("\n".join(report.lines()) + "\n\n") - if arguments.dry_run: - # Nothing was written, so there is nothing to tag and no reason to - # open a browser at a server this run is not going to start. - return 0 - - album = Album(directory, untagged_only=arguments.untagged) - sys.stdout.write(f"== tagging {len(album.files)} item(s) in {directory}\n") - url = f"http://127.0.0.1:{arguments.port}/" - sys.stdout.write(f"== open {url} -- ctrl-c here when you are done\n") - if not arguments.no_open: - webbrowser.open(url) - uvicorn.run(build_app(album), host="127.0.0.1", port=arguments.port, log_level="warning") - sys.stdout.write("\n".join(album.summary()) + "\n") - return 0 - - -def _import(source: Path, directory: Path, *, dry_run: bool) -> ImportReport: - """Import a folder, or work out what importing it would do.""" - stripper = ExiftoolStripper() - if dry_run: - return plan_import(source, directory, stripper) - return run_import(source, directory, stripper) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tool/venv_bootstrap.py b/tool/venv_bootstrap.py index 179342f..c993a65 100755 --- a/tool/venv_bootstrap.py +++ b/tool/venv_bootstrap.py @@ -2,13 +2,13 @@ # Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. """Make a script work with whatever interpreter you started it with. -``python tool/tagger.py`` failed with ``ModuleNotFoundError: fastapi`` because +``python tool/web.py`` failed with ``ModuleNotFoundError: fastapi`` because the dependencies live in ``.venv`` and nothing said so. So instead of reporting that, this fixes it: if the running interpreter cannot import what the script needs, it creates ``.venv`` if there is none, installs the project into it, and re-executes the same command line through it. -``tag.sh`` and ``run.sh`` get there through ``scripts/ensure_venv.sh``. This +``web.sh`` and ``run.sh`` get there through ``scripts/ensure_venv.sh``. This module is the same job for the case where somebody runs the ``.py`` directly, which is what the file's own docstring used to tell them to do. @@ -108,7 +108,7 @@ def _venv_creator() -> list[str]: def _install() -> bool: - """Install the project editable, so a later git pull reaches the tagger.""" + """Install the project editable, so a later git pull reaches the page.""" uv = shutil.which("uv") target = f"{REPO_ROOT}[dev]" if uv is not None: diff --git a/tool/web.py b/tool/web.py new file mode 100755 index 0000000..8d1eb36 --- /dev/null +++ b/tool/web.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""Manage the bot, in a browser, on this machine only. + +Health, configuration and the photo album in one page. It replaces the +tagger, which served the album half of this on the same port; everything it +did is still here, and the routes it did it with now live in +``signal_bot/manage/`` where the coverage gate can see them. + +Started by hand and stopped again, deliberately. This page can rewrite +``.env`` -- the file that decides which groups the bot answers in and who may +add it to one -- and a unit would keep that writable on loopback all day for +the sake of a bookmark. + + ./web.sh # the configured pack + ./web.sh --dir ~/pack --port 8140 + ./web.sh --import ~/photos # dedup, strip and import, then serve + ./web.sh --import ~/photos --dry-run # the report, changing nothing + ./web.sh --untagged # only items with no tags yet + +It prints the album summary when it stops: how many items are untagged, and +how many photos each command would have. A command with zero is dead on +arrival, and this is where that shows up rather than in the group. +""" + +from __future__ import annotations + +# Importing this sets up .venv if the interpreter running us cannot import +# what follows. It has to come first, and it is an import rather than a call +# so that nothing here needs an E402 suppression. +from venv_bootstrap import REPO_ROOT + +import argparse +import sys +import webbrowser +from pathlib import Path +from typing import TYPE_CHECKING, Final + +import httpx +import uvicorn + +from signal_bot.config_env import load_config +from signal_bot.manage.app import build_app +from signal_bot.manage.deps import ManageDeps +from signal_bot.media_strip import ExiftoolStripper +from signal_bot.pack import default_dirs +from signal_bot.pack_album import Album +from signal_bot.pack_import import plan_import, run_import +from signal_bot.portcheck import assert_port_free + +if TYPE_CHECKING: + from signal_bot.pack_report import ImportReport + +DEFAULT_PORT: Final = 8140 +DIST: Final = REPO_ROOT / "web" / "dist" + + +def _arguments() -> argparse.Namespace: + """What the launcher was asked to do.""" + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.add_argument("--dir", type=Path, default=None, help="the pack directory") + parser.add_argument("--port", type=int, default=DEFAULT_PORT) + parser.add_argument( + "--import", dest="source", type=Path, default=None, help="import media from here first" + ) + parser.add_argument( + "--dry-run", action="store_true", help="report what --import would do, change nothing" + ) + parser.add_argument( + "--untagged", action="store_true", help="present only items with no tags yet" + ) + parser.add_argument("--no-open", action="store_true", help="do not open a browser") + return parser.parse_args() + + +def main() -> int: + """Serve the management page until it is stopped, then print the summary.""" + arguments = _arguments() + directory = arguments.dir or default_dirs()[0][0] + if arguments.source is not None: + report = _import(arguments.source, directory, dry_run=arguments.dry_run) + sys.stdout.write("\n".join(report.lines()) + "\n\n") + if arguments.dry_run: + # Nothing was written, so there is nothing to serve and no reason to + # open a browser at a server this run is not going to start. + return 0 + + # Before the browser opens, not after: a stale instance on this port + # should be a refusal naming the process, not a tab showing somebody + # else's album. signal_bot/__main__.py does the same for the bot. + assert_port_free("127.0.0.1", arguments.port) + + album = Album(directory, untagged_only=arguments.untagged) + deps = ManageDeps( + config=load_config(), + env_path=REPO_ROOT / ".env", + dist_dir=DIST, + album=album, + client=httpx.AsyncClient(), + port=arguments.port, + ) + url = f"http://127.0.0.1:{arguments.port}/" + sys.stdout.write(f"== {len(album.files)} item(s) in {directory}\n") + sys.stdout.write(f"== open {url} -- ctrl-c here when you are done\n") + if not arguments.no_open: + webbrowser.open(url) + uvicorn.run(build_app(deps), host="127.0.0.1", port=arguments.port, log_level="warning") + sys.stdout.write("\n".join(album.summary()) + "\n") + return 0 + + +def _import(source: Path, directory: Path, *, dry_run: bool) -> ImportReport: + """Import a folder, or work out what importing it would do.""" + stripper = ExiftoolStripper() + if dry_run: + return plan_import(source, directory, stripper) + return run_import(source, directory, stripper) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tag.sh b/web.sh similarity index 61% rename from tag.sh rename to web.sh index 19352f9..d83326e 100755 --- a/tag.sh +++ b/web.sh @@ -1,11 +1,11 @@ #!/bin/bash # ============================================================================ -# Tag the photo album, in a browser. +# Manage the bot, in a browser: health, configuration and the album. # -# ./tag.sh --untagged # only items with no tags yet -# ./tag.sh --import ~/photos --untagged # import that folder first -# ./tag.sh --import ~/photos --dry-run # the report, changing nothing +# ./web.sh --untagged # only items with no tags yet +# ./web.sh --import ~/photos --untagged # import that folder first +# ./web.sh --import ~/photos --dry-run # the report, changing nothing # # Sets up .venv on first run, so this works on a clone with nothing installed. # ============================================================================ @@ -20,7 +20,7 @@ source "${REPO_ROOT}/scripts/ensure_venv.sh" main() { ensure_venv "$REPO_ROOT" - exec "${REPO_ROOT}/.venv/bin/python" "${REPO_ROOT}/tool/tagger.py" "$@" + exec "${REPO_ROOT}/.venv/bin/python" "${REPO_ROOT}/tool/web.py" "$@" } main "$@" diff --git a/web/eslint.config.js b/web/eslint.config.js new file mode 100644 index 0000000..1de82c2 --- /dev/null +++ b/web/eslint.config.js @@ -0,0 +1,183 @@ +// Maximally aggressive, type-aware flat config. +// +// Baseline is typescript-eslint's *type-checked* strict + stylistic presets, so +// anything they flag is an error, not a warning. On top of that: unicorn (all), +// sonarjs (cognitive complexity / duplicate logic), perfectionist (sorting), and +// react-hooks. Unused disable directives are themselves errors, so every +// `eslint-disable` in this repo must be load-bearing. +// +// Deliberately absent: +// - eslint-plugin-react : 7.37.x still calls context.getFilename(), removed in +// ESLint 10, so it crashes the run. +// - eslint-plugin-jsx-a11y: 6.10.2 declares peer ESLint "^3 || ... || ^9" — no +// ESLint 10 support yet. a11y is enforced by review + +// testing-library queries (getByLabelText) instead. +// +// TypeScript is pinned to 6.0.x, not 7.x: typescript-eslint requires +// "typescript >=4.8.4 <6.1.0", and losing every type-aware rule would be a far +// bigger regression than gaining the Go-native compiler. +import js from "@eslint/js"; +import perfectionist from "eslint-plugin-perfectionist"; +import reactHooks from "eslint-plugin-react-hooks"; +import sonarjs from "eslint-plugin-sonarjs"; +import unicorn from "eslint-plugin-unicorn"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { ignores: ["dist", "coverage"] }, + { linterOptions: { reportUnusedDisableDirectives: "error" } }, + + js.configs.recommended, + ...tseslint.configs.strictTypeChecked, + ...tseslint.configs.stylisticTypeChecked, + unicorn.configs.all, + sonarjs.configs.recommended, + perfectionist.configs["recommended-natural"], + + { + files: ["**/*.{ts,tsx}"], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + plugins: { "react-hooks": reactHooks }, + rules: { + ...reactHooks.configs.recommended.rules, + + // Numbers in template literals are unambiguous and pervasive here + // (counts, star totals, grades). + "@typescript-eslint/restrict-template-expressions": [ + "error", + { allowNumber: true }, + ], + + // Keep the rule, widen its vocabulary. `passesFilters(server, filter)` + // and `matchesQuery(...)` are predicates that read as English; forcing + // them to `isPassingFilters` would be worse prose in service of a prefix + // list. `passes`/`matches` are added, nothing is removed. + "unicorn/consistent-boolean-name": [ + "error", + { prefixes: { matches: true, passes: true } }, + ], + + // Kebab-case files exporting PascalCase components: house convention. + "unicorn/filename-case": ["error", { case: "kebabCase" }], + + // Keep the rule, drop one replacement: `props` is React's own word. + // `interface FilterBarProps` is the convention every React codebase and + // every reader expects; `FilterBarProperties` would be unidiomatic in + // service of a generic dictionary. Every other replacement stays on. + "unicorn/name-replacements": ["error", { replacements: { props: false } }], + }, + }, + + // House overrides, applied to every file including this config itself. + // + // Each rule below is switched off deliberately: these are the cases where + // "more aggressive" makes the code worse rather than safer. A rule you must + // disable at every use site is worse than no rule at all, because + // reportUnusedDisableDirectives then turns the disables into their own debt. + { + rules: { + // Directly contradicts unicorn/consistent-class-member-order: one wants + // a private field before the getter that reads it, the other wants the + // getter first, and satisfying either re-triggers the other. unicorn's + // wins because "declare the field before the code that uses it" is a + // reason, whereas alphabetical order within a class is not. + "perfectionist/sort-classes": "off", + + // Sorts Map entries by key. The marker table in parse-readme.ts is keyed + // by emoji codepoint, so "sorted" would mean ordered by + // U+1F396 < U+1F3CE < ... — meaningless to a reader, and it would shred + // the legend's own grouping (official, languages, scope, OS) into noise. + "perfectionist/sort-maps": "off", + + // Alphabetises declarations within a file, which actively fights reading + // order: it would scatter `GRADES -> gradeRank -> isTripleA` into + // `compositeRank -> GRADES -> gradeCoverage`. Alphabetical order is not + // narrative order. Every *other* perfectionist rule stays on — sorting + // imports, union members and object keys destroys no narrative. + "perfectionist/sort-modules": "off", + + // Wants JSDoc blocks written without leading `*`, which is neither the + // TypeScript convention nor what editors/typedoc render. + "unicorn/no-asterisk-prefix-in-documentation-comments": "off", + + // Flags hand-wrapped comment prose. Comments here are wrapped to 80 + // columns on purpose; the rule would force one long line per paragraph. + "unicorn/no-manually-wrapped-comments": "off", + + // `null` is load-bearing here and cannot be replaced by `undefined`: + // 1. The pipeline's output is JSON, and JSON has no `undefined`. A + // 2. The two values mean different things in this domain — `null` is + // "Glama never indexed this server", `undefined` is "indexed, but + // this axis was not graded". Collapsing them would lose information + // the UI displays differently (not-indexed vs graded-partial). + "unicorn/no-null": "off", + + // `Temporal` does not exist in this project's runtime: Node 26 reports + // `typeof Temporal === "undefined"`. The rule is unfollowable until it + // ships. The inputs here are GitHub's ISO-8601 timestamps, which + // Date.parse handles identically across engines — the cross-engine + // inconsistency the rule warns about is for non-ISO strings. + "unicorn/prefer-temporal": "off", + + // The domain vocabulary is abbreviated on purpose: `os`, `spdx`, `repo`, + // `env` are the names the source data uses, and expanding them to + // `operatingSystem` / `repository` would obscure the mapping. + "unicorn/prevent-abbreviations": "off", + }, + }, + + { + rules: { + // Caps a try block at ONE statement. Unfollowable where the whole point + // is that several steps can throw: loadPresets must wrap getItem (throws + // in private browsing), JSON.parse (throws on junk) and the shape check + // together, because any of them failing means the same thing — fall back + // to no presets. Splitting them into three try blocks would be strictly + // worse code. The rule has no maximum option, so it is all or nothing. + "unicorn/try-complexity": "off", + }, + }, + + // Tests: relax the rules that fight with mocking and fixtures. + { + rules: { + "@typescript-eslint/no-empty-function": "off", + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/require-await": "off", + "@typescript-eslint/unbound-method": "off", + "sonarjs/no-duplicate-string": "off", + // Test helpers live beside the tests that use them; hoisting every + // fixture factory to module scope to satisfy a scoping rule would + // scatter each test's setup away from the test. + "unicorn/consistent-function-scoping": "off", + // A no-op stub is the point of a stub. + "unicorn/no-empty-file": "off", + }, + }, + + // The vitest setup file exists *to* perform top-level side effects: it patches + // globals (ResizeObserver, layout metrics, pointer capture) that jsdom lacks, + // before any test imports run. These two rules cannot be satisfied by a file + // whose entire contract is "mutate the environment on import". + { + files: ["src/test/setup.ts"], + rules: { + // The stubs are property getters installed on DOM prototypes, so `this` + // is the element being measured — there is no class to put them in. + "unicorn/no-this-outside-of-class": "off", + "unicorn/no-top-level-assignment-in-function": "off", + "unicorn/no-top-level-side-effects": "off", + }, + }, + + // Plain JS config files get no type-aware linting. + { + extends: [tseslint.configs.disableTypeChecked], + files: ["**/*.{js,mjs,cjs}"], + }, +); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..d16384d --- /dev/null +++ b/web/index.html @@ -0,0 +1,20 @@ + + + + + + + + + signal-bot + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..49aa5f7 --- /dev/null +++ b/web/package.json @@ -0,0 +1,41 @@ +{ + "name": "signal-bot-web", + "private": true, + "type": "module", + "packageManager": "pnpm@11.24.0", + "engines": { "node": ">=24", "pnpm": ">=10" }, + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "lint": "tsc --noEmit && eslint .", + "test": "vitest run", + "coverage": "vitest run --coverage" + }, + "dependencies": { + "@kuhyx/web-ui": "github:kuhyx/utils#web_ui-v0.3.1&path:/web_ui", + "react": "19.2.8", + "react-dom": "19.2.8" + }, + "devDependencies": { + "@eslint/js": "10.0.1", + "@testing-library/jest-dom": "7.0.1", + "@testing-library/react": "16.3.3", + "@testing-library/user-event": "14.6.6", + "@types/node": "26.4.0", + "@types/react": "19.2.18", + "@types/react-dom": "19.2.5", + "@vitejs/plugin-react": "6.1.1", + "@vitest/coverage-v8": "4.1.11", + "eslint": "10.9.1", + "eslint-plugin-perfectionist": "5.10.1", + "eslint-plugin-react-hooks": "7.1.1", + "eslint-plugin-sonarjs": "4.2.0", + "eslint-plugin-unicorn": "74.0.0", + "jsdom": "30.0.1", + "typescript": "6.0.3", + "typescript-eslint": "8.68.0", + "vite": "8.2.2", + "vitest": "4.1.11" + } +} diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml new file mode 100644 index 0000000..cb61365 --- /dev/null +++ b/web/pnpm-lock.yaml @@ -0,0 +1,2862 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@kuhyx/web-ui': + specifier: github:kuhyx/utils#web_ui-v0.3.1&path:/web_ui + version: https://codeload.github.com/kuhyx/utils/tar.gz/6714d131983f05a0e67f55ac836e3e8d57045425#path:/web_ui(react@19.2.8) + react: + specifier: 19.2.8 + version: 19.2.8 + react-dom: + specifier: 19.2.8 + version: 19.2.8(react@19.2.8) + devDependencies: + '@eslint/js': + specifier: 10.0.1 + version: 10.0.1(eslint@10.9.1(supports-color@7.2.0)) + '@testing-library/jest-dom': + specifier: 7.0.1 + version: 7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.11) + '@testing-library/react': + specifier: 16.3.3 + version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@testing-library/user-event': + specifier: 14.6.6 + version: 14.6.6(@testing-library/dom@10.4.1) + '@types/node': + specifier: 26.4.0 + version: 26.4.0 + '@types/react': + specifier: 19.2.18 + version: 19.2.18 + '@types/react-dom': + specifier: 19.2.5 + version: 19.2.5(@types/react@19.2.18) + '@vitejs/plugin-react': + specifier: 6.1.1 + version: 6.1.1(vite@8.2.2(@types/node@26.4.0)(yaml@2.9.0)) + '@vitest/coverage-v8': + specifier: 4.1.11 + version: 4.1.11(vitest@4.1.11) + eslint: + specifier: 10.9.1 + version: 10.9.1(supports-color@7.2.0) + eslint-plugin-perfectionist: + specifier: 5.10.1 + version: 5.10.1(eslint@10.9.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint-plugin-react-hooks: + specifier: 7.1.1 + version: 7.1.1(eslint@10.9.1(supports-color@7.2.0))(supports-color@7.2.0) + eslint-plugin-sonarjs: + specifier: 4.2.0 + version: 4.2.0(eslint@10.9.1(supports-color@7.2.0)) + eslint-plugin-unicorn: + specifier: 74.0.0 + version: 74.0.0(eslint@10.9.1(supports-color@7.2.0)) + jsdom: + specifier: 30.0.1 + version: 30.0.1 + typescript: + specifier: 6.0.3 + version: 6.0.3 + typescript-eslint: + specifier: 8.68.0 + version: 8.68.0(eslint@10.9.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + vite: + specifier: 8.2.2 + version: 8.2.2(@types/node@26.4.0)(yaml@2.9.0) + vitest: + specifier: 4.1.11 + version: 4.1.11(@types/node@26.4.0)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.4.0)(yaml@2.9.0)) + +packages: + + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + + '@asamuzakjp/css-color@6.0.7': + resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==} + engines: {node: ^22.13.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@8.3.2': + resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==} + engines: {node: ^22.13.0 || >=24.0.0} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.1.1': + resolution: {integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.2.1': + resolution: {integrity: sha512-YpAJZhaHplYQkG8ib+/Fx5Y0eF2lVWi3tIvMJA6i39TLyUNp2439cifzW8VMjhlqrBjHzK5hVGugRRm2zTKI/A==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.9': + resolution: {integrity: sha512-iGGw4OsAYsS6pD29MdJ2bX/nJx65a04ZZiw6x+VwWlP2DdXf6f++Zmuv/OzALpdyfVhjbduIIF2cXM7HWBIe9A==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.7.0': + resolution: {integrity: sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/css-tree@4.0.5': + resolution: {integrity: sha512-iPmijIAq4hlIJB86PYmY/fcZORHtjphSqICDbwuw32A/JmkhZQ/K/6TjHE03zqf3n5yABpVcbRAMG8Mi9ojy8g==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.2': + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@kuhyx/web-ui@https://codeload.github.com/kuhyx/utils/tar.gz/6714d131983f05a0e67f55ac836e3e8d57045425#path:/web_ui': + resolution: {gitHosted: true, path: /web_ui, tarball: https://codeload.github.com/kuhyx/utils/tar.gz/6714d131983f05a0e67f55ac836e3e8d57045425} + version: 0.3.1 + engines: {node: '>=24', pnpm: '>=10'} + peerDependencies: + react: '>=19' + + '@oxc-project/types@0.147.0': + resolution: {integrity: sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==} + + '@rolldown/binding-android-arm-eabi@1.2.6': + resolution: {integrity: sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.6': + resolution: {integrity: sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.6': + resolution: {integrity: sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.6': + resolution: {integrity: sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.6': + resolution: {integrity: sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.6': + resolution: {integrity: sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.6': + resolution: {integrity: sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.6': + resolution: {integrity: sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.6': + resolution: {integrity: sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.6': + resolution: {integrity: sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.6': + resolution: {integrity: sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.6': + resolution: {integrity: sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.6': + resolution: {integrity: sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.6': + resolution: {integrity: sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.6': + resolution: {integrity: sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@7.0.1': + resolution: {integrity: sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==} + engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + peerDependencies: + '@testing-library/dom': '>=10 <11' + vitest: '>= 0.32' + peerDependenciesMeta: + vitest: + optional: true + + '@testing-library/react@16.3.3': + resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} + 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 + + '@testing-library/user-event@14.6.6': + resolution: {integrity: sha512-Jbs9FpkkIDw8FgSc6kOVsOv8JuuqGAL7J4X1oot77JxAoDlkNn2GRkd0aYRVuQ+pVQAiHWVkE4rX/dkF5fBiCw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@26.4.0': + resolution: {integrity: sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==} + + '@types/react-dom@19.2.5': + resolution: {integrity: sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + + '@typescript-eslint/eslint-plugin@8.68.0': + resolution: {integrity: sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.68.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.68.0': + resolution: {integrity: sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.68.0': + resolution: {integrity: sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.68.0': + resolution: {integrity: sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.68.0': + resolution: {integrity: sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.68.0': + resolution: {integrity: sha512-X77zqoY1EjeWGs/0JNxeaMfp5C5lIz4Tw8y66F1Ne8Faq6g424sBNYM6xBAqElfGZPLpWS+CZAp0DXyKDzWiHg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.68.0': + resolution: {integrity: sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.68.0': + resolution: {integrity: sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.68.0': + resolution: {integrity: sha512-PB5gJMMOg0Q5P1tsgWtEAqQacJXq0qEqRHDX/YJ4FaTMLfZPpHB3gjl2EJuiZyPABxmj4ZQYiY9m1bdAJ5y7tQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.68.0': + resolution: {integrity: sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitejs/plugin-react@6.1.1': + resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + oxc-transform-react: ^0.145.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + oxc-transform-react: + optional: true + + '@vitest/coverage-v8@4.1.11': + resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==} + peerDependencies: + '@vitest/browser': 4.1.11 + vitest: 4.1.11 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} + + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} + + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} + + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} + + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@1.0.5: + resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.11.20: + resolution: {integrity: sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==} + engines: {node: '>=6.0.0'} + hasBin: true + + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + + browserslist@4.28.8: + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + builtin-modules@3.3.0: + resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} + engines: {node: '>=6'} + + builtin-modules@5.3.0: + resolution: {integrity: sha512-hMQUl2bUFG339QygPM97E+mc8OY1IAchORZxm4a/frcYwKzozMzRVDBwHW0NjOqGElLm2O37AVQE8ikxlZHrMQ==} + engines: {node: '>=18.20'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + convert-hrtime@5.0.0: + resolution: {integrity: sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==} + engines: {node: '>=12'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + core-js-compat@3.50.0: + resolution: {integrity: sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==} + engines: {node: '>=6.4.0'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-indent@7.0.2: + resolution: {integrity: sha512-y+8xyqdGLL+6sh0tVeHcfP/QDd8gUgbasolJJpY7NgeQGSZ739bDtSiaiDgtoicy+mtYB81dKLxO9xRhCyIB3A==} + engines: {node: '>=12.20'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + electron-to-chromium@1.5.416: + resolution: {integrity: sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA==} + + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-perfectionist@5.10.1: + resolution: {integrity: sha512-Kprsp9Us0GqAesYaAIzUViw57xYp5WBqzXrcE0Mtww++E5fexWXYBipMuuD7yvyH4vvpBH0+oJ+OMAmZ0oYXkw==} + engines: {node: ^20.0.0 || >=22.0.0} + peerDependencies: + eslint: ^8.45.0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-sonarjs@4.2.0: + resolution: {integrity: sha512-bqADfuNtTL7VK6RU29eoiFTtaaBKIpVPuX3bOl+rBpWSBa0zIBVZlqZNZQjfP6s4iXkAJokv5IsD8OsACkwApg==} + peerDependencies: + eslint: ^8.0.0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-unicorn@74.0.0: + resolution: {integrity: sha512-AGnsGi2SxHg1HEAXxn9nSnZfyjvTWkxm8E8hpd/9tD6dLjBUdcD7+D6ZN64HmmCXTSXlrwVyUqe20Uyb2CaurA==} + engines: {node: '>=22'} + peerDependencies: + eslint: '>=10.4' + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.9.1: + resolution: {integrity: sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up-simple@1.0.1: + resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} + engines: {node: '>=18'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-timeout@1.0.2: + resolution: {integrity: sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA==} + engines: {node: '>=18'} + + functional-red-black-tree@1.0.1: + resolution: {integrity: sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@17.11.0: + resolution: {integrity: sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==} + engines: {node: '>=18'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + identifier-regex@1.1.0: + resolution: {integrity: sha512-SLX4H/vtcYlYnL7XqnuJKHU7Z8517TgsW9nmQiGOgMCjQ8V/deLYu6bEmbGoXe7WMMhc9+EUGyFFneHja8KabA==} + engines: {node: '>=18'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + + is-builtin-module@5.0.0: + resolution: {integrity: sha512-f4RqJKBUe5rQkJ2eJEJBXSticB3hGbN9j0yxxMQFqIW89Jp9WYFtzfTcRlstDKVUTRzSOTLKRfO9vIztenwtxA==} + engines: {node: '>=18.20'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-identifier@1.1.0: + resolution: {integrity: sha512-NhOds0mDx9lJu+1lBRO0xbwFo5nobA7GCk/0e5xjr6+6XugX985+0OyGX35BNrTkPAsdLcIKg02HUQJOK8D8kw==} + engines: {node: '>=18'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsdom@30.0.1: + resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + peerDependencies: + canvas: ^3.2.3 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsx-ast-utils-x@0.1.0: + resolution: {integrity: sha512-eQQBjBnsVtGacsG9uJNB8qOr3yA8rga4wAaGG1qRcBzSIvfhERLrWxMAM1hp5fcS6Abo8M4+bUBTekYR0qTPQw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.4: + resolution: {integrity: sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==} + + make-asynchronous@1.1.0: + resolution: {integrity: sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==} + engines: {node: '>=18'} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + mdn-data@2.29.0: + resolution: {integrity: sha512-pVxQFCcaYUEAH853+v7yoI/qzhxXSq1bTb9obMYGYAN1c3Hen+XDCEvr296XhstrwlSTNgOR7mCSD4JPjbJe5A==} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + natural-orderby@5.0.0: + resolution: {integrity: sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg==} + engines: {node: '>=18'} + + node-releases@2.0.54: + resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} + engines: {node: '>=18'} + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-event@6.0.1: + resolution: {integrity: sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w==} + engines: {node: '>=16.17'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-timeout@6.1.4: + resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==} + engines: {node: '>=14.16'} + + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + quote-js-string@0.1.0: + resolution: {integrity: sha512-Y3NoRtprEEZQD8RfxMCfS0ZTqc4e+i18OrXEXAvpM6TfC/3y+0L5rNbZiSnbBBEkDfFzbpd8o+cE8q3/anjMGA==} + engines: {node: '>=22'} + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + refa@0.12.1: + resolution: {integrity: sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + regexp-ast-analysis@0.7.1: + resolution: {integrity: sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + regjsparser@0.13.2: + resolution: {integrity: sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==} + hasBin: true + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + reserved-identifiers@1.2.0: + resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==} + engines: {node: '>=18'} + + rolldown@1.2.6: + resolution: {integrity: sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + scslre@0.3.0: + resolution: {integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==} + engines: {node: ^14.0.0 || >=16.0.0} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + strip-indent@4.1.1: + resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==} + engines: {node: '>=12'} + + super-regex@1.1.0: + resolution: {integrity: sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ==} + engines: {node: '>=18'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + time-span@5.1.0: + resolution: {integrity: sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==} + engines: {node: '>=12'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.4.11: + resolution: {integrity: sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==} + + tldts@7.4.11: + resolution: {integrity: sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==} + hasBin: true + + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + typescript-eslint@8.68.0: + resolution: {integrity: sha512-MHy0Y0ynqeEbx/S45+i/bBssdy3X6KNBfmJAP35GrgtNxu2TQ5K5xsFDhAnmsq1jvpdoZOPG1LGtJo0HWqYCrQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + undici@8.10.0: + resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} + engines: {node: '>=22.19.0'} + + update-browserslist-db@1.3.2: + resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + vite@8.2.2: + resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + web-worker@1.5.0: + resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + whatwg-url@17.1.0: + resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==} + engines: {node: ^22.14.0 || >=24.0.0} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@4.5.4: + resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==} + +snapshots: + + '@adobe/css-tools@4.5.0': {} + + '@asamuzakjp/css-color@6.0.7': + dependencies: + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.5.2 + + '@asamuzakjp/dom-selector@8.3.2': + dependencies: + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7(supports-color@7.2.0)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3(supports-color@7.2.0) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.8 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7(supports-color@7.2.0)': + dependencies: + '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0) + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8(supports-color@7.2.0)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.1.1': {} + + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.1 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.9(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + + '@eslint-community/eslint-utils@4.10.1(eslint@10.9.1(supports-color@7.2.0))': + dependencies: + eslint: 10.9.1(supports-color@7.2.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5(supports-color@7.2.0)': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3(supports-color@7.2.0) + minimatch: 10.2.6 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.7.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/css-tree@4.0.5': + dependencies: + mdn-data: 2.29.0 + source-map-js: 1.2.1 + + '@eslint/js@10.0.1(eslint@10.9.1(supports-color@7.2.0))': + optionalDependencies: + eslint: 10.9.1(supports-color@7.2.0) + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.2': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@exodus/bytes@1.15.1': {} + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@kuhyx/web-ui@https://codeload.github.com/kuhyx/utils/tar.gz/6714d131983f05a0e67f55ac836e3e8d57045425#path:/web_ui(react@19.2.8)': + dependencies: + react: 19.2.8 + + '@oxc-project/types@0.147.0': {} + + '@rolldown/binding-android-arm-eabi@1.2.6': + optional: true + + '@rolldown/binding-android-arm64@1.2.6': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.6': + optional: true + + '@rolldown/binding-darwin-x64@1.2.6': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.6': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.6': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.6': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.6': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.6': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.6': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.6': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.6': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.6': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.6': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.6': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.11)': + dependencies: + '@adobe/css-tools': 4.5.0 + '@testing-library/dom': 10.4.1 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + optionalDependencies: + vitest: 4.1.11(@types/node@26.4.0)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.4.0)(yaml@2.9.0)) + + '@testing-library/react@16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.5(@types/react@19.2.18) + + '@testing-library/user-event@14.6.6(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@26.4.0': + dependencies: + undici-types: 8.3.0 + + '@types/react-dom@19.2.5(@types/react@19.2.18)': + dependencies: + '@types/react': 19.2.18 + + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 + + '@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.9.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.68.0(eslint@10.9.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.68.0 + '@typescript-eslint/type-utils': 8.68.0(eslint@10.9.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.68.0(eslint@10.9.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.68.0 + eslint: 10.9.1(supports-color@7.2.0) + ignore: 7.0.6 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.68.0(eslint@10.9.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.68.0 + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/typescript-estree': 8.68.0(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.68.0 + debug: 4.4.3(supports-color@7.2.0) + eslint: 10.9.1(supports-color@7.2.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.68.0(supports-color@7.2.0)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.68.0(typescript@6.0.3) + '@typescript-eslint/types': 8.68.0 + debug: 4.4.3(supports-color@7.2.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.68.0': + dependencies: + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/visitor-keys': 8.68.0 + + '@typescript-eslint/tsconfig-utils@8.68.0(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + + '@typescript-eslint/type-utils@8.68.0(eslint@10.9.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/typescript-estree': 8.68.0(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.68.0(eslint@10.9.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + debug: 4.4.3(supports-color@7.2.0) + eslint: 10.9.1(supports-color@7.2.0) + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.68.0': {} + + '@typescript-eslint/typescript-estree@8.68.0(supports-color@7.2.0)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.68.0(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.68.0(typescript@6.0.3) + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/visitor-keys': 8.68.0 + debug: 4.4.3(supports-color@7.2.0) + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.68.0(eslint@10.9.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1(supports-color@7.2.0)) + '@typescript-eslint/scope-manager': 8.68.0 + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/typescript-estree': 8.68.0(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.9.1(supports-color@7.2.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.68.0': + dependencies: + '@typescript-eslint/types': 8.68.0 + eslint-visitor-keys: 5.0.1 + + '@vitejs/plugin-react@6.1.1(vite@8.2.2(@types/node@26.4.0)(yaml@2.9.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.2.2(@types/node@26.4.0)(yaml@2.9.0) + + '@vitest/coverage-v8@4.1.11(vitest@4.1.11)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.11 + ast-v8-to-istanbul: 1.0.5 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.4 + obug: 2.1.4 + std-env: 4.2.0 + tinyrainbow: 3.1.1 + vitest: 4.1.11(@types/node@26.4.0)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.4.0)(yaml@2.9.0)) + + '@vitest/expect@4.1.11': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@26.4.0)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.11 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.2.2(@types/node@26.4.0)(yaml@2.9.0) + + '@vitest/pretty-format@4.1.11': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.11': + dependencies: + '@vitest/utils': 4.1.11 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.11': {} + + '@vitest/utils@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-regex@5.0.1: {} + + ansi-styles@5.2.0: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@1.0.5: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.11.20: {} + + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + + browserslist@4.28.8: + dependencies: + baseline-browser-mapping: 2.11.20 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.416 + node-releases: 2.0.54 + update-browserslist-db: 1.3.2(browserslist@4.28.8) + + builtin-modules@3.3.0: {} + + builtin-modules@5.3.0: {} + + bytes@3.1.2: {} + + caniuse-lite@1.0.30001810: {} + + chai@6.2.2: {} + + change-case@5.4.4: {} + + ci-info@4.4.0: {} + + convert-hrtime@5.0.0: {} + + convert-source-map@2.0.0: {} + + core-js-compat@3.50.0: + dependencies: + browserslist: 4.28.8 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + + csstype@3.2.3: {} + + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + debug@4.4.3(supports-color@7.2.0): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 7.2.0 + + decimal.js@10.6.0: {} + + deep-is@0.1.4: {} + + dequal@2.0.3: {} + + detect-indent@7.0.2: {} + + detect-libc@2.1.2: {} + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + electron-to-chromium@1.5.416: {} + + entities@8.0.0: {} + + es-module-lexer@2.3.2: {} + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-perfectionist@5.10.1(eslint@10.9.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3): + dependencies: + '@typescript-eslint/utils': 8.68.0(eslint@10.9.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.9.1(supports-color@7.2.0) + natural-orderby: 5.0.0 + transitivePeerDependencies: + - supports-color + - typescript + + eslint-plugin-react-hooks@7.1.1(eslint@10.9.1(supports-color@7.2.0))(supports-color@7.2.0): + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/parser': 7.29.8 + eslint: 10.9.1(supports-color@7.2.0) + hermes-parser: 0.25.1 + zod: 4.5.4 + zod-validation-error: 4.0.2(zod@4.5.4) + transitivePeerDependencies: + - supports-color + + eslint-plugin-sonarjs@4.2.0(eslint@10.9.1(supports-color@7.2.0)): + dependencies: + '@eslint-community/regexpp': 4.12.2 + builtin-modules: 3.3.0 + bytes: 3.1.2 + eslint: 10.9.1(supports-color@7.2.0) + functional-red-black-tree: 1.0.1 + globals: 17.11.0 + jsx-ast-utils-x: 0.1.0 + lodash.merge: 4.6.2 + minimatch: 10.2.6 + scslre: 0.3.0 + semver: 7.8.5 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + yaml: 2.9.0 + + eslint-plugin-unicorn@74.0.0(eslint@10.9.1(supports-color@7.2.0)): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1(supports-color@7.2.0)) + '@eslint/css-tree': 4.0.5 + browserslist: 4.28.8 + change-case: 5.4.4 + ci-info: 4.4.0 + core-js-compat: 3.50.0 + detect-indent: 7.0.2 + entities: 8.0.0 + eslint: 10.9.1(supports-color@7.2.0) + find-up-simple: 1.0.1 + globals: 17.11.0 + indent-string: 5.0.0 + is-builtin-module: 5.0.0 + is-identifier: 1.1.0 + pluralize: 8.0.0 + quote-js-string: 0.1.0 + regjsparser: 0.13.2 + reserved-identifiers: 1.2.0 + semver: 7.8.5 + strip-indent: 4.1.1 + yaml: 2.9.0 + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.9.1(supports-color@7.2.0): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1(supports-color@7.2.0)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5(supports-color@7.2.0) + '@eslint/config-helpers': 0.7.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3(supports-color@7.2.0) + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.6 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + expect-type@1.4.0: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up-simple@1.0.1: {} + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.4 + keyv: 4.5.4 + + flatted@3.4.4: {} + + fsevents@2.3.3: + optional: true + + function-timeout@1.0.2: {} + + functional-red-black-tree@1.0.1: {} + + gensync@1.0.0-beta.2: {} + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@17.11.0: {} + + has-flag@4.0.0: {} + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + + html-escaper@2.0.2: {} + + identifier-regex@1.1.0: + dependencies: + reserved-identifiers: 1.2.0 + + ignore@5.3.2: {} + + ignore@7.0.6: {} + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + indent-string@5.0.0: {} + + is-builtin-module@5.0.0: + dependencies: + builtin-modules: 5.3.0 + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-identifier@1.1.0: + dependencies: + identifier-regex: 1.1.0 + super-regex: 1.1.0 + + is-potential-custom-element-name@1.0.1: {} + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + js-tokens@10.0.0: {} + + js-tokens@4.0.0: {} + + jsdom@30.0.1: + dependencies: + '@asamuzakjp/css-color': 6.0.7 + '@asamuzakjp/dom-selector': 8.3.2 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.9(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 8.10.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 17.1.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + jsx-ast-utils-x@0.1.0: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + lru-cache@11.5.2: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + magicast@0.5.4: + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + source-map-js: 1.2.1 + + make-asynchronous@1.1.0: + dependencies: + p-event: 6.0.1 + type-fest: 4.41.0 + web-worker: 1.5.0 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + + mdn-data@2.27.1: {} + + mdn-data@2.29.0: {} + + min-indent@1.0.1: {} + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + + ms@2.1.3: {} + + nanoid@3.3.18: {} + + natural-compare@1.4.0: {} + + natural-orderby@5.0.0: {} + + node-releases@2.0.54: {} + + obug@2.1.4: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-event@6.0.1: + dependencies: + p-timeout: 6.1.4 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-timeout@6.1.4: {} + + parse5@8.0.1: + dependencies: + entities: 8.0.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + pluralize@8.0.0: {} + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + punycode@2.3.1: {} + + quote-js-string@0.1.0: {} + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-is@17.0.2: {} + + react@19.2.8: {} + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + refa@0.12.1: + dependencies: + '@eslint-community/regexpp': 4.12.2 + + regexp-ast-analysis@0.7.1: + dependencies: + '@eslint-community/regexpp': 4.12.2 + refa: 0.12.1 + + regjsparser@0.13.2: + dependencies: + jsesc: 3.1.0 + + require-from-string@2.0.2: {} + + reserved-identifiers@1.2.0: {} + + rolldown@1.2.6: + dependencies: + '@oxc-project/types': 0.147.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.6 + '@rolldown/binding-android-arm64': 1.2.6 + '@rolldown/binding-darwin-arm64': 1.2.6 + '@rolldown/binding-darwin-x64': 1.2.6 + '@rolldown/binding-freebsd-x64': 1.2.6 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.6 + '@rolldown/binding-linux-arm64-gnu': 1.2.6 + '@rolldown/binding-linux-arm64-musl': 1.2.6 + '@rolldown/binding-linux-ppc64-gnu': 1.2.6 + '@rolldown/binding-linux-s390x-gnu': 1.2.6 + '@rolldown/binding-linux-x64-gnu': 1.2.6 + '@rolldown/binding-linux-x64-musl': 1.2.6 + '@rolldown/binding-openharmony-arm64': 1.2.6 + '@rolldown/binding-win32-arm64-msvc': 1.2.6 + '@rolldown/binding-win32-x64-msvc': 1.2.6 + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + + scslre@0.3.0: + dependencies: + '@eslint-community/regexpp': 4.12.2 + refa: 0.12.1 + regexp-ast-analysis: 0.7.1 + + semver@6.3.1: {} + + semver@7.8.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + strip-indent@4.1.1: {} + + super-regex@1.1.0: + dependencies: + function-timeout: 1.0.2 + make-asynchronous: 1.1.0 + time-span: 5.1.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + symbol-tree@3.2.4: {} + + time-span@5.1.0: + dependencies: + convert-hrtime: 5.0.0 + + tinybench@2.9.0: {} + + tinyexec@1.3.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinyrainbow@3.1.1: {} + + tldts-core@7.4.11: {} + + tldts@7.4.11: + dependencies: + tldts-core: 7.4.11 + + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.11 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + + ts-api-utils@2.5.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@4.41.0: {} + + typescript-eslint@8.68.0(eslint@10.9.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.9.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/parser': 8.68.0(eslint@10.9.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.68.0(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.68.0(eslint@10.9.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.9.1(supports-color@7.2.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + typescript@6.0.3: {} + + undici-types@8.3.0: {} + + undici@8.10.0: {} + + update-browserslist-db@1.3.2(browserslist@4.28.8): + dependencies: + browserslist: 4.28.8 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vite@8.2.2(@types/node@26.4.0)(yaml@2.9.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.26 + rolldown: 1.2.6 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.4.0 + fsevents: 2.3.3 + yaml: 2.9.0 + + vitest@4.1.11(@types/node@26.4.0)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.4.0)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@26.4.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.2(@types/node@26.4.0)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.4.0 + '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) + jsdom: 30.0.1 + transitivePeerDependencies: + - msw + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + web-worker@1.5.0: {} + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + whatwg-url@17.1.0: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + yallist@3.1.1: {} + + yaml@2.9.0: {} + + yocto-queue@0.1.0: {} + + zod-validation-error@4.0.2(zod@4.5.4): + dependencies: + zod: 4.5.4 + + zod@4.5.4: {} diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..28acd8d --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1,6 @@ + + + + + diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts new file mode 100644 index 0000000..2580fa7 --- /dev/null +++ b/web/src/api/client.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { serve } from "../test/http.ts"; +import { api, ApiError } from "./client.ts"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("the api client", () => { + it("returns the body of a successful read", async () => { + serve({ "/api/status": { body: { bot: {}, reachable: true } } }); + await expect(api.status()).resolves.toEqual({ bot: {}, reachable: true }); + }); + + it("raises the server's own sentence, not one of its own", async () => { + // The detail is usually load_config's message -- what the journal would + // have shown at the next restart. Rewording it here would make two + // different sentences for one rule. + serve({ + "/api/env": { body: { detail: "REPORT_HOUR out of range" }, status: 400 }, + }); + await expect(api.saveEnv({ REPORT_HOUR: "99" })).rejects.toThrow( + "REPORT_HOUR out of range", + ); + }); + + it("falls back to the status text when there is no detail", async () => { + serve({ "/api/status": { body: { nothing: true }, status: 500 } }); + await expect(api.status()).rejects.toBeInstanceOf(ApiError); + }); + + it("treats a path the server does not serve as a 404", async () => { + serve({}); + await expect(api.status()).rejects.toMatchObject({ status: 404 }); + }); + + it("carries the status code so a caller can tell 403 from 400", async () => { + serve({ "/api/env": { body: { detail: "nope" }, status: 403 } }); + await expect(api.saveEnv({})).rejects.toMatchObject({ status: 403 }); + }); + + it("declares json on every write, which is what refuses a form post", async () => { + const fetcher = serve({ "/api/album/tag": { body: { status: "ok" } } }); + await api.tag("one.png", ["p1"]); + const [, init] = fetcher.mock.calls[0] as [string, RequestInit]; + expect(init.headers).toMatchObject({ "content-type": "application/json" }); + expect(init.method).toBe("POST"); + }); + + it("reads the album, the groups and the restart through the same wrapper", async () => { + serve({ + "/api/album/state": { body: { files: [] } }, + "/api/groups": { body: { groups: [] } }, + "/api/restart-container": { body: { ok: true } }, + }); + await expect(api.album()).resolves.toEqual({ files: [] }); + await expect(api.groups()).resolves.toEqual({ groups: [] }); + await expect(api.restart()).resolves.toEqual({ ok: true }); + }); +}); diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..ebcefbd --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,59 @@ +import type { AlbumState, EnvironmentView, Group, Restart, Status } from "./types.ts"; + +// Every mutating call sends application/json, and it is not decoration: the +// server refuses anything else, because a cross-origin cannot set +// this header and that is what stops a web page from writing .env. +const JSON_HEADERS = { "content-type": "application/json" }; + +export class ApiError extends Error { + readonly status: number; + + constructor(message: string, options: ErrorOptions & { status: number }) { + super(message, options); + this.name = "ApiError"; + this.status = options.status; + } +} + +async function unwrap(response: Response): Promise { + const body: unknown = await response.json(); + if (!response.ok) { + // FastAPI puts the reason in `detail`, and it is usually load_config's + // own message -- the sentence the journal would have shown at the next + // restart. Showing anything else would be inventing a second wording. + const detail = + typeof body === "object" && body !== null && "detail" in body + ? String((body).detail) + : response.statusText; + throw new ApiError(detail, { status: response.status }); + } + return body as T; +} + +async function get(path: string): Promise { + return unwrap(await fetch(path)); +} + +async function send(path: string, method: string, body: unknown): Promise { + return unwrap( + await fetch(path, { + body: JSON.stringify(body), + headers: JSON_HEADERS, + method, + }), + ); +} + +export const api = { + album: async (): Promise => get("/api/album/state"), + env: async (): Promise => get("/api/env"), + groups: async (): Promise<{ groups: Group[] }> => + get<{ groups: Group[] }>("/api/groups"), + restart: async (): Promise => + send("/api/restart-container", "POST", {}), + saveEnv: async (values: Record): Promise => + send("/api/env", "PUT", { values }), + status: async (): Promise => get("/api/status"), + tag: async (file: string, keys: string[]): Promise => + send("/api/album/tag", "POST", { file, keys }), +}; diff --git a/web/src/api/types.ts b/web/src/api/types.ts new file mode 100644 index 0000000..d40b5e8 --- /dev/null +++ b/web/src/api/types.ts @@ -0,0 +1,57 @@ +// What the management app answers with. Hand-written rather than generated: +// the backend is eleven small modules in this same repository, and a +// generator would be a build step to keep working for no new information. + +export interface BotHealth { + papacy?: string; + pope?: string; + reason?: string; + status?: string; + version?: string; +} + +export interface Status { + bot: BotHealth; + reachable: boolean; +} + +export interface Group { + id: string; + name: string; +} + +export interface EnvironmentKey { + editable: boolean; + key: string; + value: string; +} + +export interface EnvironmentView { + groups_known: boolean; + keys: EnvironmentKey[]; + needs_groups: string[]; +} + +export interface AlbumFile { + keys: string[]; + name: string; +} + +export interface Person { + key: string; + triggers: string[]; +} + +export interface AlbumState { + combos: Person[]; + configured: boolean; + files: AlbumFile[]; + known_groups: Group[]; + people: Person[]; +} + +export interface Restart { + ok: boolean; + stderr: string; + stdout: string; +} diff --git a/web/src/app.test.tsx b/web/src/app.test.tsx new file mode 100644 index 0000000..731d340 --- /dev/null +++ b/web/src/app.test.tsx @@ -0,0 +1,45 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, expect, it, vi } from "vitest"; + +import { App } from "./app.tsx"; +import { ALBUM, ENV, HEALTHY } from "./test/fixtures.ts"; +import { serve } from "./test/http.ts"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function everythingAnswers(): void { + serve({ + "/api/album/state": { body: ALBUM }, + "/api/env": { body: ENV }, + "/api/status": { body: HEALTHY }, + }); +} + +it("opens on health, because that is the question being asked", async () => { + everythingAnswers(); + render(); + await waitFor(() => { + expect(screen.getByTestId("reachable")).toBeInTheDocument(); + }); + expect(screen.getByRole("button", { name: "Health" })).toHaveAttribute( + "aria-current", + "true", + ); +}); + +it("switches to settings and to the photos", async () => { + everythingAnswers(); + render(); + await userEvent.click(screen.getByRole("button", { name: "Settings" })); + await waitFor(() => { + expect(screen.getByLabelText("COOLDOWN_SECONDS")).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByRole("button", { name: "Photos" })); + await waitFor(() => { + expect(screen.getByRole("img")).toBeInTheDocument(); + }); +}); diff --git a/web/src/app.tsx b/web/src/app.tsx new file mode 100644 index 0000000..b024a9c --- /dev/null +++ b/web/src/app.tsx @@ -0,0 +1,42 @@ +import { useState } from "react"; + +import { Album } from "./views/album.tsx"; +import { Health } from "./views/health.tsx"; +import { Settings } from "./views/settings.tsx"; + +const VIEWS = { + Health: Health, + Photos: Album, + Settings: Settings, +} as const; + +type ViewName = keyof typeof VIEWS; + +const NAMES = Object.keys(VIEWS) as ViewName[]; + +export function App(): React.JSX.Element { + const [view, setView] = useState("Health"); + const Current = VIEWS[view]; + return ( +
+

signal-bot

+ + +
+ ); +} diff --git a/web/src/main.test.tsx b/web/src/main.test.tsx new file mode 100644 index 0000000..d2cd9eb --- /dev/null +++ b/web/src/main.test.tsx @@ -0,0 +1,30 @@ +import { afterEach, expect, it, vi } from "vitest"; + +import { HEALTHY } from "./test/fixtures.ts"; +import { serve } from "./test/http.ts"; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); + document.body.replaceChildren(); +}); + +it("mounts the app into the root the page provides", async () => { + serve({ "/api/status": { body: HEALTHY } }); + const root = document.createElement("div"); + root.id = "root"; + document.body.replaceChildren(root); + await import("./main.tsx"); + await vi.waitFor(() => { + expect(document.querySelector("h1")).toHaveTextContent("signal-bot"); + }); +}); + +it("does nothing at all when there is no root to mount into", async () => { + // Not a hypothetical: this module is imported by its own test file, and a + // bootstrap that assumed the element existed would throw during collection + // rather than at run time. + serve({ "/api/status": { body: HEALTHY } }); + await import("./main.tsx"); + expect(document.querySelector("h1")).toBeNull(); +}); diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..b0aa1cd --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,15 @@ +import { createRoot } from "react-dom/client"; + +import { App } from "./app.tsx"; + +// Thin enough to test rather than exclude. This repository treats a coverage +// omit as a suppression, and "the DOM bootstrap is awkward to assert" is the +// same sentence as "this code is untested". +export function mount(container: Element): void { + createRoot(container).render(); +} + +const root = document.querySelector("#root"); +if (root !== null) { + mount(root); +} diff --git a/web/src/test/fixtures.ts b/web/src/test/fixtures.ts new file mode 100644 index 0000000..1a012a9 --- /dev/null +++ b/web/src/test/fixtures.ts @@ -0,0 +1,51 @@ +// Every value here is invented. Two constraints beyond "do not use real +// data": the repository's personal-data gate refuses any file containing a +// string matching group.<16+ base64 chars>, so the fixture group ids are +// short and obviously fake; and the real album's people are private, so the +// names here are letters of the NATO alphabet, as tests/pack_fixture.py does. +import type { AlbumState, EnvironmentView as EnvironmentView, Status } from "../api/types.ts"; + +export const GROUP = { id: "group.FIXTURE-ALPHA", name: "alfa" }; + +export const HEALTHY: Status = { + bot: { papacy: "nr 1", pope: "Petrus", status: "ok", version: "0.0.0" }, + reachable: true, +}; + +export const DEAF: Status = { + bot: { reason: "no frame in 120s", status: "deaf" }, + reachable: true, +}; + +export const UNREACHABLE: Status = { bot: {}, reachable: false }; + +// Answering, but with nothing to say for itself. Older releases predate the +// version/pope fields, so the page must not render "undefined" at somebody. +export const TERSE: Status = { bot: {}, reachable: true }; + +export const ENV: EnvironmentView = { + groups_known: true, + keys: [ + { editable: true, key: "GROUP_IDS", value: "" }, + { editable: true, key: "COOLDOWN_SECONDS", value: "10" }, + { editable: false, key: "PHONE_NUMBER", value: "+15550000000" }, + ], + needs_groups: ["GROUP_IDS"], +}; + +export const ALBUM: AlbumState = { + combos: [], + configured: true, + files: [ + { keys: ["p1"], name: "one.png" }, + { keys: [], name: "two.png" }, + ], + known_groups: [GROUP], + people: [ + { key: "p1", triggers: ["!alfa"] }, + { key: "p2", triggers: ["!beta"] }, + // Half-added, which the tagger allows: add_person writes the key first + // and the page has to show something for it. + { key: "p3", triggers: [] }, + ], +}; diff --git a/web/src/test/http.ts b/web/src/test/http.ts new file mode 100644 index 0000000..c509016 --- /dev/null +++ b/web/src/test/http.ts @@ -0,0 +1,25 @@ +import { vi } from "vitest"; + +export interface Reply { + body: unknown; + status?: number; +} + +/** +Answer each request by path, so a test says what the server is doing. +*/ +export function serve(routes: Record): ReturnType { + const fetcher = vi.fn((input: string) => { + const { pathname: path } = new URL(input, "http://127.0.0.1:8140"); + const reply = routes[path] ?? { body: {}, status: 404 }; + const status = reply.status ?? 200; + return Promise.resolve({ + json: () => Promise.resolve(reply.body), + ok: status < 400, + status, + statusText: `status ${status}`, + } as Response); + }); + vi.stubGlobal("fetch", fetcher); + return fetcher; +} diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts new file mode 100644 index 0000000..f149f27 --- /dev/null +++ b/web/src/test/setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest"; diff --git a/web/src/views/album.test.tsx b/web/src/views/album.test.tsx new file mode 100644 index 0000000..0aafa18 --- /dev/null +++ b/web/src/views/album.test.tsx @@ -0,0 +1,103 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, expect, it, vi } from "vitest"; + +import { ALBUM } from "../test/fixtures.ts"; +import { serve } from "../test/http.ts"; +import { Album } from "./album.tsx"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +it("says it is opening before the album arrives", () => { + serve({ "/api/album/state": { body: ALBUM } }); + render(); + expect(screen.getByText("Opening the album…")).toBeInTheDocument(); +}); + +it("says so when there is nothing to tag yet", async () => { + serve({ "/api/album/state": { body: { ...ALBUM, files: [] } } }); + render(); + await waitFor(() => { + expect(screen.getByTestId("empty")).toBeInTheDocument(); + }); +}); + +it("asks for media by position, never by name", async () => { + // The server takes an int for exactly this reason: a filename from a + // client is a path, and this directory holds private photographs. + serve({ "/api/album/state": { body: ALBUM } }); + render(); + await waitFor(() => { + expect(screen.getByRole("img")).toHaveAttribute("src", "/api/album/media/0"); + }); +}); + +it("shows who is already tagged on the current item", async () => { + serve({ "/api/album/state": { body: ALBUM } }); + render(); + await waitFor(() => { + expect(screen.getByLabelText("1. !alfa")).toBeChecked(); + }); + expect(screen.getByLabelText("2. !beta")).not.toBeChecked(); +}); + +it("shows a half-added person by key rather than by nothing", async () => { + serve({ "/api/album/state": { body: ALBUM } }); + render(); + await waitFor(() => { + expect(screen.getByLabelText("3. p3")).toBeInTheDocument(); + }); +}); + +it("saves on the click rather than on a done button", async () => { + // The tab gets closed. A "done" button is a way to lose an evening's work. + const fetcher = serve({ + "/api/album/state": { body: ALBUM }, + "/api/album/tag": { body: { status: "ok" } }, + }); + render(); + await userEvent.click(await screen.findByLabelText("2. !beta")); + await waitFor(() => { + expect(screen.getByLabelText("2. !beta")).toBeChecked(); + }); + const [path, init] = fetcher.mock.calls.at(-1) as [string, { body: string }]; + expect(path).toBe("/api/album/tag"); + expect(JSON.parse(init.body)).toEqual({ + file: "one.png", + keys: ["p1", "p2"], + }); +}); + +it("untags somebody who was tagged by mistake", async () => { + const fetcher = serve({ + "/api/album/state": { body: ALBUM }, + "/api/album/tag": { body: { status: "ok" } }, + }); + render(); + await userEvent.click(await screen.findByLabelText("1. !alfa")); + await waitFor(() => { + expect(screen.getByLabelText("1. !alfa")).not.toBeChecked(); + }); + const [, init] = fetcher.mock.calls.at(-1) as [string, { body: string }]; + expect(JSON.parse(init.body)).toEqual({ file: "one.png", keys: [] }); +}); + +it("walks the album forwards and back", async () => { + serve({ "/api/album/state": { body: ALBUM } }); + render(); + const previous = await screen.findByRole("button", { name: "Previous" }); + expect(previous).toBeDisabled(); + + await userEvent.click(screen.getByRole("button", { name: "Next" })); + await waitFor(() => { + expect(screen.getByRole("img")).toHaveAttribute("src", "/api/album/media/1"); + }); + expect(screen.getByRole("button", { name: "Next" })).toBeDisabled(); + + await userEvent.click(screen.getByRole("button", { name: "Previous" })); + await waitFor(() => { + expect(screen.getByRole("img")).toHaveAttribute("src", "/api/album/media/0"); + }); +}); diff --git a/web/src/views/album.tsx b/web/src/views/album.tsx new file mode 100644 index 0000000..342b89f --- /dev/null +++ b/web/src/views/album.tsx @@ -0,0 +1,93 @@ +import { useCallback, useEffect, useState } from "react"; + +import type { AlbumFile, Person } from "../api/types.ts"; + +import { api } from "../api/client.ts"; + +// The file list is held apart from the rest of the album state so that +// recording a tag is a plain list update. Folding it back into a nullable +// object would add a "what if it is null" branch that cannot happen -- this +// only renders once the album has loaded -- and an untestable branch is the +// same thing as an untested one. +function tagged(files: AlbumFile[], name: string, keys: string[]): AlbumFile[] { + return files.map((entry) => (entry.name === name ? { ...entry, keys } : entry)); +} + +export function Album(): React.JSX.Element { + const [people, setPeople] = useState(null); + const [files, setFiles] = useState([]); + const [at, setAt] = useState(0); + + useEffect(() => { + void api.album().then((state) => { + setPeople(state.people); + setFiles(state.files); + }); + }, []); + + const toggle = useCallback((file: AlbumFile, personKey: string) => { + const keys = file.keys.includes(personKey) + ? file.keys.filter((held) => held !== personKey) + : [...file.keys, personKey]; + // Saved on the click, not on a "done" button: the tab gets closed, and + // a "done" button is a way to lose an evening's work. + void api.tag(file.name, keys).then(() => { + setFiles((current) => tagged(current, file.name, keys)); + }); + }, []); + + if (people === null) { + return

Opening the album…

; + } + // One list, one index space. The server serves media by position and tags + // by name against the same ordering, so filtering here would show item i + // of the album while tagging item i of a shorter list. + const file = files[at]; + if (file === undefined) { + return

This album has no items yet.

; + } + return ( +
+

+ Photos — {at + 1} of {files.length} +

+ {`album +
    + {people.map((person, index) => { + return ( +
  • + +
  • + ); + })} +
+ + +
+ ); +} diff --git a/web/src/views/health.test.tsx b/web/src/views/health.test.tsx new file mode 100644 index 0000000..ad1c738 --- /dev/null +++ b/web/src/views/health.test.tsx @@ -0,0 +1,79 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, expect, it, vi } from "vitest"; + +import { DEAF, HEALTHY, TERSE, UNREACHABLE } from "../test/fixtures.ts"; +import { serve } from "../test/http.ts"; +import { Health } from "./health.tsx"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +it("says it is asking before the answer arrives", () => { + serve({ "/api/status": { body: HEALTHY } }); + render(); + expect(screen.getByText("Asking the bot…")).toBeInTheDocument(); +}); + +it("shows the release the bot is actually running", async () => { + serve({ "/api/status": { body: HEALTHY } }); + render(); + await waitFor(() => { + expect(screen.getByTestId("reachable")).toHaveTextContent("0.0.0"); + }); +}); + +it("never renders undefined at somebody", async () => { + // A release older than the version/pope fields still answers /healthz. + serve({ "/api/status": { body: TERSE } }); + render(); + await waitFor(() => { + expect(screen.getByTestId("reachable")).toHaveTextContent("?"); + }); + expect(screen.getByTestId("reachable")).not.toHaveTextContent("undefined"); +}); + +it("says plainly when the bot is not answering", async () => { + // The single most important thing this page can tell you, so it must not + // be the thing that stops the page rendering. + serve({ "/api/status": { body: UNREACHABLE } }); + render(); + await waitFor(() => { + expect(screen.getByTestId("reachable")).toHaveTextContent("not answering"); + }); +}); + +it("shows why a bot that is up has gone deaf", async () => { + serve({ "/api/status": { body: DEAF } }); + render(); + await waitFor(() => { + expect(screen.getByTestId("reason")).toHaveTextContent("no frame in 120s"); + }); +}); + +it("reports a restart that worked", async () => { + serve({ + "/api/restart-container": { body: { ok: true, stderr: "", stdout: "" } }, + "/api/status": { body: HEALTHY }, + }); + render(); + await userEvent.click(await screen.findByRole("button")); + await waitFor(() => { + expect(screen.getByTestId("restart")).toHaveTextContent("restarted"); + }); +}); + +it("does not let a restart that failed look like success", async () => { + serve({ + "/api/restart-container": { + body: { ok: false, stderr: "no such container", stdout: "" }, + }, + "/api/status": { body: HEALTHY }, + }); + render(); + await userEvent.click(await screen.findByRole("button")); + await waitFor(() => { + expect(screen.getByTestId("restart")).toHaveTextContent("no such container"); + }); +}); diff --git a/web/src/views/health.tsx b/web/src/views/health.tsx new file mode 100644 index 0000000..70ad064 --- /dev/null +++ b/web/src/views/health.tsx @@ -0,0 +1,51 @@ +import { useCallback, useEffect, useState } from "react"; + +import type { Restart, Status } from "../api/types.ts"; + +import { api } from "../api/client.ts"; + +// The page is most useful exactly when something is down, so every read here +// degrades to a state rather than an error. +export function Health(): React.JSX.Element { + const [status, setStatus] = useState(null); + const [restart, setRestart] = useState(null); + const [busy, setBusy] = useState(false); + + const refresh = useCallback(() => { + void api.status().then(setStatus); + }, []); + + useEffect(refresh, [refresh]); + + const doRestart = useCallback(() => { + setBusy(true); + void api.restart().then((result) => { + setRestart(result); + setBusy(false); + refresh(); + }); + }, [refresh]); + + if (status === null) { + return

Asking the bot…

; + } + return ( +
+

Health

+

+ {status.reachable + ? `${status.bot.status ?? "?"} — ${status.bot.version ?? "?"} «${status.bot.pope ?? "?"}»` + : "the bot is not answering on its loopback port"} +

+ {status.bot.reason !== undefined &&

{status.bot.reason}

} + + {restart !== null && ( +

+ {restart.ok ? "restarted" : `failed: ${restart.stderr}`} +

+ )} +
+ ); +} diff --git a/web/src/views/settings.test.tsx b/web/src/views/settings.test.tsx new file mode 100644 index 0000000..247623d --- /dev/null +++ b/web/src/views/settings.test.tsx @@ -0,0 +1,82 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, expect, it, vi } from "vitest"; + +import { ENV } from "../test/fixtures.ts"; +import { serve } from "../test/http.ts"; +import { Settings } from "./settings.tsx"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +it("says it is reading before the answer arrives", () => { + serve({ "/api/env": { body: ENV } }); + render(); + expect(screen.getByText("Reading .env…")).toBeInTheDocument(); +}); + +it("will not let a key the server refuses even be typed into", async () => { + serve({ "/api/env": { body: ENV } }); + render(); + await waitFor(() => { + expect(screen.getByLabelText("PHONE_NUMBER")).toBeDisabled(); + }); + expect(screen.getByLabelText("COOLDOWN_SECONDS")).toBeEnabled(); +}); + +it("locks the group fields while no listing has been read", async () => { + // parse_group_ids only checks syntax. Writing one blind with the container + // down turns a scoping mistake into a unit that will not start. + serve({ "/api/env": { body: { ...ENV, groups_known: false } } }); + render(); + await waitFor(() => { + expect(screen.getByTestId("groups-locked")).toBeInTheDocument(); + }); + expect(screen.getByLabelText("GROUP_IDS")).toBeDisabled(); +}); + +it("asks for a restart once something has been written", async () => { + serve({ "/api/env": { body: ENV } }); + render(); + await userEvent.type(await screen.findByLabelText("COOLDOWN_SECONDS"), "5"); + await userEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => { + expect(screen.getByTestId("note")).toHaveTextContent("Restart the bot"); + }); +}); + +it("shows the server's refusal verbatim", async () => { + serve({ "/api/env": { body: ENV } }); + render(); + await userEvent.type(await screen.findByLabelText("COOLDOWN_SECONDS"), "5"); + serve({ + "/api/env": { body: { detail: "COOLDOWN_SECONDS must be an integer" }, status: 400 }, + }); + await userEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => { + expect(screen.getByTestId("note")).toHaveTextContent("must be an integer"); + }); +}); + +it("reports a failure that is not the server's own sentence", async () => { + serve({ "/api/env": { body: ENV } }); + render(); + await userEvent.type(await screen.findByLabelText("COOLDOWN_SECONDS"), "5"); + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.reject(new Error("network is down"))), + ); + await userEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => { + expect(screen.getByTestId("note")).toHaveTextContent("could not save"); + }); +}); + +it("has nothing to save until something is edited", async () => { + serve({ "/api/env": { body: ENV } }); + render(); + await waitFor(() => { + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + }); +}); diff --git a/web/src/views/settings.tsx b/web/src/views/settings.tsx new file mode 100644 index 0000000..4b92104 --- /dev/null +++ b/web/src/views/settings.tsx @@ -0,0 +1,74 @@ +import { useCallback, useEffect, useState } from "react"; + +import type { EnvironmentView as EnvironmentView } from "../api/types.ts"; + +import { api, ApiError } from "../api/client.ts"; + +// Editing .env from a browser is the whole reason the server refuses +// cross-origin forms. The form's own job is smaller: never offer a key the +// server would refuse, so a refusal is a bug rather than a normal outcome. +export function Settings(): React.JSX.Element { + const [view, setView] = useState(null); + const [edits, setEdits] = useState>({}); + const [note, setNote] = useState(""); + + useEffect(() => { + void api.env().then(setView); + }, []); + + const save = useCallback(() => { + void api + .saveEnv(edits) + .then(() => { + // The running process loaded the old values and keeps using them. + // Saying so is the whole contract; restarting is done from the + // terminal this page was started in. + setNote("Written. Restart the bot for it to take effect."); + setEdits({}); + }) + .catch((error: unknown) => { + setNote(error instanceof ApiError ? error.message : "could not save"); + }); + }, [edits]); + + if (view === null) { + return

Reading .env…

; + } + return ( +
+

Settings

+ {!view.groups_known && ( +

+ The container is not reachable, so group settings are read-only: a + group id can only be checked against a listing that was really read. +

+ )} + {view.keys.map((entry) => { + const isLocked = + !entry.editable || + (view.needs_groups.includes(entry.key) && !view.groups_known); + return ( + + ); + })} + + {note !== "" &&

{note}

} +
+ ); +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..6a975f3 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,39 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": [ + "ESNext", + "DOM", + "DOM.Iterable" + ], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "types": [ + "vitest/globals", + "node" + ], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "exactOptionalPropertyTypes": true, + "useUnknownInCatchVariables": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "allowImportingTsExtensions": true, + "erasableSyntaxOnly": true, + "noEmit": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true + }, + "include": [ + "src", + "*.ts", + "*.config.ts" + ] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..e9a0571 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,35 @@ +/// +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +// The page is served from the root of its own loopback port, and from +// nowhere else, so there is no subpath to be agnostic about. +export default defineConfig({ + build: { outDir: "dist" }, + plugins: [react()], + server: { + // `pnpm dev` talks to the real management app rather than a mock: the + // guard refuses anything whose Host is not loopback, so the proxy has + // to preserve it. + proxy: { "/api": "http://127.0.0.1:8140" }, + }, + test: { + coverage: { + // No exclude list. This repository's rule is that coverage omits are + // suppressions, and "the bootstrap is hard to test" is the same + // sentence as "this line is untested" -- so main.tsx is a shim thin + // enough to test in jsdom instead. + include: ["src/**/*.{ts,tsx}"], + provider: "v8", + thresholds: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, + environment: "jsdom", + globals: true, + setupFiles: ["./src/test/setup.ts"], + }, +});