diff --git a/.gitignore b/.gitignore index 6af8bc1..0917926 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,9 @@ coverage.xml htmlcov/ *.log .DS_Store + +# Web UI (Vite + React frontend) +node_modules/ +web/dist/ +coverage/ +*.tsbuildinfo diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 831d783..aeeaed8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -141,6 +141,20 @@ repos: require_serial: true stages: [pre-push] + # =========================================================================== + # VITEST + COVERAGE (push stage) + # =========================================================================== + - repo: local + hooks: + - id: vitest-coverage + name: vitest with 100% coverage enforcement + entry: npm --prefix web run coverage + language: system + files: ^web/src/ + pass_filenames: false + require_serial: true + stages: [pre-push] + # =========================================================================== # CODESPELL - Spell checking # =========================================================================== diff --git a/steam_backlog_enforcer/_web_dataset.py b/steam_backlog_enforcer/_web_dataset.py new file mode 100644 index 0000000..34827df --- /dev/null +++ b/steam_backlog_enforcer/_web_dataset.py @@ -0,0 +1,280 @@ +"""Read-only projection of cached data for the interactive web UI. + +Builds a compact, secrets-free dataset from the on-disk caches (snapshot, +HLTB, ProtonDB, state) so a browser UI can filter games and estimate backlog +completion times entirely client-side. This module performs **no network +calls** — it only reads caches that previous ``scan``/``stats`` runs populated. + +The projection deliberately emits *every* incomplete, non-current, +non-finished game with its raw HLTB-confidence counters and ProtonDB tiers, so +the client can move its filter thresholds *below* the CLI defaults. The CLI +default thresholds and a parity summary are included so the UI can show +"matches the CLI" and so changes that break parity are easy to spot. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from typing import Any + +from steam_backlog_enforcer._hltb_types import _read_raw_cache +from steam_backlog_enforcer._scanning_confidence import ( + _MIN_COMP_100_POLLS, + _MIN_CONFIDENCE_SUM, + _MIN_COUNT_COMP, +) +from steam_backlog_enforcer.config import State, load_snapshot +from steam_backlog_enforcer.protondb import ( + MIN_PLAYABLE_TIER, + ProtonDBRating, + _load_cache, + _rating_from_cache, +) +from steam_backlog_enforcer.steam_api import GameInfo + +# Mirrors ``_stats._HOURS_PER_DAY_PRESETS`` but mutable/JSON-friendly. +HOURS_PER_DAY_PRESETS = [2.0, 4.0, 6.0, 8.0] + + +@dataclass +class WebGame: + """One incomplete candidate game, with raw filterable fields. + + Hour fields use ``-1`` to mean "no data" (matching the cache convention), + so the client can choose to include or exclude unknown-length games. + """ + + app_id: int + name: str + completion_pct: float + playtime_minutes: int + rush_hours: float + leisure_hours: float + worst_hours: float + count_comp: int + comp_100_count: int + hltb_game_id: int + protondb_tier: str + protondb_trending_tier: str + protondb_score: float + + +@dataclass +class WebStateInfo: + """Pace inputs and current-assignment metadata for the UI.""" + + current_app_id: int | None + current_game_name: str + games_done: int + days_elapsed: int + enforcement_started_at: str + pace_games_per_day: float + + +@dataclass +class WebDefaults: + """The CLI's hardcoded filter thresholds, surfaced as editable defaults.""" + + min_comp_100_polls: int + min_count_comp: int + min_confidence_sum: int + min_playable_tier: str + hours_per_day_presets: list[float] + + +@dataclass +class DefaultSummary: + """Totals the CLI ``stats`` command would print at default thresholds. + + Used as a parity oracle: the client's own default-filtered totals must + reproduce these numbers. + """ + + qualifying: int + rush_total: float + leisure_total: float + worst_total: float + + +@dataclass +class WebDataset: + """Full payload served to the browser.""" + + games: list[WebGame] + state: WebStateInfo + defaults: WebDefaults + default_summary: DefaultSummary + generated_at: str = field(default="") + + +def _worst_hours(game: GameInfo, cache_hours: float, leisure: float) -> float: + """Replicate ``_stats`` worst-case selection exactly. + + worst = max of snapshot completionist hours, the HLTB hours-cache value, + and the leisure-100% time — considering only positive values. + """ + snap_hours = game.completionist_hours if game.completionist_hours > 0 else -1 + candidates = [v for v in (snap_hours, cache_hours, leisure) if v > 0] + return max(candidates) if candidates else -1.0 + + +def _passes_default_confidence(game: WebGame) -> bool: + """True if the game clears all three CLI HLTB-confidence thresholds.""" + if game.comp_100_count < _MIN_COMP_100_POLLS: + return False + if game.count_comp < _MIN_COUNT_COMP: + return False + return game.comp_100_count + game.count_comp >= _MIN_CONFIDENCE_SUM + + +def _has_any_time(game: WebGame) -> bool: + """True if the game has at least one positive time estimate.""" + return game.worst_hours > 0 or game.rush_hours > 0 or game.leisure_hours > 0 + + +def _build_games(games: list[GameInfo], exclude: set[int]) -> list[WebGame]: + """Project incomplete, non-excluded games into compact rows (no network).""" + raw = _read_raw_cache() + protondb_cache = _load_cache() + + rows: list[WebGame] = [] + for game in games: + if game.is_complete or game.app_id in exclude: + continue + + entry = raw.get(game.app_id, {}) + rush = float(entry.get("rush_hours", -1)) + leisure = float(entry.get("leisure_100h", -1)) + cache_hours = float(entry.get("hours", -1)) + count_comp = int(entry.get("count_comp", 0)) + comp_100_count = int(entry.get("polls", 0)) + hltb_game_id = int(entry.get("hltb_game_id", 0)) + + rating: ProtonDBRating = ( + _rating_from_cache(game.app_id, protondb_cache[str(game.app_id)]) + if str(game.app_id) in protondb_cache + else ProtonDBRating(app_id=game.app_id) + ) + + rows.append( + WebGame( + app_id=game.app_id, + name=game.name, + completion_pct=round(game.completion_pct, 1), + playtime_minutes=game.playtime_minutes, + rush_hours=rush, + leisure_hours=leisure, + worst_hours=_worst_hours(game, cache_hours, leisure), + count_comp=count_comp, + comp_100_count=comp_100_count, + hltb_game_id=hltb_game_id, + protondb_tier=rating.tier, + protondb_trending_tier=rating.trending_tier, + protondb_score=rating.score, + ) + ) + return rows + + +def _default_qualifying(rows: list[WebGame]) -> list[WebGame]: + """Apply the exact CLI default filters (confidence + ProtonDB + has-data).""" + qualifying: list[WebGame] = [] + for game in rows: + if not _passes_default_confidence(game): + continue + rating = ProtonDBRating( + app_id=game.app_id, + tier=game.protondb_tier, + trending_tier=game.protondb_trending_tier, + ) + if not rating.is_playable: + continue + if not _has_any_time(game): + continue + qualifying.append(game) + return qualifying + + +def _sum_positive(rows: list[WebGame], attr: str) -> float: + """Sum a positive-only hour attribute across rows (matches ``_sum_hours``).""" + total = sum(getattr(g, attr) for g in rows if getattr(g, attr) > 0) + return round(total, 1) + + +def _default_summary(rows: list[WebGame]) -> DefaultSummary: + """Compute the CLI parity totals at default thresholds.""" + qualifying = _default_qualifying(rows) + return DefaultSummary( + qualifying=len(qualifying), + rush_total=_sum_positive(qualifying, "rush_hours"), + leisure_total=_sum_positive(qualifying, "leisure_hours"), + worst_total=_sum_positive(qualifying, "worst_hours"), + ) + + +def _state_info(state: State, games_done: int) -> WebStateInfo: + """Build pace metadata, mirroring ``_print_pace_scenario`` inputs.""" + days_elapsed = 0 + pace = 0.0 + if state.enforcement_started_at: + try: + started = datetime.fromisoformat(state.enforcement_started_at) + except ValueError: + started = None + if started is not None: + now = datetime.now(timezone.utc) + days_elapsed = max(1, (now - started).days) + if games_done > 0: + pace = round(games_done / days_elapsed, 4) + return WebStateInfo( + current_app_id=state.current_app_id, + current_game_name=state.current_game_name, + games_done=games_done, + days_elapsed=days_elapsed, + enforcement_started_at=state.enforcement_started_at, + pace_games_per_day=pace, + ) + + +def build_web_dataset(state: State) -> WebDataset: + """Build the full web dataset from on-disk caches (no network calls). + + Args: + state: The loaded enforcer state (current game, finished IDs, pace). + + Returns: + A ``WebDataset`` with every incomplete candidate game, the CLI default + thresholds, and a parity summary. Raises no exceptions for a missing + snapshot — it returns an empty game list instead. + """ + snapshot = load_snapshot() + raw_games = ( + [GameInfo.from_snapshot(d) for d in snapshot] if snapshot is not None else [] + ) + games_done = sum(1 for g in raw_games if g.is_complete) + + exclude = set(state.finished_app_ids) + if state.current_app_id is not None: + exclude.add(state.current_app_id) + + rows = _build_games(raw_games, exclude) + + return WebDataset( + games=rows, + state=_state_info(state, games_done), + defaults=WebDefaults( + min_comp_100_polls=_MIN_COMP_100_POLLS, + min_count_comp=_MIN_COUNT_COMP, + min_confidence_sum=_MIN_CONFIDENCE_SUM, + min_playable_tier=MIN_PLAYABLE_TIER, + hours_per_day_presets=list(HOURS_PER_DAY_PRESETS), + ), + default_summary=_default_summary(rows), + generated_at=datetime.now(timezone.utc).isoformat(), + ) + + +def dataset_to_payload(dataset: WebDataset) -> dict[str, Any]: + """Serialize a ``WebDataset`` to a JSON-ready dict.""" + return asdict(dataset) diff --git a/steam_backlog_enforcer/_web_server.py b/steam_backlog_enforcer/_web_server.py new file mode 100644 index 0000000..a055709 --- /dev/null +++ b/steam_backlog_enforcer/_web_server.py @@ -0,0 +1,109 @@ +"""Minimal read-only localhost HTTP server for the interactive web UI. + +Serves the projected dataset at ``GET /api/dataset`` and the built React +bundle (``web/dist``) as static files. Binds to localhost only and never +exposes secrets: the payload comes from :func:`build_web_dataset`, which reads +the data caches but never ``config.json``. + +In development the Vite dev server proxies ``/api`` here; in production the +``serve`` command serves the built bundle and the API from one process. +""" + +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 urllib.parse import urlsplit + +from steam_backlog_enforcer._web_dataset import build_web_dataset, dataset_to_payload +from steam_backlog_enforcer.config import State +from steam_backlog_enforcer.game_install import _echo + +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" +DEFAULT_PORT = 8000 +_API_DATASET = "/api/dataset" + +# 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: cd web && npm install && npm run build" + + +class _Handler(BaseHTTPRequestHandler): + """Serve the dataset JSON 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.""" + logger.debug("%s - %s", self.address_string(), fmt % args) + + def do_GET(self) -> None: + """Dispatch a GET to the dataset API or to a static file.""" + path = urlsplit(self.path).path + if path == _API_DATASET: + self._serve_dataset() + else: + self._serve_static(path) + + def _serve_dataset(self) -> None: + """Build and send the projected dataset as JSON.""" + try: + payload = dataset_to_payload(build_web_dataset(State.load())) + body = json.dumps(payload).encode("utf-8") + except (OSError, ValueError, KeyError): + logger.exception("Failed to build web dataset") + self._send(HTTPStatus.INTERNAL_SERVER_ERROR, b"dataset error", "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.""" + 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.""" + 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.""" + return ThreadingHTTPServer((host, port), _Handler) + + +def serve(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> None: + """Run the web server until interrupted with Ctrl-C.""" + server = create_server(host, port) + _echo(f"Steam Backlog Enforcer web UI: http://{host}:{port}") + _echo("Press Ctrl-C to stop.") + try: + server.serve_forever() + except KeyboardInterrupt: + _echo("\nShutting down.") + finally: + server.server_close() diff --git a/steam_backlog_enforcer/main.py b/steam_backlog_enforcer/main.py index 8ee31d1..afe4e44 100644 --- a/steam_backlog_enforcer/main.py +++ b/steam_backlog_enforcer/main.py @@ -14,6 +14,7 @@ from steam_backlog_enforcer._enforce_loop import ( ) from steam_backlog_enforcer._hltb_types import load_hltb_cache from steam_backlog_enforcer._stats import cmd_stats +from steam_backlog_enforcer._web_server import serve from steam_backlog_enforcer._whitelist import ( WHITELIST_COOLDOWN_SECONDS, add_pending_exception, @@ -381,6 +382,11 @@ def cmd_pick(config: Config, state: State) -> None: _echo(f"\n Library: hid {hidden} games") +def cmd_serve(_config: Config, _state: State) -> None: + """Start the interactive web UI server (read-only, localhost only).""" + serve() + + COMMANDS: dict[str, tuple[str, Callable[[Config, State], object]]] = { "scan": ("Scan library & assign a game", do_scan), "check": ("Check assigned game completion", do_check), @@ -399,6 +405,7 @@ COMMANDS: dict[str, tuple[str, Callable[[Config, State], object]]] = { "done": ("Finish game, open HLTB, pick next", cmd_done), "pick": ("Manually pick your next game from candidates", cmd_pick), "stats": ("Show backlog completion-time estimates", cmd_stats), + "serve": ("Start the interactive web UI (browser) server", cmd_serve), } # Extra commands with non-standard arg handling (shown in help but not in COMMANDS). diff --git a/steam_backlog_enforcer/tests/test_web_dataset.py b/steam_backlog_enforcer/tests/test_web_dataset.py new file mode 100644 index 0000000..9c2ef5c --- /dev/null +++ b/steam_backlog_enforcer/tests/test_web_dataset.py @@ -0,0 +1,330 @@ +"""Tests for _web_dataset module — 100% branch coverage.""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import datetime, timedelta, timezone +from unittest.mock import patch + +from steam_backlog_enforcer._web_dataset import ( + HOURS_PER_DAY_PRESETS, + WebGame, + _build_games, + _default_qualifying, + _default_summary, + _has_any_time, + _passes_default_confidence, + _state_info, + _sum_positive, + _worst_hours, + build_web_dataset, + dataset_to_payload, +) +from steam_backlog_enforcer.config import State +from steam_backlog_enforcer.steam_api import GameInfo + +_PKG = "steam_backlog_enforcer._web_dataset" + + +def _gi(**over: object) -> GameInfo: + """Build a GameInfo with field overrides.""" + base = GameInfo( + app_id=1, + name="G", + total_achievements=10, + unlocked_achievements=0, + playtime_minutes=60, + completionist_hours=20.0, + comp_100_count=5, + count_comp=20, + ) + return replace(base, **over) + + +def _wg(**over: object) -> WebGame: + """Build a WebGame with field overrides.""" + base = WebGame( + app_id=1, + name="Game1", + completion_pct=0.0, + playtime_minutes=60, + rush_hours=10.0, + leisure_hours=20.0, + worst_hours=25.0, + count_comp=20, + comp_100_count=5, + hltb_game_id=0, + protondb_tier="gold", + protondb_trending_tier="gold", + protondb_score=0.8, + ) + return replace(base, **over) + + +class TestWorstHours: + """Tests for _worst_hours (mirrors _stats worst-case selection).""" + + def test_completionist_dominates(self) -> None: + game = _gi(completionist_hours=30.0) + assert _worst_hours(game, cache_hours=10.0, leisure=20.0) == 30.0 + + def test_falls_back_to_cache_and_leisure_when_completionist_zero(self) -> None: + game = _gi(completionist_hours=0.0) + assert _worst_hours(game, cache_hours=15.0, leisure=8.0) == 15.0 + + def test_minus_one_when_all_non_positive(self) -> None: + game = _gi(completionist_hours=0.0) + assert _worst_hours(game, cache_hours=-1.0, leisure=-1.0) == -1.0 + + +class TestPassesDefaultConfidence: + """Tests for _passes_default_confidence.""" + + def test_fail_low_comp_100(self) -> None: + assert _passes_default_confidence(_wg(comp_100_count=2)) is False + + def test_fail_low_count_comp(self) -> None: + assert _passes_default_confidence(_wg(comp_100_count=5, count_comp=10)) is False + + def test_pass_when_all_thresholds_met(self) -> None: + assert _passes_default_confidence(_wg(comp_100_count=5, count_comp=20)) is True + + +class TestHasAnyTime: + """Tests for _has_any_time.""" + + def test_true_when_some_positive(self) -> None: + assert _has_any_time(_wg(rush_hours=-1, leisure_hours=-1, worst_hours=5.0)) + + def test_false_when_all_non_positive(self) -> None: + game = _wg(rush_hours=-1, leisure_hours=-1, worst_hours=-1) + assert _has_any_time(game) is False + + +class TestDefaultQualifying: + """Tests for _default_qualifying — each filter rejection branch.""" + + def test_rejects_low_confidence(self) -> None: + assert _default_qualifying([_wg(count_comp=0)]) == [] + + def test_rejects_unplayable(self) -> None: + game = _wg(protondb_tier="borked", protondb_trending_tier="borked") + assert _default_qualifying([game]) == [] + + def test_rejects_no_time(self) -> None: + game = _wg(rush_hours=-1, leisure_hours=-1, worst_hours=-1) + assert _default_qualifying([game]) == [] + + def test_accepts_qualifying_game(self) -> None: + assert len(_default_qualifying([_wg()])) == 1 + + +class TestSumPositive: + """Tests for _sum_positive.""" + + def test_sums_only_positive(self) -> None: + rows = [_wg(rush_hours=10.0), _wg(rush_hours=-1.0), _wg(rush_hours=5.5)] + assert _sum_positive(rows, "rush_hours") == 15.5 + + def test_empty(self) -> None: + assert _sum_positive([], "rush_hours") == 0.0 + + +class TestDefaultSummary: + """Tests for _default_summary.""" + + def test_totals(self) -> None: + rows = [_wg(rush_hours=10.0, leisure_hours=20.0, worst_hours=25.0)] + summary = _default_summary(rows) + assert summary.qualifying == 1 + assert summary.rush_total == 10.0 + assert summary.leisure_total == 20.0 + assert summary.worst_total == 25.0 + + +class TestStateInfo: + """Tests for _state_info pace calculation.""" + + def test_no_start_date(self) -> None: + info = _state_info(State(), games_done=5) + assert info.days_elapsed == 0 + assert info.pace_games_per_day == 0.0 + + def test_invalid_start_date(self) -> None: + info = _state_info(State(enforcement_started_at="not-a-date"), games_done=5) + assert info.days_elapsed == 0 + assert info.pace_games_per_day == 0.0 + + def test_valid_start_with_games(self) -> None: + started = datetime.now(timezone.utc) - timedelta(days=50) + info = _state_info( + State(enforcement_started_at=started.isoformat()), games_done=10 + ) + assert info.days_elapsed >= 49 + assert info.pace_games_per_day > 0.0 + + def test_valid_start_zero_games_keeps_zero_pace(self) -> None: + started = datetime.now(timezone.utc) - timedelta(days=50) + info = _state_info( + State(enforcement_started_at=started.isoformat()), games_done=0 + ) + assert info.days_elapsed >= 49 + assert info.pace_games_per_day == 0.0 + + +class TestBuildGames: + """Tests for _build_games (patches cache loaders, no file I/O).""" + + def _run( + self, + games: list[GameInfo], + exclude: set[int], + raw: dict[int, dict[str, object]] | None = None, + protondb: dict[str, dict[str, object]] | None = None, + ) -> list[WebGame]: + with ( + patch(f"{_PKG}._read_raw_cache", return_value=raw or {}), + patch(f"{_PKG}._load_cache", return_value=protondb or {}), + ): + return _build_games(games, exclude) + + def test_skips_complete_games(self) -> None: + rows = self._run( + [_gi(app_id=1, total_achievements=5, unlocked_achievements=5)], set() + ) + assert rows == [] + + def test_skips_excluded_games(self) -> None: + assert self._run([_gi(app_id=1)], {1}) == [] + + def test_uses_cache_entry_when_present(self) -> None: + raw = { + 1: { + "hours": 18.0, + "polls": 7, + "count_comp": 30, + "rush_hours": 9.0, + "leisure_100h": 22.0, + "hltb_game_id": 555, + } + } + proton = {"1": {"tier": "platinum", "trending_tier": "gold", "score": 0.9}} + rows = self._run([_gi(app_id=1, completionist_hours=0.0)], set(), raw, proton) + assert len(rows) == 1 + row = rows[0] + assert row.rush_hours == 9.0 + assert row.leisure_hours == 22.0 + assert row.worst_hours == 22.0 # max(cache 18, leisure 22) + assert row.count_comp == 30 + assert row.comp_100_count == 7 + assert row.hltb_game_id == 555 + assert row.protondb_tier == "platinum" + assert row.protondb_trending_tier == "gold" + + def test_defaults_when_no_cache_entries(self) -> None: + rows = self._run([_gi(app_id=1, completionist_hours=12.0)], set()) + assert len(rows) == 1 + row = rows[0] + assert row.rush_hours == -1 + assert row.leisure_hours == -1 + assert row.worst_hours == 12.0 # completionist only + assert row.protondb_tier == "" # no protondb entry + + +class TestBuildWebDataset: + """Tests for build_web_dataset (top-level projection).""" + + def test_no_snapshot_returns_empty_games(self) -> None: + with ( + patch(f"{_PKG}.load_snapshot", return_value=None), + patch(f"{_PKG}._read_raw_cache", return_value={}), + patch(f"{_PKG}._load_cache", return_value={}), + ): + ds = build_web_dataset(State()) + assert ds.games == [] + assert ds.state.games_done == 0 + assert ds.default_summary.qualifying == 0 + assert ds.defaults.hours_per_day_presets == list(HOURS_PER_DAY_PRESETS) + + def test_excludes_current_app_id(self) -> None: + snapshot = [_gi(app_id=1).to_snapshot(), _gi(app_id=2).to_snapshot()] + raw = { + aid: { + "hours": -1, + "polls": 5, + "count_comp": 20, + "rush_hours": 10.0, + "leisure_100h": 25.0, + "hltb_game_id": 0, + } + for aid in (1, 2) + } + proton = {str(a): {"tier": "gold", "trending_tier": "gold"} for a in (1, 2)} + with ( + patch(f"{_PKG}.load_snapshot", return_value=snapshot), + patch(f"{_PKG}._read_raw_cache", return_value=raw), + patch(f"{_PKG}._load_cache", return_value=proton), + ): + ds = build_web_dataset(State(current_app_id=1)) + assert [g.app_id for g in ds.games] == [2] + + def test_parity_mini_oracle(self) -> None: + """A small hand-checked dataset reproduces qualifying + totals.""" + # g1 qualifies; g2 fails confidence; g3 is complete (excluded). + snapshot = [ + _gi(app_id=1, completionist_hours=0.0).to_snapshot(), + _gi(app_id=2, completionist_hours=0.0).to_snapshot(), + _gi(app_id=3, total_achievements=5, unlocked_achievements=5).to_snapshot(), + ] + raw = { + 1: { + "hours": -1, + "polls": 5, + "count_comp": 20, + "rush_hours": 10.0, + "leisure_100h": 25.0, + "hltb_game_id": 0, + }, + 2: { + "hours": -1, + "polls": 5, + "count_comp": 0, # fails count_comp threshold + "rush_hours": 10.0, + "leisure_100h": 25.0, + "hltb_game_id": 0, + }, + } + proton = {"1": {"tier": "gold", "trending_tier": "gold"}} + with ( + patch(f"{_PKG}.load_snapshot", return_value=snapshot), + patch(f"{_PKG}._read_raw_cache", return_value=raw), + patch(f"{_PKG}._load_cache", return_value=proton), + ): + ds = build_web_dataset(State()) + assert ds.state.games_done == 1 # g3 complete + assert len(ds.games) == 2 # g1 + g2 candidates, g3 excluded + assert ds.default_summary.qualifying == 1 # only g1 + assert ds.default_summary.rush_total == 10.0 + assert ds.default_summary.leisure_total == 25.0 + assert ds.default_summary.worst_total == 25.0 + + +class TestDatasetToPayload: + """Tests for dataset_to_payload.""" + + def test_serializes_to_dict(self) -> None: + with ( + patch(f"{_PKG}.load_snapshot", return_value=None), + patch(f"{_PKG}._read_raw_cache", return_value={}), + patch(f"{_PKG}._load_cache", return_value={}), + ): + payload = dataset_to_payload(build_web_dataset(State())) + assert set(payload) == { + "games", + "state", + "defaults", + "default_summary", + "generated_at", + } + assert isinstance(payload["games"], list) + assert isinstance(payload["state"], dict) diff --git a/steam_backlog_enforcer/tests/test_web_server.py b/steam_backlog_enforcer/tests/test_web_server.py new file mode 100644 index 0000000..fa02e60 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_web_server.py @@ -0,0 +1,181 @@ +"""Tests for _web_server module — 100% branch coverage.""" + +from __future__ import annotations + +from contextlib import contextmanager +from http.client import HTTPConnection +import json +import threading +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +from steam_backlog_enforcer import main as main_mod +from steam_backlog_enforcer._web_server import create_server, serve +from steam_backlog_enforcer.config import Config, State + +if TYPE_CHECKING: + from collections.abc import Iterator + from pathlib import Path + +_PKG = "steam_backlog_enforcer._web_server" +_DATA_PKG = "steam_backlog_enforcer._web_dataset" + + +@contextmanager +def _running() -> Iterator[int]: + """Start the server on an ephemeral port in a thread; yield the 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).""" + 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, + files: dict[str, bytes] | None = None, +) -> Path: + """Create a fake built-frontend directory.""" + dist = (tmp_path / "dist").resolve() + dist.mkdir() + if with_index: + (dist / "index.html").write_text("INDEX", encoding="utf-8") + for name, content in (files or {}).items(): + (dist / name).write_bytes(content) + return dist + + +class TestDatasetEndpoint: + """Tests for the /api/dataset route.""" + + def test_dataset_ok(self) -> None: + with ( + patch(f"{_DATA_PKG}.load_snapshot", return_value=None), + patch(f"{_DATA_PKG}._read_raw_cache", return_value={}), + patch(f"{_DATA_PKG}._load_cache", return_value={}), + _running() as port, + ): + status, body, ctype = _get(port, "/api/dataset") + assert status == 200 + assert "application/json" in ctype + assert "charset=utf-8" in ctype + assert "games" in json.loads(body) + + def test_dataset_error_returns_500(self) -> None: + with ( + patch(f"{_PKG}.build_web_dataset", side_effect=OSError("boom")), + _running() as port, + ): + status, body, _ = _get(port, "/api/dataset") + assert status == 500 + assert b"dataset error" in body + + +class TestStaticServing: + """Tests for static-file serving + SPA fallback + traversal guard.""" + + def test_serves_index(self, tmp_path: Path) -> None: + dist = _make_dist(tmp_path) + with patch(f"{_PKG}.WEB_DIST", dist), _running() as port: + status, body, ctype = _get(port, "/") + assert status == 200 + assert b"INDEX" in body + assert "text/html" in ctype + + def test_serves_js_with_charset(self, tmp_path: Path) -> None: + dist = _make_dist(tmp_path, files={"app.js": b"console.log(1)"}) + with patch(f"{_PKG}.WEB_DIST", dist), _running() as port: + status, _, ctype = _get(port, "/app.js") + assert status == 200 + assert "charset=utf-8" in ctype + + def test_serves_binary_without_charset(self, tmp_path: Path) -> None: + dist = _make_dist(tmp_path, files={"pic.png": b"\x89PNG\r\n"}) + with patch(f"{_PKG}.WEB_DIST", dist), _running() as port: + status, _, ctype = _get(port, "/pic.png") + assert status == 200 + assert "image/png" in ctype + assert "charset" not in ctype + + def test_spa_fallback_to_index(self, tmp_path: Path) -> None: + dist = _make_dist(tmp_path) + with patch(f"{_PKG}.WEB_DIST", dist), _running() as port: + status, body, _ = _get(port, "/some/spa/route") + assert status == 200 + assert b"INDEX" in body + + def test_path_traversal_blocked(self, tmp_path: Path) -> None: + dist = _make_dist(tmp_path) + with patch(f"{_PKG}.WEB_DIST", dist), _running() as port: + status, body, _ = _get(port, "/../../../../../../etc/passwd") + assert status == 200 + assert b"INDEX" in body # fell back to index, did not serve the secret + assert b"root:" not in body + + def test_not_built_returns_404(self, tmp_path: Path) -> None: + 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"not built" in body.lower() + + +class TestCreateServer: + """Tests for create_server.""" + + def test_binds_localhost(self) -> None: + server = create_server("127.0.0.1", 0) + try: + assert server.server_address[0] == "127.0.0.1" + finally: + server.server_close() + + +class TestServe: + """Tests for the blocking serve() entry point.""" + + def test_keyboard_interrupt_shuts_down(self) -> None: + fake = MagicMock() + fake.serve_forever.side_effect = KeyboardInterrupt + with ( + patch(f"{_PKG}.create_server", return_value=fake), + patch(f"{_PKG}._echo"), + ): + serve() + fake.serve_forever.assert_called_once() + fake.server_close.assert_called_once() + + def test_normal_return_closes_server(self) -> None: + fake = MagicMock() + with ( + patch(f"{_PKG}.create_server", return_value=fake), + patch(f"{_PKG}._echo"), + ): + serve() + fake.server_close.assert_called_once() + + +class TestCmdServe: + """Tests for the main.cmd_serve wiring.""" + + def test_invokes_serve(self) -> None: + with patch.object(main_mod, "serve") as mock_serve: + main_mod.cmd_serve(Config(), State()) + mock_serve.assert_called_once() diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..7dbf7eb --- /dev/null +++ b/web/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/web/eslint.config.js b/web/eslint.config.js new file mode 100644 index 0000000..cd66985 --- /dev/null +++ b/web/eslint.config.js @@ -0,0 +1,22 @@ +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' + +export default defineConfig([ + globalIgnores(['dist', 'coverage']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + }, + }, +]) diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..84b2cb2 --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + Steam Backlog Enforcer — Completion Planner + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..951fcfa --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,4051 @@ +{ + "name": "web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web", + "version": "0.0.0", + "dependencies": { + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.12.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.7", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "jsdom": "^29.1.1", + "typescript": "~6.0.2", + "typescript-eslint": "^8.59.2", + "vite": "^8.0.12", + "vitest": "^4.1.7" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "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", + "peer": true, + "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.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@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.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "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.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "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.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "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.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "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.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", + "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", + "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.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "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", + "peer": true, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz", + "integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==", + "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", + "peer": true, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "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.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "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.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "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.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "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/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "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.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "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.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "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.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "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.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "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.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/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.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "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", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "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.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "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": "24.12.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", + "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz", + "integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/type-utils": "8.60.0", + "@typescript-eslint/utils": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.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.60.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.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz", + "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.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.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz", + "integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.60.0", + "@typescript-eslint/types": "^8.60.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.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz", + "integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.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.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz", + "integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==", + "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.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz", + "integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.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.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz", + "integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==", + "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.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz", + "integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.60.0", + "@typescript-eslint/tsconfig-utils": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.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.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz", + "integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.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.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz", + "integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.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.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.0" + }, + "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", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", + "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.7", + "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.7", + "vitest": "4.1.7" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.7", + "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.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.7", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "peer": true, + "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.2", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.2.tgz", + "integrity": "sha512-dKmJxJsGItLmc5CYZKuEjuG6GnBs6PG4gohMhyFOWKaNQoYCuRZJDECaBlHmcG0lv2wc2E0uU8lESmBEumC3DQ==", + "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.10.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", + "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", + "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.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "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", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "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/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.364", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz", + "integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==", + "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.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "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.4.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz", + "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", + "@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.4", + "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.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "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.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "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.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "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.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "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": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "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.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/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.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "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.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "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.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "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.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "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.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "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.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "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.12", + "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.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "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.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.132.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" + } + }, + "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.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "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.2.3", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.3.tgz", + "integrity": "sha512-g62dB+w1/OEFnPvmX0yd/HnetYITOL+1nJW7kitOycOeAvmbWC/nu0fwmmQ/kupNojqExzyC/T++pST/jRJ2mQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "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.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.0.tgz", + "integrity": "sha512-yHBe+zVfzNZ3QfTPW/Z6KK1G2t340gFjMHqI/4KKSt/abzYydzuCnpqdaF5gCCABby+9Yfbj59oR5F2Fd5CBzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.0" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.1.tgz", + "integrity": "sha512-sc2nGvGbixlJRHwTh/qQdPXTxJU1UDJboGPQm4d/01YUJ9r/u6aeIulQvEaxUlvKDN7hb1qCLjax+jhVAPLa/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "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/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "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", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.0.tgz", + "integrity": "sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.60.0", + "@typescript-eslint/parser": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.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": "7.26.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.26.0.tgz", + "integrity": "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "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.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.2", + "tinyglobby": "^0.2.16" + }, + "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.1.18", + "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.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "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.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", + "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": "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/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.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "peer": true, + "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..3bc5563 --- /dev/null +++ b/web/package.json @@ -0,0 +1,40 @@ +{ + "name": "web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "coverage": "vitest run --coverage" + }, + "dependencies": { + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.12.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.7", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "jsdom": "^29.1.1", + "typescript": "~6.0.2", + "typescript-eslint": "^8.59.2", + "vite": "^8.0.12", + "vitest": "^4.1.7" + } +} diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..0906f9c --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx new file mode 100644 index 0000000..d6ea477 --- /dev/null +++ b/web/src/App.test.tsx @@ -0,0 +1,96 @@ +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import App from './App' +import { makeDataset, makeGame, makeState } from './test/factories' + +describe('App', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('renders the planner after data loads', async () => { + const ds = makeDataset([makeGame({ app_id: 1, name: 'Alpha' })]) + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: true, json: async () => ds }), + ) + render() + expect(screen.getByText(/Loading your backlog/i)).toBeInTheDocument() + await waitFor(() => + expect( + screen.getByRole('heading', { name: 'Backlog Completion Planner' }), + ).toBeInTheDocument(), + ) + expect(screen.getByText(/CLI default qualifies/i)).toBeInTheDocument() + }) + + it('shows an error when the API call fails', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: false, status: 500, statusText: 'Server Error' }), + ) + render() + await waitFor(() => + expect(screen.getByText(/Could not load data/i)).toBeInTheDocument(), + ) + }) + + it('handles a non-Error rejection', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue('network down')) + render() + await waitFor(() => + expect(screen.getByText(/network down/i)).toBeInTheDocument(), + ) + }) + + it('recomputes scope when basis changes and a game is excluded', async () => { + const ds = makeDataset( + [makeGame({ app_id: 1, name: 'Alpha' }), makeGame({ app_id: 2, name: 'Beta' })], + { + state: makeState({ + current_game_name: 'Hollow Knight', + enforcement_started_at: '2026-03-04T00:00:00+00:00', + pace_games_per_day: 0.9, + }), + }, + ) + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: true, json: async () => ds }), + ) + const user = userEvent.setup() + render() + await waitFor(() => + expect( + screen.getByRole('heading', { name: 'Backlog Completion Planner' }), + ).toBeInTheDocument(), + ) + // The current game appears in the header (covers the conditional branch). + expect(screen.getByText(/Hollow Knight/)).toBeInTheDocument() + expect(document.querySelector('.big')?.textContent).toBe('2') + + // Switching basis promotes the Rush card to active. + await user.click(screen.getByRole('button', { name: 'Rush' })) + expect(document.querySelector('.card.active .card-title')?.textContent).toBe('Rush') + + // Excluding a game drops the in-scope count. + await user.click(within(screen.getByRole('table')).getAllByRole('checkbox')[0]) + expect(document.querySelector('.big')?.textContent).toBe('1') + + // Re-including it restores the count (covers the toggle-off branch). + await user.click(within(screen.getByRole('table')).getAllByRole('checkbox')[0]) + expect(document.querySelector('.big')?.textContent).toBe('2') + + // Searching narrows the table (covers the search handler). + await user.type(screen.getByPlaceholderText(/Search games/i), 'Alpha') + expect(within(screen.getByRole('table')).queryByText('Beta')).toBeNull() + + // Reset restores the full scope (covers the reset handler). + await user.click(screen.getByRole('button', { name: /Reset to CLI defaults/i })) + expect(document.querySelector('.big')?.textContent).toBe('2') + }) +}) diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..49bfe87 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,129 @@ +import { useEffect, useMemo, useState } from 'react' +import { fetchDataset } from './api' +import { FilterPanel } from './components/FilterPanel' +import { GameTable } from './components/GameTable' +import { SummaryCards } from './components/SummaryCards' +import { TimelineChart } from './components/TimelineChart' +import { applyFilters } from './estimate' +import type { Filters, WebDataset, WebDefaults } from './types' + +function defaultFilters(d: WebDefaults): Filters { + return { + minCountComp: d.min_count_comp, + minComp100: d.min_comp_100_polls, + minConfidenceSum: d.min_confidence_sum, + protonMode: 'playable', + protonMinTier: d.min_playable_tier, + protonTreatMissingAsPass: true, + dailyHours: 4, + basis: 'leisure', + maxHoursPerGame: 0, + playtimeMode: 'all', + includeNoData: false, + fallbackHours: 20, + excluded: new Set(), + search: '', + targetDate: '', + } +} + +function App() { + const [dataset, setDataset] = useState(null) + const [filters, setFilters] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + fetchDataset() + .then((d) => { + setDataset(d) + setFilters(defaultFilters(d.defaults)) + }) + .catch((e: unknown) => setError(e instanceof Error ? e.message : String(e))) + }, []) + + const result = useMemo( + () => (dataset && filters ? applyFilters(dataset, filters) : null), + [dataset, filters], + ) + + if (error) { + return ( +
+

Steam Backlog Enforcer

+

Could not load data: {error}

+

+ Is the backend running? Start it with ./run.sh serve. +

+
+ ) + } + + if (!dataset || !filters || !result) { + return ( +
+

Steam Backlog Enforcer

+

Loading your backlog…

+
+ ) + } + + const update = (patch: Partial) => setFilters({ ...filters, ...patch }) + + const toggleExclude = (appId: number) => { + const next = new Set(filters.excluded) + if (next.has(appId)) next.delete(appId) + else next.add(appId) + setFilters({ ...filters, excluded: next }) + } + + const tableRows = result.rows.filter((r) => r.passesFilters) + + return ( +
+
+
+

Backlog Completion Planner

+

+ {dataset.state.current_game_name && ( + <> + Currently playing {dataset.state.current_game_name}{' '} + ·{' '} + + )} + {dataset.state.games_done} games finished since{' '} + {dataset.state.enforcement_started_at.slice(0, 10) || '—'} ·{' '} + {dataset.games.length} candidates +

+
+
+ +
+ setFilters(defaultFilters(dataset.defaults))} + /> + +
+ + + update({ search: s })} + onToggleExclude={toggleExclude} + /> +
+
+
+ ) +} + +export default App diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000..fadc44b --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,10 @@ +import type { WebDataset } from './types' + +/** Fetch the projected dataset from the Python backend. */ +export async function fetchDataset(): Promise { + const resp = await fetch('/api/dataset') + if (!resp.ok) { + throw new Error(`API returned ${resp.status} ${resp.statusText}`) + } + return (await resp.json()) as WebDataset +} diff --git a/web/src/components/FilterPanel.test.tsx b/web/src/components/FilterPanel.test.tsx new file mode 100644 index 0000000..19100b6 --- /dev/null +++ b/web/src/components/FilterPanel.test.tsx @@ -0,0 +1,122 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import type { Filters } from '../types' +import { makeDataset, makeFilters } from '../test/factories' +import { FilterPanel } from './FilterPanel' + +function setup(over: Partial = {}) { + const update = vi.fn() + const onReset = vi.fn() + render( + , + ) + return { update, onReset, user: userEvent.setup() } +} + +describe('FilterPanel', () => { + it('renders the Filters heading', () => { + setup() + expect(screen.getByRole('heading', { name: 'Filters' })).toBeInTheDocument() + }) + + it('switches the estimate basis when a segment is clicked', async () => { + const { update, user } = setup() + await user.click(screen.getByRole('button', { name: 'Rush' })) + expect(update).toHaveBeenCalledWith({ basis: 'rush' }) + }) + + it('switches ProtonDB to min-tier mode and reveals the tier select', async () => { + const { update, user } = setup() + await user.click(screen.getByRole('button', { name: 'Min tier' })) + expect(update).toHaveBeenCalledWith({ protonMode: 'minTier' }) + }) + + it('shows the tier dropdown when already in min-tier mode', () => { + setup({ protonMode: 'minTier' }) + expect(screen.getByRole('combobox')).toBeInTheDocument() + }) + + it('switches ProtonDB back to playable mode', async () => { + const { update, user } = setup({ protonMode: 'minTier' }) + await user.click(screen.getByRole('button', { name: /Playable \(CLI rule\)/i })) + expect(update).toHaveBeenCalledWith({ protonMode: 'playable' }) + }) + + it('toggles the include-no-data option', async () => { + const { update, user } = setup() + await user.click(screen.getByLabelText(/Include games with no HLTB data/i)) + expect(update).toHaveBeenCalledWith({ includeNoData: true }) + }) + + it('reveals the fallback slider when no-data games are included', () => { + setup({ includeNoData: true }) + expect(screen.getByText(/Fallback estimate/i)).toBeInTheDocument() + }) + + it('calls onReset when the reset button is clicked', async () => { + const { onReset, user } = setup() + await user.click(screen.getByRole('button', { name: /Reset to CLI defaults/i })) + expect(onReset).toHaveBeenCalledTimes(1) + }) + + it('updates daily hours and min completions via sliders', () => { + const { update } = setup() + const sliders = screen.getAllByRole('slider') + fireEvent.change(sliders[0], { target: { value: '8' } }) + expect(update).toHaveBeenCalledWith({ dailyHours: 8 }) + fireEvent.change(sliders[1], { target: { value: '40' } }) + expect(update).toHaveBeenCalledWith({ minCountComp: 40 }) + }) + + it('updates advanced confidence and the max-hours cap', () => { + const { update } = setup() + const sliders = screen.getAllByRole('slider') + fireEvent.change(sliders[2], { target: { value: '10' } }) + expect(update).toHaveBeenCalledWith({ minComp100: 10 }) + fireEvent.change(sliders[3], { target: { value: '25' } }) + expect(update).toHaveBeenCalledWith({ minConfidenceSum: 25 }) + fireEvent.change(sliders[4], { target: { value: '50' } }) + expect(update).toHaveBeenCalledWith({ maxHoursPerGame: 50 }) + }) + + it('switches the playtime mode', async () => { + const { update, user } = setup() + await user.click(screen.getByRole('button', { name: 'Started' })) + expect(update).toHaveBeenCalledWith({ playtimeMode: 'started' }) + }) + + it('updates and clears the target date', async () => { + const { update, user } = setup({ targetDate: '2030-01-01' }) + const date = document.querySelector('input[type=date]') + fireEvent.change(date as Element, { target: { value: '2031-02-03' } }) + expect(update).toHaveBeenCalledWith({ targetDate: '2031-02-03' }) + await user.click(screen.getByRole('button', { name: 'Clear' })) + expect(update).toHaveBeenCalledWith({ targetDate: '' }) + }) + + it('updates the min tier and treat-missing toggle in min-tier mode', async () => { + const { update, user } = setup({ protonMode: 'minTier' }) + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'silver' } }) + expect(update).toHaveBeenCalledWith({ protonMinTier: 'silver' }) + await user.click(screen.getByLabelText(/Keep games with no ProtonDB data/i)) + expect(update).toHaveBeenCalledWith({ protonTreatMissingAsPass: false }) + }) + + it('updates the fallback estimate when no-data games are included', () => { + const { update } = setup({ includeNoData: true }) + const sliders = screen.getAllByRole('slider') + fireEvent.change(sliders[sliders.length - 1], { target: { value: '30' } }) + expect(update).toHaveBeenCalledWith({ fallbackHours: 30 }) + }) + + it('shows the hours-cap value when maxHoursPerGame is set', () => { + setup({ maxHoursPerGame: 50 }) + expect(screen.getByText('50 h')).toBeInTheDocument() + }) +}) diff --git a/web/src/components/FilterPanel.tsx b/web/src/components/FilterPanel.tsx new file mode 100644 index 0000000..db71748 --- /dev/null +++ b/web/src/components/FilterPanel.tsx @@ -0,0 +1,241 @@ +import { SELECTABLE_TIERS } from '../protondb' +import type { + EstimateBasis, + Filters, + PlaytimeMode, + WebDefaults, +} from '../types' + +interface Props { + filters: Filters + defaults: WebDefaults + update: (patch: Partial) => void + onReset: () => void +} + +const BASES: { id: EstimateBasis; label: string }[] = [ + { id: 'rush', label: 'Rush' }, + { id: 'leisure', label: 'Leisure' }, + { id: 'worst', label: 'Worst' }, + { id: 'pace', label: 'Pace' }, +] + +const PLAYTIME: { id: PlaytimeMode; label: string }[] = [ + { id: 'all', label: 'All' }, + { id: 'started', label: 'Started' }, + { id: 'untouched', label: 'Untouched' }, +] + +export function FilterPanel({ filters, defaults, update, onReset }: Props) { + return ( + + ) +} diff --git a/web/src/components/GameTable.test.tsx b/web/src/components/GameTable.test.tsx new file mode 100644 index 0000000..cc4b1b9 --- /dev/null +++ b/web/src/components/GameTable.test.tsx @@ -0,0 +1,170 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { applyFilters } from '../estimate' +import type { GameRow } from '../estimate' +import type { WebGame } from '../types' +import { makeDataset, makeFilters, makeGame } from '../test/factories' +import { GameTable } from './GameTable' + +function rowsFor(games: WebGame[]): GameRow[] { + return applyFilters(makeDataset(games), makeFilters()).rows.filter( + (r) => r.passesFilters, + ) +} + +describe('GameTable', () => { + it('renders the row count', () => { + const rows = rowsFor([makeGame({ app_id: 1 }), makeGame({ app_id: 2 })]) + render( + , + ) + expect(screen.getByText(/Games \(2\)/)).toBeInTheDocument() + }) + + it('filters rows by the search prop', () => { + const rows = rowsFor([ + makeGame({ app_id: 1, name: 'Alpha' }), + makeGame({ app_id: 2, name: 'Beta' }), + ]) + render( + , + ) + expect(screen.getByText('Alpha')).toBeInTheDocument() + expect(screen.queryByText('Beta')).not.toBeInTheDocument() + }) + + it('calls onSearch as the user types', async () => { + const onSearch = vi.fn() + const rows = rowsFor([makeGame({ app_id: 1, name: 'Alpha' })]) + render( + , + ) + await userEvent.setup().type(screen.getByPlaceholderText(/Search games/i), 'x') + expect(onSearch).toHaveBeenCalledWith('x') + }) + + it('toggles sort direction when a header is clicked', async () => { + const rows = rowsFor([makeGame({ app_id: 1, name: 'Alpha' })]) + render( + , + ) + const user = userEvent.setup() + const header = screen.getByRole('columnheader', { name: /Game/ }) + await user.click(header) + expect(header.textContent).toContain('▲') + await user.click(header) + expect(header.textContent).toContain('▼') + }) + + it('invokes onToggleExclude when a keep checkbox is clicked', async () => { + const onToggleExclude = vi.fn() + const rows = rowsFor([makeGame({ app_id: 42, name: 'Alpha' })]) + render( + , + ) + await userEvent.setup().click(screen.getByRole('checkbox')) + expect(onToggleExclude).toHaveBeenCalledWith(42) + }) + + it('links to a direct HLTB page when the id is known, else search', () => { + const rows = rowsFor([ + makeGame({ app_id: 1, name: 'Alpha', hltb_game_id: 555 }), + makeGame({ app_id: 2, name: 'Beta', hltb_game_id: 0 }), + ]) + render( + , + ) + expect(screen.getByRole('link', { name: 'Alpha' })).toHaveAttribute( + 'href', + 'https://howlongtobeat.com/game/555', + ) + expect(screen.getByRole('link', { name: 'Beta' })).toHaveAttribute( + 'href', + 'https://howlongtobeat.com/?q=Beta', + ) + }) + + it('sorts by every column without error', async () => { + const rows = rowsFor([ + makeGame({ app_id: 1, name: 'Alpha' }), + makeGame({ app_id: 2, name: 'Beta' }), + ]) + render( + , + ) + const user = userEvent.setup() + for (const name of [ + '%', + 'Played', + 'Rush', + 'Leisure', + 'Worst', + 'HLTB n', + 'ProtonDB', + 'Game', + ]) { + await user.click(screen.getByRole('columnheader', { name: new RegExp(name) })) + } + expect(screen.getByText('Alpha')).toBeInTheDocument() + }) + + it('shows a no-data badge for games without HLTB times', () => { + const rows = applyFilters( + makeDataset([ + makeGame({ + app_id: 1, + name: 'Alpha', + rush_hours: -1, + leisure_hours: -1, + worst_hours: -1, + }), + ]), + makeFilters({ includeNoData: true }), + ).rows.filter((r) => r.passesFilters) + render( + , + ) + expect(screen.getByText('no data')).toBeInTheDocument() + }) + + it('triggers the va > vb comparator branch when rows are reverse-sorted', async () => { + // Gamma > Alpha alphabetically; default sort is leisure, so clicking Game header + // causes the comparator to see va='gamma' > vb='alpha', covering cmp=1. + const rows = rowsFor([ + makeGame({ app_id: 1, name: 'Gamma' }), + makeGame({ app_id: 2, name: 'Alpha' }), + ]) + render( + , + ) + await userEvent.setup().click(screen.getByRole('columnheader', { name: /Game/ })) + // After ascending name sort Alpha comes first. + expect(screen.getAllByRole('row')[1].textContent).toContain('Alpha') + }) + + it('caps the table and notes the overflow', () => { + const games = Array.from({ length: 301 }, (_, i) => + makeGame({ app_id: i + 1, name: `Game ${i}` }), + ) + render( + , + ) + expect(screen.getByText(/Showing first 300 of 301/i)).toBeInTheDocument() + }) +}) diff --git a/web/src/components/GameTable.tsx b/web/src/components/GameTable.tsx new file mode 100644 index 0000000..0a375a6 --- /dev/null +++ b/web/src/components/GameTable.tsx @@ -0,0 +1,159 @@ +import { useMemo, useState } from 'react' +import type { GameRow } from '../estimate' +import { fmtHoursPrecise, fmtPlaytime } from '../format' +import { tierLabel } from '../protondb' + +interface Props { + rows: GameRow[] + search: string + onSearch: (value: string) => void + onToggleExclude: (appId: number) => void +} + +type SortKey = + | 'name' + | 'completion' + | 'playtime' + | 'rush' + | 'leisure' + | 'worst' + | 'count_comp' + | 'proton' + +const DISPLAY_CAP = 300 + +const COLUMNS: { key: SortKey; label: string; numeric: boolean }[] = [ + { key: 'name', label: 'Game', numeric: false }, + { key: 'completion', label: '%', numeric: true }, + { key: 'playtime', label: 'Played', numeric: true }, + { key: 'rush', label: 'Rush', numeric: true }, + { key: 'leisure', label: 'Leisure', numeric: true }, + { key: 'worst', label: 'Worst', numeric: true }, + { key: 'count_comp', label: 'HLTB n', numeric: true }, + { key: 'proton', label: 'ProtonDB', numeric: true }, +] + +function sortValue(row: GameRow, key: SortKey): number | string { + const g = row.game + switch (key) { + case 'name': + return g.name.toLowerCase() + case 'completion': + return g.completion_pct + case 'playtime': + return g.playtime_minutes + case 'rush': + return row.rush + case 'leisure': + return row.leisure + case 'worst': + return row.worst + case 'count_comp': + return g.count_comp + case 'proton': + return g.protondb_score + } +} + +function hltbUrl(game: GameRow['game']): string { + if (game.hltb_game_id > 0) { + return `https://howlongtobeat.com/game/${game.hltb_game_id}` + } + return `https://howlongtobeat.com/?q=${encodeURIComponent(game.name)}` +} + +export function GameTable({ rows, search, onSearch, onToggleExclude }: Props) { + const [sortKey, setSortKey] = useState('leisure') + const [asc, setAsc] = useState(true) + + const visible = useMemo(() => { + const q = search.trim().toLowerCase() + const filtered = q + ? rows.filter((r) => r.game.name.toLowerCase().includes(q)) + : rows + const sorted = [...filtered].sort((a, b) => { + const va = sortValue(a, sortKey) + const vb = sortValue(b, sortKey) + const cmp = va < vb ? -1 : va > vb ? 1 : 0 + return asc ? cmp : -cmp + }) + return sorted + }, [rows, search, sortKey, asc]) + + const onHeader = (key: SortKey) => { + if (key === sortKey) setAsc(!asc) + else { + setSortKey(key) + setAsc(key === 'name') + } + } + + const shown = visible.slice(0, DISPLAY_CAP) + + return ( +
+
+

Games ({visible.length})

+ onSearch(e.target.value)} + /> +
+
+ + + + {COLUMNS.map((c) => ( + + ))} + + + + + {shown.map((r) => ( + + + + + + + + + + + + ))} + +
onHeader(c.key)} + > + {c.label} + {sortKey === c.key ? (asc ? ' ▲' : ' ▼') : ''} + Keep
+ + {r.game.name} + + {r.noData && no data} + {r.game.completion_pct.toFixed(0)}{fmtPlaytime(r.game.playtime_minutes)}{fmtHoursPrecise(r.rush)}{fmtHoursPrecise(r.leisure)}{fmtHoursPrecise(r.worst)}{r.game.count_comp} + {tierLabel(r.game.protondb_tier, r.game.protondb_trending_tier)} + + onToggleExclude(r.game.app_id)} + aria-label={r.excluded ? 'Re-include' : 'Exclude'} + /> +
+
+ {visible.length > DISPLAY_CAP && ( +

+ Showing first {DISPLAY_CAP} of {visible.length}. Use search to narrow. +

+ )} +
+ ) +} diff --git a/web/src/components/SummaryCards.test.tsx b/web/src/components/SummaryCards.test.tsx new file mode 100644 index 0000000..b775f70 --- /dev/null +++ b/web/src/components/SummaryCards.test.tsx @@ -0,0 +1,47 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { applyFilters } from '../estimate' +import type { Filters } from '../types' +import { makeDataset, makeFilters, makeGame, makeState } from '../test/factories' +import { SummaryCards } from './SummaryCards' + +function renderCards(filtersOver: Partial = {}, statePace = 0) { + const filters = makeFilters(filtersOver) + const result = applyFilters(makeDataset([makeGame({ app_id: 1 })]), filters) + render( + , + ) + return result +} + +describe('SummaryCards', () => { + it('shows the in-scope count and a CLI-parity match badge', () => { + renderCards() + expect(screen.getByText(/games in scope/i)).toBeInTheDocument() + expect(screen.getByText(/match/i)).toBeInTheDocument() + }) + + it('notes a missing start date in the pace card', () => { + renderCards({}, 0) + expect(screen.getByText(/No start date set/i)).toBeInTheDocument() + }) + + it('renders the target-date banner with required hours/day', () => { + renderCards({ targetDate: '2099-01-01', basis: 'leisure' }) + const banner = document.querySelector('.target-banner') + expect(banner?.textContent).toMatch(/you need/i) + expect(banner?.textContent).toMatch(/h\/day/i) + }) + + it('renders the target banner in games/day for the pace basis', () => { + renderCards({ targetDate: '2099-01-01', basis: 'pace' }, 0.9) + const banner = document.querySelector('.target-banner') + expect(banner?.textContent).toMatch(/games\/day/i) + }) +}) diff --git a/web/src/components/SummaryCards.tsx b/web/src/components/SummaryCards.tsx new file mode 100644 index 0000000..841f489 --- /dev/null +++ b/web/src/components/SummaryCards.tsx @@ -0,0 +1,106 @@ +import { basisTotal, etaDays, paceDays } from '../estimate' +import type { EstimateResult } from '../estimate' +import { daysUntil, fmtEta, fmtHours } from '../format' +import type { EstimateBasis, Filters, WebStateInfo } from '../types' + +interface Props { + result: EstimateResult + filters: Filters + state: WebStateInfo + presets: number[] + defaultQualifying: number +} + +interface CardData { + basis: EstimateBasis + title: string + blurb: string +} + +const CARDS: CardData[] = [ + { basis: 'rush', title: 'Rush', blurb: 'Typical fast completionist' }, + { basis: 'leisure', title: 'Leisure', blurb: 'Slow, comfortable 100%' }, + { basis: 'worst', title: 'Worst case', blurb: 'Max recorded time' }, + { basis: 'pace', title: 'At your pace', blurb: 'Based on games finished' }, +] + +function TargetBanner({ result, filters }: Props) { + if (!filters.targetDate) return null + const days = Math.max(1, daysUntil(filters.targetDate)) + let need: string + if (filters.basis === 'pace') { + const perDay = result.remainingGames / days + need = `${perDay.toFixed(2)} games/day` + } else { + const total = basisTotal(result, filters.basis) as number + need = `${(total / days).toFixed(1)} h/day` + } + return ( +
+ To finish {result.remainingGames} games by{' '} + {filters.targetDate} ({days} days) on the{' '} + {filters.basis} model, you need {need}. +
+ ) +} + +export function SummaryCards(props: Props) { + const { result, filters, state, presets } = props + + return ( +
+
+
+ {result.remainingGames.toLocaleString()} + games in scope +
+
+ CLI default qualifies {props.defaultQualifying} + {result.remainingGames === props.defaultQualifying && ( + ✓ match + )} +
+
+ + + +
+ {CARDS.map((c) => { + const active = filters.basis === c.basis + const isPace = c.basis === 'pace' + const total = basisTotal(result, c.basis) + const headlineEta = isPace + ? paceDays(result.remainingGames, state.pace_games_per_day) + : etaDays(total as number, filters.dailyHours) + + return ( +
+
{c.title}
+
{c.blurb}
+
+ {isPace + ? `${state.pace_games_per_day || 0} games/day` + : fmtHours(total as number)} +
+
+ {isPace && !state.pace_games_per_day + ? 'No start date set' + : fmtEta(headlineEta)} +
+ {!isPace && ( +
+ {presets.map((h) => ( +
+ {h} h/day + {fmtEta(etaDays(total as number, h))} +
+ ))} +
+ )} +
+ ) + })} +
+
+ ) +} diff --git a/web/src/components/TimelineChart.test.tsx b/web/src/components/TimelineChart.test.tsx new file mode 100644 index 0000000..422dc8c --- /dev/null +++ b/web/src/components/TimelineChart.test.tsx @@ -0,0 +1,64 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { applyFilters } from '../estimate' +import type { EstimateBasis } from '../types' +import { makeDataset, makeFilters, makeGame, makeState } from '../test/factories' +import { TimelineChart } from './TimelineChart' + +function renderChart( + count: number, + basis: EstimateBasis = 'leisure', + pace = 0, +) { + const games = Array.from({ length: count }, (_, i) => + makeGame({ app_id: i + 1, name: `G${i}`, leisure_hours: 10 + i }), + ) + const filters = makeFilters({ basis }) + const result = applyFilters(makeDataset(games), filters) + return render( + , + ) +} + +describe('TimelineChart', () => { + it('shows a fallback message with fewer than two games', () => { + renderChart(1) + expect(screen.getByText(/Not enough games/i)).toBeInTheDocument() + }) + + it('draws an SVG line for the hours basis', () => { + const { container } = renderChart(3, 'leisure') + expect(container.querySelector('svg.chart-svg')).not.toBeNull() + expect(container.querySelector('path.line')).not.toBeNull() + }) + + it('draws an SVG line for the pace basis when pace is known', () => { + const { container } = renderChart(3, 'pace', 0.5) + expect(container.querySelector('svg.chart-svg')).not.toBeNull() + }) + + it('shows the fallback message for the pace basis with no pace', () => { + renderChart(3, 'pace', 0) + expect(screen.getByText(/Not enough games/i)).toBeInTheDocument() + }) + + it('renders when the last timeline point has day=0 (covers || 1 fallback)', () => { + // Game 1: leisure_hours=0 but rush>0 so not noData → lengthHours=-1. + // Game 2: leisure_hours=1 → lengthHours=1. + // Cumulative with dailyHours=1: [-1, 0]. Last day=0 triggers `|| 1`. + const games = [ + makeGame({ app_id: 1, name: 'G0', leisure_hours: 0 }), + makeGame({ app_id: 2, name: 'G1', leisure_hours: 1 }), + ] + const filters = makeFilters({ basis: 'leisure', dailyHours: 1 }) + const result = applyFilters(makeDataset(games), filters) + const { container } = render( + , + ) + expect(container.querySelector('svg.chart-svg')).not.toBeNull() + }) +}) diff --git a/web/src/components/TimelineChart.tsx b/web/src/components/TimelineChart.tsx new file mode 100644 index 0000000..cc9ed4f --- /dev/null +++ b/web/src/components/TimelineChart.tsx @@ -0,0 +1,102 @@ +import type { EstimateResult } from '../estimate' +import { isoDate } from '../format' +import type { Filters, WebStateInfo } from '../types' + +interface Props { + result: EstimateResult + filters: Filters + state: WebStateInfo +} + +const W = 820 +const H = 320 +const PAD = { top: 16, right: 20, bottom: 36, left: 48 } + +interface Point { + day: number + games: number +} + +/** Build the cumulative "games finished by day N" curve for the basis. */ +function buildPoints(props: Props): Point[] { + const { result, filters, state } = props + const rows = [...result.included].sort((a, b) => a.lengthHours - b.lengthHours) + const pts: Point[] = [] + if (filters.basis === 'pace') { + const pace = state.pace_games_per_day + if (pace <= 0) return [] + rows.forEach((_, i) => pts.push({ day: (i + 1) / pace, games: i + 1 })) + return pts + } + let cum = 0 + rows.forEach((r, i) => { + cum += r.lengthHours + pts.push({ day: cum / filters.dailyHours, games: i + 1 }) + }) + return pts +} + +function dayToDate(day: number): string { + const d = new Date() + d.setDate(d.getDate() + Math.round(day)) + return isoDate(d) +} + +export function TimelineChart(props: Props) { + const pts = buildPoints(props) + if (pts.length < 2) { + return ( +
+

Completion timeline

+

Not enough games in scope to draw a timeline.

+
+ ) + } + + const maxDay = pts[pts.length - 1].day || 1 + const maxGames = pts.length + const plotW = W - PAD.left - PAD.right + const plotH = H - PAD.top - PAD.bottom + const sx = (day: number) => PAD.left + (day / maxDay) * plotW + const sy = (games: number) => PAD.top + plotH - (games / maxGames) * plotH + + const path = pts + .map((p, i) => `${i === 0 ? 'M' : 'L'}${sx(p.day).toFixed(1)} ${sy(p.games).toFixed(1)}`) + .join(' ') + const area = `${path} L${sx(maxDay).toFixed(1)} ${sy(0).toFixed(1)} L${sx(0).toFixed(1)} ${sy(0).toFixed(1)} Z` + + const xTicks = [0, 0.25, 0.5, 0.75, 1].map((f) => f * maxDay) + const yTicks = [0, 0.25, 0.5, 0.75, 1].map((f) => Math.round(f * maxGames)) + + return ( +
+

Completion timeline · {props.filters.basis}

+ + {yTicks.map((g) => ( + + + + {g} + + + ))} + {xTicks.map((d) => ( + + {dayToDate(d)} + + ))} + + + +

+ Cumulative games finished over time (shortest games first). +

+
+ ) +} diff --git a/web/src/estimate.test.ts b/web/src/estimate.test.ts new file mode 100644 index 0000000..46057af --- /dev/null +++ b/web/src/estimate.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from 'vitest' +import { applyFilters, basisTotal, etaDays, paceDays } from './estimate' +import { makeDataset, makeFilters, makeGame } from './test/factories' + +describe('applyFilters — totals and parity', () => { + it('sums each metric independently over qualifying games', () => { + const r = applyFilters( + makeDataset([ + makeGame({ app_id: 1, rush_hours: 10, leisure_hours: 25, worst_hours: 30 }), + makeGame({ app_id: 2, rush_hours: 5, leisure_hours: 8, worst_hours: 9 }), + ]), + makeFilters(), + ) + expect(r.remainingGames).toBe(2) + expect(r.rushTotal).toBe(15) + expect(r.leisureTotal).toBe(33) + expect(r.worstTotal).toBe(39) + }) + + it('omits a missing metric from its total (partial data)', () => { + const r = applyFilters( + makeDataset([makeGame({ rush_hours: -1, leisure_hours: 20, worst_hours: 25 })]), + makeFilters(), + ) + expect(r.rushTotal).toBe(0) + expect(r.leisureTotal).toBe(20) + }) + + it('skips non-positive leisure and worst when summing (rush basis)', () => { + const r = applyFilters( + makeDataset([makeGame({ rush_hours: 10, leisure_hours: -1, worst_hours: -1 })]), + makeFilters({ basis: 'rush' }), + ) + expect(r.remainingGames).toBe(1) + expect(r.rushTotal).toBe(10) + expect(r.leisureTotal).toBe(0) + expect(r.worstTotal).toBe(0) + }) +}) + +describe('applyFilters — threshold filters', () => { + it('excludes low-confidence games', () => { + const r = applyFilters(makeDataset([makeGame({ count_comp: 0 })]), makeFilters()) + expect(r.remainingGames).toBe(0) + }) + + it('rejects unplayable games in playable mode', () => { + const r = applyFilters( + makeDataset([ + makeGame({ protondb_tier: 'borked', protondb_trending_tier: 'borked' }), + ]), + makeFilters(), + ) + expect(r.remainingGames).toBe(0) + }) + + it('honours the min-tier ProtonDB mode', () => { + const g = makeGame({ protondb_tier: 'silver', protondb_trending_tier: 'silver' }) + const r = applyFilters( + makeDataset([g]), + makeFilters({ protonMode: 'minTier', protonMinTier: 'silver' }), + ) + expect(r.remainingGames).toBe(1) + }) + + it('filters by playtime (started vs untouched)', () => { + const games = [ + makeGame({ app_id: 1, playtime_minutes: 0 }), + makeGame({ app_id: 2, playtime_minutes: 30 }), + ] + expect( + applyFilters( + makeDataset(games), + makeFilters({ playtimeMode: 'started' }), + ).included.map((r) => r.game.app_id), + ).toEqual([2]) + expect( + applyFilters( + makeDataset(games), + makeFilters({ playtimeMode: 'untouched' }), + ).included.map((r) => r.game.app_id), + ).toEqual([1]) + }) + + it('applies the max time-per-game cap', () => { + const r = applyFilters( + makeDataset([ + makeGame({ app_id: 1, leisure_hours: 10 }), + makeGame({ app_id: 2, leisure_hours: 80 }), + ]), + makeFilters({ maxHoursPerGame: 50 }), + ) + expect(r.included.map((x) => x.game.app_id)).toEqual([1]) + }) +}) + +describe('applyFilters — no-data handling', () => { + const noData = makeGame({ rush_hours: -1, leisure_hours: -1, worst_hours: -1 }) + + it('excludes no-data games by default', () => { + expect(applyFilters(makeDataset([noData]), makeFilters()).remainingGames).toBe(0) + }) + + it('includes no-data games with the fallback applied to every metric', () => { + const r = applyFilters( + makeDataset([noData]), + makeFilters({ includeNoData: true, fallbackHours: 12 }), + ) + expect(r.remainingGames).toBe(1) + expect(r.rushTotal).toBe(12) + expect(r.leisureTotal).toBe(12) + expect(r.worstTotal).toBe(12) + }) +}) + +describe('applyFilters — exclusions', () => { + it('keeps passesFilters true but drops a manually excluded game from totals', () => { + const r = applyFilters( + makeDataset([makeGame({ app_id: 1 })]), + makeFilters({ excluded: new Set([1]) }), + ) + expect(r.remainingGames).toBe(0) + expect(r.rows[0].passesFilters).toBe(true) + expect(r.rows[0].excluded).toBe(true) + expect(r.rows[0].included).toBe(false) + }) +}) + +describe('basis length proxy', () => { + it('uses leisure as the length proxy for the pace basis', () => { + const r = applyFilters( + makeDataset([makeGame({ leisure_hours: 30 })]), + makeFilters({ basis: 'pace' }), + ) + expect(r.included[0].lengthHours).toBe(30) + }) + + it('uses rush hours as length for the rush basis', () => { + const r = applyFilters( + makeDataset([makeGame({ rush_hours: 7 })]), + makeFilters({ basis: 'rush' }), + ) + expect(r.included[0].lengthHours).toBe(7) + }) + + it('uses worst hours as length for the worst basis', () => { + const r = applyFilters( + makeDataset([makeGame({ worst_hours: 99 })]), + makeFilters({ basis: 'worst', maxHoursPerGame: 0 }), + ) + expect(r.included[0].lengthHours).toBe(99) + }) +}) + +describe('etaDays / paceDays / basisTotal', () => { + it('etaDays floors and guards zero inputs', () => { + expect(etaDays(40, 4)).toBe(10) + expect(etaDays(0, 4)).toBeNull() + expect(etaDays(40, 0)).toBeNull() + }) + + it('paceDays floors and guards zero inputs', () => { + expect(paceDays(10, 0.5)).toBe(20) + expect(paceDays(0, 1)).toBeNull() + expect(paceDays(10, 0)).toBeNull() + }) + + it('basisTotal returns the right total or null for pace', () => { + const r = applyFilters(makeDataset([makeGame()]), makeFilters()) + expect(basisTotal(r, 'rush')).toBe(r.rushTotal) + expect(basisTotal(r, 'leisure')).toBe(r.leisureTotal) + expect(basisTotal(r, 'worst')).toBe(r.worstTotal) + expect(basisTotal(r, 'pace')).toBeNull() + }) +}) diff --git a/web/src/estimate.ts b/web/src/estimate.ts new file mode 100644 index 0000000..c752e57 --- /dev/null +++ b/web/src/estimate.ts @@ -0,0 +1,160 @@ +// Pure filtering + completion-time estimation. Mirrors the logic in +// steam_backlog_enforcer/_stats.py and _web_dataset.py so that, at the CLI +// default thresholds, the totals reproduce the `stats` command exactly. + +import { isPlayable, passesMinTier } from './protondb' +import type { EstimateBasis, Filters, WebDataset, WebGame } from './types' + +export interface GameRow { + game: WebGame + // Effective hours per model (fallback applied for no-data games when the + // "include no-data" toggle is on; otherwise -1 when missing). + rush: number + leisure: number + worst: number + // Hours under the selected basis — used for the cap, table, and chart. + lengthHours: number + noData: boolean + passesFilters: boolean // all threshold/extra filters (not manual exclusion) + excluded: boolean // manually excluded by the user + included: boolean // counted in totals +} + +export interface EstimateResult { + rows: GameRow[] + included: GameRow[] + rushTotal: number + leisureTotal: number + worstTotal: number + remainingGames: number +} + +/** True when HLTB has no length of any kind for this game. */ +function isNoData(g: WebGame): boolean { + return g.rush_hours <= 0 && g.leisure_hours <= 0 && g.worst_hours <= 0 +} + +/** Raw per-game hours for the selected basis (pace uses leisure as a proxy). */ +function rawBasisHours(g: WebGame, basis: EstimateBasis): number { + switch (basis) { + case 'rush': + return g.rush_hours + case 'worst': + return g.worst_hours + case 'leisure': + case 'pace': + return g.leisure_hours + } +} + +/** Apply the no-data fallback: keep positive values, substitute fallback when + * the whole game is missing and the user opted to include such games. */ +function effective(raw: number, noData: boolean, f: Filters): number { + if (raw > 0) return raw + if (noData && f.includeNoData) return f.fallbackHours + return -1 +} + +function passesConfidence(g: WebGame, f: Filters): boolean { + return ( + g.comp_100_count >= f.minComp100 && + g.count_comp >= f.minCountComp && + g.comp_100_count + g.count_comp >= f.minConfidenceSum + ) +} + +function passesProton(g: WebGame, f: Filters): boolean { + if (f.protonMode === 'playable') { + return isPlayable(g.protondb_tier, g.protondb_trending_tier) + } + return passesMinTier( + g.protondb_tier, + g.protondb_trending_tier, + f.protonMinTier, + f.protonTreatMissingAsPass, + ) +} + +function passesPlaytime(g: WebGame, f: Filters): boolean { + if (f.playtimeMode === 'started') return g.playtime_minutes > 0 + if (f.playtimeMode === 'untouched') return g.playtime_minutes === 0 + return true +} + +function buildRow(g: WebGame, f: Filters): GameRow { + const noData = isNoData(g) + const rush = effective(g.rush_hours, noData, f) + const leisure = effective(g.leisure_hours, noData, f) + const worst = effective(g.worst_hours, noData, f) + const lengthHours = effective(rawBasisHours(g, f.basis), noData, f) + + // Threshold + extra filters, evaluated independently of manual exclusion. + let passes = passesConfidence(g, f) && passesProton(g, f) && passesPlaytime(g, f) + if (passes && !f.includeNoData && noData) passes = false + if (passes && f.maxHoursPerGame > 0 && lengthHours > f.maxHoursPerGame) { + passes = false + } + + const excluded = f.excluded.has(g.app_id) + return { + game: g, + rush, + leisure, + worst, + lengthHours, + noData, + passesFilters: passes, + excluded, + included: passes && !excluded, + } +} + +/** Run all filters and compute the qualifying totals. */ +export function applyFilters( + dataset: WebDataset, + filters: Filters, +): EstimateResult { + const rows = dataset.games.map((g) => buildRow(g, filters)) + const included = rows.filter((r) => r.included) + + let rushTotal = 0 + let leisureTotal = 0 + let worstTotal = 0 + for (const r of included) { + if (r.rush > 0) rushTotal += r.rush + if (r.leisure > 0) leisureTotal += r.leisure + if (r.worst > 0) worstTotal += r.worst + } + + return { + rows, + included, + rushTotal: Math.round(rushTotal * 10) / 10, + leisureTotal: Math.round(leisureTotal * 10) / 10, + worstTotal: Math.round(worstTotal * 10) / 10, + remainingGames: included.length, + } +} + +/** Days to finish `hours` at `daily` hours/day (floor — matches the CLI). */ +export function etaDays(hours: number, daily: number): number | null { + if (hours <= 0 || daily <= 0) return null + return Math.floor(hours / daily) +} + +/** Days to finish `remaining` games at `pace` games/day (floor). */ +export function paceDays(remaining: number, pace: number): number | null { + if (remaining <= 0 || pace <= 0) return null + return Math.floor(remaining / pace) +} + +/** Total hours for the selected basis, or null for the pace (count) basis. */ +export function basisTotal( + result: EstimateResult, + basis: EstimateBasis, +): number | null { + if (basis === 'rush') return result.rushTotal + if (basis === 'leisure') return result.leisureTotal + if (basis === 'worst') return result.worstTotal + return null +} diff --git a/web/src/format.test.ts b/web/src/format.test.ts new file mode 100644 index 0000000..9c07f02 --- /dev/null +++ b/web/src/format.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + daysUntil, + fmtEta, + fmtHours, + fmtHoursPrecise, + fmtPlaytime, + isoDate, +} from './format' + +afterEach(() => { + vi.useRealTimers() +}) + +describe('fmtHours', () => { + it('returns a dash for non-positive values', () => { + expect(fmtHours(0)).toBe('—') + expect(fmtHours(-3)).toBe('—') + }) + it('rounds with thousands separators', () => { + expect(fmtHours(67031.1)).toBe('67,031 h') + }) +}) + +describe('fmtHoursPrecise', () => { + it('returns a dash for non-positive values', () => { + expect(fmtHoursPrecise(-1)).toBe('—') + }) + it('keeps one decimal place', () => { + expect(fmtHoursPrecise(44.28)).toBe('44.3') + }) +}) + +describe('fmtPlaytime', () => { + it('reports untouched at zero', () => { + expect(fmtPlaytime(0)).toBe('untouched') + }) + it('converts minutes to hours', () => { + expect(fmtPlaytime(90)).toBe('1.5 h') + }) +}) + +describe('fmtEta', () => { + it('returns N/A for null', () => { + expect(fmtEta(null)).toBe('N/A') + }) + it('returns days and the target date', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-05-29T12:00:00')) + const out = fmtEta(10) + expect(out).toContain('10 days') + expect(out).toContain('2026-06-08') + }) +}) + +describe('isoDate', () => { + it('formats a date as YYYY-MM-DD', () => { + expect(isoDate(new Date(2026, 0, 5))).toBe('2026-01-05') + }) +}) + +describe('daysUntil', () => { + it('counts whole days to a future date', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-05-29T12:00:00')) + expect(daysUntil('2026-06-08')).toBe(10) + }) +}) diff --git a/web/src/format.ts b/web/src/format.ts new file mode 100644 index 0000000..a91572e --- /dev/null +++ b/web/src/format.ts @@ -0,0 +1,44 @@ +// Small display-formatting helpers shared across components. + +/** Format an hour count with thousands separators, e.g. 67031.1 → "67,031 h". */ +export function fmtHours(hours: number): string { + if (hours <= 0) return '—' + return `${Math.round(hours).toLocaleString('en-US')} h` +} + +/** Format a possibly-missing per-game hour value, e.g. 44.3 → "44.3". */ +export function fmtHoursPrecise(hours: number): string { + if (hours <= 0) return '—' + return hours.toFixed(1) +} + +/** Format playtime minutes as hours, e.g. 320 → "5.3 h" (0 → "untouched"). */ +export function fmtPlaytime(minutes: number): string { + if (minutes <= 0) return 'untouched' + return `${(minutes / 60).toFixed(1)} h` +} + +/** Format a day count and its target date, e.g. (866) → "866 days · 2028-10-11". */ +export function fmtEta(days: number | null): string { + if (days === null) return 'N/A' + const target = new Date() + target.setDate(target.getDate() + days) + return `${days.toLocaleString('en-US')} days · ${isoDate(target)}` +} + +/** Render a Date as YYYY-MM-DD in local time. */ +export function isoDate(date: Date): string { + const y = date.getFullYear() + const m = String(date.getMonth() + 1).padStart(2, '0') + const d = String(date.getDate()).padStart(2, '0') + return `${y}-${m}-${d}` +} + +/** Whole days from today (local midnight) until the given ISO date string. */ +export function daysUntil(isoDateStr: string): number { + const target = new Date(`${isoDateStr}T00:00:00`) + const today = new Date() + today.setHours(0, 0, 0, 0) + const ms = target.getTime() - today.getTime() + return Math.ceil(ms / (1000 * 60 * 60 * 24)) +} diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..4cf2c79 --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,431 @@ +:root { + --bg: #0f1117; + --panel: #161922; + --panel-2: #1b2030; + --card: #1b2838; + --border: #2a2f3a; + --text: #c6d4df; + --muted: #8b95a1; + --heading: #ffffff; + --accent: #66c0f4; + --accent-2: #a3cf06; + --warn: #f0a23b; + --danger: #e35d5d; + --radius: 10px; + --sans: system-ui, 'Segoe UI', Roboto, sans-serif; + --mono: ui-monospace, 'SF Mono', Consolas, monospace; + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font: 15px/1.5 var(--sans); +} + +h1, +h2 { + color: var(--heading); + font-weight: 600; + margin: 0; +} +h1 { + font-size: 26px; + letter-spacing: -0.4px; +} +h2 { + font-size: 16px; + margin-bottom: 10px; +} +a { + color: var(--accent); + text-decoration: none; +} +a:hover { + text-decoration: underline; +} +code { + font-family: var(--mono); + background: var(--panel-2); + padding: 2px 6px; + border-radius: 4px; + font-size: 0.9em; +} + +.app { + max-width: 1400px; + margin: 0 auto; + padding: 24px; +} +.app-head { + margin-bottom: 20px; +} +.sub { + color: var(--muted); + margin-top: 6px; +} + +.status { + max-width: 640px; + margin: 12vh auto; + text-align: center; +} +.error { + color: var(--danger); +} +.hint { + color: var(--muted); + font-size: 13px; + margin: 6px 0 0; +} + +/* ── Layout ── */ +.layout { + display: grid; + grid-template-columns: 320px 1fr; + gap: 24px; + align-items: start; +} +@media (max-width: 980px) { + .layout { + grid-template-columns: 1fr; + } +} +.content { + display: flex; + flex-direction: column; + gap: 20px; + min-width: 0; +} + +/* ── Filter panel ── */ +.panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 18px; + position: sticky; + top: 16px; +} +.panel-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; +} +.field { + padding: 12px 0; + border-top: 1px solid var(--border); +} +.field > label { + display: block; + font-size: 13px; + font-weight: 500; + color: var(--heading); + margin-bottom: 8px; +} +.field .val { + float: right; + color: var(--accent); + font-family: var(--mono); + font-weight: 400; +} +.field input[type='range'] { + width: 100%; + accent-color: var(--accent); +} +.subfield { + margin-top: 10px; +} +.subfield.row { + display: flex; + gap: 8px; + align-items: center; +} +.check { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text); + cursor: pointer; +} +.check input { + accent-color: var(--accent); +} +select, +input[type='date'], +input[type='search'] { + width: 100%; + background: var(--panel-2); + border: 1px solid var(--border); + color: var(--text); + border-radius: 6px; + padding: 7px 9px; + font: inherit; +} +details summary { + cursor: pointer; + font-size: 13px; + font-weight: 500; + color: var(--heading); +} +details[open] summary { + margin-bottom: 8px; +} + +/* ── Buttons / segmented ── */ +.segmented { + display: flex; + gap: 4px; + background: var(--panel-2); + padding: 4px; + border-radius: 8px; +} +.seg { + flex: 1; + border: none; + background: transparent; + color: var(--muted); + padding: 6px 4px; + border-radius: 6px; + cursor: pointer; + font: inherit; + font-size: 12px; +} +.seg.active { + background: var(--accent); + color: #06121c; + font-weight: 600; +} +.ghost { + background: transparent; + border: 1px solid var(--border); + color: var(--muted); + padding: 5px 10px; + border-radius: 6px; + cursor: pointer; + font: inherit; + font-size: 12px; +} +.ghost:hover { + color: var(--heading); + border-color: var(--accent); +} + +/* ── Summary ── */ +.summary { + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 18px; +} +.summary-head { + display: flex; + align-items: baseline; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 14px; +} +.big { + font-size: 34px; + font-weight: 700; + color: var(--heading); +} +.big-label { + margin-left: 8px; + color: var(--muted); +} +.parity { + color: var(--muted); + font-size: 13px; +} +.parity .ok { + color: var(--accent-2); + font-weight: 600; +} +.target-banner { + background: var(--accent-bg, rgba(102, 192, 244, 0.12)); + border: 1px solid rgba(102, 192, 244, 0.4); + border-radius: 8px; + padding: 10px 12px; + margin-bottom: 14px; + font-size: 14px; +} +.cards { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; +} +@media (max-width: 720px) { + .cards { + grid-template-columns: repeat(2, 1fr); + } +} +.card { + background: var(--card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 14px; +} +.card.active { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); +} +.card-title { + font-weight: 600; + color: var(--heading); +} +.card-blurb { + font-size: 12px; + color: var(--muted); + margin-bottom: 10px; + min-height: 28px; +} +.card-total { + font-size: 22px; + font-weight: 700; + color: var(--accent); + font-family: var(--mono); +} +.card-eta { + font-size: 13px; + color: var(--text); + margin-top: 2px; +} +.presets { + margin-top: 10px; + border-top: 1px solid var(--border); + padding-top: 8px; +} +.preset { + display: flex; + justify-content: space-between; + font-size: 11px; + color: var(--muted); + padding: 1px 0; +} + +/* ── Chart ── */ +.chart { + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 18px; +} +.chart-svg { + width: 100%; + height: auto; + display: block; +} +.chart-svg .grid { + stroke: var(--border); + stroke-width: 1; +} +.chart-svg .line { + fill: none; + stroke: var(--accent); + stroke-width: 2.5; +} +.chart-svg .area { + fill: rgba(102, 192, 244, 0.12); + stroke: none; +} +.chart-svg .axis-label { + fill: var(--muted); + font-size: 11px; + font-family: var(--mono); +} +.chart-svg .axis-label.end { + text-anchor: end; +} +.chart-svg .axis-label.mid { + text-anchor: middle; +} + +/* ── Table ── */ +.table-wrap { + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 18px; +} +.table-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} +.table-head input { + max-width: 260px; +} +.table-scroll { + max-height: 540px; + overflow: auto; + border: 1px solid var(--border); + border-radius: 8px; +} +table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} +thead th { + position: sticky; + top: 0; + background: var(--panel-2); + color: var(--heading); + text-align: left; + padding: 9px 10px; + font-weight: 600; + white-space: nowrap; + z-index: 1; +} +th.num, +td.num { + text-align: right; +} +th.clickable { + cursor: pointer; + user-select: none; +} +tbody td { + padding: 7px 10px; + border-top: 1px solid var(--border); +} +tbody tr:hover { + background: var(--panel-2); +} +tr.excluded { + opacity: 0.4; +} +tr.excluded .name a { + text-decoration: line-through; +} +td.name { + max-width: 320px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +td.proton { + font-family: var(--mono); + font-size: 12px; + color: var(--muted); +} +.badge { + margin-left: 8px; + font-size: 10px; + background: var(--warn); + color: #1a1205; + padding: 1px 5px; + border-radius: 4px; + vertical-align: middle; +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/web/src/protondb.test.ts b/web/src/protondb.test.ts new file mode 100644 index 0000000..3ab7803 --- /dev/null +++ b/web/src/protondb.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { isPlayable, passesMinTier, tierLabel } from './protondb' + +describe('isPlayable (faithful CLI compound rule)', () => { + it('keeps games with no rating or pending', () => { + expect(isPlayable('', '')).toBe(true) + expect(isPlayable('pending', '')).toBe(true) + }) + + it('single rating must be gold-or-better', () => { + expect(isPlayable('platinum', '')).toBe(true) + expect(isPlayable('gold', '')).toBe(true) + expect(isPlayable('silver', '')).toBe(false) + }) + + it('rejects when either rating is below silver', () => { + expect(isPlayable('gold', 'bronze')).toBe(false) + expect(isPlayable('bronze', 'gold')).toBe(false) + }) + + it('rejects when neither rating reaches gold', () => { + expect(isPlayable('silver', 'silver')).toBe(false) + }) + + it('accepts when one is gold-or-better and the other silver-or-better', () => { + expect(isPlayable('gold', 'silver')).toBe(true) + expect(isPlayable('silver', 'gold')).toBe(true) + expect(isPlayable('platinum', 'platinum')).toBe(true) + }) + + it('treats unknown tiers as below silver', () => { + expect(isPlayable('mystery', 'mystery')).toBe(false) + }) +}) + +describe('passesMinTier', () => { + it('honours treatMissingAsPass when no data', () => { + expect(passesMinTier('', '', 'gold', true)).toBe(true) + expect(passesMinTier('', '', 'gold', false)).toBe(false) + expect(passesMinTier('pending', 'pending', 'gold', false)).toBe(false) + }) + + it('uses the best of the two ratings', () => { + expect(passesMinTier('silver', 'gold', 'gold', false)).toBe(true) + expect(passesMinTier('silver', 'silver', 'gold', false)).toBe(false) + expect(passesMinTier('platinum', '', 'gold', false)).toBe(true) + }) +}) + +describe('tierLabel', () => { + it('renders a dash, single, or paired label', () => { + expect(tierLabel('', '')).toBe('—') + expect(tierLabel('gold', 'gold')).toBe('gold') + expect(tierLabel('gold', '')).toBe('gold') + expect(tierLabel('gold', 'silver')).toBe('gold / silver') + }) +}) diff --git a/web/src/protondb.ts b/web/src/protondb.ts new file mode 100644 index 0000000..473376f --- /dev/null +++ b/web/src/protondb.ts @@ -0,0 +1,72 @@ +// ProtonDB tier logic. `isPlayable` is a faithful port of +// ProtonDBRating.is_playable in steam_backlog_enforcer/protondb.py, so the +// UI's default ProtonDB filter reproduces the CLI's qualifying set exactly. + +export const TIER_ORDER: Record = { + native: 0, + platinum: 1, + gold: 2, + silver: 3, + bronze: 4, + borked: 5, + pending: 6, +} + +// Tiers offered in the "minimum tier" dropdown, best → worst. +export const SELECTABLE_TIERS = [ + 'native', + 'platinum', + 'gold', + 'silver', + 'bronze', +] as const + +const MIN_PLAYABLE_TIER = 'gold' +const UNKNOWN_RANK = 99 + +function rank(tier: string): number { + return TIER_ORDER[tier] ?? UNKNOWN_RANK +} + +/** + * Faithful port of the CLI's compound playability rule. + * + * A game with no rating (or "pending") is not blocked. With a single rating, + * it must be gold-or-better. With both `tier` and `trending`, neither may be + * below silver and at least one must be gold-or-better. + */ +export function isPlayable(tier: string, trending: string): boolean { + if (!tier || tier === 'pending') return true + const tierRank = rank(tier) + const minRank = TIER_ORDER[MIN_PLAYABLE_TIER] + const silverRank = TIER_ORDER.silver + if (!trending) return tierRank <= minRank + const trendRank = rank(trending) + if (tierRank > silverRank || trendRank > silverRank) return false + return !(tierRank > minRank && trendRank > minRank) +} + +/** + * Simple "minimum acceptable tier" rule for the manual ProtonDB mode. + * + * Uses the better (lower-rank) of the two available ratings. Games with no + * rating fall back to `treatMissingAsPass`. + */ +export function passesMinTier( + tier: string, + trending: string, + minTier: string, + treatMissingAsPass: boolean, +): boolean { + const present = [tier, trending].filter((t) => t && t !== 'pending') + if (present.length === 0) return treatMissingAsPass + const bestRank = Math.min(...present.map(rank)) + return bestRank <= rank(minTier) +} + +/** A short, human-readable label for a game's ProtonDB rating. */ +export function tierLabel(tier: string, trending: string): string { + if (!tier) return '—' + if (trending && trending !== tier) return `${tier} / ${trending}` + return tier +} diff --git a/web/src/test/factories.ts b/web/src/test/factories.ts new file mode 100644 index 0000000..60a7111 --- /dev/null +++ b/web/src/test/factories.ts @@ -0,0 +1,81 @@ +// Shared test factories. Lives under src/test/ which is excluded from the +// app build and from coverage. + +import type { Filters, WebDataset, WebGame, WebStateInfo } from '../types' + +export function makeGame(over: Partial = {}): WebGame { + return { + app_id: 1, + name: 'Game', + completion_pct: 0, + playtime_minutes: 60, + rush_hours: 10, + leisure_hours: 20, + worst_hours: 25, + count_comp: 20, + comp_100_count: 5, + hltb_game_id: 0, + protondb_tier: 'gold', + protondb_trending_tier: 'gold', + protondb_score: 0.8, + ...over, + } +} + +export function makeFilters(over: Partial = {}): Filters { + return { + minCountComp: 15, + minComp100: 3, + minConfidenceSum: 18, + protonMode: 'playable', + protonMinTier: 'gold', + protonTreatMissingAsPass: true, + dailyHours: 4, + basis: 'leisure', + maxHoursPerGame: 0, + playtimeMode: 'all', + includeNoData: false, + fallbackHours: 20, + excluded: new Set(), + search: '', + targetDate: '', + ...over, + } +} + +export function makeState(over: Partial = {}): WebStateInfo { + return { + current_app_id: null, + current_game_name: '', + games_done: 0, + days_elapsed: 0, + enforcement_started_at: '', + pace_games_per_day: 0, + ...over, + } +} + +export function makeDataset( + games: WebGame[] = [makeGame()], + over: Partial = {}, +): WebDataset { + return { + games, + state: makeState(), + defaults: { + min_comp_100_polls: 3, + min_count_comp: 15, + min_confidence_sum: 18, + min_playable_tier: 'gold', + hours_per_day_presets: [2, 4, 6, 8], + }, + default_summary: { + qualifying: games.length, + rush_total: 0, + leisure_total: 0, + worst_total: 0, + }, + generated_at: '2026-05-29T00:00:00+00:00', + ...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..fd6a676 --- /dev/null +++ b/web/src/types.ts @@ -0,0 +1,85 @@ +// Type definitions mirroring the JSON payload from `GET /api/dataset` +// (see steam_backlog_enforcer/_web_dataset.py). Hour fields use -1 for +// "no data", matching the cache convention. + +export interface WebGame { + app_id: number + name: string + completion_pct: number + playtime_minutes: number + rush_hours: number + leisure_hours: number + worst_hours: number + count_comp: number + comp_100_count: number + hltb_game_id: number + protondb_tier: string + protondb_trending_tier: string + protondb_score: number +} + +export interface WebStateInfo { + current_app_id: number | null + current_game_name: string + games_done: number + days_elapsed: number + enforcement_started_at: string + pace_games_per_day: number +} + +export interface WebDefaults { + min_comp_100_polls: number + min_count_comp: number + min_confidence_sum: number + min_playable_tier: string + hours_per_day_presets: number[] +} + +export interface DefaultSummary { + qualifying: number + rush_total: number + leisure_total: number + worst_total: number +} + +export interface WebDataset { + games: WebGame[] + state: WebStateInfo + defaults: WebDefaults + default_summary: DefaultSummary + generated_at: string +} + +// Which time model drives the headline finish-date and the timeline chart. +export type EstimateBasis = 'rush' | 'leisure' | 'worst' | 'pace' + +// How the ProtonDB compatibility filter behaves. +// - 'playable': faithful port of the CLI's compound `is_playable` rule. +// - 'minTier': simple "best available tier must be at least X". +export type ProtonMode = 'playable' | 'minTier' + +export type PlaytimeMode = 'all' | 'started' | 'untouched' + +export interface Filters { + // HLTB confidence thresholds (CLI defaults: 15 / 3 / 18). + minCountComp: number + minComp100: number + minConfidenceSum: number + // ProtonDB compatibility. + protonMode: ProtonMode + protonMinTier: string + protonTreatMissingAsPass: boolean + // Time budget + which model is primary. + dailyHours: number + basis: EstimateBasis + // Extra filters. + maxHoursPerGame: number // 0 = no cap + playtimeMode: PlaytimeMode + includeNoData: boolean + fallbackHours: number + // Manual exclusions (Steam app IDs) and table search. + excluded: ReadonlySet + search: string + // Target-date planner ('' = disabled), ISO yyyy-mm-dd. + targetDate: string +} diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json new file mode 100644 index 0000000..3182e28 --- /dev/null +++ b/web/tsconfig.app.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test"] +} 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..dfadb0b --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..d960435 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,34 @@ +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vitest/config' + +// The Python API (`./run.sh serve`) runs on 127.0.0.1:8000. 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:8000', + }, + }, + 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, + }, + }, + }, +})