From 8f911585c12411f1110065d77b802d8655bb19a1 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 29 Aug 2026 22:50:19 +0200 Subject: [PATCH] Serve the lock decision, its history and its health at 127.0.0.1:8770 The only status surface was a GTK tray icon, commented out of ~/.config/i3 on 2026-08-21. So when the weekly count went wrong, nothing on the machine said so for two weeks -- the same silence that let a deleted timer job stop enforcement for thirteen days in August. Three read-only endpoints, and a React 19 UI over them: /api/status the decision, the week, and whether today has a workout /api/decisions the recorded trail, newest first /api/health timers armed, disarm marker, log and trail freshness The Why view is the one that matters: it renders the run of "weekly_minimum_met 5/5" ticks that hid this week's bug, without anyone having to reach for journalctl. /api/status also carries gaming.workout_today, which is the fact steam-backlog-enforcer will cut the daily budget on. Deliberately just the fact -- the enforcer owns the hour values, so the two repos cannot come to disagree about what a workout day is. It is derived from count_day_credits, the same rule the locker enforces with. _compliance_state_word moves from status_view (tkinter) into _status_projection, and the systemd arming check moves from scripts/check_enforcement_armed.py into screen_locker/_armed_state.py, so the server can answer "is enforcement armed" without a second copy of the logic that would be free to disagree with the gate. web/ mirrors steam-backlog-enforcer/web but tightens it: that config has no "strict" in any tsconfig, extends only tseslint recommended (no type-aware rules at all), and gates neither lint nor typecheck in pre-commit. Here all three run, on strictTypeChecked + stylisticTypeChecked, with noUncheckedIndexedAccess and exactOptionalPropertyTypes. 44 tests, 100% of statements, branches, functions and lines. typescript stays pinned at 6.0.3: 7.0.2 is out, but every published typescript-eslint -- stable, canary and rc-v8 -- declares typescript ">=4.8.4 <6.1.0". The fleet allowlist already carries that entry. The 250-line cap now covers ts/tsx. It was python/shell/dart only, which would have made web/ exempt from the gate the rest of the repo lives under -- the same omission that exempted the whole Flutter app until 2026-08-15. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fcwu78wk17FWMHbbfbyicc --- .gitignore | 7 + .pre-commit-config.yaml | 33 +- pyproject.toml | 1 + screen-locker-web.service | 19 + screen_locker/_armed_state.py | 150 + screen_locker/_mcp.py | 6 +- screen_locker/_status_projection.py | 14 + screen_locker/_web_payload.py | 209 + screen_locker/_web_server.py | 222 + screen_locker/status_view.py | 17 +- screen_locker/tests/test_armed_state.py | 135 + screen_locker/tests/test_mcp.py | 2 +- screen_locker/tests/test_status_view_part7.py | 10 +- screen_locker/tests/test_web_payload.py | 144 + screen_locker/tests/test_web_payload_part2.py | 138 + screen_locker/tests/test_web_server.py | 209 + screen_locker/tests/test_web_server_part2.py | 81 + scripts/check_enforcement_armed.py | 102 +- web/eslint.config.js | 32 + web/index.html | 12 + web/package-lock.json | 4046 +++++++++++++++++ web/package.json | 41 + web/src/App.test.tsx | 53 + web/src/App.tsx | 43 + web/src/api.test.ts | 43 + web/src/api.ts | 21 + web/src/components/HealthCard.test.tsx | 61 + web/src/components/HealthCard.tsx | 56 + web/src/components/NowCard.test.tsx | 92 + web/src/components/NowCard.tsx | 58 + web/src/components/WhyTable.test.tsx | 79 + web/src/components/WhyTable.tsx | 62 + web/src/format.test.ts | 53 + web/src/format.ts | 64 + web/src/index.css | 133 + web/src/main.tsx | 12 + web/src/test/factories.ts | 88 + web/src/test/setup.ts | 8 + web/src/types.ts | 102 + web/src/useStatusPoll.test.ts | 92 + web/src/useStatusPoll.ts | 49 + web/tsconfig.app.json | 27 + web/tsconfig.json | 7 + web/tsconfig.node.json | 23 + web/tsconfig.test.json | 8 + web/vite.config.ts | 35 + 46 files changed, 6778 insertions(+), 121 deletions(-) create mode 100644 screen-locker-web.service create mode 100644 screen_locker/_armed_state.py create mode 100644 screen_locker/_web_payload.py create mode 100644 screen_locker/_web_server.py create mode 100644 screen_locker/tests/test_armed_state.py create mode 100644 screen_locker/tests/test_web_payload.py create mode 100644 screen_locker/tests/test_web_payload_part2.py create mode 100644 screen_locker/tests/test_web_server.py create mode 100644 screen_locker/tests/test_web_server_part2.py create mode 100644 web/eslint.config.js create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/src/App.test.tsx create mode 100644 web/src/App.tsx create mode 100644 web/src/api.test.ts create mode 100644 web/src/api.ts create mode 100644 web/src/components/HealthCard.test.tsx create mode 100644 web/src/components/HealthCard.tsx create mode 100644 web/src/components/NowCard.test.tsx create mode 100644 web/src/components/NowCard.tsx create mode 100644 web/src/components/WhyTable.test.tsx create mode 100644 web/src/components/WhyTable.tsx create mode 100644 web/src/format.test.ts create mode 100644 web/src/format.ts create mode 100644 web/src/index.css create mode 100644 web/src/main.tsx create mode 100644 web/src/test/factories.ts create mode 100644 web/src/test/setup.ts create mode 100644 web/src/types.ts create mode 100644 web/src/useStatusPoll.test.ts create mode 100644 web/src/useStatusPoll.ts create mode 100644 web/tsconfig.app.json create mode 100644 web/tsconfig.json create mode 100644 web/tsconfig.node.json create mode 100644 web/tsconfig.test.json create mode 100644 web/vite.config.ts diff --git a/.gitignore b/.gitignore index 36d3a8f..72b4de1 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,10 @@ screen_locker/early_bird_pending.json # ci_mirror.sh cached requirements.txt venv (rebuilt on demand) .ci-mirror-venv/ + +# Web status UI (web/) build and tooling output. +web/node_modules/ +web/dist/ +web/coverage/ +web/node_modules +web/dist diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a759e83..60de4d1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -195,8 +195,11 @@ repos: language: system # Dart was missing here until 2026-08-15, so the cap had never looked at # a single file in workout_app/ — the whole Flutter app was exempt from - # the gate it was being refactored to satisfy. - types_or: [python, shell, dart] + # the gate it was being refactored to satisfy. ts/tsx joined on + # 2026-08-29 with web/, for exactly the same reason: a language absent + # from this list is a directory the cap silently does not apply to. + types_or: [python, shell, dart, ts, tsx] + exclude: '^web/(node_modules|dist|coverage)/' - id: no-silent-failures name: Block silent failures (swallowed exceptions and silent sys.exit(0) must say why) entry: python3 scripts/check_silent_failures.py @@ -236,6 +239,32 @@ repos: require_serial: true stages: [pre-push] + # web/ gates. steam-backlog-enforcer, which this app was copied from, has + # only a coverage hook -- its lint and typecheck are unenforced, so both + # can rot without anything failing. All three run here. + - id: web-lint + name: eslint (status web UI) + entry: npm --prefix web run lint + language: system + files: '^web/(src/.*\.(ts|tsx)|eslint\.config\.js|package\.json)$' + pass_filenames: false + require_serial: true + - id: web-typecheck + name: tsc --build (status web UI) + entry: npm --prefix web run build + language: system + files: '^web/(src/.*\.(ts|tsx)|tsconfig.*\.json|vite\.config\.ts)$' + pass_filenames: false + require_serial: true + - id: web-coverage + name: vitest --coverage 100% (status web UI) + entry: npm --prefix web run coverage + language: system + files: '^web/src/' + pass_filenames: false + require_serial: true + stages: [pre-push] + # Four markdown namespaces (README / CLAUDE* / DOCS* / TODO*), plus the # rule that a TODO carries "REMOVE ME AFTER FINISH" and nothing else may. # Shared gate in ~/utils owns the namespaces and the exemption list, so diff --git a/pyproject.toml b/pyproject.toml index 0d44763..57ec04b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ [project.scripts] screen-locker-status = "screen_locker.status_view:main" +screen-locker-web = "screen_locker._web_server:main" # Optional extras. The `mcp` group is ONLY needed by the read-only MCP server # (screen_locker._mcp), which Claude Code spawns via a dedicated venv (see diff --git a/screen-locker-web.service b/screen-locker-web.service new file mode 100644 index 0000000..d0b147d --- /dev/null +++ b/screen-locker-web.service @@ -0,0 +1,19 @@ +[Unit] +Description=Screen Locker status web UI (localhost only) +# No After=graphical-session.target: this serves HTTP and reads state files, it +# never touches the display. Ordering it after the graphical session would also +# stop it starting on a headless boot, which is exactly when you most want to +# be able to ask whether enforcement is armed. + +[Service] +Type=simple +ExecStart=/usr/bin/python3 -m screen_locker._web_server +Restart=always +RestartSec=5s +# steam-backlog-enforcer asks this server whether today has a workout before +# deciding the gaming budget, and fails CLOSED to the smaller budget when the +# answer does not arrive. So a crash costs gaming time rather than silently +# handing it back -- but it should still come straight back up. + +[Install] +WantedBy=default.target diff --git a/screen_locker/_armed_state.py b/screen_locker/_armed_state.py new file mode 100644 index 0000000..9b4a8b7 --- /dev/null +++ b/screen_locker/_armed_state.py @@ -0,0 +1,150 @@ +"""Whether systemd would actually run the locker, read from systemd itself. + +The 2026-08 outage was not a logic bug. Every predicate worked; the timer that +*invokes* them had been disabled by an ordering cycle, and nothing on the +machine considered that worth mentioning. ``systemctl status`` showed no failed +unit, because a job deleted to break a cycle is not a failure -- it is an +absence, and absences do not page anyone. + +This module answers one question -- *would enforcement actually happen?* -- from +systemd's own view rather than from the unit files on disk, because the files +were correct-looking the entire time the timer sat disabled. + +It lives in the package rather than in ``scripts/`` because two surfaces need +the same answer: ``scripts/check_enforcement_armed.py`` (the gate that rides +along with ``workout-sync.service``) and the Health view of the local web UI. +Two copies of this logic would be free to disagree about whether the locker is +alive, which is the failure it exists to detect. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging +import shutil +import subprocess + +_logger = logging.getLogger(__name__) + +# Every unit that must be armed for a workout-less day to end in a lock. +# workout-locker.service itself is WantedBy=graphical-session.target and so is +# only ever a login one-shot; the timers are what make enforcement recur. +REQUIRED_TIMERS = ( + "early-bird-workout-check.timer", + "workout-locker.timer", +) + +_TIMEOUT_SECONDS = 10 + + +@dataclass(frozen=True) +class TimerState: + """What systemd currently believes about one timer.""" + + name: str + enabled: bool + enabled_raw: str + scheduled: bool + + @property + def armed(self) -> bool: + """True only when the timer is both enabled and actually scheduled. + + Both halves matter. ``is-enabled`` alone passes for a timer whose job + systemd deleted to break an ordering cycle; ``list-timers`` alone passes + for a transient ``start``ed timer that will not survive a reboot. + + Returns: + True when this timer would really fire. + """ + return self.enabled and self.scheduled + + def describe(self) -> str: + """Return a one-line human summary of this timer's state. + + Returns: + An ``OK``/``DISARMED`` line naming every reason it is not armed. + """ + if self.armed: + return f"OK {self.name}: enabled and scheduled" + problems = [] + if not self.enabled: + problems.append(f"not enabled (is-enabled={self.enabled_raw or 'unknown'})") + if not self.scheduled: + problems.append("no next trigger in list-timers") + return f"DISARMED {self.name}: {', '.join(problems)}" + + +def systemctl_available() -> bool: + """Return whether ``systemctl`` can be run at all. + + Reachable in containers and CI. Callers must treat False as "did not + check", never as "checked and fine". + + Returns: + True when ``systemctl`` is on PATH. + """ + return shutil.which("systemctl") is not None + + +def _run(args: list[str]) -> tuple[int, str]: + """Run ``args``, returning ``(returncode, stdout)``. Never raises. + + Args: + args: The argument vector, passed without a shell. + + Returns: + The process return code and its stdout; ``(1, "")`` if it could not run. + """ + try: + # Explicit argument list, never shell=True. + proc = subprocess.run( + args, + capture_output=True, + text=True, + timeout=_TIMEOUT_SECONDS, + check=False, + ) + except OSError, subprocess.SubprocessError: + # A check that cannot run must not look like a check that passed. + _logger.exception("Could not run %s", " ".join(args)) + return 1, "" + return proc.returncode, proc.stdout + + +def _scheduled_timers() -> set[str]: + """Return the timer names systemd currently has a next trigger for. + + Returns: + The subset of :data:`REQUIRED_TIMERS` with a scheduled next run. + """ + _, out = _run(["systemctl", "--user", "list-timers", "--all", "--no-pager"]) + scheduled: set[str] = set() + for line in out.splitlines(): + for name in REQUIRED_TIMERS: + # A timer with no next trigger prints "n/a" in the NEXT column. + if name in line and " n/a " not in f" {line} ": + scheduled.add(name) + return scheduled + + +def collect_states() -> list[TimerState]: + """Return the current arming state of every required timer. + + Returns: + One :class:`TimerState` per entry in :data:`REQUIRED_TIMERS`. + """ + scheduled = _scheduled_timers() + states = [] + for name in REQUIRED_TIMERS: + code, out = _run(["systemctl", "--user", "is-enabled", name]) + raw = out.strip() + states.append( + TimerState( + name=name, + enabled=code == 0 and raw == "enabled", + enabled_raw=raw, + scheduled=name in scheduled, + ) + ) + return states diff --git a/screen_locker/_mcp.py b/screen_locker/_mcp.py index 6103b05..cdf6480 100644 --- a/screen_locker/_mcp.py +++ b/screen_locker/_mcp.py @@ -52,7 +52,7 @@ from screen_locker._constants import ( ) from screen_locker._sick_tracker import load_history from screen_locker._status_data import format_summary_line, gather_status -from screen_locker.status_view import _compliance_state_word +from screen_locker._status_projection import compliance_state_word # Log to STDERR only — STDOUT carries the MCP JSON-RPC protocol frames, so a # single stray stdout write would corrupt the stream and kill the session. @@ -105,12 +105,12 @@ def get_summary() -> dict[str, Any]: ``summary_line`` is the same string the status bar shows (``format_summary_line``); ``compliance_state`` is one of ``ok`` / ``warn`` - / ``lock`` (``_compliance_state_word``). + / ``lock`` (``compliance_state_word``). """ snapshot = gather_status() return { "summary_line": format_summary_line(snapshot), - "compliance_state": _compliance_state_word(snapshot), + "compliance_state": compliance_state_word(snapshot), } diff --git a/screen_locker/_status_projection.py b/screen_locker/_status_projection.py index 0593fa1..2cb7f97 100644 --- a/screen_locker/_status_projection.py +++ b/screen_locker/_status_projection.py @@ -108,3 +108,17 @@ def format_summary_line(snapshot: StatusSnapshot) -> str: f"{mark} {week.counted_count}/{week.minimum} workouts · " f"{tonight_str} tonight · sick {sick.used_7d}/{sick.budget_7d}" ) + + +def compliance_state_word(snapshot: StatusSnapshot) -> str: + """One-word compliance state for the tray icon: ``ok`` / ``warn`` / ``lock``. + + ``lock`` — the lock would fire right now (no skip condition applies). + ``warn`` — not locked, but this week's minimum isn't met yet. + ``ok`` — not locked and the weekly minimum is already met. + """ + if snapshot.lock_explanation.fired: + return "lock" + if snapshot.week.remaining > 0: + return "warn" + return "ok" diff --git a/screen_locker/_web_payload.py b/screen_locker/_web_payload.py new file mode 100644 index 0000000..216920e --- /dev/null +++ b/screen_locker/_web_payload.py @@ -0,0 +1,209 @@ +"""JSON payloads for the local status web UI. + +One read-only view, shared by the HTTP server and anything else that wants the +same answer. Deliberately built on :func:`~screen_locker._status_data.gather_status` +rather than on a second walk of the log: ``_mcp`` already proves that snapshot +round-trips through ``json.dumps`` unchanged, and a second projection is exactly +how the status view came to explain a decision the locker never made. + +Nothing here reads a secret. The sync token and the HMAC key never appear in a +payload, and no endpoint takes input that reaches the filesystem. +""" + +from __future__ import annotations + +from dataclasses import asdict +from datetime import UTC, datetime +import logging +from pathlib import Path +from typing import Any + +from screen_locker._armed_state import collect_states, systemctl_available +from screen_locker._decision_log import DECISION_LOG_FILE, read_decisions +from screen_locker._log_io import load_workout_log +from screen_locker._status_data import gather_status +from screen_locker._status_projection import ( + compliance_state_word, + format_summary_line, +) +from screen_locker._weekly_check import count_day_credits + +_logger = logging.getLogger(__name__) + +# The workout log lives beside the package, same default as +# ``_status_data._DEFAULT_LOG_FILE``; declared here to avoid importing a +# private name across modules, exactly as ``_mcp`` does. +LOG_FILE = Path(__file__).resolve().parent / "log.json" + +# The marker that disables enforcement machine-wide. Kept in sync with +# scripts/disarm_guard.sh, which is the thing that writes it. +DISARM_MARKER = Path.home() / ".local" / "share" / "screen_locker" / "DISARMED" + +# How many decisions the Why view gets by default. The trail holds 3000; the +# browser only ever needs the recent tail to answer "why did nothing happen". +DEFAULT_DECISION_LIMIT = 200 + + +def workout_credits_today(*, now: datetime | None = None) -> int: + """Return how many workout credits today has earned so far. + + Uses :func:`~screen_locker._weekly_check.count_day_credits`, the same rule + the locker enforces with, so "did I work out today" cannot drift from "does + the locker think I worked out today". + + Args: + now: Override for the current local datetime (for testing). + + Returns: + The number of distinct credits earned today, zero if none. + """ + moment = now if now is not None else datetime.now(tz=UTC).astimezone() + today = moment.date().isoformat() + entries = load_workout_log(LOG_FILE).get(today, []) + return count_day_credits(today, [e for e in entries if isinstance(e, dict)]) + + +def build_status_payload(*, now: datetime | None = None) -> dict[str, Any]: + """Build the full status payload. + + Args: + now: Override for the current local datetime (for testing). + + Returns: + The status snapshot plus the derived one-liners and the gaming-budget + input the enforcer reads. + """ + snapshot = gather_status() + credits_today = workout_credits_today(now=now) + return { + "snapshot": asdict(snapshot), + "summary_line": format_summary_line(snapshot), + "compliance_state": compliance_state_word(snapshot), + # The gaming budget is decided by steam-backlog-enforcer, which owns the + # hour values. This reports only the fact the decision turns on, so the + # two repos cannot disagree about what a "workout day" is. + "gaming": { + "workout_today": credits_today > 0, + "credits_today": credits_today, + "reason": ( + f"{credits_today} counted workout(s) logged today" + if credits_today + else "no counted workout logged today" + ), + }, + } + + +def build_decisions_payload( + *, + limit: int = DEFAULT_DECISION_LIMIT, +) -> dict[str, Any]: + """Build the decision-history payload, newest first. + + Args: + limit: Maximum number of decisions to return. + + Returns: + The recent decisions and the total the trail holds. + """ + decisions = read_decisions() + tail = decisions[-limit:] if limit > 0 else decisions + return { + "total": len(decisions), + "returned": len(tail), + "decisions": list(reversed(tail)), + } + + +def _log_age_seconds(*, now: datetime | None = None) -> float | None: + """Return how long ago the workout log was last written. + + Args: + now: Override for the current local datetime (for testing). + + Returns: + Age in seconds, or ``None`` when the log does not exist. + """ + try: + mtime = LOG_FILE.stat().st_mtime + except OSError: + _logger.warning( + "Cannot stat the workout log at %s — reporting its age as unknown " + "rather than as fresh", + LOG_FILE, + ) + return None + moment = now if now is not None else datetime.now(tz=UTC).astimezone() + return moment.timestamp() - mtime + + +def _last_decision_age_seconds(*, now: datetime | None = None) -> float | None: + """Return how long ago a decision was last recorded. + + A locker that stopped running goes quiet rather than failing, so the age of + the newest decision is the most direct evidence that it is still alive. + + Args: + now: Override for the current local datetime (for testing). + + Returns: + Age in seconds, or ``None`` when nothing has been recorded. + """ + decisions = read_decisions() + if not decisions: + return None + stamp = decisions[-1].get("timestamp") + if not isinstance(stamp, str): + return None + try: + recorded = datetime.fromisoformat(stamp) + except ValueError: + _logger.warning( + "Newest decision has an unparsable timestamp %r — reporting its " + "age as unknown rather than as recent", + stamp, + ) + return None + moment = now if now is not None else datetime.now(tz=UTC).astimezone() + return moment.timestamp() - recorded.timestamp() + + +def build_health_payload(*, now: datetime | None = None) -> dict[str, Any]: + """Build the health payload: is the locker actually able to fire. + + Every field here exists because its absence once went unnoticed. A timer + silently deleted to break an ordering cycle, a disarm marker left in place, + a log that stopped being written -- none of those raise an error anywhere, + so each is reported as a value rather than left to be inferred from silence. + + Args: + now: Override for the current local datetime (for testing). + + Returns: + Timer arming state, the disarm marker, and freshness of log and trail. + """ + checked = systemctl_available() + timers = [ + { + "name": state.name, + "enabled": state.enabled, + "scheduled": state.scheduled, + "armed": state.armed, + "describe": state.describe(), + } + for state in (collect_states() if checked else ()) + ] + disarmed = DISARM_MARKER.exists() + return { + # "could not check" is reported as its own state: it must never be + # rendered as a green tick. + "timers_checked": checked, + "timers": timers, + "armed": checked and not disarmed and all(t["armed"] for t in timers), + "disarmed": disarmed, + "disarm_marker": str(DISARM_MARKER), + "log_file": str(LOG_FILE), + "log_age_seconds": _log_age_seconds(now=now), + "decision_log_file": str(DECISION_LOG_FILE), + "last_decision_age_seconds": _last_decision_age_seconds(now=now), + } diff --git a/screen_locker/_web_server.py b/screen_locker/_web_server.py new file mode 100644 index 0000000..75229b2 --- /dev/null +++ b/screen_locker/_web_server.py @@ -0,0 +1,222 @@ +"""Read-only localhost HTTP server for the status web UI. + +Serves the status/decision/health payloads at ``/api/*`` and the built React +bundle (``web/dist``) as static files. Binds to loopback only and never exposes +a secret: every payload comes from :mod:`screen_locker._web_payload`, which +reads state files but never the sync token or the HMAC key. + +Read-only by construction, like the MCP server it sits beside: there is no +route that logs a workout, clears a lock, or writes anything at all. A local +web page that could log a workout would be a hole straight through the gate. + +Two things depend on this process being up: the browser UI, and +steam-backlog-enforcer, which asks ``/api/status`` whether today has a workout +before deciding the gaming budget. The enforcer fails closed to the smaller +budget when this is unreachable, so a dead server costs gaming time rather than +silently handing it back. +""" + +from __future__ import annotations + +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +import logging +import mimetypes +from pathlib import Path +from typing import TYPE_CHECKING, Any +from urllib.parse import parse_qs, urlsplit + +from screen_locker._web_payload import ( + DEFAULT_DECISION_LIMIT, + build_decisions_payload, + build_health_payload, + build_status_payload, +) + +if TYPE_CHECKING: + from collections.abc import Callable + +logger = logging.getLogger(__name__) + +# Built frontend lives at /web/dist (sibling of the package directory). +WEB_DIST = (Path(__file__).resolve().parent.parent / "web" / "dist").resolve() + +DEFAULT_HOST = "127.0.0.1" +# 8000 is taken by steam-backlog-enforcer's own web UI. +DEFAULT_PORT = 8770 + +_API_STATUS = "/api/status" +_API_DECISIONS = "/api/decisions" +_API_HEALTH = "/api/health" + +# Content types that are text but not under the ``text/`` prefix. +_EXTRA_TEXT_TYPES = frozenset( + {"application/javascript", "application/json", "image/svg+xml"} +) +_NOT_BUILT_MSG = ( + b"Frontend not built. Run: npm --prefix web install && npm --prefix web run build" +) + + +def _decision_limit(query: str) -> int: + """Parse ``?limit=`` from a query string, falling back to the default. + + Args: + query: The raw query string. + + Returns: + A positive limit; the default when absent, non-numeric or out of range. + """ + raw = parse_qs(query).get("limit", [""])[0] + try: + value = int(raw) + except ValueError: + if raw: + # Benign (a hand-typed URL), but say so: a silently ignored + # parameter is how someone concludes the endpoint is broken. + logger.warning( + "Ignoring unparsable ?limit=%r; serving the default %d decisions", + raw, + DEFAULT_DECISION_LIMIT, + ) + return DEFAULT_DECISION_LIMIT + return value if value > 0 else DEFAULT_DECISION_LIMIT + + +class _Handler(BaseHTTPRequestHandler): + """Serve the status payloads and the static frontend bundle (read-only).""" + + def log_message(self, fmt: str, *args: object) -> None: + """Route the default request log to ``logging`` at debug level. + + Args: + fmt: printf-style format string from BaseHTTPRequestHandler. + args: Its arguments. + """ + logger.debug("%s - %s", self.address_string(), fmt % args) + + def do_GET(self) -> None: + """Dispatch a GET to one of the APIs or to a static file.""" + split = urlsplit(self.path) + if split.path == _API_STATUS: + self._serve_json("status", build_status_payload) + elif split.path == _API_DECISIONS: + limit = _decision_limit(split.query) + self._serve_json("decisions", lambda: build_decisions_payload(limit=limit)) + elif split.path == _API_HEALTH: + self._serve_json("health", build_health_payload) + else: + self._serve_static(split.path) + + def _serve_json(self, name: str, build: Callable[[], dict[str, Any]]) -> None: + """Build a payload and send it as JSON, or a 500 naming the failure. + + Args: + name: Payload name, used in the error message and the log. + build: Zero-argument callable returning the payload. + """ + try: + body = json.dumps(build()).encode("utf-8") + except OSError, ValueError, KeyError, TypeError: + # Never a bare swallow: the reason has to reach the journal, or a + # broken endpoint looks identical to an endpoint with nothing to say. + logger.exception("Failed to build the %s payload", name) + self._send( + HTTPStatus.INTERNAL_SERVER_ERROR, + f"{name} error - see the journal for screen-locker-web".encode(), + "text/plain", + ) + return + self._send(HTTPStatus.OK, body, "application/json") + + def _serve_static(self, path: str) -> None: + """Serve a file from ``WEB_DIST`` with SPA fallback and traversal guard. + + Args: + path: The request path. + """ + rel = path.lstrip("/") or "index.html" + candidate = (WEB_DIST / rel).resolve() + # Reject path traversal, then fall back to index.html for SPA routes. + if not candidate.is_relative_to(WEB_DIST) or not candidate.is_file(): + candidate = WEB_DIST / "index.html" + if not candidate.is_file(): + self._send(HTTPStatus.NOT_FOUND, _NOT_BUILT_MSG, "text/plain") + return + ctype, _ = mimetypes.guess_type(candidate.name) + self._send(HTTPStatus.OK, candidate.read_bytes(), ctype or "text/plain") + + def _send(self, status: HTTPStatus, body: bytes, ctype: str) -> None: + """Write a complete response with the given status, body, and type. + + Args: + status: HTTP status to send. + body: Response body. + ctype: Content type, charset appended for text types. + """ + if ctype.startswith("text/") or ctype in _EXTRA_TEXT_TYPES: + ctype = f"{ctype}; charset=utf-8" + self.send_response(status) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +def create_server( + host: str = DEFAULT_HOST, + port: int = DEFAULT_PORT, +) -> ThreadingHTTPServer: + """Create (but do not start) the threading HTTP server. + + Args: + host: Address to bind. Must stay on loopback: the API is unauthenticated. + port: Port to bind. + + Returns: + The unstarted server. + + Raises: + ValueError: If asked to bind anywhere but loopback. + """ + if not host.startswith("127.") and host != "localhost": + # The payloads are unauthenticated, so a bind must never leave this + # machine. Refuse loudly rather than quietly publishing them. + msg = f"Refusing to bind {host}: the status API is loopback-only" + raise ValueError(msg) + return ThreadingHTTPServer((host, port), _Handler) + + +def serve(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> None: + """Run the web server until interrupted. + + Args: + host: Address to bind. + port: Port to bind. + """ + server = create_server(host, port) + logger.info("Screen locker status UI: http://%s:%d", host, port) + try: + server.serve_forever() + except KeyboardInterrupt: + # Warning, not info: this process is load-bearing for the gaming + # budget, so its shutdown must be visible in the journal. + logger.warning("Interrupted — shutting the status server down.") + finally: + server.server_close() + + +def main() -> int: + """Console-script entry point for ``screen-locker-web``. + + Returns: + Process exit code. + """ + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") + serve() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/screen_locker/status_view.py b/screen_locker/status_view.py index ce097aa..2d73042 100644 --- a/screen_locker/status_view.py +++ b/screen_locker/status_view.py @@ -37,6 +37,7 @@ from screen_locker._status_data import ( format_summary_line, gather_status, ) +from screen_locker._status_projection import compliance_state_word from screen_locker._status_sections import StatusSectionsMixin from screen_locker._status_view_verify import PhoneCheckMixin, _make_bare_verifier from screen_locker._surface_group import FrameGroup @@ -199,20 +200,6 @@ class StatusWindow( self.render(gather_status()) -def _compliance_state_word(snapshot: StatusSnapshot) -> str: - """One-word compliance state for the tray icon: ``ok`` / ``warn`` / ``lock``. - - ``lock`` — the lock would fire right now (no skip condition applies). - ``warn`` — not locked, but this week's minimum isn't met yet. - ``ok`` — not locked and the weekly minimum is already met. - """ - if snapshot.lock_explanation.fired: - return "lock" - if snapshot.week.remaining > 0: - return "warn" - return "ok" - - def main(argv: list[str] | None = None) -> None: """Entry point: ``--summary``/``--state``/``--sync`` print one line. @@ -223,7 +210,7 @@ def main(argv: list[str] | None = None) -> None: print(format_summary_line(gather_status())) return if "--state" in args: - print(_compliance_state_word(gather_status())) + print(compliance_state_word(gather_status())) return if "--sync" in args: # On-disk only, like the other two -- safe on an i3blocks tick. diff --git a/screen_locker/tests/test_armed_state.py b/screen_locker/tests/test_armed_state.py new file mode 100644 index 0000000..187df48 --- /dev/null +++ b/screen_locker/tests/test_armed_state.py @@ -0,0 +1,135 @@ +"""Tests for _armed_state: is the locker actually schedulable. + +The distinction these tests defend is the one that cost thirteen days in +2026-08: ``is-enabled`` says "enabled" for a timer whose job systemd deleted to +break an ordering cycle, so enabled alone is not armed. +""" + +from __future__ import annotations + +import subprocess +from unittest.mock import MagicMock, patch + +from screen_locker._armed_state import ( + REQUIRED_TIMERS, + TimerState, + collect_states, + systemctl_available, +) + +_PKG = "screen_locker._armed_state" + +_LISTED = """NEXT LEFT LAST PASSED UNIT +Sun 2026-08-30 08:30:00 CEST 10h - - early-bird-workout-check.timer +Sat 2026-08-29 23:00:00 CEST 30min - - workout-locker.timer +""" + + +def _completed(stdout: str, code: int = 0) -> MagicMock: + """Build a stand-in for a finished subprocess. + + Args: + stdout: What the command printed. + code: Its return code. + + Returns: + An object with the two attributes ``_run`` reads. + """ + proc = MagicMock() + proc.returncode = code + proc.stdout = stdout + return proc + + +class TestTimerState: + """armed is the conjunction; describe() names every missing half.""" + + def test_enabled_and_scheduled_is_armed(self) -> None: + """Both halves present means the timer will really fire.""" + state = TimerState("t", enabled=True, enabled_raw="enabled", scheduled=True) + assert state.armed is True + assert "OK" in state.describe() + + def test_enabled_but_unscheduled_is_not_armed(self) -> None: + """The exact 2026-08 failure: enabled, but the job was deleted.""" + state = TimerState("t", enabled=True, enabled_raw="enabled", scheduled=False) + assert state.armed is False + assert "no next trigger" in state.describe() + + def test_scheduled_but_disabled_is_not_armed(self) -> None: + """A transient start does not survive a reboot.""" + state = TimerState("t", enabled=False, enabled_raw="disabled", scheduled=True) + assert state.armed is False + assert "not enabled (is-enabled=disabled)" in state.describe() + + def test_unknown_enabled_state_is_named(self) -> None: + """An empty is-enabled reads as unknown, not as a blank.""" + state = TimerState("t", enabled=False, enabled_raw="", scheduled=False) + assert "is-enabled=unknown" in state.describe() + + +class TestSystemctlAvailable: + """ "Could not check" must be distinguishable from "checked and fine".""" + + def test_present(self) -> None: + """systemctl on PATH means the check can run.""" + with patch(f"{_PKG}.shutil.which", return_value="/usr/bin/systemctl"): + assert systemctl_available() is True + + def test_absent(self) -> None: + """No systemctl (containers, CI) is reported, never assumed fine.""" + with patch(f"{_PKG}.shutil.which", return_value=None): + assert systemctl_available() is False + + +class TestCollectStates: + """collect_states reads systemd's own view, not the unit files.""" + + def test_all_armed(self) -> None: + """Both timers enabled and listed with a next trigger.""" + with patch(f"{_PKG}.subprocess.run") as run: + run.side_effect = [ + _completed(_LISTED), + *[_completed("enabled\n") for _ in REQUIRED_TIMERS], + ] + states = collect_states() + assert [s.name for s in states] == list(REQUIRED_TIMERS) + assert all(s.armed for s in states) + + def test_timer_listed_with_no_next_trigger_is_unscheduled(self) -> None: + """An "n/a" NEXT column means systemd will never run it.""" + listed = ( + "NEXT LEFT LAST PASSED UNIT\n n/a - - - workout-locker.timer\n" + ) + with patch(f"{_PKG}.subprocess.run") as run: + run.side_effect = [ + _completed(listed), + *[_completed("enabled\n") for _ in REQUIRED_TIMERS], + ] + states = collect_states() + assert all(not s.scheduled for s in states) + + def test_disabled_timer_is_reported(self) -> None: + """A non-zero is-enabled marks the timer disabled.""" + with patch(f"{_PKG}.subprocess.run") as run: + run.side_effect = [ + _completed(_LISTED), + *[_completed("disabled\n", code=1) for _ in REQUIRED_TIMERS], + ] + states = collect_states() + assert all(not s.enabled for s in states) + + def test_a_check_that_cannot_run_is_not_a_pass(self) -> None: + """When systemctl cannot be executed, nothing reads as armed.""" + with patch(f"{_PKG}.subprocess.run", side_effect=OSError("no exec")): + states = collect_states() + assert all(not s.armed for s in states) + + def test_a_timeout_is_not_a_pass_either(self) -> None: + """A hung systemctl must fail closed, same as a missing one.""" + with patch( + f"{_PKG}.subprocess.run", + side_effect=subprocess.TimeoutExpired(cmd="systemctl", timeout=10), + ): + states = collect_states() + assert all(not s.armed for s in states) diff --git a/screen_locker/tests/test_mcp.py b/screen_locker/tests/test_mcp.py index 76904fc..613dab1 100644 --- a/screen_locker/tests/test_mcp.py +++ b/screen_locker/tests/test_mcp.py @@ -103,7 +103,7 @@ class TestReadTools: with ( patch.object(_mcp, "gather_status") as gs, patch.object(_mcp, "format_summary_line", return_value="LINE") as fsl, - patch.object(_mcp, "_compliance_state_word", return_value="ok") as csw, + patch.object(_mcp, "compliance_state_word", return_value="ok") as csw, ): out = _mcp.get_summary() assert out == {"summary_line": "LINE", "compliance_state": "ok"} diff --git a/screen_locker/tests/test_status_view_part7.py b/screen_locker/tests/test_status_view_part7.py index 12dada5..4268595 100644 --- a/screen_locker/tests/test_status_view_part7.py +++ b/screen_locker/tests/test_status_view_part7.py @@ -2,9 +2,7 @@ from __future__ import annotations -from screen_locker.status_view import ( - _compliance_state_word, -) +from screen_locker._status_projection import compliance_state_word from screen_locker.tests._status_view_helpers import ( _lock_explanation, _snapshot, @@ -18,7 +16,7 @@ class TestComplianceStateWord: def test_lock_when_fired(self) -> None: """Lock when fired.""" snap = _snapshot(lock_explanation=_lock_explanation(fired=True)) - assert _compliance_state_word(snap) == "lock" + assert compliance_state_word(snap) == "lock" def test_warn_when_not_fired_but_remaining(self) -> None: """Warn when not fired but remaining.""" @@ -26,7 +24,7 @@ class TestComplianceStateWord: lock_explanation=_lock_explanation(fired=False), week=_week(remaining=2), ) - assert _compliance_state_word(snap) == "warn" + assert compliance_state_word(snap) == "warn" def test_ok_when_not_fired_and_minimum_met(self) -> None: """Ok when not fired and minimum met.""" @@ -34,4 +32,4 @@ class TestComplianceStateWord: lock_explanation=_lock_explanation(fired=False), week=_week(remaining=0), ) - assert _compliance_state_word(snap) == "ok" + assert compliance_state_word(snap) == "ok" diff --git a/screen_locker/tests/test_web_payload.py b/screen_locker/tests/test_web_payload.py new file mode 100644 index 0000000..02e9ee1 --- /dev/null +++ b/screen_locker/tests/test_web_payload.py @@ -0,0 +1,144 @@ +"""Tests for _web_payload: the JSON the UI and the enforcer both read. + +The load-bearing assertion here is that ``gaming.workout_today`` comes from the +same credit rule the locker enforces with. steam-backlog-enforcer cuts the +gaming budget on that field, so if it ever drifted from +``count_day_credits`` the two repos would disagree about what a workout day is. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +import json +from typing import TYPE_CHECKING, Any +from unittest.mock import patch + +from screen_locker._web_payload import ( + build_decisions_payload, + build_status_payload, + workout_credits_today, +) + +if TYPE_CHECKING: + from pathlib import Path + +_PKG = "screen_locker._web_payload" + +_SATURDAY = datetime(2026, 8, 29, 21, 0, 0, tzinfo=UTC) + + +def _entry(wtype: str) -> dict[str, Any]: + """Build a log entry of the given type. + + Args: + wtype: The ``workout_data.type`` value. + + Returns: + A log entry dict. + """ + return {"timestamp": "2026-08-29T10:00:00+00:00", "workout_data": {"type": wtype}} + + +def _with_log(entries: dict[str, list[dict[str, Any]]], tmp_path: Path) -> Any: + """Patch the module's LOG_FILE at a temporary log holding ``entries``. + + Args: + entries: Date-keyed log contents. + tmp_path: pytest temporary directory. + + Returns: + A patch context manager for the module-level LOG_FILE. + """ + log = tmp_path / "log.json" + log.write_text(json.dumps(entries), encoding="utf-8") + return patch(f"{_PKG}.LOG_FILE", log) + + +class TestWorkoutCreditsToday: + """Today's credits come from the enforcement rule, not a second count.""" + + def test_no_entries_is_zero(self, tmp_path: Path) -> None: + """An untouched day earns nothing.""" + with _with_log({}, tmp_path): + assert workout_credits_today(now=_SATURDAY) == 0 + + def test_a_workout_counts(self, tmp_path: Path) -> None: + """A verified workout today earns a credit.""" + with _with_log({"2026-08-29": [_entry("phone_verified")]}, tmp_path): + assert workout_credits_today(now=_SATURDAY) == 1 + + def test_the_stronglifts_pair_still_counts_once(self, tmp_path: Path) -> None: + """Both ingestion paths for one session share today's credit too.""" + entries = { + "2026-08-29": [_entry("phone_verified"), _entry("pc_workout_verified")], + } + with _with_log(entries, tmp_path): + assert workout_credits_today(now=_SATURDAY) == 1 + + def test_a_relaxed_day_skip_earns_nothing(self, tmp_path: Path) -> None: + """Dismissing the popup is not a workout — so it is a 6h gaming day.""" + with _with_log({"2026-08-29": [_entry("relaxed_day_skip")]}, tmp_path): + assert workout_credits_today(now=_SATURDAY) == 0 + + def test_yesterdays_workout_does_not_count_today(self, tmp_path: Path) -> None: + """The budget is per gaming day, so only today's entries count.""" + with _with_log({"2026-08-28": [_entry("phone_verified")]}, tmp_path): + assert workout_credits_today(now=_SATURDAY) == 0 + + def test_non_dict_entries_are_ignored(self, tmp_path: Path) -> None: + """A malformed entry must not crash the endpoint.""" + log = tmp_path / "log.json" + log.write_text(json.dumps({"2026-08-29": ["junk"]}), encoding="utf-8") + with patch(f"{_PKG}.LOG_FILE", log): + assert workout_credits_today(now=_SATURDAY) == 0 + + +class TestStatusPayload: + """The status payload is JSON-safe and carries the gaming fact.""" + + def test_no_workout_today_reads_as_a_cut_budget(self, tmp_path: Path) -> None: + """workout_today False is what makes the enforcer apply 6h.""" + with _with_log({}, tmp_path): + payload = build_status_payload(now=_SATURDAY) + assert payload["gaming"]["workout_today"] is False + assert "no counted workout" in payload["gaming"]["reason"] + + def test_a_workout_today_reads_as_earned(self, tmp_path: Path) -> None: + """A logged workout restores the full budget.""" + with _with_log({"2026-08-29": [_entry("phone_verified")]}, tmp_path): + payload = build_status_payload(now=_SATURDAY) + assert payload["gaming"]["workout_today"] is True + assert payload["gaming"]["credits_today"] == 1 + + def test_payload_round_trips_through_json(self, tmp_path: Path) -> None: + """No dataclass or datetime may leak into the HTTP body.""" + with _with_log({}, tmp_path): + payload = build_status_payload(now=_SATURDAY) + assert json.loads(json.dumps(payload))["summary_line"] + + def test_payload_carries_the_derived_one_liners(self, tmp_path: Path) -> None: + """The i3blocks line and the tray word come from the same snapshot.""" + with _with_log({}, tmp_path): + payload = build_status_payload(now=_SATURDAY) + assert payload["compliance_state"] in {"ok", "warn", "lock"} + assert "snapshot" in payload + + +class TestDecisionsPayload: + """The Why view gets a bounded, newest-first slice of the trail.""" + + def test_newest_first(self) -> None: + """The browser reads top-down; the trail is stored oldest-first.""" + trail = [{"timestamp": "1"}, {"timestamp": "2"}, {"timestamp": "3"}] + with patch(f"{_PKG}.read_decisions", return_value=trail): + payload = build_decisions_payload(limit=2) + assert [d["timestamp"] for d in payload["decisions"]] == ["3", "2"] + assert payload["total"] == 3 + assert payload["returned"] == 2 + + def test_a_non_positive_limit_returns_everything(self) -> None: + """Guards the slice: limit=0 must not silently mean "no history".""" + trail = [{"timestamp": "1"}, {"timestamp": "2"}] + with patch(f"{_PKG}.read_decisions", return_value=trail): + payload = build_decisions_payload(limit=0) + assert payload["returned"] == 2 diff --git a/screen_locker/tests/test_web_payload_part2.py b/screen_locker/tests/test_web_payload_part2.py new file mode 100644 index 0000000..0db3722 --- /dev/null +++ b/screen_locker/tests/test_web_payload_part2.py @@ -0,0 +1,138 @@ +"""Tests for _web_payload.build_health_payload. + +Split out of test_web_payload.py for the 250-line cap. + +Every field asserted here exists because its absence once went unnoticed: a +timer systemd deleted to break an ordering cycle, a disarm marker nobody +removed, a log that quietly stopped being written. The rule the tests enforce +is that "could not check" is its own state and never reads as a pass. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import TYPE_CHECKING +from unittest.mock import patch + +from screen_locker._armed_state import TimerState +from screen_locker._web_payload import build_health_payload + +if TYPE_CHECKING: + from pathlib import Path + +_PKG = "screen_locker._web_payload" + +_SATURDAY = datetime(2026, 8, 29, 21, 0, 0, tzinfo=UTC) + + +class TestHealthPayload: + """Health turns every silent absence into a rendered value.""" + + def _states(self, *, armed: bool) -> list[TimerState]: + """Build timer states for the fake. + + Args: + armed: Whether the timers should read as armed. + + Returns: + One state per required timer. + """ + return [ + TimerState("workout-locker.timer", armed, "enabled", armed), + ] + + def test_armed_when_everything_checks_out(self, tmp_path: Path) -> None: + """Timers armed, no marker, systemctl present.""" + with ( + patch(f"{_PKG}.systemctl_available", return_value=True), + patch(f"{_PKG}.collect_states", return_value=self._states(armed=True)), + patch(f"{_PKG}.DISARM_MARKER", tmp_path / "absent"), + ): + payload = build_health_payload(now=_SATURDAY) + assert payload["armed"] is True + assert payload["timers"][0]["armed"] is True + + def test_unchecked_is_never_armed(self, tmp_path: Path) -> None: + """Without systemctl the answer is unknown, which is not a pass.""" + with ( + patch(f"{_PKG}.systemctl_available", return_value=False), + patch(f"{_PKG}.DISARM_MARKER", tmp_path / "absent"), + ): + payload = build_health_payload(now=_SATURDAY) + assert payload["timers_checked"] is False + assert payload["armed"] is False + assert payload["timers"] == [] + + def test_disarm_marker_overrides_armed_timers(self, tmp_path: Path) -> None: + """The marker is the deliberate off switch; it must win.""" + marker = tmp_path / "DISARMED" + marker.write_text("off for a reason", encoding="utf-8") + with ( + patch(f"{_PKG}.systemctl_available", return_value=True), + patch(f"{_PKG}.collect_states", return_value=self._states(armed=True)), + patch(f"{_PKG}.DISARM_MARKER", marker), + ): + payload = build_health_payload(now=_SATURDAY) + assert payload["disarmed"] is True + assert payload["armed"] is False + + def test_missing_log_reports_unknown_age(self, tmp_path: Path) -> None: + """A log that is not there reads as unknown, not as fresh.""" + with ( + patch(f"{_PKG}.systemctl_available", return_value=False), + patch(f"{_PKG}.DISARM_MARKER", tmp_path / "absent"), + patch(f"{_PKG}.LOG_FILE", tmp_path / "missing.json"), + patch(f"{_PKG}.read_decisions", return_value=[]), + ): + payload = build_health_payload(now=_SATURDAY) + assert payload["log_age_seconds"] is None + assert payload["last_decision_age_seconds"] is None + + def test_decision_age_is_measured_from_the_newest_entry( + self, + tmp_path: Path, + ) -> None: + """A quiet locker shows up as an old last decision.""" + trail = [{"timestamp": "2026-08-29T20:00:00+00:00"}] + with ( + patch(f"{_PKG}.systemctl_available", return_value=False), + patch(f"{_PKG}.DISARM_MARKER", tmp_path / "absent"), + patch(f"{_PKG}.read_decisions", return_value=trail), + ): + payload = build_health_payload(now=_SATURDAY) + assert payload["last_decision_age_seconds"] == 3600 + + def test_a_corrupt_timestamp_reads_as_unknown(self, tmp_path: Path) -> None: + """An unparsable stamp must not crash the Health view.""" + with ( + patch(f"{_PKG}.systemctl_available", return_value=False), + patch(f"{_PKG}.DISARM_MARKER", tmp_path / "absent"), + patch(f"{_PKG}.read_decisions", return_value=[{"timestamp": "nonsense"}]), + ): + payload = build_health_payload(now=_SATURDAY) + assert payload["last_decision_age_seconds"] is None + + def test_a_missing_timestamp_reads_as_unknown(self, tmp_path: Path) -> None: + """A decision line without a timestamp is handled, not assumed.""" + with ( + patch(f"{_PKG}.systemctl_available", return_value=False), + patch(f"{_PKG}.DISARM_MARKER", tmp_path / "absent"), + patch(f"{_PKG}.read_decisions", return_value=[{"reason": "enforced"}]), + ): + payload = build_health_payload(now=_SATURDAY) + assert payload["last_decision_age_seconds"] is None + + def test_log_age_is_measured_from_mtime(self, tmp_path: Path) -> None: + """A written log reports a real, non-negative age.""" + log = tmp_path / "log.json" + log.write_text("{}", encoding="utf-8") + with ( + patch(f"{_PKG}.systemctl_available", return_value=False), + patch(f"{_PKG}.DISARM_MARKER", tmp_path / "absent"), + patch(f"{_PKG}.LOG_FILE", log), + patch(f"{_PKG}.read_decisions", return_value=[]), + ): + payload = build_health_payload() + age = payload["log_age_seconds"] + assert isinstance(age, float) + assert age >= 0 diff --git a/screen_locker/tests/test_web_server.py b/screen_locker/tests/test_web_server.py new file mode 100644 index 0000000..ca3daa6 --- /dev/null +++ b/screen_locker/tests/test_web_server.py @@ -0,0 +1,209 @@ +"""Tests for _web_server: routing, static serving, and the loopback guard. + +The server is started on an ephemeral port and driven over real HTTP rather +than by calling handler methods directly, so the response codes and content +types tested here are the ones a browser and the enforcer actually see. +""" + +from __future__ import annotations + +from contextlib import contextmanager +from http.client import HTTPConnection +import json +import socket +import threading +from typing import TYPE_CHECKING +from unittest.mock import patch + +import pytest + +from screen_locker._web_server import _decision_limit, create_server, main, serve + +if TYPE_CHECKING: + from collections.abc import Iterator + from pathlib import Path + +_PKG = "screen_locker._web_server" + +# conftest's autouse _block_real_network patches +# "screen_locker._http_workout_fetch.socket.create_connection", and that +# attribute path resolves to the *global* socket module -- so every test in the +# suite has socket.create_connection stubbed, including loopback connections to +# a server we started ourselves. Captured at import time, before any fixture +# runs, which is the only moment the real function is still reachable. The +# fixture's own docstring says tests needing a real probe patch it locally. +_REAL_CREATE_CONNECTION = socket.create_connection + + +@pytest.fixture +def real_loopback(monkeypatch: pytest.MonkeyPatch) -> None: + """Restore real socket connections for tests that drive the server. + + Args: + monkeypatch: pytest's attribute patcher, which undoes this on teardown. + """ + monkeypatch.setattr(socket, "create_connection", _REAL_CREATE_CONNECTION) + + +@contextmanager +def _running() -> Iterator[int]: + """Start the server on an ephemeral port in a thread; yield the port. + + Yields: + The bound port. + """ + server = create_server("127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield server.server_address[1] + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + +def _get(port: int, path: str) -> tuple[int, bytes, str]: + """Make a GET request, returning ``(status, body, content-type)``. + + Args: + port: Port to connect to. + path: Request path. + + Returns: + The status code, body bytes and Content-Type header. + """ + conn = HTTPConnection("127.0.0.1", port, timeout=5) + try: + conn.request("GET", path) + resp = conn.getresponse() + return resp.status, resp.read(), resp.headers.get("Content-Type", "") + finally: + conn.close() + + +def _make_dist(tmp_path: Path, *, with_index: bool = True) -> Path: + """Create a fake built-frontend directory. + + Args: + tmp_path: pytest temporary directory. + with_index: Whether to write an ``index.html``. + + Returns: + The resolved dist directory. + """ + dist = (tmp_path / "dist").resolve() + dist.mkdir() + if with_index: + (dist / "index.html").write_text("INDEX", encoding="utf-8") + (dist / "app.css").write_text("body{}", encoding="utf-8") + (dist / "icon.png").write_bytes(b"\x89PNG\r\n\x1a\n") + return dist + + +class TestDecisionLimit: + """?limit= is parsed defensively; a bad value never reaches the reader.""" + + def test_absent_limit_uses_the_default(self) -> None: + """No query string means the default page size.""" + assert _decision_limit("") > 0 + + def test_explicit_limit_is_honoured(self) -> None: + """A sane limit passes through.""" + assert _decision_limit("limit=5") == 5 + + def test_non_numeric_limit_falls_back(self) -> None: + """Garbage in the query string must not 500 the endpoint.""" + assert _decision_limit("limit=abc") == _decision_limit("") + + def test_zero_or_negative_limit_falls_back(self) -> None: + """A limit of zero would render an empty history for no reason.""" + assert _decision_limit("limit=0") == _decision_limit("") + + +class TestApiRoutes: + """Each API path serves its own payload as JSON.""" + + @pytest.fixture(autouse=True) + def _network(self, real_loopback: None) -> None: + """Every test in this class talks to the server over real HTTP.""" + + def test_status_route(self) -> None: + """/api/status serves the status payload.""" + with ( + patch(f"{_PKG}.build_status_payload", return_value={"ok": True}), + _running() as port, + ): + status, body, ctype = _get(port, "/api/status") + assert status == 200 + assert json.loads(body) == {"ok": True} + assert ctype.startswith("application/json") + + def test_health_route(self) -> None: + """/api/health serves the health payload.""" + with ( + patch(f"{_PKG}.build_health_payload", return_value={"armed": False}), + _running() as port, + ): + status, body, _ = _get(port, "/api/health") + assert status == 200 + assert json.loads(body) == {"armed": False} + + def test_decisions_route_passes_the_limit_through(self) -> None: + """/api/decisions forwards ?limit= to the payload builder.""" + with ( + patch( + f"{_PKG}.build_decisions_payload", return_value={"total": 0} + ) as build, + _running() as port, + ): + status, _, _ = _get(port, "/api/decisions?limit=7") + assert status == 200 + build.assert_called_once_with(limit=7) + + def test_a_broken_payload_returns_500_and_says_which(self) -> None: + """A failing builder must never look like an endpoint with no news.""" + with ( + patch(f"{_PKG}.build_status_payload", side_effect=OSError("disk gone")), + _running() as port, + ): + status, body, _ = _get(port, "/api/status") + assert status == 500 + assert b"status error" in body + + +class TestBindGuard: + """The API is unauthenticated, so it may only ever bind loopback.""" + + @pytest.mark.parametrize("host", ["127.0.0.1", "localhost"]) + def test_loopback_is_allowed(self, host: str) -> None: + """Both loopback spellings are accepted.""" + server = create_server(host, 0) + server.server_close() + + def test_a_public_bind_is_refused(self) -> None: + """A LAN address would publish the payloads beyond this machine.""" + with pytest.raises(ValueError, match="loopback-only"): + create_server("192.0.2.1", 0) + + +class TestServeEntryPoints: + """serve() and main() wire the server to the process lifetime.""" + + def test_serve_closes_the_socket_on_interrupt(self) -> None: + """Ctrl-C shuts down cleanly rather than leaving the port bound.""" + server = create_server("127.0.0.1", 0) + with ( + patch(f"{_PKG}.create_server", return_value=server), + patch.object(server, "serve_forever", side_effect=KeyboardInterrupt), + patch.object(server, "server_close") as close, + ): + serve() + close.assert_called_once() + server.server_close() + + def test_main_returns_zero(self) -> None: + """The console-script entry point reports success on a clean exit.""" + with patch(f"{_PKG}.serve") as served: + assert main() == 0 + served.assert_called_once() diff --git a/screen_locker/tests/test_web_server_part2.py b/screen_locker/tests/test_web_server_part2.py new file mode 100644 index 0000000..271ae7e --- /dev/null +++ b/screen_locker/tests/test_web_server_part2.py @@ -0,0 +1,81 @@ +"""Tests for _web_server static serving: SPA fallback and traversal guard. + +Split out of test_web_server.py for the 250-line cap; the server harness and +the real_loopback fixture stay in that module and are imported here. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import patch + +import pytest + +from screen_locker.tests.test_web_server import ( + _PKG, + _get, + _make_dist, + _running, + real_loopback, +) + +if TYPE_CHECKING: + from pathlib import Path + +__all__ = ["real_loopback"] + + +class TestStaticServing: + """The built bundle is served with an SPA fallback and a traversal guard.""" + + @pytest.fixture(autouse=True) + def _network(self, real_loopback: None) -> None: + """Every test in this class talks to the server over real HTTP.""" + + def test_index_is_served_at_the_root(self, tmp_path: Path) -> None: + """/ serves index.html.""" + with patch(f"{_PKG}.WEB_DIST", _make_dist(tmp_path)), _running() as port: + status, body, ctype = _get(port, "/") + assert status == 200 + assert b"INDEX" in body + assert ctype.startswith("text/html") + + def test_an_asset_is_served_with_its_own_type(self, tmp_path: Path) -> None: + """A real file is served rather than the SPA fallback.""" + with patch(f"{_PKG}.WEB_DIST", _make_dist(tmp_path)), _running() as port: + status, body, ctype = _get(port, "/app.css") + assert status == 200 + assert body == b"body{}" + assert ctype.startswith("text/css") + + def test_a_binary_asset_gets_no_charset(self, tmp_path: Path) -> None: + """A charset on image/png would be wrong; only text types get one.""" + with patch(f"{_PKG}.WEB_DIST", _make_dist(tmp_path)), _running() as port: + status, body, ctype = _get(port, "/icon.png") + assert status == 200 + assert body.startswith(b"\x89PNG") + assert ctype == "image/png" + + def test_an_unknown_route_falls_back_to_index(self, tmp_path: Path) -> None: + """SPA routes are handled by the bundle, not by the server.""" + with patch(f"{_PKG}.WEB_DIST", _make_dist(tmp_path)), _running() as port: + status, body, _ = _get(port, "/why/2026-08-29") + assert status == 200 + assert b"INDEX" in body + + def test_traversal_never_escapes_the_bundle(self, tmp_path: Path) -> None: + """A ../ path is refused, not resolved against the filesystem.""" + secret = tmp_path / "secret.txt" + secret.write_text("do not serve me", encoding="utf-8") + with patch(f"{_PKG}.WEB_DIST", _make_dist(tmp_path)), _running() as port: + status, body, _ = _get(port, "/../secret.txt") + assert status == 200 + assert b"do not serve me" not in body + + def test_missing_bundle_says_how_to_build_it(self, tmp_path: Path) -> None: + """404 carries the build command instead of an empty page.""" + dist = _make_dist(tmp_path, with_index=False) + with patch(f"{_PKG}.WEB_DIST", dist), _running() as port: + status, body, _ = _get(port, "/") + assert status == 404 + assert b"npm --prefix web run build" in body diff --git a/scripts/check_enforcement_armed.py b/scripts/check_enforcement_armed.py index ca1599c..071de25 100755 --- a/scripts/check_enforcement_armed.py +++ b/scripts/check_enforcement_armed.py @@ -28,109 +28,23 @@ Usage: from __future__ import annotations import argparse -from dataclasses import dataclass import logging -import shutil -import subprocess import sys +from screen_locker._armed_state import collect_states, systemctl_available + _logger = logging.getLogger(__name__) -# Every unit that must be armed for a workout-less day to end in a lock. -# workout-locker.service itself is WantedBy=graphical-session.target and so is -# only ever a login one-shot; the timers are what make enforcement recur. -REQUIRED_TIMERS = ( - "early-bird-workout-check.timer", - "workout-locker.timer", -) - -_TIMEOUT_SECONDS = 10 - - -@dataclass(frozen=True) -class TimerState: - """What systemd currently believes about one timer.""" - - name: str - enabled: bool - enabled_raw: str - scheduled: bool - - @property - def armed(self) -> bool: - """True only when the timer is both enabled and actually scheduled. - - Both halves matter. ``is-enabled`` alone passes for a timer whose job - systemd deleted to break an ordering cycle; ``list-timers`` alone passes - for a transient ``start``ed timer that will not survive a reboot. - """ - return self.enabled and self.scheduled - - def describe(self) -> str: - """Return a one-line human summary of this timer's state.""" - if self.armed: - return f"OK {self.name}: enabled and scheduled" - problems = [] - if not self.enabled: - problems.append(f"not enabled (is-enabled={self.enabled_raw or 'unknown'})") - if not self.scheduled: - problems.append("no next trigger in list-timers") - return f"DISARMED {self.name}: {', '.join(problems)}" - def _report(message: str) -> None: - """Write one line to stderr, so a disarmed locker is visible in the unit.""" + """Write one line to stderr, so a disarmed locker is visible in the unit. + + Args: + message: The line to write. + """ sys.stderr.write(f"{message}\n") -def _run(args: list[str]) -> tuple[int, str]: - """Run ``args``, returning (returncode, stdout). Never raises.""" - try: - # Explicit argument list, never shell=True. - proc = subprocess.run( - args, - capture_output=True, - text=True, - timeout=_TIMEOUT_SECONDS, - check=False, - ) - except OSError, subprocess.SubprocessError: - # A check that cannot run must not look like a check that passed. - _logger.exception("Could not run %s", " ".join(args)) - return 1, "" - return proc.returncode, proc.stdout - - -def _scheduled_timers() -> set[str]: - """Return the timer names systemd currently has a next trigger for.""" - _, out = _run(["systemctl", "--user", "list-timers", "--all", "--no-pager"]) - scheduled: set[str] = set() - for line in out.splitlines(): - for name in REQUIRED_TIMERS: - # A timer with no next trigger prints "n/a" in the NEXT column. - if name in line and " n/a " not in f" {line} ": - scheduled.add(name) - return scheduled - - -def collect_states() -> list[TimerState]: - """Return the current arming state of every required timer.""" - scheduled = _scheduled_timers() - states = [] - for name in REQUIRED_TIMERS: - code, out = _run(["systemctl", "--user", "is-enabled", name]) - raw = out.strip() - states.append( - TimerState( - name=name, - enabled=code == 0 and raw == "enabled", - enabled_raw=raw, - scheduled=name in scheduled, - ) - ) - return states - - def main(argv: list[str] | None = None) -> int: """Report arming state; return 1 when enforcement would not happen.""" parser = argparse.ArgumentParser(description=__doc__) @@ -141,7 +55,7 @@ def main(argv: list[str] | None = None) -> int: ) args = parser.parse_args(argv) - if shutil.which("systemctl") is None: + if not systemctl_available(): # Reachable in containers/CI. Say so explicitly rather than passing: # "could not check" must never be recorded as "checked and fine". _logger.error("systemctl not found — the arming check could not run") diff --git a/web/eslint.config.js b/web/eslint.config.js new file mode 100644 index 0000000..510b84b --- /dev/null +++ b/web/eslint.config.js @@ -0,0 +1,32 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +// Deliberately stricter than steam-backlog-enforcer/web, which this was copied +// from: that one extends only `tseslint.configs.recommended` and so runs no +// type-aware rules at all. `strictTypeChecked` needs `projectService`, which is +// why tsconfig.test.json exists — test files are outside tsconfig.app.json's +// program and would otherwise be unlintable. +export default defineConfig([ + globalIgnores(['dist', 'coverage']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.strictTypeChecked, + tseslint.configs.stylisticTypeChecked, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + }, +]) diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..31eb8b1 --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + Screen Locker — status + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..8017693 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,4046 @@ +{ + "name": "screen-locker-web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "screen-locker-web", + "version": "0.0.0", + "dependencies": { + "react": "19.2.8", + "react-dom": "19.2.8" + }, + "devDependencies": { + "@eslint/js": "10.0.1", + "@testing-library/dom": "10.4.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-react-hooks": "7.1.1", + "eslint-plugin-react-refresh": "0.5.5", + "globals": "17.11.0", + "jsdom": "30.0.1", + "typescript": "6.0.3", + "typescript-eslint": "8.68.0", + "vite": "8.2.2", + "vitest": "4.1.11" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "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.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.1.tgz", + "integrity": "sha512-YpAJZhaHplYQkG8ib+/Fx5Y0eF2lVWi3tIvMJA6i39TLyUNp2439cifzW8VMjhlqrBjHzK5hVGugRRm2zTKI/A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.9.tgz", + "integrity": "sha512-iGGw4OsAYsS6pD29MdJ2bX/nJx65a04ZZiw6x+VwWlP2DdXf6f++Zmuv/OzALpdyfVhjbduIIF2cXM7HWBIe9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "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 + } + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz", + "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz", + "integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz", + "integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz", + "integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz", + "integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz", + "integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz", + "integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz", + "integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz", + "integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz", + "integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz", + "integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz", + "integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz", + "integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz", + "integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz", + "integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz", + "integrity": "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.6", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.6.tgz", + "integrity": "sha512-Jbs9FpkkIDw8FgSc6kOVsOv8JuuqGAL7J4X1oot77JxAoDlkNn2GRkd0aYRVuQ+pVQAiHWVkE4rX/dkF5fBiCw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.4.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz", + "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz", + "integrity": "sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.68.0", + "@typescript-eslint/type-utils": "8.68.0", + "@typescript-eslint/utils": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.68.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.68.0.tgz", + "integrity": "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.68.0", + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.68.0.tgz", + "integrity": "sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.68.0", + "@typescript-eslint/types": "^8.68.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.68.0.tgz", + "integrity": "sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.68.0.tgz", + "integrity": "sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.68.0.tgz", + "integrity": "sha512-X77zqoY1EjeWGs/0JNxeaMfp5C5lIz4Tw8y66F1Ne8Faq6g424sBNYM6xBAqElfGZPLpWS+CZAp0DXyKDzWiHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0", + "@typescript-eslint/utils": "8.68.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.68.0.tgz", + "integrity": "sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.68.0.tgz", + "integrity": "sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.68.0", + "@typescript-eslint/tsconfig-utils": "8.68.0", + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.68.0.tgz", + "integrity": "sha512-PB5gJMMOg0Q5P1tsgWtEAqQacJXq0qEqRHDX/YJ4FaTMLfZPpHB3gjl2EJuiZyPABxmj4ZQYiY9m1bdAJ5y7tQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.68.0", + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.68.0.tgz", + "integrity": "sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.68.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", + "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.11", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.11", + "vitest": "4.1.11" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.416", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.416.tgz", + "integrity": "sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.9.1.tgz", + "integrity": "sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "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.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "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" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.5.tgz", + "integrity": "sha512-vG7yLURXNvCHy0FBdbZRwIu0BLPJMlUUJS2Ep7ud9w1YCLftFZtuEjyjhym0Qq9yuZ6LJUitNlu/hMk0gakXAw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.11.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.11.0.tgz", + "integrity": "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@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.9.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" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", + "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz", + "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.147.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.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" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.11", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.11.tgz", + "integrity": "sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.11" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.11", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.11.tgz", + "integrity": "sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.68.0.tgz", + "integrity": "sha512-MHy0Y0ynqeEbx/S45+i/bBssdy3X6KNBfmJAP35GrgtNxu2TQ5K5xsFDhAnmsq1jvpdoZOPG1LGtJo0HWqYCrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.68.0", + "@typescript-eslint/parser": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0", + "@typescript-eslint/utils": "8.68.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.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 + } + } + }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@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.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "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 + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", + "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..639e02c --- /dev/null +++ b/web/package.json @@ -0,0 +1,41 @@ +{ + "name": "screen-locker-web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "typecheck": "tsc -b --noEmit false --emitDeclarationOnly false", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "coverage": "vitest run --coverage" + }, + "dependencies": { + "react": "19.2.8", + "react-dom": "19.2.8" + }, + "devDependencies": { + "@eslint/js": "10.0.1", + "@testing-library/dom": "10.4.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-react-hooks": "7.1.1", + "eslint-plugin-react-refresh": "0.5.5", + "globals": "17.11.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/src/App.test.tsx b/web/src/App.test.tsx new file mode 100644 index 0000000..6f711fd --- /dev/null +++ b/web/src/App.test.tsx @@ -0,0 +1,53 @@ +import { render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { App } from './App' +import { makeDecisions, makeHealth, makeStatus } from './test/factories' + +function stubEndpoints(overrides: Record = {}): void { + vi.stubGlobal( + 'fetch', + vi.fn((url: string) => { + const body = + url.startsWith('/api/status') + ? (overrides.status ?? makeStatus()) + : url.startsWith('/api/health') + ? (overrides.health ?? makeHealth()) + : (overrides.decisions ?? makeDecisions()) + return Promise.resolve({ ok: true, json: () => Promise.resolve(body) }) + }), + ) +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('App', () => { + it('renders all three views once the endpoints answer', async () => { + stubEndpoints() + render() + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Now' })).toBeInTheDocument() + }) + expect(screen.getByRole('heading', { name: 'Health' })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Why' })).toBeInTheDocument() + }) + + it('shows a loading line before the first status arrives', () => { + vi.stubGlobal('fetch', vi.fn(() => new Promise(() => undefined))) + render() + expect(screen.getByText('Reading status…')).toBeInTheDocument() + }) + + it('surfaces a failing endpoint instead of silently showing nothing', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: false, status: 503, statusText: 'Unavailable' }), + ) + render() + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument() + }) + expect(screen.getByRole('alert')).toHaveTextContent('API returned 503') + }) +}) diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..2eac631 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,43 @@ +import { useCallback } from 'react' +import { HealthCard } from './components/HealthCard' +import { NowCard } from './components/NowCard' +import { WhyTable } from './components/WhyTable' +import { fetchDecisions, fetchHealth, fetchStatus } from './api' +import { usePoll } from './useStatusPoll' + +const DECISION_LIMIT = 200 + +export function App() { + // Stable identities: usePoll re-subscribes whenever the fetcher changes, and + // an inline arrow would restart every interval on each render. + const decisionFetcher = useCallback(() => fetchDecisions(DECISION_LIMIT), []) + const status = usePoll(fetchStatus) + const decisions = usePoll(decisionFetcher) + const health = usePoll(fetchHealth) + + const errors = [status.error, decisions.error, health.error].filter( + (e): e is string => e !== null, + ) + + return ( +
+

Screen locker

+ {errors.length > 0 && ( +
+ {/* Shown rather than swallowed: a UI that silently keeps rendering + stale data is the same failure the locker itself had. */} + {errors.map((message) => ( +
{message}
+ ))} +
+ )} + {status.data === null ? ( +

Reading status…

+ ) : ( + + )} + {health.data !== null && } + {decisions.data !== null && } +
+ ) +} diff --git a/web/src/api.test.ts b/web/src/api.test.ts new file mode 100644 index 0000000..9e4606d --- /dev/null +++ b/web/src/api.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { fetchDecisions, fetchHealth, fetchStatus } from './api' + +function mockFetch(response: Partial): void { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response)) +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('api', () => { + it('returns the parsed body for a 200', async () => { + mockFetch({ ok: true, json: () => Promise.resolve({ total: 1 }) }) + await expect(fetchDecisions(5)).resolves.toStrictEqual({ total: 1 }) + }) + + it('passes the limit through in the query string', async () => { + const spy = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({}) }) + vi.stubGlobal('fetch', spy) + await fetchDecisions(42) + expect(spy).toHaveBeenCalledWith('/api/decisions?limit=42') + }) + + it('throws with the status text on a failure, rather than returning empty', async () => { + mockFetch({ ok: false, status: 500, statusText: 'Internal Server Error' }) + await expect(fetchStatus()).rejects.toThrow('API returned 500 Internal Server Error') + }) + + it('requests the health endpoint', async () => { + const spy = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({}) }) + vi.stubGlobal('fetch', spy) + await fetchHealth() + expect(spy).toHaveBeenCalledWith('/api/health') + }) + + it('requests the status endpoint', async () => { + const spy = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({}) }) + vi.stubGlobal('fetch', spy) + await fetchStatus() + expect(spy).toHaveBeenCalledWith('/api/status') + }) +}) diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000..e53d809 --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,21 @@ +import type { DecisionsPayload, HealthPayload, StatusPayload } from './types' + +async function getJson(path: string): Promise { + const resp = await fetch(path) + if (!resp.ok) { + throw new Error(`API returned ${String(resp.status)} ${resp.statusText}`) + } + return (await resp.json()) as T +} + +export function fetchStatus(): Promise { + return getJson('/api/status') +} + +export function fetchDecisions(limit: number): Promise { + return getJson(`/api/decisions?limit=${String(limit)}`) +} + +export function fetchHealth(): Promise { + return getJson('/api/health') +} diff --git a/web/src/components/HealthCard.test.tsx b/web/src/components/HealthCard.test.tsx new file mode 100644 index 0000000..0fe962b --- /dev/null +++ b/web/src/components/HealthCard.test.tsx @@ -0,0 +1,61 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { HealthCard } from './HealthCard' +import { makeHealth } from '../test/factories' + +describe('HealthCard', () => { + it('reports an armed locker', () => { + render() + expect(screen.getByText('armed')).toBeInTheDocument() + expect(screen.getByText('enabled and scheduled')).toBeInTheDocument() + }) + + it('says an unchecked arming state is NOT a pass', () => { + render( + , + ) + expect(screen.getByText(/This is not a pass/)).toBeInTheDocument() + }) + + it('shows the disarm marker when enforcement is switched off', () => { + render() + expect(screen.getByText(/present at/)).toBeInTheDocument() + expect(screen.getByText('NOT armed')).toBeInTheDocument() + }) + + it('shows a disarmed timer with its own reason', () => { + render( + , + ) + expect( + screen.getByText('DISARMED workout-locker.timer: not enabled'), + ).toBeInTheDocument() + }) + + it('renders unknown ages rather than hiding them', () => { + render( + , + ) + expect(screen.getAllByText('unknown ago')).toHaveLength(2) + }) +}) diff --git a/web/src/components/HealthCard.tsx b/web/src/components/HealthCard.tsx new file mode 100644 index 0000000..38642d9 --- /dev/null +++ b/web/src/components/HealthCard.tsx @@ -0,0 +1,56 @@ +import { formatAge } from '../format' +import type { HealthPayload } from '../types' + +interface Props { + readonly health: HealthPayload +} + +/** + * Whether the locker could actually fire. + * + * Every row here is something that once went wrong silently: a timer systemd + * deleted to break an ordering cycle, a disarm marker nobody removed, a log + * that stopped being written. Absences are rendered as values so they cannot + * be mistaken for good news. + */ +export function HealthCard({ health }: Props) { + const armedClass = health.armed ? 'state-ok' : 'state-lock' + return ( +
+

Health

+ {!health.timers_checked && ( +

+ {'systemctl unavailable — arming was NOT checked. This is not a pass.'} +

+ )} +
+ Enforcement + {health.armed ? 'armed' : 'NOT armed'} +
+ {health.disarmed && ( +
+ Disarm marker + {`present at ${health.disarm_marker}`} +
+ )} + {health.timers.map((timer) => ( +
+ {timer.name} + + {timer.armed ? 'enabled and scheduled' : timer.describe} + +
+ ))} +
+ Workout log written + {`${formatAge(health.log_age_seconds)} ago`} +
+
+ Last decision recorded + + {`${formatAge(health.last_decision_age_seconds)} ago`} + +
+
+ ) +} diff --git a/web/src/components/NowCard.test.tsx b/web/src/components/NowCard.test.tsx new file mode 100644 index 0000000..bf04979 --- /dev/null +++ b/web/src/components/NowCard.test.tsx @@ -0,0 +1,92 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { NowCard } from './NowCard' +import { makeStatus } from '../test/factories' + +describe('NowCard', () => { + it('shows the cut budget and says why when no workout was logged', () => { + render() + expect(screen.getByText('No lock')).toBeInTheDocument() + expect(screen.getByText('6h')).toBeInTheDocument() + expect( + screen.getByText('cut from 8h — no workout logged today'), + ).toBeInTheDocument() + }) + + it('shows the earned budget once a workout is logged', () => { + const status = makeStatus({ + gaming: { + workout_today: true, + credits_today: 1, + reason: '1 counted workout(s) logged today', + }, + }) + render() + expect(screen.getByText('8h')).toBeInTheDocument() + expect( + screen.getByText('earned by a workout logged today'), + ).toBeInTheDocument() + }) + + it('says the lock would fire when it would', () => { + const base = makeStatus() + render( + , + ) + expect(screen.getByText('Lock would fire')).toBeInTheDocument() + }) + + it('counts down the remaining workouts', () => { + render() + expect(screen.getByText(/3\/5 workouts — 2 to go/)).toBeInTheDocument() + }) + + it('drops the countdown once the week is met', () => { + const base = makeStatus() + render( + , + ) + expect(screen.getByText(/5\/5 workouts$/)).toBeInTheDocument() + }) + + it('flags degraded sources rather than rendering a confident answer', () => { + const base = makeStatus() + render( + , + ) + expect(screen.getByText(/degraded/)).toBeInTheDocument() + }) +}) diff --git a/web/src/components/NowCard.tsx b/web/src/components/NowCard.tsx new file mode 100644 index 0000000..93ff7b1 --- /dev/null +++ b/web/src/components/NowCard.tsx @@ -0,0 +1,58 @@ +import { budgetHours } from '../format' +import type { StatusPayload } from '../types' + +interface Props { + readonly status: StatusPayload +} + +/** The decision as it stands right now, in words rather than slugs. */ +export function NowCard({ status }: Props) { + const { snapshot, gaming, compliance_state: state } = status + const { week, lock_explanation: lock } = snapshot + const hours = budgetHours(gaming.workout_today) + + return ( +
+

Now

+

+ {lock.fired ? 'Lock would fire' : 'No lock'} +

+

{lock.reason}

+ +
+ This week + + {week.counted_count}/{week.minimum} workouts + {week.remaining > 0 ? ` — ${String(week.remaining)} to go` : ''} + +
+
+ Today + {gaming.reason} +
+
+ Gaming budget today + + + {hours}h + {' '} + + {gaming.workout_today + ? 'earned by a workout logged today' + : 'cut from 8h — no workout logged today'} + + +
+
+ Decision stage + {lock.stage} +
+ {lock.sources_degraded && ( +
+ Data sources + degraded — some inputs unreadable +
+ )} +
+ ) +} diff --git a/web/src/components/WhyTable.test.tsx b/web/src/components/WhyTable.test.tsx new file mode 100644 index 0000000..2a911c2 --- /dev/null +++ b/web/src/components/WhyTable.test.tsx @@ -0,0 +1,79 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { WhyTable } from './WhyTable' +import { makeDecisions } from '../test/factories' + +describe('WhyTable', () => { + it('renders the decision that hid the 2026-08-29 bug', () => { + render() + expect( + screen.getByText('Skipped — weekly minimum already met'), + ).toBeInTheDocument() + expect(screen.getByText('5/5')).toBeInTheDocument() + expect(screen.getByText('also: early_bird_window_active')).toBeInTheDocument() + }) + + it('says how much of the trail is shown', () => { + render() + expect(screen.getByText(/Showing 1 of 3000/)).toBeInTheDocument() + }) + + it('marks a decision that actually locked', () => { + render( + , + ) + expect(screen.getByText('locked')).toBeInTheDocument() + }) + + it('renders a no-decision run as a dash with no week column', () => { + render( + , + ) + expect(screen.getByText('—')).toBeInTheDocument() + expect( + screen.getByText('No decision (status or sync-only run)'), + ).toBeInTheDocument() + }) + + it('omits the also line when there is none', () => { + render( + , + ) + expect(screen.queryByText(/^also:/)).not.toBeInTheDocument() + // weekly_required missing falls back to 0 rather than rendering undefined. + expect(screen.getByText('2/0')).toBeInTheDocument() + }) +}) diff --git a/web/src/components/WhyTable.tsx b/web/src/components/WhyTable.tsx new file mode 100644 index 0000000..5951062 --- /dev/null +++ b/web/src/components/WhyTable.tsx @@ -0,0 +1,62 @@ +import { describeReason, formatStamp } from '../format' +import type { DecisionsPayload } from '../types' + +interface Props { + readonly decisions: DecisionsPayload +} + +/** + * The decision history. + * + * This is the view that would have surfaced the 2026-08-29 bug without anyone + * reading the journal: every half-hourly tick recorded + * `weekly_minimum_met 5/5` while the real total was three. + */ +export function WhyTable({ decisions }: Props) { + return ( +
+

Why

+

+ Showing {decisions.returned} of {decisions.total} recorded decisions, + newest first. +

+ + + + + + + + + + + {decisions.decisions.map((decision, index) => ( + + + + + + + ))} + +
WhenOutcomeReasonWeek
{formatStamp(decision.timestamp)} + {decision.locked === null + ? '—' + : decision.locked + ? 'locked' + : 'no lock'} + + {describeReason(decision.reason)} + {decision.also !== undefined && decision.also !== '' && ( +
also: {decision.also}
+ )} +
+ {decision.weekly_count === undefined + ? '' + : `${String(decision.weekly_count)}/${String( + decision.weekly_required ?? 0, + )}`} +
+
+ ) +} diff --git a/web/src/format.test.ts b/web/src/format.test.ts new file mode 100644 index 0000000..2c502e6 --- /dev/null +++ b/web/src/format.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { budgetHours, describeReason, formatAge, formatStamp } from './format' + +describe('formatAge', () => { + it('reports a missing age as unknown, never as fresh', () => { + expect(formatAge(null)).toBe('unknown') + }) + + it('handles a clock skew rather than printing a negative age', () => { + expect(formatAge(-5)).toBe('in the future') + }) + + it.each([ + [30, '30s'], + [90, '1m'], + [3600, '1h'], + [90000, '1d'], + ])('renders %i seconds as %s', (seconds, expected) => { + expect(formatAge(seconds)).toBe(expected) + }) +}) + +describe('formatStamp', () => { + it('renders a valid ISO timestamp', () => { + expect(formatStamp('2026-08-29T20:01:02+00:00')).toMatch(/Aug/) + }) + + it('passes an unparseable value through unchanged', () => { + expect(formatStamp('not a date')).toBe('not a date') + }) +}) + +describe('describeReason', () => { + it('spells out a known slug', () => { + expect(describeReason('weekly_minimum_met')).toBe( + 'Skipped — weekly minimum already met', + ) + }) + + it('degrades gracefully for a slug added on the Python side later', () => { + expect(describeReason('some_new_reason')).toBe('some new reason') + }) +}) + +describe('budgetHours', () => { + it('gives 8h when a workout was logged today', () => { + expect(budgetHours(true)).toBe(8) + }) + + it('cuts to 6h when none was', () => { + expect(budgetHours(false)).toBe(6) + }) +}) diff --git a/web/src/format.ts b/web/src/format.ts new file mode 100644 index 0000000..d9a65fb --- /dev/null +++ b/web/src/format.ts @@ -0,0 +1,64 @@ +/** Pure formatting helpers, shared by the three views. */ + +const MINUTE = 60 +const HOUR = 60 * MINUTE +const DAY = 24 * HOUR + +/** + * Render a duration in seconds as a short human age ("3m", "5h", "2d"). + * + * Returns "unknown" for null so a missing timestamp can never be mistaken for + * a fresh one — the whole point of the Health view is that silence is visible. + */ +export function formatAge(seconds: number | null): string { + if (seconds === null) return 'unknown' + if (seconds < 0) return 'in the future' + if (seconds < MINUTE) return `${String(Math.floor(seconds))}s` + if (seconds < HOUR) return `${String(Math.floor(seconds / MINUTE))}m` + if (seconds < DAY) return `${String(Math.floor(seconds / HOUR))}h` + return `${String(Math.floor(seconds / DAY))}d` +} + +/** Render an ISO-8601 timestamp as a local "MMM DD HH:MM" stamp. */ +export function formatStamp(iso: string): string { + const when = new Date(iso) + if (Number.isNaN(when.getTime())) return iso + return when.toLocaleString(undefined, { + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) +} + +/** + * Turn a decision reason slug into a sentence. + * + * Falls back to the slug with underscores stripped, so a reason added to the + * Python side later still reads sensibly here instead of rendering blank. + */ +export function describeReason(reason: string): string { + const known: Record = { + enforced: 'Locked — no skip condition applied', + weekly_minimum_met: 'Skipped — weekly minimum already met', + scheduled_skip_day: 'Skipped — this date was scheduled off', + relaxed_day: 'Relaxed day (Tue–Thu) — lock optional', + relaxed_day_already_skipped: 'Skipped — relaxed day already dismissed', + workout_logged_today: 'Skipped — a workout was logged today', + early_bird_window_active: 'Skipped — inside the early-bird window', + early_bird_banked: 'Skipped — early-bird marker banked', + early_bird_auto_upgraded: 'Skipped — early-bird workout confirmed', + sick_day: 'Skipped — sick day', + sick_day_auto_upgraded: 'Skipped — sick day, workout confirmed', + wake_alarm_skip: 'Skipped — earned by the wake alarm', + heat_skip: 'Skipped — too hot', + no_sick_day_to_verify: 'Nothing to verify', + mode_makes_no_decision: 'No decision (status or sync-only run)', + } + return known[reason] ?? reason.replace(/_/g, ' ') +} + +/** Budget hours the enforcer will apply, given whether today has a workout. */ +export function budgetHours(workoutToday: boolean): number { + return workoutToday ? 8 : 6 +} diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..837c548 --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,133 @@ +/* + * Tokens from ~/utils/unified-design-system (DOCS-tokens.md). Values are copied + * rather than imported because that system ships as documentation plus + * per-stack generators, not an npm package -- keep these in step with it rather + * than inventing new ones. + */ +:root { + --ink: #211d1b; + --ink-raised-1: #2b2624; + --ink-raised-2: #38312e; + --line-dark: #463e3a; + --text-on-dark: #eceae9; + --muted-on-dark: #aaa09a; + + --accent: #b8862e; + --success: #8a9a3c; + --warning: #e0a63c; + --danger: #e2585f; + + --space-1: 4px; + --space-2: 8px; + --space-3: 16px; + --space-4: 24px; + --space-5: 32px; + + --radius: 8px; + + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--ink); + color: var(--text-on-dark); + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; + font-size: 16px; + line-height: 1.5; +} + +main { + margin: 0 auto; + max-width: 900px; + padding: var(--space-4) var(--space-3); +} + +h1 { + font-size: 24px; + letter-spacing: -0.1px; + margin: 0 0 var(--space-4); +} + +h2 { + font-size: 20px; + margin: 0 0 var(--space-3); +} + +.card { + background: var(--ink-raised-1); + border: 1px solid var(--line-dark); + border-radius: var(--radius); + margin-bottom: var(--space-4); + padding: var(--space-4); +} + +.headline { + font-size: 32px; + letter-spacing: -0.2px; + margin: 0 0 var(--space-2); +} + +.muted { + color: var(--muted-on-dark); +} + +.state-ok { color: var(--success); } +.state-warn { color: var(--warning); } +.state-lock { color: var(--danger); } + +.row { + border-top: 1px solid var(--line-dark); + display: flex; + gap: var(--space-3); + justify-content: space-between; + padding: var(--space-2) 0; +} + +.row:first-of-type { + border-top: none; +} + +table { + border-collapse: collapse; + font-size: 14px; + width: 100%; +} + +th { + color: var(--muted-on-dark); + font-weight: 600; + letter-spacing: 0.05em; + text-align: left; +} + +th, +td { + border-bottom: 1px solid var(--line-dark); + padding: var(--space-2) var(--space-2) var(--space-2) 0; + vertical-align: top; +} + +.pill { + background: var(--ink-raised-2); + border-radius: 999px; + font-size: 12px; + letter-spacing: 0.1em; + padding: var(--space-1) var(--space-2); + white-space: nowrap; +} + +.pill-lock { background: var(--danger); color: var(--ink); } +.pill-ok { background: var(--success); color: var(--ink); } + +.error { + border: 1px solid var(--danger); + border-radius: var(--radius); + color: var(--danger); + margin-bottom: var(--space-4); + padding: var(--space-3); +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..7450d9d --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,12 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { App } from './App' +import './index.css' + +const root = document.getElementById('root') +if (root === null) throw new Error('#root is missing from index.html') +createRoot(root).render( + + + , +) diff --git a/web/src/test/factories.ts b/web/src/test/factories.ts new file mode 100644 index 0000000..2d5106d --- /dev/null +++ b/web/src/test/factories.ts @@ -0,0 +1,88 @@ +import type { + DecisionsPayload, + HealthPayload, + StatusPayload, +} from '../types' + +export function makeStatus(over: Partial = {}): StatusPayload { + return { + snapshot: { + today: { + date: '2026-08-29', + label: 'Sat Aug 29', + entry_types: [], + source: '', + counted: false, + day_count: 0, + is_sick_day: false, + }, + week: { + days: [], + counted_count: 3, + minimum: 5, + remaining: 2, + extra: 0, + }, + lock_explanation: { + fired: false, + stage: 'scheduled_skip', + reason: 'Manually scheduled skip day.', + trace: [], + heat_skip_evaluated: false, + sources_degraded: false, + }, + generated_at: '2026-08-29T20:30:00+00:00', + }, + summary_line: '… 3/5 workouts', + compliance_state: 'warn', + gaming: { + workout_today: false, + credits_today: 0, + reason: 'no counted workout logged today', + }, + ...over, + } +} + +export function makeHealth(over: Partial = {}): HealthPayload { + return { + timers_checked: true, + timers: [ + { + name: 'workout-locker.timer', + enabled: true, + scheduled: true, + armed: true, + describe: 'OK workout-locker.timer: enabled and scheduled', + }, + ], + armed: true, + disarmed: false, + disarm_marker: '/home/kuhy/.local/share/screen_locker/DISARMED', + log_file: '/home/kuhy/screen-locker/screen_locker/log.json', + log_age_seconds: 3600, + decision_log_file: '/home/kuhy/.local/share/screen_locker/decisions.jsonl', + last_decision_age_seconds: 120, + ...over, + } +} + +export function makeDecisions( + over: Partial = {}, +): DecisionsPayload { + return { + total: 3000, + returned: 1, + decisions: [ + { + timestamp: '2026-08-29T20:01:02+00:00', + locked: false, + reason: 'weekly_minimum_met', + weekly_count: 5, + weekly_required: 5, + also: 'early_bird_window_active', + }, + ], + ...over, + } +} diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts new file mode 100644 index 0000000..8be92f5 --- /dev/null +++ b/web/src/test/setup.ts @@ -0,0 +1,8 @@ +import '@testing-library/jest-dom/vitest' +import { cleanup } from '@testing-library/react' +import { afterEach } from 'vitest' + +// React Testing Library does not auto-clean when Vitest globals are disabled. +afterEach(() => { + cleanup() +}) diff --git a/web/src/types.ts b/web/src/types.ts new file mode 100644 index 0000000..0af188d --- /dev/null +++ b/web/src/types.ts @@ -0,0 +1,102 @@ +/** Shapes served by screen_locker._web_payload. */ + +/** One predicate the lock decision walked past, and whether it fired. */ +export interface TraceStep { + readonly name: string + readonly fired: boolean + readonly reason: string +} + +/** Why the locker would or would not fire right now. */ +export interface LockExplanation { + readonly fired: boolean + readonly stage: string + readonly reason: string + readonly trace: readonly TraceStep[] + readonly heat_skip_evaluated: boolean + readonly sources_degraded: boolean +} + +/** One day of this ISO week. */ +export interface DayStatus { + readonly date: string + readonly label: string + readonly entry_types: readonly string[] + readonly source: string + readonly counted: boolean + readonly day_count: number + readonly is_sick_day: boolean +} + +/** This ISO week against the weekly minimum. */ +export interface WeeklySummary { + readonly days: readonly DayStatus[] + readonly counted_count: number + readonly minimum: number + readonly remaining: number + readonly extra: number +} + +/** The parts of the status snapshot this UI renders. */ +export interface StatusSnapshot { + readonly today: DayStatus + readonly week: WeeklySummary + readonly lock_explanation: LockExplanation + readonly generated_at: string +} + +/** The fact the gaming budget turns on. */ +export interface GamingFact { + readonly workout_today: boolean + readonly credits_today: number + readonly reason: string +} + +/** GET /api/status. */ +export interface StatusPayload { + readonly snapshot: StatusSnapshot + readonly summary_line: string + readonly compliance_state: string + readonly gaming: GamingFact +} + +/** One recorded decision from decisions.jsonl. */ +export interface Decision { + readonly timestamp: string + readonly locked: boolean | null + readonly reason: string + readonly detail?: string + readonly weekly_count?: number + readonly weekly_required?: number + readonly also?: string + readonly mode?: string +} + +/** GET /api/decisions. */ +export interface DecisionsPayload { + readonly total: number + readonly returned: number + readonly decisions: readonly Decision[] +} + +/** One systemd timer's arming state. */ +export interface TimerHealth { + readonly name: string + readonly enabled: boolean + readonly scheduled: boolean + readonly armed: boolean + readonly describe: string +} + +/** GET /api/health. */ +export interface HealthPayload { + readonly timers_checked: boolean + readonly timers: readonly TimerHealth[] + readonly armed: boolean + readonly disarmed: boolean + readonly disarm_marker: string + readonly log_file: string + readonly log_age_seconds: number | null + readonly decision_log_file: string + readonly last_decision_age_seconds: number | null +} diff --git a/web/src/useStatusPoll.test.ts b/web/src/useStatusPoll.test.ts new file mode 100644 index 0000000..1bbfdb0 --- /dev/null +++ b/web/src/useStatusPoll.test.ts @@ -0,0 +1,92 @@ +import { renderHook, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { POLL_MS, usePoll } from './useStatusPoll' + +afterEach(() => { + vi.useRealTimers() +}) + +describe('usePoll', () => { + it('loads immediately rather than waiting for the first interval', async () => { + const fetcher = vi.fn().mockResolvedValue('value') + const { result } = renderHook(() => usePoll(fetcher)) + await waitFor(() => { + expect(result.current.data).toBe('value') + }) + expect(result.current.error).toBeNull() + }) + + it('surfaces an Error message', async () => { + const fetcher = vi.fn().mockRejectedValue(new Error('boom')) + const { result } = renderHook(() => usePoll(fetcher)) + await waitFor(() => { + expect(result.current.error).toBe('boom') + }) + }) + + it('stringifies a non-Error rejection instead of rendering nothing', async () => { + const fetcher = vi.fn().mockRejectedValue('plain string') + const { result } = renderHook(() => usePoll(fetcher)) + await waitFor(() => { + expect(result.current.error).toBe('plain string') + }) + }) + + it('clears a previous error once a load succeeds again', async () => { + const fetcher = vi + .fn() + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValue('ok') + const { result, rerender } = renderHook( + ({ interval }: { interval: number }) => usePoll(fetcher, interval), + + { initialProps: { interval: 100000 } }, + ) + await waitFor(() => { + expect(result.current.error).toBe('boom') + }) + rerender({ interval: 200000 }) + await waitFor(() => { + expect(result.current.data).toBe('ok') + }) + expect(result.current.error).toBeNull() + }) + + it('polls again on the interval', async () => { + vi.useFakeTimers() + const fetcher = vi.fn().mockResolvedValue('value') + renderHook(() => usePoll(fetcher, 1000)) + expect(fetcher).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(2000) + expect(fetcher).toHaveBeenCalledTimes(3) + }) + + it('stops updating after unmount', async () => { + const fetcher = vi.fn().mockResolvedValue('value') + const { result, unmount } = renderHook(() => usePoll(fetcher)) + unmount() + await Promise.resolve() + expect(result.current.data).toBeNull() + }) + + it('ignores a rejection that lands after unmount', async () => { + // The cancelled guard in the catch path: without it React warns about a + // state update on an unmounted component, and a stale error would stick. + let reject: (reason: Error) => void = () => undefined + const fetcher = vi.fn( + () => + new Promise((_resolve, rej) => { + reject = rej + }), + ) + const { result, unmount } = renderHook(() => usePoll(fetcher)) + unmount() + reject(new Error('late failure')) + await Promise.resolve() + expect(result.current.error).toBeNull() + }) + + it('exposes a sane default interval', () => { + expect(POLL_MS).toBeGreaterThan(0) + }) +}) diff --git a/web/src/useStatusPoll.ts b/web/src/useStatusPoll.ts new file mode 100644 index 0000000..39d0034 --- /dev/null +++ b/web/src/useStatusPoll.ts @@ -0,0 +1,49 @@ +import { useEffect, useState } from 'react' + +export const POLL_MS = 15000 + +interface PollResult { + readonly data: T | null + readonly error: string | null +} + +/** + * Poll a fetcher on an interval, keeping the last good value on failure. + * + * Deliberately has no `document.hidden` guard. The enforcer's equivalent hook + * had one and it swallowed the first load, leaving automation-driven tabs stuck + * on the loading message forever; see steam-backlog-enforcer useBudgetPoll. + */ +export function usePoll( + fetcher: () => Promise, + intervalMs: number = POLL_MS, +): PollResult { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + const load = () => { + fetcher() + .then((value) => { + if (!cancelled) { + setData(value) + setError(null) + } + }) + .catch((e: unknown) => { + if (!cancelled) { + setError(e instanceof Error ? e.message : String(e)) + } + }) + } + load() + const handle = setInterval(load, intervalMs) + return () => { + cancelled = true + clearInterval(handle) + } + }, [fetcher, intervalMs]) + + return { data, error } +} diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json new file mode 100644 index 0000000..11dd0f1 --- /dev/null +++ b/web/tsconfig.app.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["es2023", "DOM", "DOM.Iterable"], + "module": "esnext", + "moduleResolution": "bundler", + "types": ["vite/client"], + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..d4e4600 --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["es2023"], + "module": "esnext", + "moduleResolution": "bundler", + "types": ["node"], + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + "strict": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/web/tsconfig.test.json b/web/tsconfig.test.json new file mode 100644 index 0000000..0ea63b6 --- /dev/null +++ b/web/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.app.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.test.tsbuildinfo", + "types": ["vite/client", "node"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..450da95 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,35 @@ +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vitest/config' + +// The Python API (`screen-locker-web`) runs on 127.0.0.1:8770 — 8000 belongs to +// steam-backlog-enforcer's own UI. In dev, Vite serves the UI and proxies +// `/api/*` to it, so there are no CORS concerns. In production the Python +// server serves the built bundle from `web/dist`. +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + '/api': 'http://127.0.0.1:8770', + }, + }, + test: { + environment: 'jsdom', + setupFiles: './src/test/setup.ts', + coverage: { + provider: 'v8', + include: ['src/**/*.{ts,tsx}'], + exclude: [ + 'src/**/*.test.{ts,tsx}', + 'src/test/**', + 'src/main.tsx', + 'src/vite-env.d.ts', + ], + thresholds: { + statements: 100, + branches: 100, + functions: 100, + lines: 100, + }, + }, + }, +})