diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3ff4621..93b68ef 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,6 +20,9 @@ repos: args: [--markdown-linebreak-ext=md] - id: end-of-file-fixer - id: check-yaml + # pnpm 12 writes pnpm-lock.yaml as two YAML documents (the pinned + # packageManager's own lock, then the project lock); parse both. + args: [--allow-multiple-documents] - id: check-json - id: check-toml - id: check-added-large-files @@ -177,7 +180,7 @@ repos: hooks: - id: vitest-coverage name: vitest with 100% coverage enforcement - entry: npm --prefix web run coverage + entry: pnpm --dir web coverage language: system files: ^web/src/ pass_filenames: false diff --git a/DOCS-web-control-api.md b/DOCS-web-control-api.md index 127d7d9..7cec01f 100644 --- a/DOCS-web-control-api.md +++ b/DOCS-web-control-api.md @@ -3,7 +3,7 @@ The web UI replaces `run.sh` as the primary way to drive the enforcer. Every CLI command is reachable from the UI; the CLI stays as the backend, the systemd entry point and the recovery path. This file is the frozen contract -between the Python server, the root daemon and the React app. The TypeScript +between the Python server, the root daemon and the Astro web UI. The TypeScript mirror is `web/src/api/contract.ts`; change both together or neither. ## Trust model @@ -26,13 +26,13 @@ mirror is `web/src/api/contract.ts`; change both together or neither. - A per-launch token (32 random bytes, hex) is written to `$XDG_RUNTIME_DIR/steam-backlog-enforcer/web-.token` (mode 0600; one file per port, so a second server never clobbers the live one's) and - injected into `index.html` as ``. + injected into every served `.html` page as ``. Mutating requests send it as `X-SBE-Token`; `EventSource` sends it as `?token=`. Missing or wrong token → `403 bad_token`; the UI then reloads the page once (at most once per 30 s) to pick up a restarted server's token. -- Dev (`npm run dev`): Vite proxies `/api` to `127.0.0.1:8000` (`SBE_PORT` - overrides) with `Host` and `Origin` rewritten to that origin, and injects - the token meta from that port's token file (serve only, never in a build). +- Dev (`pnpm dev`): Astro proxies `/api` to `127.0.0.1:8000` (`SBE_PORT` + overrides) with `Host` and `Origin` rewritten to that origin, and renders + the token meta from that port's token file (dev only, never in a build). - Secrets (`steam_api_key`) are write-only: accepted by `POST /api/setup`, never returned by any endpoint. diff --git a/steam_backlog_enforcer/_web_build.py b/steam_backlog_enforcer/_web_build.py index 3925d64..174fce5 100644 --- a/steam_backlog_enforcer/_web_build.py +++ b/steam_backlog_enforcer/_web_build.py @@ -18,26 +18,26 @@ from steam_backlog_enforcer.game_install import _echo WEB_DIR = (Path(__file__).resolve().parent.parent / "web").resolve() _BUILT_MARKER = WEB_DIR / "dist" / "index.html" -# Everything whose change invalidates the built bundle. Anything under src, -# plus the build configuration itself - a vite/tsconfig edit changes the -# output even when not one line of src moved. -_SOURCE_DIR = WEB_DIR / "src" +# Everything whose change invalidates the built bundle. Anything under src +# (pages, styles, scripts) or public (copied verbatim into dist), plus the +# build configuration itself - an astro/tsconfig edit or a dependency bump +# changes the output even when not one line of src moved. +_SOURCE_DIRS = (WEB_DIR / "src", WEB_DIR / "public") _SOURCE_FILES = ( - WEB_DIR / "index.html", + WEB_DIR / "astro.config.mjs", WEB_DIR / "package.json", - WEB_DIR / "vite.config.ts", + WEB_DIR / "pnpm-lock.yaml", + WEB_DIR / "pnpm-workspace.yaml", WEB_DIR / "tsconfig.json", - WEB_DIR / "tsconfig.app.json", - WEB_DIR / "tsconfig.node.json", ) _BUILD_TIMEOUT_SECONDS = 600 -_NPM_MISSING = ( - "npm was not found on PATH, so the stale web bundle cannot be rebuilt.\n" - "npm is installed via nvm here, which only puts it on an interactive\n" - "shell's PATH - a systemd unit or cron job will not see it. Either run\n" - "serve from a normal shell, or build once by hand: cd web && npm run build" +_PNPM_MISSING = ( + "pnpm was not found on PATH, so the stale web bundle cannot be rebuilt.\n" + "A systemd unit or cron job may not see a pnpm installed per user.\n" + "Either run serve from a normal shell, or build once by hand:\n" + "cd web && pnpm install && pnpm build" ) @@ -59,26 +59,27 @@ def frontend_is_stale() -> bool: if built == 0.0: return WEB_DIR.is_dir() sources = list(_SOURCE_FILES) - if _SOURCE_DIR.is_dir(): - sources.extend(path for path in _SOURCE_DIR.rglob("*") if path.is_file()) + for source_dir in _SOURCE_DIRS: + if source_dir.is_dir(): + sources.extend(path for path in source_dir.rglob("*") if path.is_file()) return any(_mtime(path) > built for path in sources) def build_frontend() -> bool: - """Run ``npm run build`` in ``web/``. + """Run ``pnpm build`` in ``web/``. Returns: True if the bundle was rebuilt, False if the build failed (the captured output is printed either way). """ - npm = shutil.which("npm") - if npm is None: - _echo(_NPM_MISSING) + pnpm = shutil.which("pnpm") + if pnpm is None: + _echo(_PNPM_MISSING) return False _echo("Frontend sources changed - rebuilding web/dist ...", flush=True) result = subprocess.run( - [npm, "run", "build"], + [pnpm, "build"], cwd=WEB_DIR, capture_output=True, text=True, diff --git a/steam_backlog_enforcer/_web_server.py b/steam_backlog_enforcer/_web_server.py index ff3bd98..400a2a7 100644 --- a/steam_backlog_enforcer/_web_server.py +++ b/steam_backlog_enforcer/_web_server.py @@ -5,7 +5,7 @@ DNS-rebinding guard, :mod:`._web_auth`), fails closed on stale code (:mod:`._serve_stale`), checks ``Origin`` and the per-launch token on anything that is not a GET (and on the SSE stream), then hands off to a route from :mod:`._web_routes`, the SSE streamer (:mod:`._web_sse`) or the -static bundle (``web/dist``, with the token injected into ``index.html``). +static bundle (``web/dist``, with the token injected into every ``.html`` page). Binds to loopback only; never serves secrets and never sends CORS headers. In development the Vite dev server proxies ``/api`` here; in production the @@ -43,7 +43,8 @@ WEB_DIST = (Path(__file__).resolve().parent.parent / "web" / "dist").resolve() DEFAULT_HOST = "127.0.0.1" DEFAULT_PORT = 8000 _INDEX = "index.html" -_NOT_BUILT_MSG = b"Frontend not built. Run: cd web && npm install && npm run build" +_NOT_FOUND_PAGE = "404.html" +_NOT_BUILT_MSG = b"Frontend not built. Run: cd web && pnpm install && pnpm build" _STALE_MSG = ( "This server is running outdated code and has stopped answering rather " "than report numbers the enforcer is not applying. It restarts on " @@ -159,12 +160,17 @@ class _Handler(BaseHTTPRequestHandler): return True def _serve_static(self, path: str) -> None: - """Serve a file from ``WEB_DIST`` with SPA fallback and traversal guard.""" - rel = path.lstrip("/") or _INDEX - 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 + """Serve a file from ``WEB_DIST``: one static page per directory. + + ``/library`` and ``/library/`` serve ``library/index.html``; anything + unknown (or escaping the bundle) gets ``404.html`` with status 404. + Every ``.html`` page carries this launch's token and is never cached. + """ + status = HTTPStatus.OK + candidate = _resolve_static(path) + if candidate is None: + status = HTTPStatus.NOT_FOUND + candidate = WEB_DIST / _NOT_FOUND_PAGE if not candidate.is_file(): send_bytes(self, HTTPStatus.NOT_FOUND, _NOT_BUILT_MSG, "text/plain") return @@ -174,11 +180,25 @@ class _Handler(BaseHTTPRequestHandler): ctype = f"{ctype}; charset=utf-8" body = candidate.read_bytes() headers: dict[str, str] = {} - if candidate.name == _INDEX and candidate.parent == WEB_DIST: + if candidate.suffix == ".html": # Carries this launch's token: never cache it. body = _web_auth.inject_token(body, self.server.token) headers["Cache-Control"] = "no-store" - send_bytes(self, HTTPStatus.OK, body, ctype, headers) + send_bytes(self, status, body, ctype, headers) + + +def _resolve_static(path: str) -> Path | None: + """The bundle file for URL *path*, or None when it has none. + + A directory resolves to its ``index.html``; a path escaping ``WEB_DIST`` + resolves to nothing, never to the file it points at. + """ + candidate = (WEB_DIST / path.lstrip("/")).resolve() + if not candidate.is_relative_to(WEB_DIST): + return None + if candidate.is_dir(): + candidate /= _INDEX + return candidate if candidate.is_file() else None # Content types that are text but not under the ``text/`` prefix. diff --git a/steam_backlog_enforcer/tests/_no_subprocess.py b/steam_backlog_enforcer/tests/_no_subprocess.py index 3d26e12..6fc85cc 100644 --- a/steam_backlog_enforcer/tests/_no_subprocess.py +++ b/steam_backlog_enforcer/tests/_no_subprocess.py @@ -100,7 +100,7 @@ def _block_real_subprocesses() -> Iterator[None]: "steam_backlog_enforcer._fast_install.subprocess.Popen", noop_popen, ), - # A real `npm run build` here would rewrite web/dist/index.html, which + # A real `pnpm build` here would rewrite web/dist/index.html, which # then perturbs frontend_is_stale() for every later test in the run. patch( "steam_backlog_enforcer._web_build.subprocess.run", diff --git a/steam_backlog_enforcer/tests/test_web_build.py b/steam_backlog_enforcer/tests/test_web_build.py index 249142e..28a6552 100644 --- a/steam_backlog_enforcer/tests/test_web_build.py +++ b/steam_backlog_enforcer/tests/test_web_build.py @@ -32,7 +32,7 @@ def web(tmp_path: Path) -> Iterator[Path]: with ( patch.object(_web_build, "WEB_DIR", root), patch.object(_web_build, "_BUILT_MARKER", root / "dist" / "index.html"), - patch.object(_web_build, "_SOURCE_DIR", root / "src"), + patch.object(_web_build, "_SOURCE_DIRS", (root / "src", root / "public")), patch.object( _web_build, "_SOURCE_FILES", @@ -88,6 +88,12 @@ class TestFrontendIsStale: _touch(web / "src" / "nested" / "app.tsx", 2000.0) assert _web_build.frontend_is_stale() is True + def test_newer_public_asset_is_stale(self, web: Path) -> None: + # public/ is copied verbatim into dist/: a new favicon needs a build. + _touch(web / "dist" / "index.html", 1000.0) + _touch(web / "public" / "favicon.svg", 2000.0) + assert _web_build.frontend_is_stale() is True + def test_all_sources_older_is_current(self, web: Path) -> None: _touch(web / "package.json", 1000.0) _touch(web / "src" / "app.tsx", 1000.0) @@ -105,36 +111,52 @@ class TestFrontendIsStale: def test_missing_src_dir_still_checks_flat_sources(self, web: Path) -> None: _touch(web / "dist" / "index.html", 1000.0) _touch(web / "index.html", 2000.0) - with patch.object(_web_build, "_SOURCE_DIR", web / "absent"): + with patch.object(_web_build, "_SOURCE_DIRS", (web / "absent",)): assert _web_build.frontend_is_stale() is True +class TestSourceFiles: + """The watched build inputs match the Astro layout.""" + + def test_astro_inputs_are_watched_and_vite_ones_are_gone(self) -> None: + names = {path.name for path in _web_build._SOURCE_FILES} + assert { + "astro.config.mjs", + "package.json", + "pnpm-lock.yaml", + "pnpm-workspace.yaml", + } <= names + assert {path.name for path in _web_build._SOURCE_DIRS} == {"src", "public"} + assert not names & {"vite.config.ts", "tsconfig.app.json", "index.html"} + + class TestBuildFrontend: """Tests for build_frontend. subprocess is never actually executed.""" - def test_missing_npm_explains_and_fails(self, web: Path) -> None: + def test_missing_pnpm_explains_and_fails(self, web: Path) -> None: with ( patch(f"{_PKG}.shutil.which", return_value=None), patch(f"{_PKG}._echo") as echo, ): assert _web_build.build_frontend() is False - assert "npm was not found" in echo.call_args[0][0] + assert "pnpm was not found" in echo.call_args[0][0] + assert "npm install" not in echo.call_args[0][0].replace("pnpm install", "") def test_successful_build_returns_true(self, web: Path) -> None: run = MagicMock(return_value=MagicMock(returncode=0)) with ( - patch(f"{_PKG}.shutil.which", return_value="/usr/bin/npm"), + patch(f"{_PKG}.shutil.which", return_value="/usr/bin/pnpm"), patch(f"{_PKG}.subprocess.run", run), patch(f"{_PKG}._echo"), ): assert _web_build.build_frontend() is True - assert run.call_args[0][0] == ["/usr/bin/npm", "run", "build"] + assert run.call_args[0][0] == ["/usr/bin/pnpm", "build"] assert run.call_args[1]["cwd"] == web def test_failed_build_prints_output_and_returns_false(self, web: Path) -> None: result = MagicMock(returncode=1, stdout=" out \n", stderr=" err \n") with ( - patch(f"{_PKG}.shutil.which", return_value="/usr/bin/npm"), + patch(f"{_PKG}.shutil.which", return_value="/usr/bin/pnpm"), patch(f"{_PKG}.subprocess.run", return_value=result), patch(f"{_PKG}._echo") as echo, ): @@ -147,7 +169,7 @@ class TestBuildFrontend: def test_timeout_propagates(self, web: Path) -> None: # A hung build must not be swallowed into "bundle is fine". with ( - patch(f"{_PKG}.shutil.which", return_value="/usr/bin/npm"), + patch(f"{_PKG}.shutil.which", return_value="/usr/bin/pnpm"), patch( f"{_PKG}.subprocess.run", side_effect=subprocess.TimeoutExpired("npm", 1), diff --git a/steam_backlog_enforcer/tests/test_web_server.py b/steam_backlog_enforcer/tests/test_web_server.py index 0613052..5accd94 100644 --- a/steam_backlog_enforcer/tests/test_web_server.py +++ b/steam_backlog_enforcer/tests/test_web_server.py @@ -127,7 +127,7 @@ class TestDatasetEndpoint: class TestStaticServing: - """Tests for static-file serving + SPA fallback + traversal guard.""" + """Tests for static-file serving, the 404 page and the traversal guard.""" def test_serves_index(self, tmp_path: Path) -> None: dist = _make_dist(tmp_path) @@ -152,19 +152,19 @@ class TestStaticServing: 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) + def test_unknown_route_gets_the_404_page(self, tmp_path: Path) -> None: + dist = _make_dist(tmp_path, files={"404.html": b"MISSING"}) 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 + status, body, _ = _get(port, "/some/unknown/route") + assert status == 404 + assert b"MISSING" in body def test_path_traversal_blocked(self, tmp_path: Path) -> None: - dist = _make_dist(tmp_path) + dist = _make_dist(tmp_path, files={"404.html": b"MISSING"}) 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 status == 404 + assert b"MISSING" in body # got the 404 page, not the secret assert b"root:" not in body def test_not_built_returns_404(self, tmp_path: Path) -> None: diff --git a/steam_backlog_enforcer/tests/test_web_server_static.py b/steam_backlog_enforcer/tests/test_web_server_static.py index 8722752..8b6df9c 100644 --- a/steam_backlog_enforcer/tests/test_web_server_static.py +++ b/steam_backlog_enforcer/tests/test_web_server_static.py @@ -40,7 +40,9 @@ def dist(tmp_path: Path) -> Iterator[Path]: (root / "noext").write_text("plain", "utf-8") sub = root / "sub" sub.mkdir() - (sub / "index.html").write_text("SUB", "utf-8") + (sub / "index.html").write_text("SUB", "utf-8") + (sub / "page.html").write_text("PAGE", "utf-8") + (root / "404.html").write_text("MISSING", "utf-8") (tmp_path / "secret.txt").write_text("top secret", "utf-8") with patch(f"{_PKG}.WEB_DIST", root): yield root @@ -93,19 +95,36 @@ class TestStatic: def test_unknown_extension_is_plain_text(self) -> None: assert _get("/noext")[2] == "text/plain; charset=utf-8" - def test_a_nested_index_is_served_without_a_token(self) -> None: - _, body, _, cache, _ = _get("/sub/index.html") - assert (body, cache) == (b"SUB", "") + @pytest.mark.parametrize("path", ["/sub", "/sub/", "/sub/index.html", "/sub/?id=7"]) + def test_a_directory_serves_its_own_index_with_a_token(self, path: str) -> None: + status, body, ctype, cache, token = _get(path) + assert (status, b"SUB" in body) == (200, True) + assert f'content="{token}"'.encode() in body + assert (ctype, cache) == ("text/html; charset=utf-8", "no-store") - def test_spa_route_falls_back_to_index(self) -> None: - status, body, _, _, _ = _get("/library/42") - assert (status, b"INDEX" in body) == (200, True) + def test_every_html_file_carries_the_token_and_is_not_cached(self) -> None: + status, body, _, cache, token = _get("/sub/page.html") + assert (status, cache) == (200, "no-store") + assert f'content="{token}"'.encode() in body - def test_path_traversal_gets_the_index_not_the_file(self) -> None: + def test_unknown_route_is_the_404_page_with_a_token(self) -> None: + status, body, _, cache, token = _get("/library/42") + assert (status, b"MISSING" in body, cache) == (404, True, "no-store") + assert f'content="{token}"'.encode() in body + + def test_path_traversal_gets_the_404_page_not_the_file(self) -> None: status, body, _, _, _ = _get("/..%2Fsecret.txt") - assert status == 200 + assert (status, b"MISSING" in body) == (404, True) assert b"top secret" not in body + def test_unknown_route_without_a_404_page_is_not_built(self, dist: Path) -> None: + (dist / "404.html").unlink() + status, body, _, _, _ = _get("/nope") + assert (status, body) == (404, _web_server._NOT_BUILT_MSG) + + def test_not_built_message_names_pnpm(self) -> None: + assert b"pnpm install && pnpm build" in _web_server._NOT_BUILT_MSG + def test_unbuilt_frontend_is_a_404(self, tmp_path: Path) -> None: with patch(f"{_PKG}.WEB_DIST", (tmp_path / "missing").resolve()): status, body, _, _, _ = _get("/") diff --git a/web/.gitignore b/web/.gitignore index a547bf3..88d7197 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -22,3 +22,7 @@ dist-ssr *.njsproj *.sln *.sw? + +# Astro generated types +.astro +coverage diff --git a/web/README.md b/web/README.md index 7dbf7eb..be0236c 100644 --- a/web/README.md +++ b/web/README.md @@ -1,73 +1,7 @@ -# React + TypeScript + Vite +# web -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +The loopback control UI: Astro static pages (one per section) with vanilla +TypeScript custom elements, served from `dist/` by `steam_backlog_enforcer._web_server`. -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... - }, - }, -]) -``` +`pnpm dev` (proxies `/api` to `./run.sh serve`), `pnpm dev:mock` (in-memory API), +`pnpm test`, `pnpm coverage`, `pnpm lint`, `pnpm build`. diff --git a/web/astro.config.mjs b/web/astro.config.mjs new file mode 100644 index 0000000..8c5b7c4 --- /dev/null +++ b/web/astro.config.mjs @@ -0,0 +1,33 @@ +import { defineConfig } from 'astro/config' + +// Static pages, one directory per section (`dist/library/index.html`), served +// by steam_backlog_enforcer._web_server, which injects the per-launch token +// into every page. In dev, `/api` is proxied to the Python server (SBE_PORT, +// default 8000) presenting itself as that server's own origin so its +// DNS-rebinding and origin checks pass; `--mode mock` (pnpm dev:mock) swaps +// the proxy for the in-memory contract mock in mock/. Both are dev-only: +// nothing here reaches `astro build` output. +const apiPort = process.env.SBE_PORT ?? '8000' +const apiOrigin = `http://127.0.0.1:${apiPort}` +const mode = process.argv.includes('--mode') ? process.argv[process.argv.indexOf('--mode') + 1] : '' + +// The dev token meta comes from src/components/DevToken.astro (dev only). +async function devPlugins() { + if (mode !== 'mock') return [] + const { mockApi } = await import('./mock/plugin.ts') + return [mockApi()] +} + +export default defineConfig({ + output: 'static', + build: { format: 'directory', assets: 'assets' }, + trailingSlash: 'ignore', + devToolbar: { enabled: false }, + vite: { + plugins: process.argv.includes('dev') ? await devPlugins() : [], + server: + mode === 'mock' + ? {} + : { proxy: { '/api': { target: apiOrigin, changeOrigin: true, headers: { Origin: apiOrigin } } } }, + }, +}) diff --git a/web/eslint.config.js b/web/eslint.config.js index 9d2d2a5..564a8e9 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -1,30 +1,37 @@ import js from '@eslint/js' +import astro from 'eslint-plugin-astro' 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']), +// typescript-eslint strict + stylistic (type-checked) on .ts; .astro files get +// eslint-plugin-astro's recommended set (their scripts also pass astro check). +export default tseslint.config( + { ignores: ['dist', 'coverage', '.astro', 'src/env.d.ts'] }, + { linterOptions: { reportUnusedDisableDirectives: 'error' } }, + js.configs.recommended, + ...[...tseslint.configs.strictTypeChecked, ...tseslint.configs.stylisticTypeChecked].map((c) => ({ + ...c, + files: ['**/*.ts'], + })), + ...astro.configs['flat/recommended'], { - files: ['**/*.{ts,tsx}'], - extends: [ - js.configs.recommended, - tseslint.configs.recommended, - // `recommended` includes the React Compiler rules since plugin v6. - reactHooks.configs.flat.recommended, - reactRefresh.configs.vite, - ], + files: ['**/*.ts'], languageOptions: { globals: globals.browser, + parserOptions: { project: './tsconfig.json', tsconfigRootDir: import.meta.dirname }, + }, + rules: { + '@typescript-eslint/restrict-template-expressions': ['error', { allowNumber: true }], }, }, { - // The dev-only mock API and the Vite config run in Node, not the browser. - files: ['mock/**/*.ts', 'vite.config.ts'], - languageOptions: { - globals: globals.node, - }, + files: ['**/*.test.ts', 'src/test/**'], + rules: { '@typescript-eslint/unbound-method': 'off' }, }, -]) + { + // The dev-only mock API and the configs run in Node, not the browser. + files: ['mock/**/*.ts', '*.js', '*.mjs', 'vitest.config.ts'], + languageOptions: { globals: globals.node }, + }, + { files: ['*.js', '*.mjs'], ...tseslint.configs.disableTypeChecked }, +) diff --git a/web/index.html b/web/index.html deleted file mode 100644 index db20107..0000000 --- a/web/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - Steam Backlog Enforcer - - -
- - - diff --git a/web/mock/plugin.ts b/web/mock/plugin.ts index e2c162d..3d89b27 100644 --- a/web/mock/plugin.ts +++ b/web/mock/plugin.ts @@ -1,20 +1,24 @@ // Dev-only Vite plugin that serves the whole web-control API contract from -// memory (`npm run dev:mock`). `apply: 'serve'` plus the mode gate in -// vite.config.ts keep it out of every production build. +// memory (`pnpm dev:mock`). `apply: 'serve'` plus the mode gate in +// astro.config.mjs keep it out of every production build. import { randomBytes } from 'node:crypto' -import type { Plugin } from 'vite' -import { TOKEN_META } from '../src/api/contract.ts' +import type { AstroUserConfig } from 'astro' import { handle } from './routes.ts' +// Vite is Astro's, not a direct dependency: take its Plugin type from there. +type PluginOption = NonNullable['plugins']>[number] +type Plugin = Extract + export function mockApi(): Plugin { // Per-launch token, like the real server's web-.token. const token = randomBytes(32).toString('hex') return { name: 'sbe-mock-api', apply: 'serve', - transformIndexHtml() { - return [{ tag: 'meta', attrs: { name: TOKEN_META, content: token }, injectTo: 'head' }] + // src/components/DevToken.astro renders this into every page in dev. + config() { + process.env.SBE_DEV_TOKEN = token }, configureServer(server) { server.middlewares.use((req, res, next) => { diff --git a/web/package-lock.json b/web/package-lock.json deleted file mode 100644 index 8a573fb..0000000 --- a/web/package-lock.json +++ /dev/null @@ -1,4556 +0,0 @@ -{ - "name": "web", - "version": "0.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "web", - "version": "0.0.0", - "dependencies": { - "@tanstack/react-query": "5.107.1", - "@tanstack/react-router": "1.170.46", - "react": "19.3.1", - "react-dom": "19.3.1" - }, - "devDependencies": { - "@babel/core": "8.1.0", - "@eslint/js": "10.0.1", - "@rolldown/plugin-babel": "0.3.0", - "@testing-library/dom": "10.4.2", - "@testing-library/jest-dom": "7.0.1", - "@testing-library/react": "16.3.3", - "@testing-library/user-event": "14.6.8", - "@types/node": "26.6.6", - "@types/react": "19.3.0", - "@types/react-dom": "19.3.0", - "@vitejs/plugin-react": "6.2.1", - "@vitest/coverage-v8": "5.1.0", - "babel-plugin-react-compiler": "1.0.0", - "eslint": "10.14.0", - "eslint-plugin-react-hooks": "7.1.1", - "eslint-plugin-react-refresh": "0.5.7", - "globals": "17.14.0", - "jsdom": "31.0.0", - "typescript": "6.0.3", - "typescript-eslint": "8.72.1", - "vite": "8.3.6", - "vitest": "5.1.0" - } - }, - "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/@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": "8.0.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-8.0.5.tgz", - "integrity": "sha512-YLsYoQMvL8l8WrGpN3Zj7O1wK5LEBN+cQtux7BcuHyxIXve724XG+zuJ1n3U1cUweRtTzQOA4IHbuQw3N34SZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/core": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-8.1.0.tgz", - "integrity": "sha512-3jqm7xh3kJCmIeOK8sk6/EvOWdSzvOqvjt0tJEDOHHmzWjcQSvIh2/tq1I9RA741tl0QZdMzPJWCulWE+Dy5ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^8.0.6", - "@babel/generator": "^8.1.0", - "@babel/helper-compilation-targets": "^8.0.7", - "@babel/helpers": "^8.1.0", - "@babel/parser": "^8.1.0", - "@babel/template": "^8.0.0", - "@babel/traverse": "^8.1.0", - "@babel/types": "^8.1.0", - "@types/gensync": "^1.0.5", - "convert-source-map": "^2.0.0", - "empathic": "^2.0.1", - "gensync": "^1.0.0-beta.2", - "import-meta-resolve": "^4.2.0", - "json5": "^2.2.3", - "obug": "^3.0.0", - "verkit": "^0.5.0" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/@babel/code-frame": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.6.tgz", - "integrity": "sha512-uMHhvmDmwFiLuiozaoRYTZHqMFuu3fem/X9JY78lohAuLoh8LSGGC8JqUzo2cVuBsBxkjMZNk56CE7ock8Xxkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^8.0.6", - "js-tokens": "^10.0.0" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/core/node_modules/@babel/helper-string-parser": { - "version": "8.0.8", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.8.tgz", - "integrity": "sha512-fVM+gSuKbdv9cpmyRNh5EvsDHUZCdxQH3FdnFKSkNYHpbsn95bbrxm+ES3MVeQjcqwi2O5ur/DjLfmTQaO60ng==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/core/node_modules/@babel/helper-validator-identifier": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.6.tgz", - "integrity": "sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/core/node_modules/@babel/parser": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.1.0.tgz", - "integrity": "sha512-fpqZYAC+Tthr19JbEKxXhCGutvfm/+roMe+ULrgUKI+GVyzkQiTMTSheORcWIAzW8T3Oo3W5UavWSvAWmsp7fA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^8.1.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/core/node_modules/@babel/types": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.1.0.tgz", - "integrity": "sha512-8V8aSTDx012YPmDQF30vrokyxVFdRAU2YBS2VzrnfLAGkP8floVnjckwgFcD0Jrvf6GWMSRdCrYVsk3tyMwZRg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^8.0.8", - "@babel/helper-validator-identifier": "^8.0.6" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/core/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/@babel/generator": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.1.0.tgz", - "integrity": "sha512-7fiBJ61wDV3AHbg7n/EfLBv3RU+lsdRP8QNJnydkKWA7e48FnnvKQWbVHsJQCBE7JHTj4xlzZL9fvQYsBUip2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^8.1.0", - "@babel/types": "^8.1.0", - "@jridgewell/gen-mapping": "0.4.0-beta.0", - "@jridgewell/trace-mapping": "^0.3.31", - "@types/jsesc": "^2.5.0", - "jsesc": "^3.0.2" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/generator/node_modules/@babel/helper-string-parser": { - "version": "8.0.8", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.8.tgz", - "integrity": "sha512-fVM+gSuKbdv9cpmyRNh5EvsDHUZCdxQH3FdnFKSkNYHpbsn95bbrxm+ES3MVeQjcqwi2O5ur/DjLfmTQaO60ng==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/generator/node_modules/@babel/helper-validator-identifier": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.6.tgz", - "integrity": "sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/generator/node_modules/@babel/parser": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.1.0.tgz", - "integrity": "sha512-fpqZYAC+Tthr19JbEKxXhCGutvfm/+roMe+ULrgUKI+GVyzkQiTMTSheORcWIAzW8T3Oo3W5UavWSvAWmsp7fA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^8.1.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/generator/node_modules/@babel/types": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.1.0.tgz", - "integrity": "sha512-8V8aSTDx012YPmDQF30vrokyxVFdRAU2YBS2VzrnfLAGkP8floVnjckwgFcD0Jrvf6GWMSRdCrYVsk3tyMwZRg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^8.0.8", - "@babel/helper-validator-identifier": "^8.0.6" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "8.0.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-8.0.7.tgz", - "integrity": "sha512-ilAZCdOOlOVA7dQbaFNw2f/59p6GW33NxTopc3DvEE3efp3ZXTrGX/yZScaKw8/vd9bq31Ag0EcXiLw32bAgJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^8.0.5", - "@babel/helper-validator-option": "^8.0.0", - "browserslist": "^4.24.0", - "flru": "1.0.2", - "verkit": "^0.5.0" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-8.0.6.tgz", - "integrity": "sha512-OdnA/N52LhzFpeUuVz5YGuSWjc7TPHiFmVYZbPlSgExocO2GwbxVpSni9MVs5wwW+wZ4kPuwBEBN9pIPDYz1mA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.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-imports/node_modules/@babel/generator": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", - "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.8", - "@babel/types": "^7.29.8", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports/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/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/helper-module-imports/node_modules/@babel/traverse": { - "version": "7.29.10", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", - "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.8", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.9", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.8", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports/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/@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": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-8.0.0.tgz", - "integrity": "sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/helpers": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-8.1.0.tgz", - "integrity": "sha512-949bjw10dhaRdtQeCpfVShFA4jIcto97BkqmWnRjpzyQuHVZ7EDriaoCn/4D8h1NqSKlmI20xPU0vHf3jQbQRg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^8.0.0", - "@babel/types": "^8.1.0" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/helpers/node_modules/@babel/helper-string-parser": { - "version": "8.0.8", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.8.tgz", - "integrity": "sha512-fVM+gSuKbdv9cpmyRNh5EvsDHUZCdxQH3FdnFKSkNYHpbsn95bbrxm+ES3MVeQjcqwi2O5ur/DjLfmTQaO60ng==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/helpers/node_modules/@babel/helper-validator-identifier": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.6.tgz", - "integrity": "sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/helpers/node_modules/@babel/types": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.1.0.tgz", - "integrity": "sha512-8V8aSTDx012YPmDQF30vrokyxVFdRAU2YBS2VzrnfLAGkP8floVnjckwgFcD0Jrvf6GWMSRdCrYVsk3tyMwZRg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^8.0.8", - "@babel/helper-validator-identifier": "^8.0.6" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.9", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", - "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.8" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", - "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/template": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-8.0.0.tgz", - "integrity": "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^8.0.0", - "@babel/parser": "^8.0.0", - "@babel/types": "^8.0.0" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/template/node_modules/@babel/code-frame": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.6.tgz", - "integrity": "sha512-uMHhvmDmwFiLuiozaoRYTZHqMFuu3fem/X9JY78lohAuLoh8LSGGC8JqUzo2cVuBsBxkjMZNk56CE7ock8Xxkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^8.0.6", - "js-tokens": "^10.0.0" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/template/node_modules/@babel/helper-string-parser": { - "version": "8.0.8", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.8.tgz", - "integrity": "sha512-fVM+gSuKbdv9cpmyRNh5EvsDHUZCdxQH3FdnFKSkNYHpbsn95bbrxm+ES3MVeQjcqwi2O5ur/DjLfmTQaO60ng==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/template/node_modules/@babel/helper-validator-identifier": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.6.tgz", - "integrity": "sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/template/node_modules/@babel/parser": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.1.0.tgz", - "integrity": "sha512-fpqZYAC+Tthr19JbEKxXhCGutvfm/+roMe+ULrgUKI+GVyzkQiTMTSheORcWIAzW8T3Oo3W5UavWSvAWmsp7fA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^8.1.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/template/node_modules/@babel/types": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.1.0.tgz", - "integrity": "sha512-8V8aSTDx012YPmDQF30vrokyxVFdRAU2YBS2VzrnfLAGkP8floVnjckwgFcD0Jrvf6GWMSRdCrYVsk3tyMwZRg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^8.0.8", - "@babel/helper-validator-identifier": "^8.0.6" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/template/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/@babel/traverse": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-8.1.0.tgz", - "integrity": "sha512-oQ0QI6MR4cm6bOR/Ua5HLPxvO7vborT7Le5HNt9GmZUKpNsaPl2yj4gsQ440lXaZjc4vmUbSeVgEcK4cHuCL2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^8.0.6", - "@babel/generator": "^8.1.0", - "@babel/helper-globals": "^8.0.6", - "@babel/parser": "^8.1.0", - "@babel/template": "^8.0.0", - "@babel/types": "^8.1.0", - "obug": "^3.0.0" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/traverse/node_modules/@babel/code-frame": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.6.tgz", - "integrity": "sha512-uMHhvmDmwFiLuiozaoRYTZHqMFuu3fem/X9JY78lohAuLoh8LSGGC8JqUzo2cVuBsBxkjMZNk56CE7ock8Xxkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^8.0.6", - "js-tokens": "^10.0.0" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/traverse/node_modules/@babel/helper-string-parser": { - "version": "8.0.8", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.8.tgz", - "integrity": "sha512-fVM+gSuKbdv9cpmyRNh5EvsDHUZCdxQH3FdnFKSkNYHpbsn95bbrxm+ES3MVeQjcqwi2O5ur/DjLfmTQaO60ng==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/traverse/node_modules/@babel/helper-validator-identifier": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.6.tgz", - "integrity": "sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/traverse/node_modules/@babel/parser": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.1.0.tgz", - "integrity": "sha512-fpqZYAC+Tthr19JbEKxXhCGutvfm/+roMe+ULrgUKI+GVyzkQiTMTSheORcWIAzW8T3Oo3W5UavWSvAWmsp7fA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^8.1.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/traverse/node_modules/@babel/types": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.1.0.tgz", - "integrity": "sha512-8V8aSTDx012YPmDQF30vrokyxVFdRAU2YBS2VzrnfLAGkP8floVnjckwgFcD0Jrvf6GWMSRdCrYVsk3tyMwZRg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^8.0.8", - "@babel/helper-validator-identifier": "^8.0.6" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/traverse/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/@babel/types": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", - "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@bcoe/v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", - "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@cacheable/memory": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", - "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cacheable/utils": "^2.5.0", - "@keyv/bigmap": "^1.3.1", - "hookified": "^1.15.1", - "keyv": "^5.6.0" - } - }, - "node_modules/@cacheable/utils": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", - "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "hashery": "^1.5.1", - "keyv": "^5.6.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.7.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", - "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.2.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/core": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", - "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/js": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", - "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "eslint": "^10.0.0" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/@eslint/object-schema": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", - "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", - "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", - "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.16.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", - "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", - "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.4.0-beta.0", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.4.0-beta.0.tgz", - "integrity": "sha512-JdGNkbE4GlNPYQhM0L95fBQr7ctLZJ276QXQLTad4t1oSdnnCI3fDq9DW3BqYAWv8Wc3+HS+4Gsii1oPMCfz1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.6.0-beta.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/remapping/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/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", - "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@keyv/bigmap": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", - "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "hashery": "^1.4.0", - "hookified": "^1.15.0" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "keyv": "^5.6.0" - } - }, - "node_modules/@keyv/serialize": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", - "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@oxc-project/types": { - "version": "0.156.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.156.0.tgz", - "integrity": "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/oxc-project" - } - }, - "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.16.tgz", - "integrity": "sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.16.tgz", - "integrity": "sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.16.tgz", - "integrity": "sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.16.tgz", - "integrity": "sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.16.tgz", - "integrity": "sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.16.tgz", - "integrity": "sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.16.tgz", - "integrity": "sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.16.tgz", - "integrity": "sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.16.tgz", - "integrity": "sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.16.tgz", - "integrity": "sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.16.tgz", - "integrity": "sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.16.tgz", - "integrity": "sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.16.tgz", - "integrity": "sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.16.tgz", - "integrity": "sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.16.tgz", - "integrity": "sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/plugin-babel": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@rolldown/plugin-babel/-/plugin-babel-0.3.0.tgz", - "integrity": "sha512-cB9Z6aqn7i8Zflv1D4tyIy/wvnWt+QVUPX2e3Jxq/4fvdX+Xk2gQyNN2sfYscfLIZN1LCQbCl4olpf9Ki+U1zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "artichokie": "^0.4.5", - "picomatch": "^4.0.7" - }, - "engines": { - "node": ">=22.12.0 || ^24.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.29.0 || ^8.0.0-rc.1", - "@babel/plugin-transform-runtime": "^7.29.0 || ^8.0.0-rc.1", - "@babel/runtime": "^7.27.0 || ^8.0.0-rc.1", - "rolldown": "^1.0.0-rc.5", - "vite": "^8.0.0" - }, - "peerDependenciesMeta": { - "@babel/plugin-transform-runtime": { - "optional": true - }, - "@babel/runtime": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "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/@tanstack/history": { - "version": "1.162.5", - "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.162.5.tgz", - "integrity": "sha512-w1+YvwmTkwt5n8yUqEH3IWZ9DsmorCZW/EQ3KYHnqEgIkOZpH54NvW6/Epq3Dzb8H0ifIeYX8PExvrNLIH5FIA==", - "license": "MIT", - "engines": { - "node": ">=20.19" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/query-core": { - "version": "5.107.1", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.107.1.tgz", - "integrity": "sha512-PSyAik11oQBEVOiVy5hjZWHmoUqOXqK1WYy4v55mcxozEfKaQgpUMsgnXhWjuESzSht8JfzqMS1m6M/9CpNgvg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/react-query": { - "version": "5.107.1", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.107.1.tgz", - "integrity": "sha512-udCOOLp3ZAheUrIHLCe+ZZge6nr2ZUrGMSWcCuXsjY1Bf2JeeE09oxst97PILiCY6N8xo/7bIo3Hvz13zh06FA==", - "license": "MIT", - "dependencies": { - "@tanstack/query-core": "5.107.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "@types/react": "^18 || ^19", - "react": "^18 || ^19" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@tanstack/react-router": { - "version": "1.170.46", - "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.170.46.tgz", - "integrity": "sha512-ez1iNIhsPjGlaDHg3BXWs67EWVIYzB2tbopEHYDESdkO7owgkGb5de52+5Kv2v1fxdkggcNzo35bdk4arTVGQw==", - "license": "MIT", - "dependencies": { - "@tanstack/history": "1.162.5", - "@tanstack/react-store": "^0.11.2", - "@tanstack/router-core": "1.171.39", - "isbot": "^5.1.22" - }, - "engines": { - "node": ">=20.19" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": ">=18.0.0 || >=19.0.0", - "react-dom": ">=18.0.0 || >=19.0.0" - } - }, - "node_modules/@tanstack/react-store": { - "version": "0.11.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.11.2.tgz", - "integrity": "sha512-oj5O5DmiMWE+ih+SioX2ayC1kbK7d3fpLx0B75fPJr9nY3zU+hoW010yr64qXsY/9fyiLVE2bn5mGNV3owm8RQ==", - "license": "MIT", - "dependencies": { - "@tanstack/store": "0.11.2", - "use-sync-external-store": "^1.6.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/@tanstack/router-core": { - "version": "1.171.39", - "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.171.39.tgz", - "integrity": "sha512-qUhoJ0meuSUqGYQbHbcJYdASr06YtK+bulbj6vtrhKYFkLgkSsZ6tXmMyym7sFxiSJSPYgasgozrL01LyyR/6A==", - "license": "MIT", - "dependencies": { - "@tanstack/history": "1.162.5", - "cookie-es": "^3.0.0", - "seroval": "^1.6.7", - "seroval-plugins": "^1.6.7" - }, - "engines": { - "node": ">=20.19" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/store": { - "version": "0.11.2", - "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.11.2.tgz", - "integrity": "sha512-sJ4mjol8uQsHV0gOJzzjwXfh2Fwm+Sz0+8deqiTm4jGbMdjzNSW+xZCFm0kUa870uhd8yi+DpKnZb5Kc4apa0Q==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@testing-library/dom": { - "version": "10.4.2", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", - "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@testing-library/jest-dom": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", - "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@adobe/css-tools": "^4.4.0", - "aria-query": "^5.0.0", - "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.6.3", - "picocolors": "^1.1.1", - "redent": "^3.0.0" - }, - "engines": { - "node": ">=22", - "npm": ">=6", - "yarn": ">=1" - }, - "peerDependencies": { - "@testing-library/dom": ">=10 <11", - "vitest": ">= 0.32" - }, - "peerDependenciesMeta": { - "vitest": { - "optional": true - } - } - }, - "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", - "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@testing-library/react": { - "version": "16.3.3", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", - "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@testing-library/dom": "^10.0.0", - "@types/react": "^18.0.0 || ^19.0.0", - "@types/react-dom": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@testing-library/user-event": { - "version": "14.6.8", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.8.tgz", - "integrity": "sha512-vgSSqnANMnkcYR/vUNDbwS7aM3OErRLNUG8vCVVlj4oc6cKffUrccX7UAS5AmJQQNZTSibi9z5GvvRZ0aYgUCQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12", - "npm": ">=6" - }, - "peerDependencies": { - "@testing-library/dom": ">=7.21.4" - } - }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/esrecurse": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", - "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/gensync": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/gensync/-/gensync-1.0.5.tgz", - "integrity": "sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/jsesc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", - "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "26.6.6", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz", - "integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~8.9.0" - } - }, - "node_modules/@types/react": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", - "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", - "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.3.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.72.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.1.tgz", - "integrity": "sha512-5CokTWXpmbylfFabsbvHC0CAw3XLaGnoa+nFJX5FR592h/LoZsENUE6p/rg5xvkKrFEXz6pYmgI29AgXc2N3BQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.72.1", - "@typescript-eslint/type-utils": "8.72.1", - "@typescript-eslint/utils": "8.72.1", - "@typescript-eslint/visitor-keys": "8.72.1", - "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.72.1", - "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.14", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.14.tgz", - "integrity": "sha512-wCBiZ77u1tuWde6G/nfOtLaCTYcFDU5yCzARzK10y+rLhzYoL7a7yEemcopcc2Mzow7gZOIjnV2ym1NYmdiJOQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.72.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.72.1.tgz", - "integrity": "sha512-ZGwZP4OCfyA0AQTpEAMWz+rtX01DRhQWW5hB0ucKDEIX5U7jdmPSaMx94rQfaPAQzIYz2BimOgKOJtjfpRFQ9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.72.1", - "@typescript-eslint/types": "8.72.1", - "@typescript-eslint/typescript-estree": "8.72.1", - "@typescript-eslint/visitor-keys": "8.72.1", - "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.72.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.1.tgz", - "integrity": "sha512-fAuVMkwdO/huI8nyo/WKnNKf6Gag93S56Rh3Ts5mGcavwSdaN8tRg1gCeIGX8PF+nzp1BjGfO4gHhGzW1J6jTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.72.1", - "@typescript-eslint/types": "^8.72.1", - "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.72.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.1.tgz", - "integrity": "sha512-lC/t6eMCZZQAAKZEYGL6RDVSuFehpdmMT6h+nJIjIa6oylN/PwDgDVtEIbB7O9xFfZYtZAYLoLlDNWKJhtF+uQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.72.1", - "@typescript-eslint/visitor-keys": "8.72.1" - }, - "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.72.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.1.tgz", - "integrity": "sha512-e4D5R5vpeAEPBxT2BAzsFQL3sbwyeeeo5+aczgNSRK1V734jE7pTqaGFU+fXapWEQwnZbpFaTn32+Z8DwS6OTg==", - "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.72.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.72.1.tgz", - "integrity": "sha512-iY2wlmyrXJmx4yxUGP3aYCFF3cA/6ShddOKmLU2dx7LnEykbAM8WThlxZEfd1NddqceF6eJmZUayxx9oKIYNlg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.72.1", - "@typescript-eslint/typescript-estree": "8.72.1", - "@typescript-eslint/utils": "8.72.1", - "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.72.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.1.tgz", - "integrity": "sha512-LmE5IhQet1BWOWEXqBrVtBPsMAOQ6Nzk35d+WLh3mk4EUF33sqlfxjio1MdYeSvKSG5skactKKuixBIKXhL4Rw==", - "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.72.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.1.tgz", - "integrity": "sha512-DAlUoUV93hIvrtIQLwCbmZQYVZwOMhiHJrOVUUIWicFZaIYjTRqlY3I/NZevfkLmSqkNHRu2qYDcq9lrn16oOQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.72.1", - "@typescript-eslint/tsconfig-utils": "8.72.1", - "@typescript-eslint/types": "8.72.1", - "@typescript-eslint/visitor-keys": "8.72.1", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.72.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.1.tgz", - "integrity": "sha512-5v5liypurJBFHCBNqh/RjsymAD+VWcE0djuGY/BUqp5fC3Qn+luWDEksjuTtWjPn6i5zQlUtkZCRG/CYG0yCkA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.72.1", - "@typescript-eslint/types": "8.72.1", - "@typescript-eslint/typescript-estree": "8.72.1" - }, - "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.72.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.1.tgz", - "integrity": "sha512-SB8xqipSzZHUePCCLNeYxqnxq83X61/qzNhjQGbTfjxGZIeOEP7tBgWYX05Tcd6CgE02WdTJnh5b8kMZJ/+bnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.72.1", - "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.2.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.2.1.tgz", - "integrity": "sha512-9KyWCij3r0+qLG3Q62vBmv0VyevmYibAtxaCIVK9o0k6CXgaajZRIe8tpfchQcdZArJ5b/7OrIz4W3b2uxVRDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rolldown/pluginutils": "^1.0.1" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0 || ^0.3.0", - "babel-plugin-react-compiler": "^1.0.0", - "oxc-transform-react": ">=0.152.0 <0.157.0", - "vite": "^8.0.0" - }, - "peerDependenciesMeta": { - "@rolldown/plugin-babel": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - }, - "oxc-transform-react": { - "optional": true - } - } - }, - "node_modules/@vitest/coverage-v8": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.1.0.tgz", - "integrity": "sha512-OY9aShBQG5KOIw2zCo9fDoCHI9RHliNHJ//nM7m6jGg41Hyke9JOGWmeMlK74xYErsxMoK8mIB3gAeEyFVA7LA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@bcoe/v8-coverage": "^1.0.2", - "@vitest/istanbul-lib-coverage": "^2.1.0", - "@vitest/istanbul-lib-report": "^2.1.0", - "ast-v8-to-istanbul": "^1.0.7", - "magicast": "^0.5.5", - "obug": "^3.0.0", - "std-env": "^4.3.0", - "tinyrainbow": "^3.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@vitest/browser": "5.1.0", - "vitest": "5.1.0" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } - } - }, - "node_modules/@vitest/istanbul-lib-coverage": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-coverage/-/istanbul-lib-coverage-2.1.0.tgz", - "integrity": "sha512-WOjuS+xcjSIy7ruYBQ+9BUcV7XZQ0FyVcbWbWxW9TRx/kLGx5SQaeFRIFDJxjNplb6YsT7SpKo2zuXXUo91OIQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=22" - } - }, - "node_modules/@vitest/istanbul-lib-report": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-report/-/istanbul-lib-report-2.1.0.tgz", - "integrity": "sha512-+ZFta25nWtGCOBG9/IET8w1RO5Oawsd6H8Uf6AJ9EA/StFuKVtKAEJEJ6uRt84OH+T4YkI0ASmIHAhRA+0KVxw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/istanbul-lib-coverage": "2.1.0" - }, - "engines": { - "node": ">=22" - } - }, - "node_modules/@vitest/mocker": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.1.0.tgz", - "integrity": "sha512-5umB9/jlWcqVS6fsrFmv5Gdnpu6wAUio/lEOwAmvB7v+lFo/IW2B9FhAD0p19GCULTgMzLCdUmuCDCnAzWgppQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "0.3.31", - "@vitest/spy": "5.1.0", - "estree-walker": "^3.0.3", - "magic-string": "^1.4.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9 || ^3.0.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/spy": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.1.0.tgz", - "integrity": "sha512-2DPO5OMHWKrBngC+fCmTQqemNF0gidRhp3lxbBmRhW+M1XJrfgPpqNqWVevXtn2f2ufD5ZaR1B6B641aAlcX5A==", - "dev": true, - "license": "MIT", - "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", - "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/artichokie": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/artichokie/-/artichokie-0.4.5.tgz", - "integrity": "sha512-ma9643oYgSgA6CeDvKIU0zxL1eKOkbgH8/v83xFpFYB2VOPFoEmmg1iC+ooHFjLIJ3qdtfwPbEKpIW3/YxPovg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/sapphi-red" - } - }, - "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.7", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.7.tgz", - "integrity": "sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==", - "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/babel-plugin-react-compiler": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", - "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.26.0" - } - }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.11.28", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz", - "integrity": "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/bcp-47": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/bcp-47/-/bcp-47-2.1.2.tgz", - "integrity": "sha512-wsv86UuNFuPnVieKLyECTT+CP1WSAHeoIg5P3xu2kA9Z+7fVPoKt7dxzSeEhtJel0AI+NW3fIW3KtTKQKUCngg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-alphabetical": "^2.0.0", - "is-alphanumerical": "^2.0.0", - "is-decimal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/bcp-47-match": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/bcp-47-match/-/bcp-47-match-2.0.3.tgz", - "integrity": "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/brace-expansion": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", - "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/browserslist": { - "version": "4.29.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", - "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.11.26", - "caniuse-lite": "^1.0.30001813", - "electron-to-chromium": "^1.5.439", - "node-releases": "^2.0.57", - "update-browserslist-db": "^1.3.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/cacheable": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", - "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cacheable/memory": "^2.2.0", - "@cacheable/utils": "^2.5.0", - "hookified": "^1.15.0", - "keyv": "^5.6.0", - "qified": "^0.10.1" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001815", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz", - "integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==", - "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.3.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", - "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", - "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/cookie-es": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", - "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", - "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.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==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/data-urls": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz", - "integrity": "sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^17.1.2" - }, - "engines": { - "node": "^22.14.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.452", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.452.tgz", - "integrity": "sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg==", - "dev": true, - "license": "ISC" - }, - "node_modules/empathic": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.1.0.tgz", - "integrity": "sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - } - }, - "node_modules/entities": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-9.0.0.tgz", - "integrity": "sha512-oJFNDJg3kTPwAD1cNn2+WmUq4wFoo09npc3Ydf1D7ED4smRiWrsKnaLxykrTWgyGEcusc87P5dxlAleol9kpkg==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20.19.0" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/es-module-lexer": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", - "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", - "dev": true, - "license": "MIT" - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "10.14.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.14.0.tgz", - "integrity": "sha512-T0pNsxkb9IKmtQjhpK3d7nWOpFv+YHuMbTJnSVMoQblF0onvXfErrtp4V1jHtsQPan7dKaGVD0q1WCXnzMF31A==", - "dev": true, - "license": "MIT", - "workspaces": [ - "packages/*" - ], - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.7.0", - "@eslint/core": "^1.2.1", - "@eslint/plugin-kit": "^0.7.3", - "@humanfs/node": "^0.16.8", - "@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": "11.1.5 || >11.1.6 <12", - "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.6", - "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-hooks/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/eslint-plugin-react-hooks/node_modules/@babel/core": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/eslint-plugin-react-hooks/node_modules/@babel/generator": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", - "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.8", - "@babel/types": "^7.29.8", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/eslint-plugin-react-hooks/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/eslint-plugin-react-hooks/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/eslint-plugin-react-hooks/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/eslint-plugin-react-hooks/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/eslint-plugin-react-hooks/node_modules/@babel/helpers": { - "version": "7.29.10", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", - "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.8" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/eslint-plugin-react-hooks/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/eslint-plugin-react-hooks/node_modules/@babel/traverse": { - "version": "7.29.10", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", - "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.8", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.9", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.8", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/eslint-plugin-react-hooks/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/eslint-plugin-react-refresh": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.7.tgz", - "integrity": "sha512-XhJSzLljuYD4UjNuFGJu2v7aD3sqNVK12w5/DCUfrNfHWegZo2+TsavNx8MuxMwEquuFAzNbNfXKU0WoZ+YUIg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "eslint": "^9 || ^10" - } - }, - "node_modules/eslint-scope": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", - "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@types/esrecurse": "^4.3.1", - "@types/estree": "^1.0.8", - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", - "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.16.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^5.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/expect-type": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", - "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/file-entry-cache": { - "version": "11.1.5", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", - "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^6.1.23" - } - }, - "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": "6.1.23", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", - "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "cacheable": "^2.5.0", - "flatted": "^3.4.2", - "hookified": "^1.15.0" - } - }, - "node_modules/flatted": { - "version": "3.4.4", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", - "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", - "dev": true, - "license": "ISC" - }, - "node_modules/flru": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/flru/-/flru-1.0.2.tgz", - "integrity": "sha512-kWyh8ADvHBFz6ua5xYOPnUroZTT/bwWfrCeL0Wj1dzG4/YOmOcfJ99W8dOVyyynJN35rZ9aCOtHChqQovV7yog==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "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.14.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.14.0.tgz", - "integrity": "sha512-UtdkTVyAJePQv0J2M9Xe4L0ruNSDV314XiIJsADI2Zf40bcdAonE/D/OhAU8AT99oij9mhLByb+NvA3QWZYgCg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/hashery": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", - "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "hookified": "^1.15.0" - }, - "engines": { - "node": ">=20" - } - }, - "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/hookified": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", - "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", - "dev": true, - "license": "MIT" - }, - "node_modules/html-encoding-sniffer": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", - "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.15.1" - }, - "engines": { - "node": "^22.13.0 || >=24.0.0" - } - }, - "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/import-meta-resolve": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", - "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "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-alphabetical": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", - "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-alphanumerical": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", - "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-alphabetical": "^2.0.0", - "is-decimal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-decimal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", - "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "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/isbot": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.2.2.tgz", - "integrity": "sha512-iQcBXcd+Rv/pkubRyGh2utW2j1oPG5hZY6TUhVPpqK4G+o3IbxpJNx04hgksjc/N7GK5pEorUxDeg31cFgEk/w==", - "license": "Unlicense", - "engines": { - "node": ">=18" - } - }, - "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/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": "31.0.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-31.0.0.tgz", - "integrity": "sha512-xAkhD9z1eyFC5+QUi9K24CaZJgdCQ2GOGLW1GdVDoQvkp/AxEH9TQ0pAplPOlIiV92hfcgnFOADZypbjgbBT6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.16.0", - "bcp-47": "^2.1.2", - "bcp-47-match": "^2.0.3", - "data-urls": "^8.0.0", - "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^7.0.0", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.5.3", - "parse5": "^8.1.0", - "parse5-parser-stream": "^8.1.0", - "saxes": "^6.0.0", - "tough-cookie": "^6.0.2", - "undici": "^8.12.3", - "w3c-xmlserializer": "^6.0.0", - "webidl-conversions": "^8.0.1", - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^17.3.0", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": "^22.22.2 || ^24.15.0 || >=26.0.0" - }, - "peerDependencies": { - "@napi-rs/canvas": "^1.0.13" - }, - "peerDependenciesMeta": { - "@napi-rs/canvas": { - "optional": true - } - } - }, - "node_modules/jsdom/node_modules/lru-cache": { - "version": "11.5.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", - "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", - "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-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": "5.6.0", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", - "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@keyv/serialize": "^1.1.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/lightningcss": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", - "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.33.0", - "lightningcss-darwin-arm64": "1.33.0", - "lightningcss-darwin-x64": "1.33.0", - "lightningcss-freebsd-x64": "1.33.0", - "lightningcss-linux-arm-gnueabihf": "1.33.0", - "lightningcss-linux-arm64-gnu": "1.33.0", - "lightningcss-linux-arm64-musl": "1.33.0", - "lightningcss-linux-x64-gnu": "1.33.0", - "lightningcss-linux-x64-musl": "1.33.0", - "lightningcss-win32-arm64-msvc": "1.33.0", - "lightningcss-win32-x64-msvc": "1.33.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", - "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", - "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", - "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", - "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", - "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", - "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", - "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", - "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", - "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", - "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", - "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, - "license": "MIT", - "bin": { - "lz-string": "bin/bin.js" - } - }, - "node_modules/magic-string": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.5.tgz", - "integrity": "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.6.0" - } - }, - "node_modules/magicast": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz", - "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", - "source-map-js": "^1.2.1" - } - }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.20", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", - "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", - "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.58", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.58.tgz", - "integrity": "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/obug": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", - "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT", - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parse5": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.1.1.tgz", - "integrity": "sha512-EcR/6WDcU7b2N3vQSxKALCk9lXGtUMQxXio008OEMBE0kavu/7JLfBEeFPzi43nMqVDSbEn3QCK+BMCc9zO//w==", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^9.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5-parser-stream": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-8.1.1.tgz", - "integrity": "sha512-hMcVbbUJcQmP8rURWUdYrT1flRiBiBVjq/RYGguJpDUgIXSqdYKlE0CxthmdMWoSlBAuL5bUBa73g0pttSy7Hw==", - "dev": true, - "license": "MIT", - "dependencies": { - "parse5": "^8.1.1" - }, - "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/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", - "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.32", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", - "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", - "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.20", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.2" - }, - "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/qified": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", - "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "hookified": "^2.1.1" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/qified/node_modules/hookified": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", - "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", - "dev": true, - "license": "MIT" - }, - "node_modules/react": { - "version": "19.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz", - "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz", - "integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==", - "license": "MIT", - "dependencies": { - "scheduler": "^0.28.0" - }, - "peerDependencies": { - "react": "^19.3.1" - } - }, - "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/rolldown": { - "version": "1.2.16", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.16.tgz", - "integrity": "sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.156.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.16", - "@rolldown/binding-android-arm64": "1.2.16", - "@rolldown/binding-darwin-arm64": "1.2.16", - "@rolldown/binding-darwin-x64": "1.2.16", - "@rolldown/binding-freebsd-x64": "1.2.16", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.16", - "@rolldown/binding-linux-arm64-gnu": "1.2.16", - "@rolldown/binding-linux-arm64-musl": "1.2.16", - "@rolldown/binding-linux-ppc64-gnu": "1.2.16", - "@rolldown/binding-linux-s390x-gnu": "1.2.16", - "@rolldown/binding-linux-x64-gnu": "1.2.16", - "@rolldown/binding-linux-x64-musl": "1.2.16", - "@rolldown/binding-openharmony-arm64": "1.2.16", - "@rolldown/binding-win32-arm64-msvc": "1.2.16", - "@rolldown/binding-win32-x64-msvc": "1.2.16" - } - }, - "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.28.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", - "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", - "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/seroval": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.7.1.tgz", - "integrity": "sha512-tSRMcPkD/bu1rmY0/XfFP2zLPD8jXVuzHli6Y2UFi9AiG/d0kOd8LAc/G6NolpW8MA1PtgDUhywKCmerS4E3tg==", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/seroval-plugins": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.7.1.tgz", - "integrity": "sha512-5EiMqQiw2/KqzTCyJNdpMvBFbKlidtcinyRwZg77pW5Z+QllOp2JUkEpTdThkdsEFXsPGf8ofTpu5yC1kXwv0g==", - "license": "MIT", - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "seroval": "^1.0" - } - }, - "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/source-map-js": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", - "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/std-env": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", - "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", - "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/tinybench": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", - "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/tinyexec": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", - "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyrainbow": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", - "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tldts": { - "version": "7.4.11", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.11.tgz", - "integrity": "sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tldts-core": "^7.4.11" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "7.4.11", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.11.tgz", - "integrity": "sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tough-cookie": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", - "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^7.0.5" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/tr46": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-7.0.0.tgz", - "integrity": "sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": "^22.14.0 || >=24.0.0" - } - }, - "node_modules/ts-api-utils": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", - "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/typescript-eslint": { - "version": "8.72.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.72.1.tgz", - "integrity": "sha512-3Li9FfPmQp45nxEtFD1ZCrYFI3cYK5kpyNtmm1H7lMagm8u9sy6PTjy3ytwQZs7KNukOyKFHAZ6z6GjYFeaeXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.72.1", - "@typescript-eslint/parser": "8.72.1", - "@typescript-eslint/typescript-estree": "8.72.1", - "@typescript-eslint/utils": "8.72.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/undici": { - "version": "8.12.4", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.4.tgz", - "integrity": "sha512-8Mx+uNKKER2bA4AdtedkqRbwUCIJFr2nJckXRe5yrDqzePqwLP7VNcctN8/XdmDSbF7mS+jJKIszlL+vbam1VA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/undici-types": { - "version": "8.9.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", - "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", - "dev": true, - "license": "MIT" - }, - "node_modules/update-browserslist-db": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", - "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", - "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/use-sync-external-store": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", - "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/verkit": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/verkit/-/verkit-0.5.0.tgz", - "integrity": "sha512-u4J1hr6Y+C/hit34GwL3eIcUf3wngT8TlYUjK7tk0jxydKLAq3sYvifjdTDA6U3KCKRcQCQDGf/srjxfZF3l7g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12.0" - }, - "funding": { - "url": "https://github.com/sponsors/sxzz" - } - }, - "node_modules/vite": { - "version": "8.3.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.6.tgz", - "integrity": "sha512-UI7dK29NnS54Fk+mmo7o+jI6U8Fp7hDl+X5Q1k45WH+d4dSlbLVUETGQ+hfkNgvLQGyoElPj837nZyv4MBc0VA==", - "dev": true, - "license": "MIT", - "dependencies": { - "lightningcss": "^1.33.0", - "picomatch": "^4.0.7", - "postcss": "^8.5.32", - "rolldown": "~1.2.16", - "tinyglobby": "^0.2.17" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.7.1 || ^0.8.0", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vitest": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.1.0.tgz", - "integrity": "sha512-MzmNFnrwa8v8lF1bLxw3UJ4EREwVOVb75VqJZmUDRjJ6EluK/x9th7qTbaeDk41WxE1GxWqmTXcdsfobfH5sOA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/mocker": "5.1.0", - "chai": "^6.3.0", - "es-module-lexer": "^2.3.2", - "expect-type": "^1.4.0", - "magic-string": "^1.4.3", - "obug": "^3.0.0", - "picomatch": "^4.0.7", - "std-env": "^4.3.0", - "tinybench": "^6.2.2", - "tinyexec": "^1.3.1", - "tinyglobby": "^0.2.17", - "why-is-node-running": "3.2.1" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^22.12.0 || ^24.0.0 || >=26.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "5.1.0", - "@vitest/browser-preview": "5.1.0", - "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", - "@vitest/coverage-istanbul": "5.1.0", - "@vitest/coverage-v8": "5.1.0", - "@vitest/ui": "5.1.0", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.4.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": "6.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", - "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": "^22.22.2 || ^24.15.0 || >=26.0.0" - } - }, - "node_modules/webidl-conversions": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", - "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20" - } - }, - "node_modules/whatwg-mimetype": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", - "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/whatwg-url": { - "version": "17.3.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.3.0.tgz", - "integrity": "sha512-KwOAJrpf+GTgBvcBQJcP38sMF0DsQKdwtG+TxM2pW44P6E5k0hDbWrDmyuJDIF0Uef0aQq0ZoJs+5oI+k8ddhg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.16.0", - "tr46": "^7.0.0", - "webidl-conversions": "^8.0.1" - }, - "engines": { - "node": "^22.14.0 || >=24.0.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/why-is-node-running": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", - "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", - "dev": true, - "license": "MIT", - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=20.11" - } - }, - "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", - "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 index 9da8c09..a6a5077 100644 --- a/web/package.json +++ b/web/package.json @@ -3,44 +3,39 @@ "private": true, "version": "0.0.0", "type": "module", + "packageManager": "pnpm@12.18.0", + "engines": { + "node": ">=24", + "pnpm": ">=10" + }, "scripts": { - "dev": "vite", - "dev:mock": "vite --mode mock", - "build": "tsc -b && vite build", - "lint": "eslint .", - "preview": "vite preview", + "dev": "astro dev", + "dev:mock": "astro dev --mode mock", + "build": "astro check && astro build", + "lint": "astro check && eslint src", + "preview": "astro preview", "test": "vitest run", "test:watch": "vitest", "coverage": "vitest run --coverage" }, "dependencies": { - "@tanstack/react-query": "5.107.1", - "@tanstack/react-router": "1.170.46", - "react": "19.3.1", - "react-dom": "19.3.1" + "@kuhyx/web-ui": "github:kuhyx/utils#web_ui-v0.5.0&path:/web_ui", + "astro": "7.4.7" }, "devDependencies": { - "@babel/core": "8.1.0", + "@astrojs/check": "0.9.12", "@eslint/js": "10.0.1", - "@rolldown/plugin-babel": "0.3.0", "@testing-library/dom": "10.4.2", "@testing-library/jest-dom": "7.0.1", - "@testing-library/react": "16.3.3", "@testing-library/user-event": "14.6.8", "@types/node": "26.6.6", - "@types/react": "19.3.0", - "@types/react-dom": "19.3.0", - "@vitejs/plugin-react": "6.2.1", "@vitest/coverage-v8": "5.1.0", - "babel-plugin-react-compiler": "1.0.0", "eslint": "10.14.0", - "eslint-plugin-react-hooks": "7.1.1", - "eslint-plugin-react-refresh": "0.5.7", + "eslint-plugin-astro": "3.2.1", "globals": "17.14.0", "jsdom": "31.0.0", "typescript": "6.0.3", "typescript-eslint": "8.72.1", - "vite": "8.3.6", "vitest": "5.1.0" } } diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index cf3ad6a..e9bb4f8 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -1,74 +1,209 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.18.0 + version: 12.18.0 + +packages: + + '@pnpm/exe.android-arm64@12.18.0': + resolution: {integrity: sha512-fOF6IwgvnUKEmVedGqiAsNPfN2Ry8GvaqzSQOiF3RJFbpBfPKO/6YT54s1GjToyYinRxw9preHHPe4y2N3SzhA==} + cpu: [arm64] + os: [android] + + '@pnpm/exe.android-x64@12.18.0': + resolution: {integrity: sha512-6OiolnaCsB3/GcJ9XNrenbUH3zFHRJ+5QynrMUUEyThbgnnCzWxa199hpQlOmxN4rAXuYQ0IYgWFNUZxyzg31Q==} + cpu: [x64] + os: [android] + + '@pnpm/exe.darwin-arm64@12.18.0': + resolution: {integrity: sha512-zkq+4YzVIX39ETYLyC3W7LY7MpHEU/J3nRhHl4i2yPld2Cf2+rEKRh3ejJ4AmaQ77HHJLCktY4Z3jq0fpcxFXg==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.18.0': + resolution: {integrity: sha512-qMsL9qx92unOTa6hgkfiTJv8ceZaTx0IQg7InhYDVX2ZrTECujaVEEVypnJK5m3eGUsP1KD7Y4qIgleCwfRsYw==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.freebsd-x64@12.18.0': + resolution: {integrity: sha512-/9vy/4ms52cU4MjZ5NhHd6N2d9L9W0kARf5/ff2tweAHu0VeUzs/0qp/7ZHFuddm2Wa/m0AKQoPum9AVUCqKsw==} + cpu: [x64] + os: [freebsd] + + '@pnpm/exe.linux-arm64-musl@12.18.0': + resolution: {integrity: sha512-Vp+TIMPPyackVCXkOdEKH/RmAn/r07EcIyrgZYQK7VrkksqZOie0GnWIx+85mPqeFEYCRtXIZAFJ9L4ceTndDg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.18.0': + resolution: {integrity: sha512-2dpyVBCHT+FfpuQh9i+7MXAqNWYLCvD5vYr5p6Eimxoga8USLnjbUgyj72AOrO0hjc5ri3TEFP3jiyBHjkSZpQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-ppc64@12.18.0': + resolution: {integrity: sha512-BBOOOfNJ2QwIIya3eKqOjiN4WNTszfRz7HCvw9XggcT7+ftMjNP8XJ/GfW+ltCBeUFR9d8WBVsr2m/NLjX2CZQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-riscv64@12.18.0': + resolution: {integrity: sha512-7kMN77xSOYJX8GPSH5RbNAc5oM61LMmcSUukDDUwllnZPXm8GRPskCNsTWy8SCw01Kr1FB+5SwGNMiTdiziLNQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-s390x@12.18.0': + resolution: {integrity: sha512-htuSCpEcmDzhKoJqXbPkvfr176dBHU0QIF5mWcRiqxRXAB/1JoYiMYotfjWpk+ztgpB9qlx4XnWlLFAf7gzc6g==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.18.0': + resolution: {integrity: sha512-Uj+vzfIQ62kDP10x36yHZIXVs5ohdNtiPYwn5FhCLlNScHPo0OAI+ShGDtLofiBXKE7umu71Jmjm7N8a8zVcDw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.18.0': + resolution: {integrity: sha512-OJ+X2wcraldCwj75pCIerWARQSRpdfj0eBTs/2ODyhfeW46ylOnjTIE/GKra6Mn6E72/gBmXS8igrUioa+F9fQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.18.0': + resolution: {integrity: sha512-wEY6jiACGcbhhi5yD59doHqNTndS7AaujaPmtWAu1DKkHru2hy5fJ9yUGecW72Ypaw2/I3BEprfMOopap2NQtw==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.18.0': + resolution: {integrity: sha512-5FJkghO7RLRkQpWxeVEygW9XsmnNuxzPfXbgwFWCHsFyFhpDn/vOdL88ToQEM0Kt5igdW5s6U0/IDH8Dvkt3pg==} + cpu: [x64] + os: [win32] + + pnpm@12.18.0: + resolution: {integrity: sha512-nXiC1dyCFH7PxOBiqjSDTgrQKWcQoYN6ERWuASEn02txCeMhrBWYod0KZVl2gbHpmRfhLtDXSerpEYeUM2O+sw==} + engines: {node: '>=18'} + hasBin: true + +snapshots: + + '@pnpm/exe.android-arm64@12.18.0': + optional: true + + '@pnpm/exe.android-x64@12.18.0': + optional: true + + '@pnpm/exe.darwin-arm64@12.18.0': + optional: true + + '@pnpm/exe.darwin-x64@12.18.0': + optional: true + + '@pnpm/exe.freebsd-x64@12.18.0': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.18.0': + optional: true + + '@pnpm/exe.linux-arm64@12.18.0': + optional: true + + '@pnpm/exe.linux-ppc64@12.18.0': + optional: true + + '@pnpm/exe.linux-riscv64@12.18.0': + optional: true + + '@pnpm/exe.linux-s390x@12.18.0': + optional: true + + '@pnpm/exe.linux-x64-musl@12.18.0': + optional: true + + '@pnpm/exe.linux-x64@12.18.0': + optional: true + + '@pnpm/exe.win32-arm64@12.18.0': + optional: true + + '@pnpm/exe.win32-x64@12.18.0': + optional: true + + pnpm@12.18.0: + optionalDependencies: + '@pnpm/exe.android-arm64': 12.18.0 + '@pnpm/exe.android-x64': 12.18.0 + '@pnpm/exe.darwin-arm64': 12.18.0 + '@pnpm/exe.darwin-x64': 12.18.0 + '@pnpm/exe.freebsd-x64': 12.18.0 + '@pnpm/exe.linux-arm64': 12.18.0 + '@pnpm/exe.linux-arm64-musl': 12.18.0 + '@pnpm/exe.linux-ppc64': 12.18.0 + '@pnpm/exe.linux-riscv64': 12.18.0 + '@pnpm/exe.linux-s390x': 12.18.0 + '@pnpm/exe.linux-x64': 12.18.0 + '@pnpm/exe.linux-x64-musl': 12.18.0 + '@pnpm/exe.win32-arm64': 12.18.0 + '@pnpm/exe.win32-x64': 12.18.0 + +--- lockfileVersion: '9.0' settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 + importers: .: dependencies: - '@tanstack/react-query': - specifier: 5.107.1 - version: 5.107.1(@types/react@19.3.0)(react@19.3.1) - '@tanstack/react-router': - specifier: 1.170.46 - version: 1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1) - react: - specifier: 19.3.1 - version: 19.3.1 - react-dom: - specifier: 19.3.1 - version: 19.3.1(react@19.3.1) + '@kuhyx/web-ui': + specifier: github:kuhyx/utils#web_ui-v0.5.0&path:/web_ui + version: https://codeload.github.com/kuhyx/utils/tar.gz/decf5824e609b099f9455be93c4d09b4c1fd6cae#path:/web_ui(astro@7.4.7(@types/node@26.6.6)(yaml@2.9.1)) + astro: + specifier: 7.4.7 + version: 7.4.7(@types/node@26.6.6)(yaml@2.9.1) devDependencies: - '@babel/core': - specifier: 8.1.0 - version: 8.1.0 + '@astrojs/check': + specifier: 0.9.12 + version: 0.9.12(@emnapi/runtime@1.11.3)(prettier@3.9.10)(typescript@6.0.3) '@eslint/js': specifier: 10.0.1 version: 10.0.1(eslint@10.14.0(supports-color@7.2.0)) - '@rolldown/plugin-babel': - specifier: 0.3.0 - version: 0.3.0(@babel/core@8.1.0)(@babel/runtime@7.29.7)(rolldown@1.2.16)(vite@8.3.6(@types/node@26.6.6)) '@testing-library/dom': specifier: 10.4.2 version: 10.4.2 '@testing-library/jest-dom': specifier: 7.0.1 version: 7.0.1(@testing-library/dom@10.4.2)(vitest@5.1.0) - '@testing-library/react': - specifier: 16.3.3 - version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) '@testing-library/user-event': specifier: 14.6.8 version: 14.6.8(@testing-library/dom@10.4.2) '@types/node': specifier: 26.6.6 version: 26.6.6 - '@types/react': - specifier: 19.3.0 - version: 19.3.0 - '@types/react-dom': - specifier: 19.3.0 - version: 19.3.0(@types/react@19.3.0) - '@vitejs/plugin-react': - specifier: 6.2.1 - version: 6.2.1(@rolldown/plugin-babel@0.3.0(@babel/core@8.1.0)(@babel/runtime@7.29.7)(rolldown@1.2.16)(vite@8.3.6(@types/node@26.6.6)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.6(@types/node@26.6.6)) '@vitest/coverage-v8': specifier: 5.1.0 version: 5.1.0(vitest@5.1.0) - babel-plugin-react-compiler: - specifier: 1.0.0 - version: 1.0.0 eslint: specifier: 10.14.0 version: 10.14.0(supports-color@7.2.0) - eslint-plugin-react-hooks: - specifier: 7.1.1 - version: 7.1.1(eslint@10.14.0(supports-color@7.2.0))(supports-color@7.2.0) - eslint-plugin-react-refresh: - specifier: 0.5.7 - version: 0.5.7(eslint@10.14.0(supports-color@7.2.0)) + eslint-plugin-astro: + specifier: 3.2.1 + version: 3.2.1(@typescript-eslint/parser@8.72.1(eslint@10.14.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.14.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript-eslint@8.72.1(eslint@10.14.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)) globals: specifier: 17.14.0 version: 17.14.0 @@ -81,156 +216,261 @@ importers: typescript-eslint: specifier: 8.72.1 version: 8.72.1(eslint@10.14.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) - vite: - specifier: 8.3.6 - version: 8.3.6(@types/node@26.6.6) vitest: specifier: 5.1.0 - version: 5.1.0(@types/node@26.6.6)(@vitest/coverage-v8@5.1.0)(jsdom@31.0.0)(vite@8.3.6(@types/node@26.6.6)) + version: 5.1.0(@types/node@26.6.6)(@vitest/coverage-v8@5.1.0)(jsdom@31.0.0)(vite@8.3.5(@types/node@26.6.6)(yaml@2.9.1)) packages: '@adobe/css-tools@4.5.0': resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@astrojs/astro2tsx@0.1.3': + resolution: {integrity: sha512-GMDC7Eo0myBUA/QLBl0u+OcegJ8LiBhccmNZR34KmprQNP8gQUJlWlOOZWUsxNA2rNS6GuPYcGDyLoPnjmn4/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@astrojs/check@0.9.12': + resolution: {integrity: sha512-hazpqxIfZWj2VhyPyR0pOEQ/IhFDM/2nIApL96zMXABdD8NrUKYDmhxTy7h+drTDl9MQG/Sd1ET/4amadVJUUA==} + hasBin: true + peerDependencies: + typescript: ^5.0.0 || ^6.0.0 + + '@astrojs/compiler-binding-android-arm64@0.5.2': + resolution: {integrity: sha512-kckIxon6Rm/Bxzh9mGPYpRRqUbToUQoHptCmpMmE+Yt0Xumq8CqrKUidyYhrBsrHVO7bxuRzzWUUqvpf+iYceQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@astrojs/compiler-binding-darwin-arm64@0.5.2': + resolution: {integrity: sha512-1ZG5Ar3dD8HtodX9XH1mRmE0qBXZlvjYXSFfpgFwocqfWnvhFuqtGUNseL8wgfImiRMztIjpQqlogQFQQtgiag==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@astrojs/compiler-binding-darwin-x64@0.5.2': + resolution: {integrity: sha512-+kl35S7hiHJ2cb7eujwEsPFYYuSgE11uEE2Z20lAJ+undpgulMcW+weQqFPOSSZccEKHOiLIY+kQHkb4e4092w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@astrojs/compiler-binding-linux-arm64-gnu@0.5.2': + resolution: {integrity: sha512-BqlR0EHNQLPLwgL9+xogjsAc7zGf0cHho2CPdhv0P1yqbVMwGVaGAqrANP6VYAXCqSC43AF4Czcxc2n+oqdesg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@astrojs/compiler-binding-linux-arm64-musl@0.5.2': + resolution: {integrity: sha512-GRGyby3AZWw5d6JQzidyxPyvGgHWsSusrpgKyFVpDTKctABF7L3Pd5V1cRJbV36/rcu+Eh292/wL7pF7MkmGkQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@astrojs/compiler-binding-linux-x64-gnu@0.5.2': + resolution: {integrity: sha512-9XAqGTMQAtIslMsjK3xdyY6OI9d59OZqcLBB6pvD/DcoR0daYswfrKx2azdDERDY4AqAnWPC7odVM1BmxYv3Vw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@astrojs/compiler-binding-linux-x64-musl@0.5.2': + resolution: {integrity: sha512-jOViewGeTh9bSfS8vK72ECxoy2yvmA+Pd/ZcEvBI5c7+7IcfHwZ5qA4L9fRYuOhSrAifvQ2dV4vm1XAtloaWBw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@astrojs/compiler-binding-wasm32-wasi@0.5.2': + resolution: {integrity: sha512-Z7p64doar1uvR5C9Xs6j1CV9lGlPVQX7OQ2aAtZ/xKC5aGS6GB13nyc2UfQXTQLvZrvVwsyaflTQy6ziY5Bekw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@astrojs/compiler-binding-win32-arm64-msvc@0.5.2': + resolution: {integrity: sha512-Tr13bh+ahTWsjXjzctK7pFpWV+dUU4FtBfETlR7gO+ueex0F+RDch7eRr0YfKysaRhk9tWm14lsCJ5uWyFPNHQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@astrojs/compiler-binding-win32-x64-msvc@0.5.2': + resolution: {integrity: sha512-SfKkI+jOQYLh6wojPvVPV0lFfQnxisweN9B+qCzRvZp7DHAuGif8Y0WcC5PUgN4jb8VKy57LggQDtggR46Du1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@astrojs/compiler-binding@0.5.2': + resolution: {integrity: sha512-lEn76+0JcMQ5RDXU0wSMWJg2ZppiZvSULpghC29cfv4o9bhvWFDbQjsCtvjjXN9iVLFQ7fL3wTR2L2UU13mP8g==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@astrojs/compiler-rs@0.5.2': + resolution: {integrity: sha512-IvwYLB8t1M86rKO4oPdBi9GP4gzXcgrVlNgocjwos1jeeUuEIV8d9Crn1PvYD/5IKteWOxpIHomf7e8iYlkE+Q==} + engines: {node: '>=22.12.0'} + + '@astrojs/internal-helpers@0.13.1': + resolution: {integrity: sha512-LgGLGzoC0wCT3mjDYrxz+hct/tws0enpWsLJW3bswHR3PKgusp/H9xRIT8rTdsyvLM0TcRhhvRL30J2oaZNcqA==} + + '@astrojs/language-server@2.17.3': + resolution: {integrity: sha512-QIAFu1e/4quJMTPdmBOm+ZPXc5NDGZE4/YQjgR3EgjhGLl/tdidaWJDr5WmO2lesVc+BW1qN/xNBURmuePDG2A==} + hasBin: true + peerDependencies: + prettier: ^3.0.0 + prettier-plugin-astro: '>=0.11.0' + peerDependenciesMeta: + prettier: + optional: true + prettier-plugin-astro: + optional: true + + '@astrojs/markdown-satteri@0.4.7': + resolution: {integrity: sha512-MZvnec/898GGGHQZfkArVbkdZmL1imjIM8RWkmzbAmse737zr8D9inUjFQ5eLBXV3vPobOUs/ZRV/D6Po/bVDg==} + + '@astrojs/prism@4.1.2': + resolution: {integrity: sha512-jMrUEpFDarDO1KWPvVc9eSa8DSfwBlwEOHwX+8zvZQpiqXRGDxFnOXHsYqV6DP7w+0qIgTr98gS1GdQlm8ypWg==} + engines: {node: '>=22.23.4'} + + '@astrojs/telemetry@3.4.2': + resolution: {integrity: sha512-2JOMRUxJrBSfiVDfeKLv1wBmvSIx1Y6u2YEO8awZ9qSq3u/rNAiUyFdOYi8dHv91kHrmYEQX7pRDw0J4xyj3UA==} + engines: {node: '>=22.23.4'} + + '@astrojs/yaml2ts@0.2.4': + resolution: {integrity: sha512-8oddpOae35pJsXPQXhTkM0ypfKPskVsh2bCxRtbf7e+/Epw2nReakFYpLKjZMEr75CsoF203PMnCocpfz0s69A==} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} - '@babel/code-frame@8.0.6': - resolution: {integrity: sha512-uMHhvmDmwFiLuiozaoRYTZHqMFuu3fem/X9JY78lohAuLoh8LSGGC8JqUzo2cVuBsBxkjMZNk56CE7ock8Xxkw==} - engines: {node: ^22.18.0 || >=24.11.0} - - '@babel/compat-data@7.29.7': - resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} - engines: {node: '>=6.9.0'} - - '@babel/compat-data@8.0.5': - resolution: {integrity: sha512-YLsYoQMvL8l8WrGpN3Zj7O1wK5LEBN+cQtux7BcuHyxIXve724XG+zuJ1n3U1cUweRtTzQOA4IHbuQw3N34SZw==} - engines: {node: ^22.18.0 || >=24.11.0} - - '@babel/core@7.29.7': - resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} - engines: {node: '>=6.9.0'} - - '@babel/core@8.1.0': - resolution: {integrity: sha512-3jqm7xh3kJCmIeOK8sk6/EvOWdSzvOqvjt0tJEDOHHmzWjcQSvIh2/tq1I9RA741tl0QZdMzPJWCulWE+Dy5ww==} - engines: {node: ^22.18.0 || >=24.11.0} - - '@babel/generator@7.29.8': - resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} - engines: {node: '>=6.9.0'} - - '@babel/generator@8.1.0': - resolution: {integrity: sha512-7fiBJ61wDV3AHbg7n/EfLBv3RU+lsdRP8QNJnydkKWA7e48FnnvKQWbVHsJQCBE7JHTj4xlzZL9fvQYsBUip2A==} - engines: {node: ^22.18.0 || >=24.11.0} - - '@babel/helper-compilation-targets@7.29.7': - resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} - engines: {node: '>=6.9.0'} - - '@babel/helper-compilation-targets@8.0.7': - resolution: {integrity: sha512-ilAZCdOOlOVA7dQbaFNw2f/59p6GW33NxTopc3DvEE3efp3ZXTrGX/yZScaKw8/vd9bq31Ag0EcXiLw32bAgJg==} - engines: {node: ^22.18.0 || >=24.11.0} - - '@babel/helper-globals@7.29.7': - resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-globals@8.0.6': - resolution: {integrity: sha512-OdnA/N52LhzFpeUuVz5YGuSWjc7TPHiFmVYZbPlSgExocO2GwbxVpSni9MVs5wwW+wZ4kPuwBEBN9pIPDYz1mA==} - engines: {node: ^22.18.0 || >=24.11.0} - - '@babel/helper-module-imports@7.29.7': - resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-transforms@7.29.7': - resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@8.0.8': - resolution: {integrity: sha512-fVM+gSuKbdv9cpmyRNh5EvsDHUZCdxQH3FdnFKSkNYHpbsn95bbrxm+ES3MVeQjcqwi2O5ur/DjLfmTQaO60ng==} - engines: {node: ^22.18.0 || >=24.11.0} - '@babel/helper-validator-identifier@7.29.7': resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@8.0.6': - resolution: {integrity: sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ==} - engines: {node: ^22.18.0 || >=24.11.0} - - '@babel/helper-validator-option@7.29.7': - resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-option@8.0.0': - resolution: {integrity: sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q==} - engines: {node: ^22.18.0 || >=24.11.0} - - '@babel/helpers@7.29.7': - resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} - engines: {node: '>=6.9.0'} - - '@babel/helpers@8.1.0': - resolution: {integrity: sha512-949bjw10dhaRdtQeCpfVShFA4jIcto97BkqmWnRjpzyQuHVZ7EDriaoCn/4D8h1NqSKlmI20xPU0vHf3jQbQRg==} - engines: {node: ^22.18.0 || >=24.11.0} - '@babel/parser@7.29.8': resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} engines: {node: '>=6.0.0'} hasBin: true - '@babel/parser@8.1.0': - resolution: {integrity: sha512-fpqZYAC+Tthr19JbEKxXhCGutvfm/+roMe+ULrgUKI+GVyzkQiTMTSheORcWIAzW8T3Oo3W5UavWSvAWmsp7fA==} - engines: {node: ^22.18.0 || >=24.11.0} - hasBin: true - '@babel/runtime@7.29.7': resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} - '@babel/template@7.29.7': - resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} - engines: {node: '>=6.9.0'} - - '@babel/template@8.0.0': - resolution: {integrity: sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==} - engines: {node: ^22.18.0 || >=24.11.0} - - '@babel/traverse@7.29.8': - resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} - engines: {node: '>=6.9.0'} - - '@babel/traverse@8.1.0': - resolution: {integrity: sha512-oQ0QI6MR4cm6bOR/Ua5HLPxvO7vborT7Le5HNt9GmZUKpNsaPl2yj4gsQ440lXaZjc4vmUbSeVgEcK4cHuCL2A==} - engines: {node: ^22.18.0 || >=24.11.0} - '@babel/types@7.29.8': resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} - '@babel/types@8.1.0': - resolution: {integrity: sha512-8V8aSTDx012YPmDQF30vrokyxVFdRAU2YBS2VzrnfLAGkP8floVnjckwgFcD0Jrvf6GWMSRdCrYVsk3tyMwZRg==} - engines: {node: ^22.18.0 || >=24.11.0} - '@bcoe/v8-coverage@1.0.2': resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@bruits/satteri-android-arm64@0.11.6': + resolution: {integrity: sha512-9Cb6ZRDn29c0kluGA9urp/So/JnJOJJSf4J40xYLAIAj3GuqTNsCyquS4A1/yLuuHE2ngYJNnCylAQnDM3L9ow==} + cpu: [arm64] + os: [android] + + '@bruits/satteri-darwin-arm64@0.11.6': + resolution: {integrity: sha512-kcr7QOPGrckuyhMZmUKYcyoXgjQN4vJ/6tCWHE2866mztZPNrTuy5itTwSAbIs1tK0XpTW42tBQ+XPNnkg0XIg==} + cpu: [arm64] + os: [darwin] + + '@bruits/satteri-darwin-x64@0.11.6': + resolution: {integrity: sha512-nwh4Atd/Cxw2SNS6+Gs0Esf/w2i4qyHzwcECaTgoFyxse3m0dpBpu8eqkqqIPfBc1lWhfe4i9LXuk7rG+4pwQw==} + cpu: [x64] + os: [darwin] + + '@bruits/satteri-freebsd-x64@0.11.6': + resolution: {integrity: sha512-qMkNZRYN3cLshfYawQYjfDS05A208QCOVh1adtEteYH+03MkuOoVVhYkzDRIKhWdfM3gFFEaUjXDOGfVqGjEJg==} + cpu: [x64] + os: [freebsd] + + '@bruits/satteri-linux-arm64-gnu@0.11.6': + resolution: {integrity: sha512-A7cHeCZQkvF3oTgQ6/5LvVriauE1dHo3u3gAME6H1X/+KMeum5Wras3Y7rYUcvPym1CxdmzjZh9WlYD62Ul1xg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@bruits/satteri-linux-arm64-musl@0.11.6': + resolution: {integrity: sha512-P/YAudp6VmUr63SFaZefwPIefsw92oZ1wQ6vqrtO/GY9qB3OxLqUVjSIcnKAtqMp4kDW8xBEdSwHz4GZU7P2+g==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@bruits/satteri-linux-x64-gnu@0.11.6': + resolution: {integrity: sha512-nhj90eZFLSA+Icd6n+oYpAi/UXr/mppLGd8jdEOB/larvWAZ/ewFxpT7CLzuNCg7r75+gkdHv9JuRb5eERY1Kg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@bruits/satteri-linux-x64-musl@0.11.6': + resolution: {integrity: sha512-mkWxwHd5Q2dDs+OZK/P1tZFvSPrh2Bb69utWHcBNM0wyJCRWd61T4jU1tF21lGL1C5dy7P+LvaT0y6PQDJj89Q==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@bruits/satteri-wasm32-wasi@0.11.6': + resolution: {integrity: sha512-GrjNThC6BfA0u8LZwGpOeEFVKoJZsriowLtj/rBYj18fW7/UZouyzn/0Tsxg+tMaxMUgC9HFA078EmfAsdVONg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@bruits/satteri-win32-arm64-msvc@0.11.6': + resolution: {integrity: sha512-Plesa0QYtJoa3nlObg1Ypj9oD1VOioHMldHUUtG61T/JNU11EcN0pyj0tDsT3DW2pAxKrV0Q8xrfzGtBHuSjfg==} + cpu: [arm64] + os: [win32] + + '@bruits/satteri-win32-x64-msvc@0.11.6': + resolution: {integrity: sha512-fGs5d/7jBIPkYmXkhm9unoEfDuRZ5r66NLxbr+6c8bm0f7ywgPHgW3HJ9L7fKvyqhw49ePtbT4vkWREju8/U6w==} + cpu: [x64] + os: [win32] + '@cacheable/memory@2.2.0': resolution: {integrity: sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==} '@cacheable/utils@2.5.0': resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==} + '@capsizecss/unpack@4.0.1': + resolution: {integrity: sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==} + engines: {node: '>=18'} + + '@clack/core@1.5.1': + resolution: {integrity: sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.8.1': + resolution: {integrity: sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==} + engines: {node: '>= 20.12.0'} + + '@emmetio/abbreviation@2.3.3': + resolution: {integrity: sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==} + + '@emmetio/css-abbreviation@2.1.8': + resolution: {integrity: sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==} + + '@emmetio/css-parser@0.4.1': + resolution: {integrity: sha512-2bC6m0MV/voF4CTZiAbG5MWKbq5EBmDPKu9Sb7s7nVcEzNQlrZP6mFFFlIaISM8X6514H9shWMme1fCm8cWAfQ==} + + '@emmetio/html-matcher@1.3.0': + resolution: {integrity: sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ==} + + '@emmetio/scanner@1.0.4': + resolution: {integrity: sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==} + + '@emmetio/stream-reader-utils@0.1.0': + resolution: {integrity: sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A==} + + '@emmetio/stream-reader@2.2.0': + resolution: {integrity: sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw==} + + '@emnapi/core@1.11.3': + resolution: {integrity: sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==} + + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@emnapi/wasi-threads@1.2.3': + resolution: {integrity: sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==} + '@eslint-community/eslint-utils@4.10.1': resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -299,14 +539,167 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} - '@jridgewell/gen-mapping@0.4.0-beta.0': - resolution: {integrity: sha512-JdGNkbE4GlNPYQhM0L95fBQr7ctLZJ276QXQLTad4t1oSdnnCI3fDq9DW3BqYAWv8Wc3+HS+4Gsii1oPMCfz1w==} + '@img/sharp-darwin-arm64@0.35.5': + resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + '@img/sharp-darwin-x64@0.35.5': + resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.5': + resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.4': + resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.4': + resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.4': + resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.4': + resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.4': + resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.4': + resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.4': + resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.4': + resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.5': + resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.5': + resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.5': + resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.5': + resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.5': + resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.5': + resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.5': + resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.5': + resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.5': + resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.5': + resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.5': + resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.5': + resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.5': + resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} @@ -327,160 +720,277 @@ packages: '@keyv/serialize@1.1.1': resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} - '@oxc-project/types@0.156.0': - resolution: {integrity: sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==} + '@kuhyx/web-ui@https://codeload.github.com/kuhyx/utils/tar.gz/decf5824e609b099f9455be93c4d09b4c1fd6cae#path:/web_ui': + resolution: {gitHosted: true, integrity: sha512-Uf0AXSh0DaeI/mC7UpgpGrty1g0FlLOIUBtYh1I/qSWiJ/oQeUKoAHfOW/eTJzWurVFLTQMQgP4DFfmBgaz1fw==, path: /web_ui, tarball: https://codeload.github.com/kuhyx/utils/tar.gz/decf5824e609b099f9455be93c4d09b4c1fd6cae} + version: 0.5.0 + engines: {node: '>=24', pnpm: '>=10'} + peerDependencies: + astro: '>=7.4.0' - '@rolldown/binding-android-arm-eabi@1.2.16': - resolution: {integrity: sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==} + '@napi-rs/wasm-runtime@1.2.4': + resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 + + '@napi-rs/wasm-runtime@1.2.6': + resolution: {integrity: sha512-mRyDmEw9/fCDXCGfL1sbgIw/GUK0PVxRW4YwgPN61F3LQ+fSyoBFarJVzgGyhF6kRzEQZ0FejDqm4WLXSeqQmw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 + + '@napi-rs/wasm-runtime@1.2.7': + resolution: {integrity: sha512-zJccwplOAbgfZju95IUHazLnx9RAPtPN2YGJXI6UbrgeaR9QfPjGrQL6ERkSAtnVCA4Q+83ApGyaX5zdpCaKkQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 + + '@oslojs/encoding@1.1.0': + resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + + '@oxc-project/types@0.155.0': + resolution: {integrity: sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==} + + '@oxc-project/types@0.157.0': + resolution: {integrity: sha512-95iTox1qdt6WGWce3xwNOVayByOfLaYAq+qGPpub6NWbBh9sv0GG3+zyfh9ZBCO/jVu5A0EWI+qQI7g6fevjxg==} + + '@pkgr/core@0.3.6': + resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} + engines: {node: ^14.18.0 || >=16.0.0} + + '@rolldown/binding-android-arm-eabi@1.2.15': + resolution: {integrity: sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@rolldown/binding-android-arm64@1.2.16': - resolution: {integrity: sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==} + '@rolldown/binding-android-arm-eabi@1.3.0': + resolution: {integrity: sha512-zjq+pUSE36RJG9nJXqrRfMCOxekzVhlaYMtFELHaOcRH4UqTnkWhbTbEixKEawsb0ZTVMRbn8RLYcuU4DYk8qg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.15': + resolution: {integrity: sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.2.16': - resolution: {integrity: sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==} + '@rolldown/binding-android-arm64@1.3.0': + resolution: {integrity: sha512-HdnCiSmMGgEILGVLU1o4dcOblLGj8sMnqEFrDY/S7KRti1nTQHAqGGs2AniPspbK90AFS3pM23K/R0J3hPQXYQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.15': + resolution: {integrity: sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.2.16': - resolution: {integrity: sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==} + '@rolldown/binding-darwin-arm64@1.3.0': + resolution: {integrity: sha512-TkQ2JGC8/9UxoCeH5dSJj1gxuFhzw200QTJJd2YNPRXznSSM4XvpKV2Cs+RM2BcM8sa6xa4rmXNuiJ64+1993g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.15': + resolution: {integrity: sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.2.16': - resolution: {integrity: sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==} + '@rolldown/binding-darwin-x64@1.3.0': + resolution: {integrity: sha512-Gr+iZW50RJZo4BPUazaDh/Azx7W0Rl04sy2MkXD+H4RkxzUX5hIrjaVw1BK+XHsccKExQAmCy6DhvRLlUzaYUg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.15': + resolution: {integrity: sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.2.16': - resolution: {integrity: sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==} + '@rolldown/binding-freebsd-x64@1.3.0': + resolution: {integrity: sha512-RwAtx8peSqUoBf8pxE0L62xKXBNnA3BtdFV+07t13t0TQoASCtV9tnU67aKKMRZSiD06SU07chB5B8fWFdW3gQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.15': + resolution: {integrity: sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.2.16': - resolution: {integrity: sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==} + '@rolldown/binding-linux-arm-gnueabihf@1.3.0': + resolution: {integrity: sha512-ow8fq6kYaIx75D0EVT5GeF4DcawcEYYXJ2CvEaOzyygW1jtVDQU/9o5YYidctHBRDUgPzGaTNB6n/VMTmd1ymw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.15': + resolution: {integrity: sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.2.16': - resolution: {integrity: sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==} + '@rolldown/binding-linux-arm64-gnu@1.3.0': + resolution: {integrity: sha512-PLOWuiz+rdqSgiLgN7bxvy6DjXLj8gnJxzRS243LNnLi96VeyB7VFsC3eblcKv3Xwd3jQqJfbCWRhLijTIZyvQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.15': + resolution: {integrity: sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.2.16': - resolution: {integrity: sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==} + '@rolldown/binding-linux-arm64-musl@1.3.0': + resolution: {integrity: sha512-CQ/k85cc342j5SnZ/gT9p132NXZRGTgLRhfItdYqUg+jpvL5HYFve0GB+zQDCYGwDjtDWIoqKSOitkmZMZc/tw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.15': + resolution: {integrity: sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.2.16': - resolution: {integrity: sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==} + '@rolldown/binding-linux-ppc64-gnu@1.3.0': + resolution: {integrity: sha512-wk+ZG7xDP2px3cQRFoNoNp52/w1O5hTTtciHxuastreMOp8isYUTCpcG/rEYnSQBI+nycN+B9d9pBGua+qvV7w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.15': + resolution: {integrity: sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.2.16': - resolution: {integrity: sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==} + '@rolldown/binding-linux-s390x-gnu@1.3.0': + resolution: {integrity: sha512-sEgyZt72Rj7V5q6OZ7A4U2I5v4EMGEv3Fp5QmoncmKbUMTrGmpyH7MwENfERGbfUYjxdBmktw4JJLys5I1aQNQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.15': + resolution: {integrity: sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.2.16': - resolution: {integrity: sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==} + '@rolldown/binding-linux-x64-gnu@1.3.0': + resolution: {integrity: sha512-PvL0BoBUwVugK1pFd+zyVk1L9F6ucmQ7oF2dpy8zKPHNlbW2zrU9Jst3fWYfG7z3owjxqhwAvi5G4o0AikrJMw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.15': + resolution: {integrity: sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.2.16': - resolution: {integrity: sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==} + '@rolldown/binding-linux-x64-musl@1.3.0': + resolution: {integrity: sha512-5N4DdzAmLtnyuh027H6ViC99BeoA4T0RwKS6qUrl6zY70AKRsNDrnEZeWnqrKmDRFxXmv8zf9yhF2DxN/SfyGQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.15': + resolution: {integrity: sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-win32-arm64-msvc@1.2.16': - resolution: {integrity: sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==} + '@rolldown/binding-openharmony-arm64@1.3.0': + resolution: {integrity: sha512-Tz+2b+yF5eVRGX3DWebn2JIvZkNYI1gkafI+nF2fau2zeQDOpBAS8w1PxHi4ke//NIOE6+2DNYyq1veIjzAXqQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.3.0': + resolution: {integrity: sha512-ntK6OFor3nQtS1TenZP5wX59oPpkKEmour3AipGHB32u+Z8QXxjVdG+7vNSvgerRWIj6vjg4XpKu42slb0vlXQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.2.15': + resolution: {integrity: sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.2.16': - resolution: {integrity: sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==} + '@rolldown/binding-win32-arm64-msvc@1.3.0': + resolution: {integrity: sha512-ldH38lBlOpS+b4fbSG9euQjhYXLE18PIz5hyvf6gvFfyVLodRE/Tsu4jV8UmPqjjce6lotmUUwscxPuer4lczQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.15': + resolution: {integrity: sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@rolldown/plugin-babel@0.3.0': - resolution: {integrity: sha512-cB9Z6aqn7i8Zflv1D4tyIy/wvnWt+QVUPX2e3Jxq/4fvdX+Xk2gQyNN2sfYscfLIZN1LCQbCl4olpf9Ki+U1zw==} - engines: {node: '>=22.12.0 || ^24.0.0'} - peerDependencies: - '@babel/core': ^7.29.0 || ^8.0.0-rc.1 - '@babel/plugin-transform-runtime': ^7.29.0 || ^8.0.0-rc.1 - '@babel/runtime': ^7.27.0 || ^8.0.0-rc.1 - rolldown: ^1.0.0-rc.5 - vite: ^8.0.0 - peerDependenciesMeta: - '@babel/plugin-transform-runtime': - optional: true - '@babel/runtime': - optional: true - vite: - optional: true + '@rolldown/binding-win32-x64-msvc@1.3.0': + resolution: {integrity: sha512-HglhUzUPuXNTGFt5ZwJalSEvavhFSl+nxpQM9urkzkt+2oGI1ZSwgyytk9aGw15/0kk1TCHPc57nv3DdiG8/yQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - '@tanstack/history@1.162.5': - resolution: {integrity: sha512-w1+YvwmTkwt5n8yUqEH3IWZ9DsmorCZW/EQ3KYHnqEgIkOZpH54NvW6/Epq3Dzb8H0ifIeYX8PExvrNLIH5FIA==} - engines: {node: '>=20.19'} + '@shikijs/core@4.6.0': + resolution: {integrity: sha512-bfd4OKgvoyXYiYmMRuleA656P4V7M/Du9lcTl38Ngm6KszMj7bq4J1IGZwSYR4c95Tv5UQALtYNWUwaI9UT3xw==} + engines: {node: '>=20'} - '@tanstack/query-core@5.107.1': - resolution: {integrity: sha512-PSyAik11oQBEVOiVy5hjZWHmoUqOXqK1WYy4v55mcxozEfKaQgpUMsgnXhWjuESzSht8JfzqMS1m6M/9CpNgvg==} + '@shikijs/engine-javascript@4.6.0': + resolution: {integrity: sha512-K0oqxFoL+kEMdz89mHLFO15Tsbv3kFZ6WR1cXZKMvLSc+oqaM00A6pyga1qSeXB22dP0tTiSApRcGBaKWsEccg==} + engines: {node: '>=20'} - '@tanstack/react-query@5.107.1': - resolution: {integrity: sha512-udCOOLp3ZAheUrIHLCe+ZZge6nr2ZUrGMSWcCuXsjY1Bf2JeeE09oxst97PILiCY6N8xo/7bIo3Hvz13zh06FA==} - peerDependencies: - '@types/react': ^18 || ^19 - react: ^18 || ^19 - peerDependenciesMeta: - '@types/react': - optional: true + '@shikijs/engine-oniguruma@4.6.0': + resolution: {integrity: sha512-aggd6Q7SuJOXNnsUbfG2XesxBJKBMAw/PDTUo9PyRjMaaJJ2dNrI+q0C+25ARXAK9yLHAlwxc9oRT2u68YKZ7w==} + engines: {node: '>=20'} - '@tanstack/react-router@1.170.46': - resolution: {integrity: sha512-ez1iNIhsPjGlaDHg3BXWs67EWVIYzB2tbopEHYDESdkO7owgkGb5de52+5Kv2v1fxdkggcNzo35bdk4arTVGQw==} - engines: {node: '>=20.19'} - peerDependencies: - react: '>=18.0.0 || >=19.0.0' - react-dom: '>=18.0.0 || >=19.0.0' + '@shikijs/langs@4.6.0': + resolution: {integrity: sha512-1yMEX3oCVV54LUq/ovfpRkDQoYDkcvjSWFFa/YLUDMiSoRzMi2uxLJYbv41ahT8Lu9lFMaa7hecd3Q39trqUyg==} + engines: {node: '>=20'} - '@tanstack/react-store@0.11.2': - resolution: {integrity: sha512-oj5O5DmiMWE+ih+SioX2ayC1kbK7d3fpLx0B75fPJr9nY3zU+hoW010yr64qXsY/9fyiLVE2bn5mGNV3owm8RQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@shikijs/primitive@4.6.0': + resolution: {integrity: sha512-c54+TQEo9ToqgfL/AU8FpjnV09+GA9Oa7M5ROkkXteZfp5XzpAJGXjjvZQFQioJOwm/w/6Z3lIzQstAkl8coag==} + engines: {node: '>=20'} - '@tanstack/router-core@1.171.39': - resolution: {integrity: sha512-qUhoJ0meuSUqGYQbHbcJYdASr06YtK+bulbj6vtrhKYFkLgkSsZ6tXmMyym7sFxiSJSPYgasgozrL01LyyR/6A==} - engines: {node: '>=20.19'} + '@shikijs/themes@4.6.0': + resolution: {integrity: sha512-qHAVAs80RiGD+IRuH9G+JPDOEc9A/kHsng4EzCUCwB9bLzW6Z0y9WETh2YyCcADJBmfrIaJ1Jp7KBNP6FHkgtQ==} + engines: {node: '>=20'} - '@tanstack/store@0.11.2': - resolution: {integrity: sha512-sJ4mjol8uQsHV0gOJzzjwXfh2Fwm+Sz0+8deqiTm4jGbMdjzNSW+xZCFm0kUa870uhd8yi+DpKnZb5Kc4apa0Q==} + '@shikijs/types@4.6.0': + resolution: {integrity: sha512-+ENG1adsKt/X7cRYw1Nl7BEpBmPOjmQ7bR/eGmHuMlT8gtOFf3KEi5l31DJYyAWhW6Y6bjP1plU7FmaxWGENFw==} + engines: {node: '>=20'} + + '@shikijs/vscode-textmate@10.1.0': + resolution: {integrity: sha512-cVBuhDQjBZaNMTwBquYfPydrUP+E3hE6oYGasx8h6ZrODIM7/r+gxLDXdd66oszbrggZHEntdE6j+5gYIVyMWQ==} '@testing-library/dom@10.4.2': resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} @@ -496,27 +1006,15 @@ packages: vitest: optional: true - '@testing-library/react@16.3.3': - resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} - engines: {node: '>=18'} - peerDependencies: - '@testing-library/dom': ^10.0.0 - '@types/react': ^18.0.0 || ^19.0.0 - '@types/react-dom': ^18.0.0 || ^19.0.0 - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@testing-library/user-event@14.6.8': resolution: {integrity: sha512-vgSSqnANMnkcYR/vUNDbwS7aM3OErRLNUG8vCVVlj4oc6cKffUrccX7UAS5AmJQQNZTSibi9z5GvvRZ0aYgUCQ==} engines: {node: '>=12', npm: '>=6'} peerDependencies: '@testing-library/dom': '>=7.21.4' + '@tybys/wasm-util@0.10.4': + resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -529,28 +1027,29 @@ packages: '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - '@types/gensync@1.0.5': - resolution: {integrity: sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg==} - - '@types/jsesc@2.5.1': - resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/nlcst@2.0.3': + resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==} + '@types/node@26.6.6': resolution: {integrity: sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==} - '@types/react-dom@19.3.0': - resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} - peerDependencies: - '@types/react': ^19.3.0 - - '@types/react@19.3.0': - resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} '@typescript-eslint/eslint-plugin@8.72.1': resolution: {integrity: sha512-5CokTWXpmbylfFabsbvHC0CAw3XLaGnoa+nFJX5FR592h/LoZsENUE6p/rg5xvkKrFEXz6pYmgI29AgXc2N3BQ==} @@ -611,21 +1110,8 @@ packages: resolution: {integrity: sha512-SB8xqipSzZHUePCCLNeYxqnxq83X61/qzNhjQGbTfjxGZIeOEP7tBgWYX05Tcd6CgE02WdTJnh5b8kMZJ/+bnQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@vitejs/plugin-react@6.2.1': - resolution: {integrity: sha512-9KyWCij3r0+qLG3Q62vBmv0VyevmYibAtxaCIVK9o0k6CXgaajZRIe8tpfchQcdZArJ5b/7OrIz4W3b2uxVRDA==} - engines: {node: ^20.19.0 || >=22.12.0} - peerDependencies: - '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 || ^0.3.0 - babel-plugin-react-compiler: ^1.0.0 - oxc-transform-react: '>=0.152.0 <0.157.0' - vite: ^8.0.0 - peerDependenciesMeta: - '@rolldown/plugin-babel': - optional: true - babel-plugin-react-compiler: - optional: true - oxc-transform-react: - optional: true + '@ungap/structured-clone@1.4.0': + resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} '@vitest/coverage-v8@5.1.0': resolution: {integrity: sha512-OY9aShBQG5KOIw2zCo9fDoCHI9RHliNHJ//nM7m6jGg41Hyke9JOGWmeMlK74xYErsxMoK8mIB3gAeEyFVA7LA==} @@ -658,6 +1144,47 @@ packages: '@vitest/spy@5.1.0': resolution: {integrity: sha512-2DPO5OMHWKrBngC+fCmTQqemNF0gidRhp3lxbBmRhW+M1XJrfgPpqNqWVevXtn2f2ufD5ZaR1B6B641aAlcX5A==} + '@volar/kit@2.4.30': + resolution: {integrity: sha512-VocLtumfXnqnPQEWc9Yx7uC1uOh9gyjVNlrn2+6Zx/z9aM6yTLhPK8lSqM9Ilu81XRa48I00lcYJWLi2BLxx8Q==} + peerDependencies: + typescript: <7 + + '@volar/language-core@2.4.30': + resolution: {integrity: sha512-73fjwEV8wat6aOZ6U/y/hqtE30ocrIWECg67b0txGczOMNw+MffLmvJ5fnFfN9drVUEYZzLdaNo6VAy6G6avMw==} + + '@volar/language-server@2.4.30': + resolution: {integrity: sha512-tcP9fzzUBbsLIFHhgnRU4ncQjMn9G9Vax0ffkQmdCrXVEYqRroUepmyC5AVgiju5bCiLD6a55qen5iOTQHkb3g==} + peerDependencies: + typescript: <7 + peerDependenciesMeta: + typescript: + optional: true + + '@volar/language-service@2.4.30': + resolution: {integrity: sha512-w/xDsASiqC71ggCOm7sTDXDNk/l72XEptodeRC68tgZ0e5QM4hM3zAqBGC7mBctvhrpYwKfMG+t9xcUo2hEFtA==} + peerDependencies: + typescript: <7 + peerDependenciesMeta: + typescript: + optional: true + + '@volar/source-map@2.4.30': + resolution: {integrity: sha512-zym1ob/KwDDyN+0jGVtxwbxJSRRnUvv5HspCtz3yEfVVdCG4/dzxJktMGPAgemqRuVs81O0o/y0q3qOUErR8kg==} + + '@volar/typescript@2.4.30': + resolution: {integrity: sha512-/DgA5tgYFPfrLFuSjIMXMGHk1LQv2+P8eny6+BhGNzFJELWIW7UZlc56ZLIRFhLsPpMfIFxM8/HvOPBggrjTZg==} + peerDependencies: + typescript: <7 + peerDependenciesMeta: + typescript: + optional: true + + '@vscode/emmet-helper@2.11.0': + resolution: {integrity: sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw==} + + '@vscode/l10n@0.0.18': + resolution: {integrity: sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -668,17 +1195,49 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-i18n@4.2.0: + resolution: {integrity: sha512-v/ei2UkCEeuKNXh8RToiFsUclmU+G57LO1Oo22OagNMENIw+Yb8eMwvHu7Vn9fmkjJyv6XclhJ8TbuigSglPkg==} + peerDependencies: + ajv: ^8.0.0-beta.0 + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + am-i-vibing@0.5.0: + resolution: {integrity: sha512-KLcsDhqf1XRrJmJr+9I/C0EX2K+sGrAGZFgkmd84c8TJO9zJBu+/kFInjazu1KfJWFhWDKdRWkpBzEs7s35DVA==} + hasBin: true + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.5.0: + resolution: {integrity: sha512-zhvbbbdt1kbw7EJ9OQoxmQcuaNR9P5kTj7e/YdmOSU10aHWbycofJxA6jKfoEy3vlLdctOF9ZHaxLtmSm998wQ==} + engines: {node: '>=12'} + ansi-styles@5.2.0: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} @@ -686,10 +1245,6 @@ packages: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} - artichokie@0.4.5: - resolution: {integrity: sha512-ma9643oYgSgA6CeDvKIU0zxL1eKOkbgH8/v83xFpFYB2VOPFoEmmg1iC+ooHFjLIJ3qdtfwPbEKpIW3/YxPovg==} - engines: {node: ^18.0.0 || >=20.0.0} - assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -697,58 +1252,127 @@ packages: ast-v8-to-istanbul@1.0.7: resolution: {integrity: sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==} - babel-plugin-react-compiler@1.0.0: - resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==} + astro-eslint-parser@3.2.0: + resolution: {integrity: sha512-WavQUarfgwSbpCq4xsdcIBdX5KArfI2d6DD8EIloJIMthnB1508iNpx4C77EhUyMd03LpEt8HAPzwF/cG0V39g==} + engines: {node: ^22.22.3 || ^24.16.0 || >=26.3.0} + + astro@7.4.7: + resolution: {integrity: sha512-fH54IOaCkZY1paEjygKn8CPfuJ3w8ns7PourcPML5AouJkQuTBqBQ2RbbIbBhutZbg2qn5se6Yl9DYybzqjC0Q==} + engines: {node: '>=22.23.4', npm: '>=9.6.5', pnpm: '>=7.1.0'} + hasBin: true + peerDependencies: + '@astrojs/markdown-remark': ^7.3.0 + peerDependenciesMeta: + '@astrojs/markdown-remark': + optional: true + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.11.19: - resolution: {integrity: sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw==} - engines: {node: '>=6.0.0'} - hasBin: true - bcp-47-match@2.0.3: resolution: {integrity: sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==} bcp-47@2.1.2: resolution: {integrity: sha512-wsv86UuNFuPnVieKLyECTT+CP1WSAHeoIg5P3xu2kA9Z+7fVPoKt7dxzSeEhtJel0AI+NW3fIW3KtTKQKUCngg==} + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + brace-expansion@5.0.9: resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} engines: {node: 20 || >=22} - browserslist@4.28.8: - resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - cacheable@2.5.0: resolution: {integrity: sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==} - caniuse-lite@1.0.30001810: - resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} chai@6.3.0: resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} engines: {node: '>=18'} - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} - cookie-es@3.1.1: - resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + cliui@9.0.1: + resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} + engines: {node: '>=20'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + + common-ancestor-path@2.0.0: + resolution: {integrity: sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==} + engines: {node: '>= 18'} + + cookie-es@1.2.3: + resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==} + + cookie@2.0.2: + resolution: {integrity: sha512-SL0cVuEdETEp7pN0FuZb01toc3vnODEfVUCooxG1SJN0yIirh7JMb9X9bvfCFf0vzzbT68RdbKDHYTiWy7a7iw==} + engines: {node: '>=22'} cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + crossws@0.3.5: + resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} + + css-select@6.0.0: + resolution: {integrity: sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==} + + css-tree@2.2.1: + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css-what@7.0.0: + resolution: {integrity: sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==} + engines: {node: '>= 6'} + css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} - csstype@3.2.3: - resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} data-urls@8.0.0: resolution: {integrity: sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==} @@ -769,26 +1393,63 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + devalue@6.0.3: + resolution: {integrity: sha512-3woMEITftG1wKeO/4d/QXH1DVKctTSZg3jNGPpjp9hNr5Hos8MCFF5McTML5ZtgKDObm2R591B0spRp2IVernA==} + engines: {node: '>=18'} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} + engines: {node: '>=0.3.1'} + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} - electron-to-chromium@1.5.415: - resolution: {integrity: sha512-958V+Kbhtgz+SxXeEVKBjrlKRBIDAYvUJfwhjxMZ5S6ut9jAl7l9ZKBkBrvjyjZE36PabLUo2L8kEeV5O4vgJg==} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} - empathic@2.1.0: - resolution: {integrity: sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw==} - engines: {node: '>=14'} + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dset@3.1.4: + resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} + engines: {node: '>=4'} + + emmet@2.4.11: + resolution: {integrity: sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} entities@9.0.0: resolution: {integrity: sha512-oJFNDJg3kTPwAD1cNn2+WmUq4wFoo09npc3Ydf1D7ED4smRiWrsKnaLxykrTWgyGEcusc87P5dxlAleol9kpkg==} @@ -797,6 +1458,9 @@ packages: es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + es-module-lexer@3.0.4: + resolution: {integrity: sha512-BldNY5mwkVu+cQ5Ymn3e6hB9Sx8XHx4ZevWbiUyZbfKxJxYO27Hf0wfzkxIaD5CAJMaGl3YspKELj0ff/7nCMA==} + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -805,16 +1469,24 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - eslint-plugin-react-hooks@7.1.1: - resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} - engines: {node: '>=18'} + eslint-plugin-astro@3.2.1: + resolution: {integrity: sha512-IgU7LIkpEg0r6bQGwc3Ck5jHgkUk3rOrEfRhUxDtMx/M0GD3SG/turMtvwj989KRR12Wt1l89faZ5ynjvVL7hg==} + engines: {node: ^22.22.3 || ^24.16.0 || >=26.3.0} peerDependencies: - eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 - - eslint-plugin-react-refresh@0.5.7: - resolution: {integrity: sha512-XhJSzLljuYD4UjNuFGJu2v7aD3sqNVK12w5/DCUfrNfHWegZo2+TsavNx8MuxMwEquuFAzNbNfXKU0WoZ+YUIg==} - peerDependencies: - eslint: ^9 || ^10 + '@typescript-eslint/parser': '>=8.61.0' + eslint: '>=10.0.0' + eslint-plugin-jsx-a11y: '>=6.10.2' + eslint-plugin-jsx-a11y-x: '>=0.2.0' + typescript-eslint: '>=8.61.0' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint-plugin-jsx-a11y: + optional: true + eslint-plugin-jsx-a11y-x: + optional: true + typescript-eslint: + optional: true eslint-scope@9.1.2: resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} @@ -865,6 +1537,9 @@ packages: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -874,6 +1549,18 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-uri@3.1.9: + resolution: {integrity: sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==} + + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -896,18 +1583,39 @@ packages: flatted@3.4.4: resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} - flru@1.0.2: - resolution: {integrity: sha512-kWyh8ADvHBFz6ua5xYOPnUroZTT/bwWfrCeL0Wj1dzG4/YOmOcfJ99W8dOVyyynJN35rZ9aCOtHChqQovV7yog==} - engines: {node: '>=6'} + flattie@1.1.1: + resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} + engines: {node: '>=8'} + + fnv1a-64@0.1.2: + resolution: {integrity: sha512-mJbcILTPybAR1jdOwt/lVv8N2cffeJFFP+gVbSAwLEx1ujXH27RpPd8Rokec/KX9c76UYIB6Co+H4lQzxPnJfA==} + + fontace@0.4.1: + resolution: {integrity: sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==} + + fontkitten@1.0.3: + resolution: {integrity: sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==} + engines: {node: '>=20'} fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.7.0: + resolution: {integrity: sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==} + engines: {node: '>=18'} + + get-tsconfig@5.0.0-beta.6: + resolution: {integrity: sha512-X6fBC0pmImC70gvX2zm56go9hx0MyoGVdG0tUCkg/D+Xnh5TJsOZ7iDbOdI3PvmtrDxnu1YdDufpK2QJX1Meqw==} + engines: {node: '>=20.20.0'} + + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} @@ -917,6 +1625,9 @@ packages: resolution: {integrity: sha512-UtdkTVyAJePQv0J2M9Xe4L0ruNSDV314XiIJsADI2Zf40bcdAonE/D/OhAU8AT99oij9mhLByb+NvA3QWZYgCg==} engines: {node: '>=18'} + h3@1.15.11: + resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -925,11 +1636,11 @@ packages: resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==} engines: {node: '>=20'} - hermes-estree@0.25.1: - resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + hast-util-to-html@9.0.6: + resolution: {integrity: sha512-1SZUxCN3TtJAE7e2BQN2MBHsoZ1gnmi9GoCP5Di59CSWptIezSAW09yDZyVbanb78V9eCwrKqdQsjtfVTxYHWA==} - hermes-parser@0.25.1: - resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} hookified@1.15.1: resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} @@ -941,6 +1652,15 @@ packages: resolution: {integrity: sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==} engines: {node: ^22.13.0 || >=24.0.0} + html-escaper@3.0.3: + resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + http-cache-semantics@4.3.0: + resolution: {integrity: sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -949,9 +1669,6 @@ packages: resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} engines: {node: '>= 4'} - import-meta-resolve@4.2.0: - resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} - imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -960,6 +1677,9 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + iron-webcrypto@1.2.1: + resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -969,6 +1689,11 @@ packages: is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-docker@4.0.0: + resolution: {integrity: sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA==} + engines: {node: '>=20'} + hasBin: true + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -977,13 +1702,13 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} - isbot@5.2.2: - resolution: {integrity: sha512-iQcBXcd+Rv/pkubRyGh2utW2j1oPG5hZY6TUhVPpqK4G+o3IbxpJNx04hgksjc/N7GK5pEorUxDeg31cFgEk/w==} - engines: {node: '>=18'} - isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1002,25 +1727,28 @@ packages: '@napi-rs/canvas': optional: true - jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} - hasBin: true - json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true + jsonc-parser@2.3.1: + resolution: {integrity: sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==} + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} keyv@5.6.0: resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -1107,18 +1835,40 @@ packages: resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} engines: {node: 20 || >=22} - lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true - magic-string@1.4.3: - resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} + magic-string@1.4.5: + resolution: {integrity: sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==} magicast@0.5.5: resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==} + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.3: + resolution: {integrity: sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==} + min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} @@ -1127,9 +1877,16 @@ packages: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + nanoid@3.3.20: resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -1138,14 +1895,39 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - node-releases@2.0.54: - resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} - engines: {node: '>=18'} + neotraverse@1.0.1: + resolution: {integrity: sha512-WmmLty1YWwJl9yZi77v2dVIV6X2kuYV8YYBI/G3LWGKdGHmHUvL1z7FW0iDvEvGAwNEoc5x1tOOOyDnf5jJw/w==} + engines: {node: '>= 10'} + + nlcst-to-string@4.0.0: + resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-mock-http@1.0.5: + resolution: {integrity: sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} obug@3.0.0: resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} engines: {node: '>=12.20.0'} + ofetch@1.5.1: + resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} + + oniguruma-parser@0.12.2: + resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} + + oniguruma-to-es@4.3.6: + resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1154,16 +1936,30 @@ packages: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-limit@7.3.3: + resolution: {integrity: sha512-SKgVvDU5TNIxQ4r30U75BNLC6a+6GOEjngrk/ysM5+Zu3oJuk1SC5ApzIzY/7PjCEjoVPMWJ0zho/4QUiEB7TQ==} + engines: {node: '>=20'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-queue@10.0.0: + resolution: {integrity: sha512-P2wrCri3bhGLPo3kX7qYVXbo/5GvaVATU0DpO24CbeT53Y4z35t8JUWku04ERpXHUYB5rPMi3TkWE3fmJgx5rA==} + engines: {node: '>=22'} + + package-manager-detector@1.9.0: + resolution: {integrity: sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==} + parse5-parser-stream@8.1.0: resolution: {integrity: sha512-58MPfNTA+DsIzro41rU94xghnu/h2N4yEPdPdGPlZVHM/QvA5jlVGYVo7IzlHbFLHYx3cZUnr9s9HBrA0tEYyw==} parse5@8.1.0: resolution: {integrity: sha512-sq3qBFpee5jeocbFbO48VHlGr8sT7mbXS0vxlKN6sottBtJzrOG3998sr0llSZcQAGgfpdmEYAIZE0P9z0dE8w==} + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1172,25 +1968,52 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + piccolore@0.1.3: + resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + picomatch@4.0.7: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} - postcss@8.5.32: - resolution: {integrity: sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==} + postcss-selector-parser@7.1.7: + resolution: {integrity: sha512-wWBro/7hJ40ye6TPiXkUQoXKCw23xSsjriUvqd7Uls9Sso3oLiON1flc18cCdnjgCRXw4fBVqUJJeOdDTh3P3g==} + engines: {node: '>=4'} + + postcss@8.5.31: + resolution: {integrity: sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prettier@3.9.10: + resolution: {integrity: sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==} + engines: {node: '>=14'} + hasBin: true + pretty-format@27.5.1: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + + process-ancestry@0.1.0: + resolution: {integrity: sha512-tGqJW/UnclpYASFcM6Xh8D8l/BMtaQ9+CSG0vlJSJTcdMM4lDRv4c6H0Pdcsfted+bVczdYSfk2fdukg2gQkZg==} + engines: {node: '>=18.0.0'} + + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -1199,51 +2022,79 @@ packages: resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==} engines: {node: '>=20'} - react-dom@19.3.1: - resolution: {integrity: sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==} - peerDependencies: - react: ^19.3.1 + radix3@1.1.2: + resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} - react@19.3.1: - resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==} - engines: {node: '>=0.10.0'} + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} - rolldown@1.2.16: - resolution: {integrity: sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==} + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + + request-light@0.5.8: + resolution: {integrity: sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==} + + request-light@0.7.0: + resolution: {integrity: sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + retext-smartypants@6.2.0: + resolution: {integrity: sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==} + + rolldown@1.2.15: + resolution: {integrity: sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rolldown@1.3.0: + resolution: {integrity: sha512-7j1jdVlzWzbRat9EDMsB/QmyMcc6hOO0vbcma8LGjBTmKlpS5Hem+5fpntc/HoRV5tLrG+Qo6I1ZHV/ZuL5S8g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + satteri@0.11.6: + resolution: {integrity: sha512-ddGCTqSfwL+A+odhdjj8E+ZMu1h0QEnG3VTmu7fPJL2+80GdjxDh1AeCw8mn2aa95eQSosG4e2QBYYfCc5nyiA==} + + sax@1.6.1: + resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} + engines: {node: '>=11.0.0'} + saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} - scheduler@0.28.0: - resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} - - semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} hasBin: true - seroval-plugins@1.6.9: - resolution: {integrity: sha512-kiDK/Tb8IWZ61WEU8pA2PDmAuw37gKph9Y77OZlD56mg0Y2g0Bg7gZ1HeHhs5d40w+nRoUq/wj08V26ilbez9g==} - engines: {node: '>=10'} + sharp@0.35.5: + resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} + engines: {node: '>=20.9.0'} peerDependencies: - seroval: ^1.0 - - seroval@1.6.9: - resolution: {integrity: sha512-UeEzHwAK+yl0YLbsvROV1t5yMYEwZZ0CfKYxDvUgS2fO9Vxdf8djqVIB+OuVpuawRNPqw68LE1Du/GMsW2ZPqA==} - engines: {node: '>=10'} + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} @@ -1253,13 +2104,42 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shiki@4.6.0: + resolution: {integrity: sha512-imjLsVHgPuWhvpU3P+zOekvMYqnel7QmKsFC4Sn0fCuAvaaalBsxwKEsrU8Ko5BdFnZA6Go/8a0RzahS634UgA==} + engines: {node: '>=20'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + smol-toml@1.9.1: + resolution: {integrity: sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ==} + engines: {node: '>= 18'} + source-map-js@1.2.2: resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + std-env@4.3.0: resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.3.0: + resolution: {integrity: sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==} + engines: {node: '>=20'} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-indent@3.0.0: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} @@ -1268,10 +2148,26 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + svgo@4.1.0: + resolution: {integrity: sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q==} + engines: {node: '>=16'} + hasBin: true + + synckit@0.11.13: + resolution: {integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==} + engines: {node: ^14.18.0 || >=16.0.0} + + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tinybench@6.2.2: resolution: {integrity: sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==} engines: {node: '>=20.0.0'} + tinyclip@1.0.3: + resolution: {integrity: sha512-nTv2raKFMzGG6JJp0lpNDsRPdfFE6UHlu2rVEjXo8vkx1ZS3LW2CVXqIaLu8xZlu+6tbFs9V58Qc/Jmk/Ii7wA==} + engines: {node: ^16.14.0 || >= 17.3.0} + tinyexec@1.3.1: resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} engines: {node: '>=18'} @@ -1280,6 +2176,9 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyps@0.5.1: + resolution: {integrity: sha512-B6Ykif5HflC3AdDkS4Zst/2muYVfeXoVMy+KorqH80s/1x4vunIORpNySH681X7fjj+dFM9MDKD6/Hsccjb87Q==} + tinyrainbow@3.2.0: resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==} engines: {node: '>=14.0.0'} @@ -1299,16 +2198,31 @@ packages: resolution: {integrity: sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==} engines: {node: ^22.14.0 || >=24.0.0} + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} peerDependencies: typescript: '>=4.8.4' + tslib@2.9.1: + resolution: {integrity: sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + typesafe-path@0.2.2: + resolution: {integrity: sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA==} + + typescript-auto-import-cache@0.3.6: + resolution: {integrity: sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ==} + typescript-eslint@8.72.1: resolution: {integrity: sha512-3Li9FfPmQp45nxEtFD1ZCrYFI3cYK5kpyNtmm1H7lMagm8u9sy6PTjy3ytwQZs7KNukOyKFHAZ6z6GjYFeaeXw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1319,34 +2233,129 @@ packages: typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + ultrahtml@1.7.0: + resolution: {integrity: sha512-2xRd0VHoAQE4M+vF/DvFFB7pUV0ZxTW1TLi7lHQWnF/Sb5TPeEUV/l+hxcNnGO00ZXGnR0voCMmYRKQf+rvJ2g==} + + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} undici-types@8.9.0: resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + undici@8.12.1: + resolution: {integrity: sha512-usz3OVQBeh1LxNn0DzU0voLXMgn5CU/WUdpBsY6Ti5afNao+vTkmn4H/NxoXolc10cOqe2+YIG0Hspq2rsl64w==} + engines: {node: '>=22.19.0'} + undici@8.12.4: resolution: {integrity: sha512-8Mx+uNKKER2bA4AdtedkqRbwUCIJFr2nJckXRe5yrDqzePqwLP7VNcctN8/XdmDSbF7mS+jJKIszlL+vbam1VA==} engines: {node: '>=22.19.0'} - update-browserslist-db@1.3.1: - resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==} - hasBin: true + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unifont@1.0.3: + resolution: {integrity: sha512-XGnEwj5sWdCbiBX01mWLSBncS0yMRjnLygau3IfdOlfjMacNIQvTGW4VH7pWBq+gGWArfSef/CensMLjMmIr/A==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.1.0: + resolution: {integrity: sha512-vKXxz6O38sKDj1mtx/k2cWvkkFBuYGx6uGrlzBkVeWCRF/F8sdI0j3Wbkz/5rVNRRuVc8UrgRdbCxNhmYXgP3w==} + + unist-util-visit@5.2.0: + resolution: {integrity: sha512-fpsYs/VVoZxfGY9/j3FQpZmN8wdLJaV1Pt38AaJNZu9uKIHcFU3halCdBveEq1HGKO1OEfJY65w1zvoFV1b3qQ==} + + unstorage@1.17.6: + resolution: {integrity: sha512-gjKtcmn/KwZuJZO9o2sc0esAQnhmTw0LviDHwevTXbJMYs0dRTp8ZEEWXHU0vTpzbN3cA0yxlTeyXssMp3zDqw==} peerDependencies: - browserslist: '>= 4.21.0' + '@azure/app-configuration': ^1.8.0 + '@azure/cosmos': ^4.2.0 + '@azure/data-tables': ^13.3.0 + '@azure/identity': ^4.6.0 + '@azure/keyvault-secrets': ^4.9.0 + '@azure/storage-blob': ^12.26.0 + '@capacitor/preferences': ^6 || ^7 || ^8 + '@deno/kv': '>=0.9.0' + '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + '@planetscale/database': ^1.19.0 + '@upstash/redis': ^1.34.3 + '@vercel/blob': '>=0.27.1' + '@vercel/functions': ^2.2.12 || ^3.0.0 + '@vercel/kv': ^1 || ^2 || ^3 + aws4fetch: ^1.0.20 + db0: '>=0.2.1' + idb-keyval: ^6.2.1 + ioredis: ^5.4.2 + uploadthing: ^7.4.4 + peerDependenciesMeta: + '@azure/app-configuration': + optional: true + '@azure/cosmos': + optional: true + '@azure/data-tables': + optional: true + '@azure/identity': + optional: true + '@azure/keyvault-secrets': + optional: true + '@azure/storage-blob': + optional: true + '@capacitor/preferences': + optional: true + '@deno/kv': + optional: true + '@netlify/blobs': + optional: true + '@planetscale/database': + optional: true + '@upstash/redis': + optional: true + '@vercel/blob': + optional: true + '@vercel/functions': + optional: true + '@vercel/kv': + optional: true + aws4fetch: + optional: true + db0: + optional: true + idb-keyval: + optional: true + ioredis: + optional: true + uploadthing: + optional: true uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - use-sync-external-store@1.7.0: - resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} verkit@0.5.0: resolution: {integrity: sha512-u4J1hr6Y+C/hit34GwL3eIcUf3wngT8TlYUjK7tk0jxydKLAq3sYvifjdTDA6U3KCKRcQCQDGf/srjxfZF3l7g==} engines: {node: '>=18.12.0'} - vite@8.3.6: - resolution: {integrity: sha512-UI7dK29NnS54Fk+mmo7o+jI6U8Fp7hDl+X5Q1k45WH+d4dSlbLVUETGQ+hfkNgvLQGyoElPj837nZyv4MBc0VA==} + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite@8.3.5: + resolution: {integrity: sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -1388,6 +2397,14 @@ packages: yaml: optional: true + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + vitest@5.1.0: resolution: {integrity: sha512-MzmNFnrwa8v8lF1bLxw3UJ4EREwVOVb75VqJZmUDRjJ6EluK/x9th7qTbaeDk41WxE1GxWqmTXcdsfobfH5sOA==} engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} @@ -1429,6 +2446,114 @@ packages: jsdom: optional: true + volar-service-css@0.0.71: + resolution: {integrity: sha512-wRRFt9BpjMKCazcgOh67MSjUjiWUCAh99DyYSDIOTuxaRjEtDC7PpB0k1Y1wbJIW/pVtMUSVbpPo3UGSm0Byxw==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-emmet@0.0.71: + resolution: {integrity: sha512-zqjzt6bN95e3CUstBm0PBFAJnrfz0ZAARka87fart46/gNCLLuP3Vujy8V/J8HEziTFLnfkgIASLFYPUhonJcA==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-html@0.0.71: + resolution: {integrity: sha512-e8tHPhgQ7ooLfudAEIku+kgd9pWkq3SSz8RbnQDI1+Eb8wbenkLGHqoirLqz5ORLV6wIMr2Iv08RWBG5eOcgpw==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-prettier@0.0.71: + resolution: {integrity: sha512-Rz7JVH3qD108UCdmIEiZvOBNljMt2nLFdbN8AXcDfn7xD9F5I2aCIsDVqBbXw21PsnxG0b7MfwtNF+zPS/NKUg==} + peerDependencies: + '@volar/language-service': ~2.4.0 + prettier: ^2.2 || ^3.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + prettier: + optional: true + + volar-service-typescript-twoslash-queries@0.0.71: + resolution: {integrity: sha512-9K2k72s4n7rV9s4bX0MyjbX9iBribvKZbBJKuEmTCZfeWJXs6Yh7bGpY4eoc7UufAjvpheBqwyZCOIPBvxCv0A==} + peerDependencies: + '@volar/language-service': ~2.4.0 + typescript: '*' + peerDependenciesMeta: + '@volar/language-service': + optional: true + typescript: + optional: true + + volar-service-typescript@0.0.71: + resolution: {integrity: sha512-yTtM/BVT6hoyEYnDtaCyAtNhdNeS/mhTTABlBOdw3NNiRBUin3IznFJpgfjer4c6RYopiPjjQjc9VFhxVl1mLw==} + peerDependencies: + '@volar/language-service': ~2.4.0 + typescript: '*' + peerDependenciesMeta: + '@volar/language-service': + optional: true + typescript: + optional: true + + volar-service-yaml@0.0.71: + resolution: {integrity: sha512-qYGWGuVpUTnZGu5P/CR4KLK4aIR8RrcVnmfZ2eRcj9q/I8VZCoC5yy9FtEvfNvnDp4MU17yhdJcvpQPIqhJS2Q==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + vscode-css-languageservice@6.3.10: + resolution: {integrity: sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA==} + + vscode-html-languageservice@5.6.2: + resolution: {integrity: sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg==} + + vscode-json-languageservice@4.1.8: + resolution: {integrity: sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg==} + engines: {npm: '>=7.0.0'} + + vscode-jsonrpc@10.0.0: + resolution: {integrity: sha512-USSfxg0vDIpTQqlQBGxF3mBARyoS8cU7LHLpbYNh6dOJt32dlskxPWB0Irrh5TllTmyf7oNLAvqDqzklbwwnpA==} + engines: {node: '>=14.17.0'} + + vscode-jsonrpc@8.2.0: + resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.17.5: + resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} + + vscode-languageserver-protocol@3.18.5: + resolution: {integrity: sha512-FW4fBMzZ99N8xeob4py0l050wgCoRgTpEq5Xcsb9lM1bc29UKGR/wSV+4knaqFxssFHajy7OzNiKP/m3U1ZEuA==} + + vscode-languageserver-textdocument@1.0.16: + resolution: {integrity: sha512-qeNEuOi9a3qkqRf7Gfez2WL2VwlBTLCNekHBGO9kxPVUp9XuY15yfTJ9jwvOwKI0I2MhUUfZq95NSp+LK1CLQg==} + + vscode-languageserver-types@3.17.5: + resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} + + vscode-languageserver-types@3.18.5: + resolution: {integrity: sha512-kPd6+JQNz3HMJvjKlqUxERSlZHAntl/aGpsk7ImwIh0ewDKDcjgAEyVSYciPiOJD3ftbK9IrfiqUeTZLSbCizw==} + + vscode-languageserver@9.0.1: + resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} + hasBin: true + + vscode-nls@5.2.0: + resolution: {integrity: sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==} + + vscode-uri@3.2.0: + resolution: {integrity: sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==} + w3c-xmlserializer@6.0.0: resolution: {integrity: sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} @@ -1459,6 +2584,10 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -1466,211 +2595,241 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} - yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + xxhash-wasm@1.1.0: + resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yaml-language-server@1.23.0: + resolution: {integrity: sha512-3qVyCOexLCWw06PQa5kRPwvMWMZ/eZeCRWUvgD6a0OkqL/4iCnxy2WumbWifa937Uo5xhyWJ0uxlU39ljhNh7A==} + hasBin: true + + yaml@2.8.3: + resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + engines: {node: '>= 14.6'} + hasBin: true + + yaml@2.9.1: + resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yargs@18.2.0: + resolution: {integrity: sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - zod-validation-error@4.0.2: - resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} - engines: {node: '>=18.0.0'} - peerDependencies: - zod: ^3.25.0 || ^4.0.0 + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} - zod@4.4.3: - resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zod@4.6.7: + resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} snapshots: '@adobe/css-tools@4.5.0': {} + '@astrojs/astro2tsx@0.1.3(@emnapi/runtime@1.11.3)': + dependencies: + '@emnapi/core': 1.11.3 + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + transitivePeerDependencies: + - '@emnapi/runtime' + + '@astrojs/check@0.9.12(@emnapi/runtime@1.11.3)(prettier@3.9.10)(typescript@6.0.3)': + dependencies: + '@astrojs/language-server': 2.17.3(@emnapi/runtime@1.11.3)(prettier@3.9.10)(typescript@6.0.3) + chokidar: 5.0.0 + kleur: 4.1.5 + typescript: 6.0.3 + yargs: 18.2.0 + transitivePeerDependencies: + - '@emnapi/runtime' + - prettier + - prettier-plugin-astro + + '@astrojs/compiler-binding-android-arm64@0.5.2': + optional: true + + '@astrojs/compiler-binding-darwin-arm64@0.5.2': + optional: true + + '@astrojs/compiler-binding-darwin-x64@0.5.2': + optional: true + + '@astrojs/compiler-binding-linux-arm64-gnu@0.5.2': + optional: true + + '@astrojs/compiler-binding-linux-arm64-musl@0.5.2': + optional: true + + '@astrojs/compiler-binding-linux-x64-gnu@0.5.2': + optional: true + + '@astrojs/compiler-binding-linux-x64-musl@0.5.2': + optional: true + + '@astrojs/compiler-binding-wasm32-wasi@0.5.2': + dependencies: + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 + '@napi-rs/wasm-runtime': 1.2.7(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + optional: true + + '@astrojs/compiler-binding-win32-arm64-msvc@0.5.2': + optional: true + + '@astrojs/compiler-binding-win32-x64-msvc@0.5.2': + optional: true + + '@astrojs/compiler-binding@0.5.2': + optionalDependencies: + '@astrojs/compiler-binding-android-arm64': 0.5.2 + '@astrojs/compiler-binding-darwin-arm64': 0.5.2 + '@astrojs/compiler-binding-darwin-x64': 0.5.2 + '@astrojs/compiler-binding-linux-arm64-gnu': 0.5.2 + '@astrojs/compiler-binding-linux-arm64-musl': 0.5.2 + '@astrojs/compiler-binding-linux-x64-gnu': 0.5.2 + '@astrojs/compiler-binding-linux-x64-musl': 0.5.2 + '@astrojs/compiler-binding-wasm32-wasi': 0.5.2 + '@astrojs/compiler-binding-win32-arm64-msvc': 0.5.2 + '@astrojs/compiler-binding-win32-x64-msvc': 0.5.2 + + '@astrojs/compiler-rs@0.5.2': + dependencies: + '@astrojs/compiler-binding': 0.5.2 + + '@astrojs/internal-helpers@0.13.1': + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + picomatch: 4.0.7 + retext-smartypants: 6.2.0 + shiki: 4.6.0 + smol-toml: 1.9.1 + unified: 11.0.5 + yaml: 2.9.1 + + '@astrojs/language-server@2.17.3(@emnapi/runtime@1.11.3)(prettier@3.9.10)(typescript@6.0.3)': + dependencies: + '@astrojs/astro2tsx': 0.1.3(@emnapi/runtime@1.11.3) + '@astrojs/yaml2ts': 0.2.4 + '@volar/kit': 2.4.30(typescript@6.0.3) + '@volar/language-core': 2.4.30 + '@volar/language-server': 2.4.30(typescript@6.0.3) + '@volar/language-service': 2.4.30(typescript@6.0.3) + muggle-string: 0.4.1 + tinyglobby: 0.2.17 + volar-service-css: 0.0.71(@volar/language-service@2.4.30(typescript@6.0.3)) + volar-service-emmet: 0.0.71(@volar/language-service@2.4.30(typescript@6.0.3)) + volar-service-html: 0.0.71(@volar/language-service@2.4.30(typescript@6.0.3)) + volar-service-prettier: 0.0.71(@volar/language-service@2.4.30(typescript@6.0.3))(prettier@3.9.10) + volar-service-typescript: 0.0.71(@volar/language-service@2.4.30(typescript@6.0.3))(typescript@6.0.3) + volar-service-typescript-twoslash-queries: 0.0.71(@volar/language-service@2.4.30(typescript@6.0.3))(typescript@6.0.3) + volar-service-yaml: 0.0.71(@volar/language-service@2.4.30(typescript@6.0.3)) + vscode-html-languageservice: 5.6.2 + vscode-uri: 3.2.0 + optionalDependencies: + prettier: 3.9.10 + transitivePeerDependencies: + - '@emnapi/runtime' + - typescript + + '@astrojs/markdown-satteri@0.4.7': + dependencies: + '@astrojs/internal-helpers': 0.13.1 + '@astrojs/prism': 4.1.2 + github-slugger: 2.0.0 + satteri: 0.11.6 + + '@astrojs/prism@4.1.2': + dependencies: + prismjs: 1.30.0 + + '@astrojs/telemetry@3.4.2': + dependencies: + ci-info: 4.4.0 + dset: 3.1.4 + is-docker: 4.0.0 + package-manager-detector: 1.9.0 + + '@astrojs/yaml2ts@0.2.4': + dependencies: + yaml: 2.9.1 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/code-frame@8.0.6': - dependencies: - '@babel/helper-validator-identifier': 8.0.6 - js-tokens: 10.0.0 - - '@babel/compat-data@7.29.7': {} - - '@babel/compat-data@8.0.5': {} - - '@babel/core@7.29.7(supports-color@7.2.0)': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.8 - '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/helpers': 7.29.7 - '@babel/parser': 7.29.8 - '@babel/template': 7.29.7 - '@babel/traverse': 7.29.8(supports-color@7.2.0) - '@babel/types': 7.29.8 - '@jridgewell/remapping': 2.3.5 - convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@7.2.0) - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - '@babel/core@8.1.0': - dependencies: - '@babel/code-frame': 8.0.6 - '@babel/generator': 8.1.0 - '@babel/helper-compilation-targets': 8.0.7 - '@babel/helpers': 8.1.0 - '@babel/parser': 8.1.0 - '@babel/template': 8.0.0 - '@babel/traverse': 8.1.0 - '@babel/types': 8.1.0 - '@types/gensync': 1.0.5 - convert-source-map: 2.0.0 - empathic: 2.1.0 - gensync: 1.0.0-beta.2 - import-meta-resolve: 4.2.0 - json5: 2.2.3 - obug: 3.0.0 - verkit: 0.5.0 - - '@babel/generator@7.29.8': - dependencies: - '@babel/parser': 7.29.8 - '@babel/types': 7.29.8 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - - '@babel/generator@8.1.0': - dependencies: - '@babel/parser': 8.1.0 - '@babel/types': 8.1.0 - '@jridgewell/gen-mapping': 0.4.0-beta.0 - '@jridgewell/trace-mapping': 0.3.31 - '@types/jsesc': 2.5.1 - jsesc: 3.1.0 - - '@babel/helper-compilation-targets@7.29.7': - dependencies: - '@babel/compat-data': 7.29.7 - '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.8 - lru-cache: 5.1.1 - semver: 6.3.1 - - '@babel/helper-compilation-targets@8.0.7': - dependencies: - '@babel/compat-data': 8.0.5 - '@babel/helper-validator-option': 8.0.0 - browserslist: 4.28.8 - flru: 1.0.2 - verkit: 0.5.0 - - '@babel/helper-globals@7.29.7': {} - - '@babel/helper-globals@8.0.6': {} - - '@babel/helper-module-imports@7.29.7(supports-color@7.2.0)': - dependencies: - '@babel/traverse': 7.29.8(supports-color@7.2.0) - '@babel/types': 7.29.8 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0) - '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.8(supports-color@7.2.0) - transitivePeerDependencies: - - supports-color - '@babel/helper-string-parser@7.29.7': {} - '@babel/helper-string-parser@8.0.8': {} - '@babel/helper-validator-identifier@7.29.7': {} - '@babel/helper-validator-identifier@8.0.6': {} - - '@babel/helper-validator-option@7.29.7': {} - - '@babel/helper-validator-option@8.0.0': {} - - '@babel/helpers@7.29.7': - dependencies: - '@babel/template': 7.29.7 - '@babel/types': 7.29.8 - - '@babel/helpers@8.1.0': - dependencies: - '@babel/template': 8.0.0 - '@babel/types': 8.1.0 - '@babel/parser@7.29.8': dependencies: '@babel/types': 7.29.8 - '@babel/parser@8.1.0': - dependencies: - '@babel/types': 8.1.0 - '@babel/runtime@7.29.7': {} - '@babel/template@7.29.7': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/parser': 7.29.8 - '@babel/types': 7.29.8 - - '@babel/template@8.0.0': - dependencies: - '@babel/code-frame': 8.0.6 - '@babel/parser': 8.1.0 - '@babel/types': 8.1.0 - - '@babel/traverse@7.29.8(supports-color@7.2.0)': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.8 - '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.8 - '@babel/template': 7.29.7 - '@babel/types': 7.29.8 - debug: 4.4.3(supports-color@7.2.0) - transitivePeerDependencies: - - supports-color - - '@babel/traverse@8.1.0': - dependencies: - '@babel/code-frame': 8.0.6 - '@babel/generator': 8.1.0 - '@babel/helper-globals': 8.0.6 - '@babel/parser': 8.1.0 - '@babel/template': 8.0.0 - '@babel/types': 8.1.0 - obug: 3.0.0 - '@babel/types@7.29.8': dependencies: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/types@8.1.0': - dependencies: - '@babel/helper-string-parser': 8.0.8 - '@babel/helper-validator-identifier': 8.0.6 - '@bcoe/v8-coverage@1.0.2': {} + '@bruits/satteri-android-arm64@0.11.6': + optional: true + + '@bruits/satteri-darwin-arm64@0.11.6': + optional: true + + '@bruits/satteri-darwin-x64@0.11.6': + optional: true + + '@bruits/satteri-freebsd-x64@0.11.6': + optional: true + + '@bruits/satteri-linux-arm64-gnu@0.11.6': + optional: true + + '@bruits/satteri-linux-arm64-musl@0.11.6': + optional: true + + '@bruits/satteri-linux-x64-gnu@0.11.6': + optional: true + + '@bruits/satteri-linux-x64-musl@0.11.6': + optional: true + + '@bruits/satteri-wasm32-wasi@0.11.6': + dependencies: + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 + '@napi-rs/wasm-runtime': 1.2.7(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + optional: true + + '@bruits/satteri-win32-arm64-msvc@0.11.6': + optional: true + + '@bruits/satteri-win32-x64-msvc@0.11.6': + optional: true + '@cacheable/memory@2.2.0': dependencies: '@cacheable/utils': 2.5.0 @@ -1683,6 +2842,58 @@ snapshots: hashery: 1.5.1 keyv: 5.6.0 + '@capsizecss/unpack@4.0.1': + dependencies: + fontkitten: 1.0.3 + + '@clack/core@1.5.1': + dependencies: + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@clack/prompts@1.8.1': + dependencies: + '@clack/core': 1.5.1 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@emmetio/abbreviation@2.3.3': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/css-abbreviation@2.1.8': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/css-parser@0.4.1': + dependencies: + '@emmetio/stream-reader': 2.2.0 + '@emmetio/stream-reader-utils': 0.1.0 + + '@emmetio/html-matcher@1.3.0': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/scanner@1.0.4': {} + + '@emmetio/stream-reader-utils@0.1.0': {} + + '@emmetio/stream-reader@2.2.0': {} + + '@emnapi/core@1.11.3': + dependencies: + '@emnapi/wasi-threads': 1.2.3 + tslib: 2.9.1 + + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.9.1 + + '@emnapi/wasi-threads@1.2.3': + dependencies: + tslib: 2.9.1 + '@eslint-community/eslint-utils@4.10.1(eslint@10.14.0(supports-color@7.2.0))': dependencies: eslint: 10.14.0(supports-color@7.2.0) @@ -1735,20 +2946,112 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.6.0 - '@jridgewell/trace-mapping': 0.3.31 + '@img/colour@1.1.0': + optional: true - '@jridgewell/gen-mapping@0.4.0-beta.0': - dependencies: - '@jridgewell/sourcemap-codec': 1.6.0 - '@jridgewell/trace-mapping': 0.3.31 + '@img/sharp-darwin-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.4 + optional: true - '@jridgewell/remapping@2.3.5': + '@img/sharp-darwin-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.4 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.5': dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 + '@img/sharp-wasm32': 0.35.5 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + optional: true + + '@img/sharp-linux-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.4 + optional: true + + '@img/sharp-linux-arm@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.4 + optional: true + + '@img/sharp-linux-ppc64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.4 + optional: true + + '@img/sharp-linux-riscv64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.4 + optional: true + + '@img/sharp-linux-s390x@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.4 + optional: true + + '@img/sharp-linux-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + optional: true + + '@img/sharp-wasm32@0.35.5': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.5': + dependencies: + '@img/sharp-wasm32': 0.35.5 + optional: true + + '@img/sharp-win32-arm64@0.35.5': + optional: true + + '@img/sharp-win32-ia32@0.35.5': + optional: true + + '@img/sharp-win32-x64@0.35.5': + optional: true '@jridgewell/resolve-uri@3.1.2': {} @@ -1767,100 +3070,176 @@ snapshots: '@keyv/serialize@1.1.1': {} - '@oxc-project/types@0.156.0': {} - - '@rolldown/binding-android-arm-eabi@1.2.16': - optional: true - - '@rolldown/binding-android-arm64@1.2.16': - optional: true - - '@rolldown/binding-darwin-arm64@1.2.16': - optional: true - - '@rolldown/binding-darwin-x64@1.2.16': - optional: true - - '@rolldown/binding-freebsd-x64@1.2.16': - optional: true - - '@rolldown/binding-linux-arm-gnueabihf@1.2.16': - optional: true - - '@rolldown/binding-linux-arm64-gnu@1.2.16': - optional: true - - '@rolldown/binding-linux-arm64-musl@1.2.16': - optional: true - - '@rolldown/binding-linux-ppc64-gnu@1.2.16': - optional: true - - '@rolldown/binding-linux-s390x-gnu@1.2.16': - optional: true - - '@rolldown/binding-linux-x64-gnu@1.2.16': - optional: true - - '@rolldown/binding-linux-x64-musl@1.2.16': - optional: true - - '@rolldown/binding-openharmony-arm64@1.2.16': - optional: true - - '@rolldown/binding-win32-arm64-msvc@1.2.16': - optional: true - - '@rolldown/binding-win32-x64-msvc@1.2.16': - optional: true - - '@rolldown/plugin-babel@0.3.0(@babel/core@8.1.0)(@babel/runtime@7.29.7)(rolldown@1.2.16)(vite@8.3.6(@types/node@26.6.6))': + '@kuhyx/web-ui@https://codeload.github.com/kuhyx/utils/tar.gz/decf5824e609b099f9455be93c4d09b4c1fd6cae#path:/web_ui(astro@7.4.7(@types/node@26.6.6)(yaml@2.9.1))': dependencies: - '@babel/core': 8.1.0 - artichokie: 0.4.5 - picomatch: 4.0.7 - rolldown: 1.2.16 - optionalDependencies: - '@babel/runtime': 7.29.7 - vite: 8.3.6(@types/node@26.6.6) + astro: 7.4.7(@types/node@26.6.6)(yaml@2.9.1) + + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': + dependencies: + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 + '@tybys/wasm-util': 0.10.4 + + '@napi-rs/wasm-runtime@1.2.6(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': + dependencies: + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 + '@tybys/wasm-util': 0.10.4 + optional: true + + '@napi-rs/wasm-runtime@1.2.7(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': + dependencies: + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 + '@tybys/wasm-util': 0.10.4 + optional: true + + '@oslojs/encoding@1.1.0': {} + + '@oxc-project/types@0.155.0': {} + + '@oxc-project/types@0.157.0': {} + + '@pkgr/core@0.3.6': {} + + '@rolldown/binding-android-arm-eabi@1.2.15': + optional: true + + '@rolldown/binding-android-arm-eabi@1.3.0': + optional: true + + '@rolldown/binding-android-arm64@1.2.15': + optional: true + + '@rolldown/binding-android-arm64@1.3.0': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.15': + optional: true + + '@rolldown/binding-darwin-arm64@1.3.0': + optional: true + + '@rolldown/binding-darwin-x64@1.2.15': + optional: true + + '@rolldown/binding-darwin-x64@1.3.0': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.15': + optional: true + + '@rolldown/binding-freebsd-x64@1.3.0': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.15': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.3.0': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.15': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.3.0': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.15': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.3.0': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.15': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.3.0': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.15': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.3.0': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.15': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.3.0': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.15': + optional: true + + '@rolldown/binding-linux-x64-musl@1.3.0': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.15': + optional: true + + '@rolldown/binding-openharmony-arm64@1.3.0': + optional: true + + '@rolldown/binding-wasm32-wasi@1.3.0': + dependencies: + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 + '@napi-rs/wasm-runtime': 1.2.6(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.15': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.3.0': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.15': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.3.0': + optional: true '@rolldown/pluginutils@1.0.1': {} - '@tanstack/history@1.162.5': {} - - '@tanstack/query-core@5.107.1': {} - - '@tanstack/react-query@5.107.1(@types/react@19.3.0)(react@19.3.1)': + '@shikijs/core@4.6.0': dependencies: - '@tanstack/query-core': 5.107.1 - react: 19.3.1 - optionalDependencies: - '@types/react': 19.3.0 + '@shikijs/primitive': 4.6.0 + '@shikijs/types': 4.6.0 + '@shikijs/vscode-textmate': 10.1.0 + '@types/hast': 3.0.5 + hast-util-to-html: 9.0.6 - '@tanstack/react-router@1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + '@shikijs/engine-javascript@4.6.0': dependencies: - '@tanstack/history': 1.162.5 - '@tanstack/react-store': 0.11.2(react-dom@19.3.1(react@19.3.1))(react@19.3.1) - '@tanstack/router-core': 1.171.39 - isbot: 5.2.2 - react: 19.3.1 - react-dom: 19.3.1(react@19.3.1) + '@shikijs/types': 4.6.0 + '@shikijs/vscode-textmate': 10.1.0 + oniguruma-to-es: 4.3.6 - '@tanstack/react-store@0.11.2(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + '@shikijs/engine-oniguruma@4.6.0': dependencies: - '@tanstack/store': 0.11.2 - react: 19.3.1 - react-dom: 19.3.1(react@19.3.1) - use-sync-external-store: 1.7.0(react@19.3.1) + '@shikijs/types': 4.6.0 + '@shikijs/vscode-textmate': 10.1.0 - '@tanstack/router-core@1.171.39': + '@shikijs/langs@4.6.0': dependencies: - '@tanstack/history': 1.162.5 - cookie-es: 3.1.1 - seroval: 1.6.9 - seroval-plugins: 1.6.9(seroval@1.6.9) + '@shikijs/types': 4.6.0 - '@tanstack/store@0.11.2': {} + '@shikijs/primitive@4.6.0': + dependencies: + '@shikijs/types': 4.6.0 + '@shikijs/vscode-textmate': 10.1.0 + '@types/hast': 3.0.5 + + '@shikijs/themes@4.6.0': + dependencies: + '@shikijs/types': 4.6.0 + + '@shikijs/types@4.6.0': + dependencies: + '@shikijs/vscode-textmate': 10.1.0 + '@types/hast': 3.0.5 + + '@shikijs/vscode-textmate@10.1.0': {} '@testing-library/dom@10.4.2': dependencies: @@ -1883,22 +3262,16 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 optionalDependencies: - vitest: 5.1.0(@types/node@26.6.6)(@vitest/coverage-v8@5.1.0)(jsdom@31.0.0)(vite@8.3.6(@types/node@26.6.6)) - - '@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': - dependencies: - '@babel/runtime': 7.29.7 - '@testing-library/dom': 10.4.2 - react: 19.3.1 - react-dom: 19.3.1(react@19.3.1) - optionalDependencies: - '@types/react': 19.3.0 - '@types/react-dom': 19.3.0(@types/react@19.3.0) + vitest: 5.1.0(@types/node@26.6.6)(@vitest/coverage-v8@5.1.0)(jsdom@31.0.0)(vite@8.3.5(@types/node@26.6.6)(yaml@2.9.1)) '@testing-library/user-event@14.6.8(@testing-library/dom@10.4.2)': dependencies: '@testing-library/dom': 10.4.2 + '@tybys/wasm-util@0.10.4': + dependencies: + tslib: 2.9.1 + '@types/aria-query@5.0.4': {} '@types/chai@5.2.3': @@ -1910,25 +3283,31 @@ snapshots: '@types/esrecurse@4.3.1': {} + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.9 + '@types/estree@1.0.9': {} - '@types/gensync@1.0.5': {} - - '@types/jsesc@2.5.1': {} + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 '@types/json-schema@7.0.15': {} + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/nlcst@2.0.3': + dependencies: + '@types/unist': 3.0.3 + '@types/node@26.6.6': dependencies: undici-types: 8.9.0 - '@types/react-dom@19.3.0(@types/react@19.3.0)': - dependencies: - '@types/react': 19.3.0 - - '@types/react@19.3.0': - dependencies: - csstype: 3.2.3 + '@types/unist@3.0.3': {} '@typescript-eslint/eslint-plugin@8.72.1(@typescript-eslint/parser@8.72.1(eslint@10.14.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.14.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: @@ -2021,13 +3400,7 @@ snapshots: '@typescript-eslint/types': 8.72.1 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-react@6.2.1(@rolldown/plugin-babel@0.3.0(@babel/core@8.1.0)(@babel/runtime@7.29.7)(rolldown@1.2.16)(vite@8.3.6(@types/node@26.6.6)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.6(@types/node@26.6.6))': - dependencies: - '@rolldown/pluginutils': 1.0.1 - vite: 8.3.6(@types/node@26.6.6) - optionalDependencies: - '@rolldown/plugin-babel': 0.3.0(@babel/core@8.1.0)(@babel/runtime@7.29.7)(rolldown@1.2.16)(vite@8.3.6(@types/node@26.6.6)) - babel-plugin-react-compiler: 1.0.0 + '@ungap/structured-clone@1.4.0': {} '@vitest/coverage-v8@5.1.0(vitest@5.1.0)': dependencies: @@ -2039,7 +3412,7 @@ snapshots: obug: 3.0.0 std-env: 4.3.0 tinyrainbow: 3.2.0 - vitest: 5.1.0(@types/node@26.6.6)(@vitest/coverage-v8@5.1.0)(jsdom@31.0.0)(vite@8.3.6(@types/node@26.6.6)) + vitest: 5.1.0(@types/node@26.6.6)(@vitest/coverage-v8@5.1.0)(jsdom@31.0.0)(vite@8.3.5(@types/node@26.6.6)(yaml@2.9.1)) '@vitest/istanbul-lib-coverage@2.1.0': {} @@ -2047,23 +3420,88 @@ snapshots: dependencies: '@vitest/istanbul-lib-coverage': 2.1.0 - '@vitest/mocker@5.1.0(vite@8.3.6(@types/node@26.6.6))': + '@vitest/mocker@5.1.0(vite@8.3.5(@types/node@26.6.6)(yaml@2.9.1))': dependencies: '@jridgewell/trace-mapping': 0.3.31 '@vitest/spy': 5.1.0 estree-walker: 3.0.3 - magic-string: 1.4.3 + magic-string: 1.4.5 optionalDependencies: - vite: 8.3.6(@types/node@26.6.6) + vite: 8.3.5(@types/node@26.6.6)(yaml@2.9.1) '@vitest/spy@5.1.0': {} + '@volar/kit@2.4.30(typescript@6.0.3)': + dependencies: + '@volar/language-service': 2.4.30(typescript@6.0.3) + '@volar/typescript': 2.4.30(typescript@6.0.3) + typesafe-path: 0.2.2 + typescript: 6.0.3 + vscode-languageserver-textdocument: 1.0.16 + vscode-uri: 3.2.0 + + '@volar/language-core@2.4.30': + dependencies: + '@volar/source-map': 2.4.30 + + '@volar/language-server@2.4.30(typescript@6.0.3)': + dependencies: + '@volar/language-core': 2.4.30 + '@volar/language-service': 2.4.30(typescript@6.0.3) + '@volar/typescript': 2.4.30(typescript@6.0.3) + path-browserify: 1.0.1 + request-light: 0.7.0 + vscode-languageserver: 9.0.1 + vscode-languageserver-protocol: 3.18.5 + vscode-languageserver-textdocument: 1.0.16 + vscode-uri: 3.2.0 + optionalDependencies: + typescript: 6.0.3 + + '@volar/language-service@2.4.30(typescript@6.0.3)': + dependencies: + '@volar/language-core': 2.4.30 + vscode-languageserver-protocol: 3.18.5 + vscode-languageserver-textdocument: 1.0.16 + vscode-uri: 3.2.0 + optionalDependencies: + typescript: 6.0.3 + + '@volar/source-map@2.4.30': {} + + '@volar/typescript@2.4.30(typescript@6.0.3)': + dependencies: + '@volar/language-core': 2.4.30 + '@volar/language-service': 2.4.30(typescript@6.0.3) + path-browserify: 1.0.1 + vscode-uri: 3.2.0 + optionalDependencies: + typescript: 6.0.3 + + '@vscode/emmet-helper@2.11.0': + dependencies: + emmet: 2.4.11 + jsonc-parser: 2.3.1 + vscode-languageserver-textdocument: 1.0.16 + vscode-languageserver-types: 3.18.5 + vscode-uri: 3.2.0 + + '@vscode/l10n@0.0.18': {} + acorn-jsx@5.3.2(acorn@8.18.0): dependencies: acorn: 8.18.0 acorn@8.18.0: {} + ajv-draft-04@1.0.0(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv-i18n@4.2.0(ajv@8.20.0): + dependencies: + ajv: 8.20.0 + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -2071,18 +3509,36 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.9 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + am-i-vibing@0.5.0: + dependencies: + process-ancestry: 0.1.0 + ansi-regex@5.0.1: {} + ansi-regex@6.5.0: {} + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + aria-query@5.3.0: dependencies: dequal: 2.0.3 aria-query@5.3.2: {} - artichokie@0.4.5: {} - assertion-error@2.0.1: {} ast-v8-to-istanbul@1.0.7: @@ -2091,14 +3547,115 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 - babel-plugin-react-compiler@1.0.0: + astro-eslint-parser@3.2.0(supports-color@7.2.0): dependencies: - '@babel/types': 7.29.8 + '@astrojs/compiler-rs': 0.5.2 + '@typescript-eslint/scope-manager': 8.72.1 + '@typescript-eslint/types': 8.72.1 + debug: 4.4.3(supports-color@7.2.0) + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + semver: 7.8.5 + tinyglobby: 0.2.17 + transitivePeerDependencies: + - supports-color + + astro@7.4.7(@types/node@26.6.6)(yaml@2.9.1): + dependencies: + '@astrojs/compiler-rs': 0.5.2 + '@astrojs/internal-helpers': 0.13.1 + '@astrojs/markdown-satteri': 0.4.7 + '@astrojs/telemetry': 3.4.2 + '@capsizecss/unpack': 4.0.1 + '@clack/prompts': 1.8.1 + '@oslojs/encoding': 1.1.0 + am-i-vibing: 0.5.0 + aria-query: 5.3.2 + axobject-query: 4.1.0 + ci-info: 4.4.0 + clsx: 2.1.1 + common-ancestor-path: 2.0.0 + cookie: 2.0.2 + devalue: 6.0.3 + diff: 9.0.0 + dset: 3.1.4 + es-module-lexer: 3.0.4 + flattie: 1.1.1 + fontace: 0.4.1 + get-tsconfig: 5.0.0-beta.6 + github-slugger: 2.0.0 + html-escaper: 3.0.3 + http-cache-semantics: 4.3.0 + jsonc-parser: 3.3.1 + magic-string: 1.4.5 + magicast: 0.5.5 + mrmime: 2.0.1 + neotraverse: 1.0.1 + obug: 3.0.0 + p-limit: 7.3.3 + p-queue: 10.0.0 + package-manager-detector: 1.9.0 + piccolore: 0.1.3 + picomatch: 4.0.7 + rolldown: 1.3.0 + shiki: 4.6.0 + smol-toml: 1.9.1 + svgo: 4.1.0 + tinyclip: 1.0.3 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + tinyps: 0.5.1 + ultrahtml: 1.7.0 + unifont: 1.0.3 + unstorage: 1.17.6 + verkit: 0.5.0 + vite: 8.3.5(@types/node@26.6.6)(yaml@2.9.1) + vitefu: 1.1.3(vite@8.3.5(@types/node@26.6.6)(yaml@2.9.1)) + xxhash-wasm: 1.1.0 + yargs-parser: 22.0.0 + zod: 4.6.7 + optionalDependencies: + sharp: 0.35.5(@types/node@26.6.6) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@types/node' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - '@vitejs/devtools' + - aws4fetch + - db0 + - esbuild + - idb-keyval + - ioredis + - jiti + - less + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - uploadthing + - yaml + + axobject-query@4.1.0: {} + + bail@2.0.2: {} balanced-match@4.0.4: {} - baseline-browser-mapping@2.11.19: {} - bcp-47-match@2.0.3: {} bcp-47@2.1.2: @@ -2107,18 +3664,12 @@ snapshots: is-alphanumerical: 2.0.1 is-decimal: 2.0.1 + boolbase@1.0.0: {} + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 - browserslist@4.28.8: - dependencies: - baseline-browser-mapping: 2.11.19 - caniuse-lite: 1.0.30001810 - electron-to-chromium: 1.5.415 - node-releases: 2.0.54 - update-browserslist-db: 1.3.1(browserslist@4.28.8) - cacheable@2.5.0: dependencies: '@cacheable/memory': 2.2.0 @@ -2127,13 +3678,37 @@ snapshots: keyv: 5.6.0 qified: 0.10.1 - caniuse-lite@1.0.30001810: {} + ccount@2.0.1: {} chai@6.3.0: {} - convert-source-map@2.0.0: {} + character-entities-html4@2.1.0: {} - cookie-es@3.1.1: {} + character-entities-legacy@3.0.0: {} + + chokidar@5.0.0: + dependencies: + readdirp: 5.1.1 + + ci-info@4.4.0: {} + + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + + clsx@2.1.1: {} + + comma-separated-tokens@2.0.3: {} + + commander@11.1.0: {} + + common-ancestor-path@2.0.0: {} + + cookie-es@1.2.3: {} + + cookie@2.0.2: {} cross-spawn@7.0.6: dependencies: @@ -2141,9 +3716,37 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + crossws@0.3.5: + dependencies: + uncrypto: 0.1.3 + + css-select@6.0.0: + dependencies: + boolbase: 1.0.0 + css-what: 7.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-tree@2.2.1: + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.2.2 + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.2 + + css-what@7.0.0: {} + css.escape@1.5.1: {} - csstype@3.2.3: {} + cssesc@3.0.0: {} + + csso@5.0.5: + dependencies: + css-tree: 2.2.1 data-urls@8.0.0: dependencies: @@ -2162,41 +3765,83 @@ snapshots: deep-is@0.1.4: {} + defu@6.1.7: {} + dequal@2.0.3: {} + destr@2.0.5: {} + detect-libc@2.1.2: {} + devalue@6.0.3: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + diff@9.0.0: {} + dom-accessibility-api@0.5.16: {} dom-accessibility-api@0.6.3: {} - electron-to-chromium@1.5.415: {} + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 - empathic@2.1.0: {} + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dset@3.1.4: {} + + emmet@2.4.11: + dependencies: + '@emmetio/abbreviation': 2.3.3 + '@emmetio/css-abbreviation': 2.1.8 + + emoji-regex@10.6.0: {} + + entities@4.5.0: {} entities@9.0.0: {} es-module-lexer@2.3.2: {} + es-module-lexer@3.0.4: {} + escalade@3.2.0: {} escape-string-regexp@4.0.0: {} - eslint-plugin-react-hooks@7.1.1(eslint@10.14.0(supports-color@7.2.0))(supports-color@7.2.0): + eslint-plugin-astro@3.2.1(@typescript-eslint/parser@8.72.1(eslint@10.14.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.14.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript-eslint@8.72.1(eslint@10.14.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)): dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/parser': 7.29.8 + '@eslint-community/eslint-utils': 4.10.1(eslint@10.14.0(supports-color@7.2.0)) + '@jridgewell/sourcemap-codec': 1.6.0 + '@typescript-eslint/types': 8.72.1 + astro-eslint-parser: 3.2.0(supports-color@7.2.0) eslint: 10.14.0(supports-color@7.2.0) - hermes-parser: 0.25.1 - zod: 4.4.3 - zod-validation-error: 4.0.2(zod@4.4.3) + espree: 11.2.0 + globals: 17.14.0 + postcss: 8.5.31 + postcss-selector-parser: 7.1.7 + synckit: 0.11.13 + optionalDependencies: + '@typescript-eslint/parser': 8.72.1(eslint@10.14.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + typescript-eslint: 8.72.1(eslint@10.14.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) transitivePeerDependencies: - supports-color - eslint-plugin-react-refresh@0.5.7(eslint@10.14.0(supports-color@7.2.0)): - dependencies: - eslint: 10.14.0(supports-color@7.2.0) - eslint-scope@9.1.2: dependencies: '@types/esrecurse': 4.3.1 @@ -2267,12 +3912,26 @@ snapshots: expect-type@1.4.0: {} + extend@3.0.2: {} + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-uri@3.1.9: {} + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: picomatch: 4.0.7 @@ -2294,12 +3953,30 @@ snapshots: flatted@3.4.4: {} - flru@1.0.2: {} + flattie@1.1.1: {} + + fnv1a-64@0.1.2: {} + + fontace@0.4.1: + dependencies: + fontkitten: 1.0.3 + + fontkitten@1.0.3: + dependencies: + tiny-inflate: 1.0.3 fsevents@2.3.3: optional: true - gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} + + get-east-asian-width@1.7.0: {} + + get-tsconfig@5.0.0-beta.6: + dependencies: + resolve-pkg-maps: 1.0.0 + + github-slugger@2.0.0: {} glob-parent@6.0.2: dependencies: @@ -2307,6 +3984,18 @@ snapshots: globals@17.14.0: {} + h3@1.15.11: + dependencies: + cookie-es: 1.2.3 + crossws: 0.3.5 + defu: 6.1.7 + destr: 2.0.5 + iron-webcrypto: 1.2.1 + node-mock-http: 1.0.5 + radix3: 1.1.2 + ufo: 1.6.4 + uncrypto: 0.1.3 + has-flag@4.0.0: optional: true @@ -2314,11 +4003,23 @@ snapshots: dependencies: hookified: 1.15.1 - hermes-estree@0.25.1: {} - - hermes-parser@0.25.1: + hast-util-to-html@9.0.6: dependencies: - hermes-estree: 0.25.1 + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.5 hookified@1.15.1: {} @@ -2330,16 +4031,22 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + html-escaper@3.0.3: {} + + html-void-elements@3.0.0: {} + + http-cache-semantics@4.3.0: {} + ignore@5.3.2: {} ignore@7.0.6: {} - import-meta-resolve@4.2.0: {} - imurmurhash@0.1.4: {} indent-string@4.0.0: {} + iron-webcrypto@1.2.1: {} + is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: @@ -2349,15 +4056,17 @@ snapshots: is-decimal@2.0.1: {} + is-docker@4.0.0: {} + is-extglob@2.1.1: {} is-glob@4.0.3: dependencies: is-extglob: 2.1.1 - is-potential-custom-element-name@1.0.1: {} + is-plain-obj@4.1.0: {} - isbot@5.2.2: {} + is-potential-custom-element-name@1.0.1: {} isexe@2.0.0: {} @@ -2388,18 +4097,22 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' - jsesc@3.1.0: {} - json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} - json5@2.2.3: {} + jsonc-parser@2.3.1: {} + + jsonc-parser@3.3.1: {} keyv@5.6.0: dependencies: '@keyv/serialize': 1.1.1 + kleur@4.1.5: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -2460,13 +4173,9 @@ snapshots: lru-cache@11.5.3: {} - lru-cache@5.1.1: - dependencies: - yallist: 3.1.1 - lz-string@1.5.0: {} - magic-string@1.4.3: + magic-string@1.4.5: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -2476,22 +4185,87 @@ snapshots: '@babel/types': 7.29.8 source-map-js: 1.2.2 + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.4.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.2.0 + vfile: 6.0.3 + + mdn-data@2.0.28: {} + + mdn-data@2.27.1: {} + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-encode@2.0.1: {} + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.3: {} + min-indent@1.0.1: {} minimatch@10.2.6: dependencies: brace-expansion: 5.0.9 + mrmime@2.0.1: {} + ms@2.1.3: {} + muggle-string@0.4.1: {} + nanoid@3.3.20: {} natural-compare@1.4.0: {} - node-releases@2.0.54: {} + neotraverse@1.0.1: {} + + nlcst-to-string@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + + node-fetch-native@1.6.7: {} + + node-mock-http@1.0.5: {} + + normalize-path@3.0.0: {} + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 obug@3.0.0: {} + ofetch@1.5.1: + dependencies: + destr: 2.0.5 + node-fetch-native: 1.6.7 + ufo: 1.6.4 + + oniguruma-parser@0.12.2: {} + + oniguruma-to-es@4.3.6: + dependencies: + oniguruma-parser: 0.12.2 + regex: 6.1.0 + regex-recursion: 6.0.2 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -2505,10 +4279,18 @@ snapshots: dependencies: yocto-queue: 0.1.0 + p-limit@7.3.3: + dependencies: + yocto-queue: 1.2.2 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-queue@10.0.0: {} + + package-manager-detector@1.9.0: {} + parse5-parser-stream@8.1.0: dependencies: parse5: 8.1.0 @@ -2517,15 +4299,26 @@ snapshots: dependencies: entities: 9.0.0 + path-browserify@1.0.1: {} + path-exists@4.0.0: {} path-key@3.1.1: {} + piccolore@0.1.3: {} + picocolors@1.1.1: {} + picomatch@2.3.2: {} + picomatch@4.0.7: {} - postcss@8.5.32: + postcss-selector-parser@7.1.7: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss@8.5.31: dependencies: nanoid: 3.3.20 picocolors: 1.1.1 @@ -2533,68 +4326,164 @@ snapshots: prelude-ls@1.2.1: {} + prettier@3.9.10: {} + pretty-format@27.5.1: dependencies: ansi-regex: 5.0.1 ansi-styles: 5.2.0 react-is: 17.0.2 + prismjs@1.30.0: {} + + process-ancestry@0.1.0: {} + + property-information@7.2.0: {} + punycode@2.3.1: {} qified@0.10.1: dependencies: hookified: 2.2.0 - react-dom@19.3.1(react@19.3.1): - dependencies: - react: 19.3.1 - scheduler: 0.28.0 + radix3@1.1.2: {} react-is@17.0.2: {} - react@19.3.1: {} + readdirp@5.1.1: {} redent@3.0.0: dependencies: indent-string: 4.0.0 strip-indent: 3.0.0 - rolldown@1.2.16: + regex-recursion@6.0.2: dependencies: - '@oxc-project/types': 0.156.0 + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + + request-light@0.5.8: {} + + request-light@0.7.0: {} + + require-from-string@2.0.2: {} + + resolve-pkg-maps@1.0.0: {} + + retext-smartypants@6.2.0: + dependencies: + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unist-util-visit: 5.2.0 + + rolldown@1.2.15: + dependencies: + '@oxc-project/types': 0.155.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm-eabi': 1.2.16 - '@rolldown/binding-android-arm64': 1.2.16 - '@rolldown/binding-darwin-arm64': 1.2.16 - '@rolldown/binding-darwin-x64': 1.2.16 - '@rolldown/binding-freebsd-x64': 1.2.16 - '@rolldown/binding-linux-arm-gnueabihf': 1.2.16 - '@rolldown/binding-linux-arm64-gnu': 1.2.16 - '@rolldown/binding-linux-arm64-musl': 1.2.16 - '@rolldown/binding-linux-ppc64-gnu': 1.2.16 - '@rolldown/binding-linux-s390x-gnu': 1.2.16 - '@rolldown/binding-linux-x64-gnu': 1.2.16 - '@rolldown/binding-linux-x64-musl': 1.2.16 - '@rolldown/binding-openharmony-arm64': 1.2.16 - '@rolldown/binding-win32-arm64-msvc': 1.2.16 - '@rolldown/binding-win32-x64-msvc': 1.2.16 + '@rolldown/binding-android-arm-eabi': 1.2.15 + '@rolldown/binding-android-arm64': 1.2.15 + '@rolldown/binding-darwin-arm64': 1.2.15 + '@rolldown/binding-darwin-x64': 1.2.15 + '@rolldown/binding-freebsd-x64': 1.2.15 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.15 + '@rolldown/binding-linux-arm64-gnu': 1.2.15 + '@rolldown/binding-linux-arm64-musl': 1.2.15 + '@rolldown/binding-linux-ppc64-gnu': 1.2.15 + '@rolldown/binding-linux-s390x-gnu': 1.2.15 + '@rolldown/binding-linux-x64-gnu': 1.2.15 + '@rolldown/binding-linux-x64-musl': 1.2.15 + '@rolldown/binding-openharmony-arm64': 1.2.15 + '@rolldown/binding-win32-arm64-msvc': 1.2.15 + '@rolldown/binding-win32-x64-msvc': 1.2.15 + + rolldown@1.3.0: + dependencies: + '@oxc-project/types': 0.157.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.3.0 + '@rolldown/binding-android-arm64': 1.3.0 + '@rolldown/binding-darwin-arm64': 1.3.0 + '@rolldown/binding-darwin-x64': 1.3.0 + '@rolldown/binding-freebsd-x64': 1.3.0 + '@rolldown/binding-linux-arm-gnueabihf': 1.3.0 + '@rolldown/binding-linux-arm64-gnu': 1.3.0 + '@rolldown/binding-linux-arm64-musl': 1.3.0 + '@rolldown/binding-linux-ppc64-gnu': 1.3.0 + '@rolldown/binding-linux-s390x-gnu': 1.3.0 + '@rolldown/binding-linux-x64-gnu': 1.3.0 + '@rolldown/binding-linux-x64-musl': 1.3.0 + '@rolldown/binding-openharmony-arm64': 1.3.0 + '@rolldown/binding-wasm32-wasi': 1.3.0 + '@rolldown/binding-win32-arm64-msvc': 1.3.0 + '@rolldown/binding-win32-x64-msvc': 1.3.0 + + satteri@0.11.6: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + optionalDependencies: + '@bruits/satteri-android-arm64': 0.11.6 + '@bruits/satteri-darwin-arm64': 0.11.6 + '@bruits/satteri-darwin-x64': 0.11.6 + '@bruits/satteri-freebsd-x64': 0.11.6 + '@bruits/satteri-linux-arm64-gnu': 0.11.6 + '@bruits/satteri-linux-arm64-musl': 0.11.6 + '@bruits/satteri-linux-x64-gnu': 0.11.6 + '@bruits/satteri-linux-x64-musl': 0.11.6 + '@bruits/satteri-wasm32-wasi': 0.11.6 + '@bruits/satteri-win32-arm64-msvc': 0.11.6 + '@bruits/satteri-win32-x64-msvc': 0.11.6 + + sax@1.6.1: {} saxes@6.0.0: dependencies: xmlchars: 2.2.0 - scheduler@0.28.0: {} - - semver@6.3.1: {} - semver@7.8.5: {} - seroval-plugins@1.6.9(seroval@1.6.9): + sharp@0.35.5(@types/node@26.6.6): dependencies: - seroval: 1.6.9 - - seroval@1.6.9: {} + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.5 + '@img/sharp-darwin-x64': 0.35.5 + '@img/sharp-freebsd-wasm32': 0.35.5 + '@img/sharp-libvips-darwin-arm64': 1.3.4 + '@img/sharp-libvips-darwin-x64': 1.3.4 + '@img/sharp-libvips-linux-arm': 1.3.4 + '@img/sharp-libvips-linux-arm64': 1.3.4 + '@img/sharp-libvips-linux-ppc64': 1.3.4 + '@img/sharp-libvips-linux-riscv64': 1.3.4 + '@img/sharp-libvips-linux-s390x': 1.3.4 + '@img/sharp-libvips-linux-x64': 1.3.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + '@img/sharp-linux-arm': 0.35.5 + '@img/sharp-linux-arm64': 0.35.5 + '@img/sharp-linux-ppc64': 0.35.5 + '@img/sharp-linux-riscv64': 0.35.5 + '@img/sharp-linux-s390x': 0.35.5 + '@img/sharp-linux-x64': 0.35.5 + '@img/sharp-linuxmusl-arm64': 0.35.5 + '@img/sharp-linuxmusl-x64': 0.35.5 + '@img/sharp-webcontainers-wasm32': 0.35.5 + '@img/sharp-win32-arm64': 0.35.5 + '@img/sharp-win32-ia32': 0.35.5 + '@img/sharp-win32-x64': 0.35.5 + '@types/node': 26.6.6 + optional: true shebang-command@2.0.0: dependencies: @@ -2602,10 +4491,47 @@ snapshots: shebang-regex@3.0.0: {} + shiki@4.6.0: + dependencies: + '@shikijs/core': 4.6.0 + '@shikijs/engine-javascript': 4.6.0 + '@shikijs/engine-oniguruma': 4.6.0 + '@shikijs/langs': 4.6.0 + '@shikijs/themes': 4.6.0 + '@shikijs/types': 4.6.0 + '@shikijs/vscode-textmate': 10.1.0 + '@types/hast': 3.0.5 + + sisteransi@1.0.5: {} + + smol-toml@1.9.1: {} + source-map-js@1.2.2: {} + space-separated-tokens@2.0.2: {} + std-env@4.3.0: {} + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.7.0 + strip-ansi: 7.2.0 + + string-width@8.3.0: + dependencies: + get-east-asian-width: 1.7.0 + strip-ansi: 7.2.0 + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.5.0 + strip-indent@3.0.0: dependencies: min-indent: 1.0.1 @@ -2615,8 +4541,26 @@ snapshots: has-flag: 4.0.0 optional: true + svgo@4.1.0: + dependencies: + commander: 11.1.0 + css-select: 6.0.0 + css-tree: 3.2.1 + css-what: 7.0.0 + csso: 5.0.5 + picocolors: 1.1.1 + sax: 1.6.1 + + synckit@0.11.13: + dependencies: + '@pkgr/core': 0.3.6 + + tiny-inflate@1.0.3: {} + tinybench@6.2.2: {} + tinyclip@1.0.3: {} + tinyexec@1.3.1: {} tinyglobby@0.2.17: @@ -2624,6 +4568,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 + tinyps@0.5.1: {} + tinyrainbow@3.2.0: {} tldts-core@7.4.11: {} @@ -2640,14 +4586,26 @@ snapshots: dependencies: punycode: 2.3.1 + trim-lines@3.0.1: {} + + trough@2.2.0: {} + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: typescript: 6.0.3 + tslib@2.9.1: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 + typesafe-path@0.2.2: {} + + typescript-auto-import-cache@0.3.6: + dependencies: + semver: 7.8.5 + typescript-eslint@8.72.1(eslint@10.14.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3): dependencies: '@typescript-eslint/eslint-plugin': 8.72.1(@typescript-eslint/parser@8.72.1(eslint@10.14.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.14.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) @@ -2661,52 +4619,118 @@ snapshots: typescript@6.0.3: {} + ufo@1.6.4: {} + + ultrahtml@1.7.0: {} + + uncrypto@0.1.3: {} + undici-types@8.9.0: {} + undici@8.12.1: {} + undici@8.12.4: {} - update-browserslist-db@1.3.1(browserslist@4.28.8): + unified@11.0.5: dependencies: - browserslist: 4.28.8 - escalade: 3.2.0 - picocolors: 1.1.1 + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unifont@1.0.3: + dependencies: + css-tree: 3.2.1 + fnv1a-64: 0.1.2 + std-env: 4.3.0 + undici: 8.12.1 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.2.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.1.0 + + unstorage@1.17.6: + dependencies: + anymatch: 3.1.3 + chokidar: 5.0.0 + destr: 2.0.5 + h3: 1.15.11 + lru-cache: 11.5.3 + node-fetch-native: 1.6.7 + ofetch: 1.5.1 + ufo: 1.6.4 uri-js@4.4.1: dependencies: punycode: 2.3.1 - use-sync-external-store@1.7.0(react@19.3.1): - dependencies: - react: 19.3.1 + util-deprecate@1.0.2: {} verkit@0.5.0: {} - vite@8.3.6(@types/node@26.6.6): + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@8.3.5(@types/node@26.6.6)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 - postcss: 8.5.32 - rolldown: 1.2.16 + postcss: 8.5.31 + rolldown: 1.2.15 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 26.6.6 fsevents: 2.3.3 + yaml: 2.9.1 - vitest@5.1.0(@types/node@26.6.6)(@vitest/coverage-v8@5.1.0)(jsdom@31.0.0)(vite@8.3.6(@types/node@26.6.6)): + vitefu@1.1.3(vite@8.3.5(@types/node@26.6.6)(yaml@2.9.1)): + optionalDependencies: + vite: 8.3.5(@types/node@26.6.6)(yaml@2.9.1) + + vitest@5.1.0(@types/node@26.6.6)(@vitest/coverage-v8@5.1.0)(jsdom@31.0.0)(vite@8.3.5(@types/node@26.6.6)(yaml@2.9.1)): dependencies: '@types/chai': 5.2.3 - '@vitest/mocker': 5.1.0(vite@8.3.6(@types/node@26.6.6)) + '@vitest/mocker': 5.1.0(vite@8.3.5(@types/node@26.6.6)(yaml@2.9.1)) chai: 6.3.0 es-module-lexer: 2.3.2 expect-type: 1.4.0 - magic-string: 1.4.3 + magic-string: 1.4.5 obug: 3.0.0 picomatch: 4.0.7 std-env: 4.3.0 tinybench: 6.2.2 tinyexec: 1.3.1 tinyglobby: 0.2.17 - vite: 8.3.6(@types/node@26.6.6) + vite: 8.3.5(@types/node@26.6.6)(yaml@2.9.1) why-is-node-running: 3.2.1 optionalDependencies: '@types/node': 26.6.6 @@ -2715,6 +4739,114 @@ snapshots: transitivePeerDependencies: - msw + volar-service-css@0.0.71(@volar/language-service@2.4.30(typescript@6.0.3)): + dependencies: + vscode-css-languageservice: 6.3.10 + vscode-languageserver-textdocument: 1.0.16 + vscode-uri: 3.2.0 + optionalDependencies: + '@volar/language-service': 2.4.30(typescript@6.0.3) + + volar-service-emmet@0.0.71(@volar/language-service@2.4.30(typescript@6.0.3)): + dependencies: + '@emmetio/css-parser': 0.4.1 + '@emmetio/html-matcher': 1.3.0 + '@vscode/emmet-helper': 2.11.0 + vscode-uri: 3.2.0 + optionalDependencies: + '@volar/language-service': 2.4.30(typescript@6.0.3) + + volar-service-html@0.0.71(@volar/language-service@2.4.30(typescript@6.0.3)): + dependencies: + vscode-html-languageservice: 5.6.2 + vscode-languageserver-textdocument: 1.0.16 + vscode-uri: 3.2.0 + optionalDependencies: + '@volar/language-service': 2.4.30(typescript@6.0.3) + + volar-service-prettier@0.0.71(@volar/language-service@2.4.30(typescript@6.0.3))(prettier@3.9.10): + dependencies: + vscode-uri: 3.2.0 + optionalDependencies: + '@volar/language-service': 2.4.30(typescript@6.0.3) + prettier: 3.9.10 + + volar-service-typescript-twoslash-queries@0.0.71(@volar/language-service@2.4.30(typescript@6.0.3))(typescript@6.0.3): + dependencies: + vscode-uri: 3.2.0 + optionalDependencies: + '@volar/language-service': 2.4.30(typescript@6.0.3) + typescript: 6.0.3 + + volar-service-typescript@0.0.71(@volar/language-service@2.4.30(typescript@6.0.3))(typescript@6.0.3): + dependencies: + path-browserify: 1.0.1 + semver: 7.8.5 + typescript-auto-import-cache: 0.3.6 + vscode-languageserver-textdocument: 1.0.16 + vscode-nls: 5.2.0 + vscode-uri: 3.2.0 + optionalDependencies: + '@volar/language-service': 2.4.30(typescript@6.0.3) + typescript: 6.0.3 + + volar-service-yaml@0.0.71(@volar/language-service@2.4.30(typescript@6.0.3)): + dependencies: + vscode-uri: 3.2.0 + yaml-language-server: 1.23.0 + optionalDependencies: + '@volar/language-service': 2.4.30(typescript@6.0.3) + + vscode-css-languageservice@6.3.10: + dependencies: + '@vscode/l10n': 0.0.18 + vscode-languageserver-textdocument: 1.0.16 + vscode-languageserver-types: 3.17.5 + vscode-uri: 3.2.0 + + vscode-html-languageservice@5.6.2: + dependencies: + '@vscode/l10n': 0.0.18 + vscode-languageserver-textdocument: 1.0.16 + vscode-languageserver-types: 3.18.5 + vscode-uri: 3.2.0 + + vscode-json-languageservice@4.1.8: + dependencies: + jsonc-parser: 3.3.1 + vscode-languageserver-textdocument: 1.0.16 + vscode-languageserver-types: 3.18.5 + vscode-nls: 5.2.0 + vscode-uri: 3.2.0 + + vscode-jsonrpc@10.0.0: {} + + vscode-jsonrpc@8.2.0: {} + + vscode-languageserver-protocol@3.17.5: + dependencies: + vscode-jsonrpc: 8.2.0 + vscode-languageserver-types: 3.17.5 + + vscode-languageserver-protocol@3.18.5: + dependencies: + vscode-jsonrpc: 10.0.0 + vscode-languageserver-types: 3.18.5 + + vscode-languageserver-textdocument@1.0.16: {} + + vscode-languageserver-types@3.17.5: {} + + vscode-languageserver-types@3.18.5: {} + + vscode-languageserver@9.0.1: + dependencies: + vscode-languageserver-protocol: 3.17.5 + + vscode-nls@5.2.0: {} + + vscode-uri@3.2.0: {} + w3c-xmlserializer@6.0.0: dependencies: xml-name-validator: 5.0.0 @@ -2739,16 +4871,54 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} - yallist@3.1.1: {} + xxhash-wasm@1.1.0: {} + + y18n@5.0.8: {} + + yaml-language-server@1.23.0: + dependencies: + '@vscode/l10n': 0.0.18 + ajv: 8.20.0 + ajv-draft-04: 1.0.0(ajv@8.20.0) + ajv-i18n: 4.2.0(ajv@8.20.0) + prettier: 3.9.10 + request-light: 0.5.8 + vscode-json-languageservice: 4.1.8 + vscode-languageserver: 9.0.1 + vscode-languageserver-textdocument: 1.0.16 + vscode-languageserver-types: 3.18.5 + vscode-uri: 3.2.0 + yaml: 2.8.3 + + yaml@2.8.3: {} + + yaml@2.9.1: {} + + yargs-parser@22.0.0: {} + + yargs@18.2.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 8.3.0 + y18n: 5.0.8 + yargs-parser: 22.0.0 yocto-queue@0.1.0: {} - zod-validation-error@4.0.2(zod@4.4.3): - dependencies: - zod: 4.4.3 + yocto-queue@1.2.2: {} - zod@4.4.3: {} + zod@4.6.7: {} + + zwitch@2.0.4: {} diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml new file mode 100644 index 0000000..53e51a8 --- /dev/null +++ b/web/pnpm-workspace.yaml @@ -0,0 +1,13 @@ +# pnpm build-script allowlist: esbuild links its platform binary in a +# postinstall (Vite, under Astro, depends on it). +allowBuilds: + esbuild: true + sharp: false +minimumReleaseAgeExclude: + - astro@7.4.7 +# A peer range admits @emnapi 2.0.0-beta.2, whose runtime crashes +# @astrojs/check on exit ("bridge.deleteEnv is not a function"); hold the +# newest stable 1.x until 2.0 is final. +overrides: + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx deleted file mode 100644 index 539592f..0000000 --- a/web/src/App.test.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { act, render, screen, waitFor } 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 { refreshAfterJob } from './api/queries' -import { hub } from './jobs/hub' -import { router } from './router' -import { makeBudget, makeDataset } from './test/factories' -import { makeInstalled, makeJob, makeLibGame, makeSpec, makeStatus } from './test/fixtures' -import { installEventSource, stubApi } from './test/harness' - -const routes = (over: Record = {}) => ({ - 'GET /api/setup': { configured: true, has_api_key: true, steam_id: '76561198000000000' }, - 'GET /api/server': { stale: false, started_at: '2026-10-10T10:00:00Z', version: '1' }, - 'GET /api/daemon': { state: 'running', started_at: null, pid: 1, journal_tail: [], restart_available_at: null }, - 'GET /api/status': makeStatus(), - 'GET /api/budget': makeBudget(), - 'GET /api/dataset': makeDataset(), - 'GET /api/stats': { default_summary: makeDataset().default_summary, pace_vs_hltb: null }, - 'GET /api/installed': { games: [makeInstalled()] }, - 'GET /api/library': { games: [makeLibGame()] }, - 'GET /api/backups': [], - 'GET /api/jobs': [], - 'GET /api/jobs/abc': makeJob({ id: 'abc', command: 'scan' }), - 'GET /api/commands': [makeSpec('scan'), makeSpec('done')], - ...over, -}) - -// The router only follows history while a RouterProvider is mounted, so -// awaiting router.navigate() before render would never settle: set the URL, -// mount, then have the router read it. -async function openAt(path: string) { - router.history.replace(path) - const view = render() - await act(async () => { - await router.load() - }) - return view -} - -describe('App', () => { - beforeEach(installEventSource) - afterEach(() => { - router.history.replace('/') - vi.unstubAllGlobals() - }) - - it('refreshes every cached view when any job ends', () => { - expect(hub.onTerminal).toBe(refreshAfterJob) - }) - - it('opens on the dashboard inside the shell', async () => { - stubApi(routes()) - await openAt('/') - expect(await screen.findByRole('heading', { name: 'Dashboard' })).toBeInTheDocument() - expect(screen.getByRole('navigation', { name: 'Sections' })).toBeInTheDocument() - expect(await screen.findByText('Hollow Knight')).toBeInTheDocument() - }) - - it('walks the sidebar to another section', async () => { - stubApi(routes()) - await openAt('/') - await userEvent.click(await screen.findByRole('link', { name: /Store/ })) - expect(await screen.findByRole('heading', { name: 'Store' })).toBeInTheDocument() - expect(router.state.location.pathname).toBe('/store') - }) - - it('shows the library at /library', async () => { - stubApi(routes()) - await openAt('/library') - expect(await screen.findByText('1 of 1 games')).toBeInTheDocument() - }) - - describe('/gaming search', () => { - const modes: [string, string, string][] = [ - ['true', '?demo=true', 'Show production budget'], - ['1', '?demo=1', 'Show production budget'], - ['"1"', '?demo=%221%22', 'Show production budget'], - ['anything else', '?demo=maybe', 'Show demo budget'], - ['absent', '', 'Show demo budget'], - ] - it.each(modes)('demo=%s', async (_label, query, link) => { - stubApi(routes({ 'GET /api/budget?demo=1': makeBudget() })) - await openAt(`/gaming${query}`) - expect(await screen.findByRole('link', { name: link })).toBeInTheDocument() - }) - }) - - it('routes /jobs/$jobId to the job’s page', async () => { - stubApi(routes()) - await openAt('/jobs/abc') - expect(await screen.findByRole('heading', { name: 'Scan', level: 1 })).toBeInTheDocument() - expect(screen.getByText('abc')).toBeInTheDocument() - }) - - it('shows the not-found screen inside the shell', async () => { - stubApi(routes()) - await openAt('/nope') - expect(await screen.findByRole('heading', { name: 'Not found' })).toBeInTheDocument() - expect(screen.getByRole('navigation', { name: 'Sections' })).toBeInTheDocument() - }) - - it('redirects an unconfigured install to Setup', async () => { - stubApi(routes({ 'GET /api/setup': { configured: false, has_api_key: false, steam_id: null } })) - await openAt('/picks') - await waitFor(() => expect(router.state.location.pathname).toBe('/setup')) - expect(await screen.findByText('Not configured yet')).toBeInTheDocument() - }) -}) diff --git a/web/src/App.tsx b/web/src/App.tsx deleted file mode 100644 index a880f1f..0000000 --- a/web/src/App.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { QueryClientProvider } from '@tanstack/react-query' -import { RouterProvider } from '@tanstack/react-router' -import { queryClient, refreshAfterJob } from './api/queries' -import { hub } from './jobs/hub' -import { router } from './router' - -// A finished job can change anything (assignment, installed list, budget), -// so every cached view is refetched when one ends. -hub.onTerminal = refreshAfterJob - -/** The control UI: every `run.sh` command, routed by section. */ -function App() { - return ( - - - - ) -} - -export default App diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index 7640e10..18592e8 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -1,11 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TOKEN_META } from './contract' import { api, ApiFailure, request, sessionToken, startJob } from './client' -import { apiError, bodiesOf, Reply, stubApi } from '../test/harness' +import { apiError, bodiesOf, Reply, stubApi, type Handler } from '../test/harness' import { makeJob, makePending } from '../test/fixtures' function setToken(token: string | null) { - document.head.querySelectorAll(`meta[name="${TOKEN_META}"]`).forEach((m) => m.remove()) + document.head.querySelectorAll(`meta[name="${TOKEN_META}"]`).forEach((m) => { + m.remove() + }) if (token !== null) { const m = document.createElement('meta') m.name = TOKEN_META @@ -15,7 +17,9 @@ function setToken(token: string | null) { } describe('sessionToken', () => { - afterEach(() => setToken(null)) + afterEach(() => { + setToken(null) + }) it('reads the meta tag, empty when absent', () => { expect(sessionToken()).toBe('') setToken('abc') @@ -24,7 +28,9 @@ describe('sessionToken', () => { }) describe('request', () => { - beforeEach(() => setToken('tok')) + beforeEach(() => { + setToken('tok') + }) afterEach(() => { setToken(null) sessionStorage.clear() @@ -76,7 +82,7 @@ describe('request', () => { const reload = vi.fn() beforeEach(() => { reload.mockClear() - vi.stubGlobal('location', { ...window.location, reload }) + vi.stubGlobal('location', { href: window.location.href, reload }) }) it('reloads once, then not again inside the guard window', async () => { @@ -107,7 +113,7 @@ describe('api', () => { afterEach(() => vi.unstubAllGlobals()) it('hits every endpoint with the right verb and path', async () => { - const routes: Record = {} + const routes: Record = {} const keys = [ 'GET /api/status', 'GET /api/stats', 'GET /api/installed', 'GET /api/library', 'GET /api/commands', 'GET /api/setup', 'POST /api/setup', 'GET /api/daemon', 'GET /api/server', 'POST /api/server/restart', diff --git a/web/src/api/client.ts b/web/src/api/client.ts index d16ecb9..e801e7d 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -103,20 +103,20 @@ export const api = { saveSetup: (req: SetupRequest) => request('POST', ENDPOINTS.setup, req), daemon: () => get(ENDPOINTS.daemon), server: () => get(ENDPOINTS.server), - restartServer: () => request('POST', ENDPOINTS.serverRestart), + restartServer: () => request('POST', ENDPOINTS.serverRestart), backups: () => get(ENDPOINTS.backups), restoreBackup: (id: string, phrase: string) => request('POST', ENDPOINTS.backupRestore(encodeURIComponent(id)), { confirm_phrase: phrase }), jobs: () => get(ENDPOINTS.jobs), job: (id: string) => get(ENDPOINTS.job(id)), - answer: (id: string, answer: PromptAnswer) => request('POST', ENDPOINTS.jobAnswer(id), answer), - cancelJob: (id: string) => request('POST', ENDPOINTS.jobCancel(id)), + answer: (id: string, answer: PromptAnswer) => request('POST', ENDPOINTS.jobAnswer(id), answer), + cancelJob: (id: string) => request('POST', ENDPOINTS.jobCancel(id)), heartbeat: (id: string) => request('POST', ENDPOINTS.pendingHeartbeat(id)), commit: (id: string, phrase: string) => request('POST', ENDPOINTS.pendingCommit(id), { confirm_phrase: phrase }), /** `keepalive` lets the DELETE survive the page being closed. */ dropPending: (id: string, keepalive = false) => - request('DELETE', ENDPOINTS.pending(id), undefined, keepalive), + request('DELETE', ENDPOINTS.pending(id), undefined, keepalive), } /** POST /api/jobs: 201 → Job, 202 → PendingAction (countdown commands). */ diff --git a/web/src/api/queries.test.ts b/web/src/api/queries.test.ts index 32fde99..de8e5c5 100644 --- a/web/src/api/queries.test.ts +++ b/web/src/api/queries.test.ts @@ -1,59 +1,138 @@ -import { QueryObserver } from '@tanstack/react-query' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { makeBudget, makeDataset } from '../test/factories' import { makeJob } from '../test/fixtures' -import { stubApi } from '../test/harness' -import { isActive, q, queryClient, refreshAfterJob } from './queries' - -type Interval = number | false | undefined | ((query: never) => number | false | undefined) -const intervalOf = (o: { refetchInterval?: unknown }, data: unknown): unknown => { - const f = o.refetchInterval as Interval - return typeof f === 'function' ? (f as (x: unknown) => unknown)({ state: { data } }) : f -} +import { apiError, Reply, stubApi, type Handler } from '../test/harness' +import { isActive, q, refreshAfterJob } from './queries' describe('queries', () => { - afterEach(() => vi.unstubAllGlobals()) + beforeEach(() => { + vi.useFakeTimers() + }) it('isActive is false only for terminal states', () => { expect(isActive(makeJob({ state: 'running' }))).toBe(true) expect(isActive(makeJob({ state: 'waiting_input' }))).toBe(true) expect(isActive(makeJob({ state: 'succeeded' }))).toBe(false) + expect(isActive(makeJob({ state: 'failed' }))).toBe(false) + expect(isActive(makeJob({ state: 'cancelled' }))).toBe(false) }) - it('polls jobs fast while one is active, slowly otherwise', () => { - expect(intervalOf(q.jobs, [makeJob({ state: 'running' })])).toBe(2_000) - expect(intervalOf(q.jobs, [makeJob({ state: 'failed' })])).toBe(15_000) - expect(intervalOf(q.jobs, undefined)).toBe(15_000) - }) - - it('polls one job until it ends', () => { - const o = q.job('j1') - expect(intervalOf(o, undefined)).toBe(2_000) - expect(intervalOf(o, makeJob({ state: 'running' }))).toBe(2_000) - expect(intervalOf(o, makeJob({ state: 'succeeded' }))).toBe(false) - }) - - it('every query function reaches its endpoint', async () => { + it('every query reaches its endpoint', async () => { const mock = stubApi({ 'GET /api/status': {}, 'GET /api/stats': {}, 'GET /api/installed': {}, 'GET /api/library': {}, 'GET /api/commands': [], 'GET /api/setup': {}, 'GET /api/daemon': {}, 'GET /api/server': {}, 'GET /api/backups': [], 'GET /api/dataset': makeDataset(), 'GET /api/budget': makeBudget(), - 'GET /api/jobs': [], 'GET /api/jobs/j1': makeJob(), + 'GET /api/budget?demo=1': makeBudget(), 'GET /api/jobs': [], 'GET /api/jobs/j1': makeJob(), }) const all = [q.status, q.stats, q.installed, q.library, q.commands, q.setup, q.daemon, q.server, - q.backups, q.dataset, q.budget, q.jobs, q.job('j1')] - for (const o of all) await queryClient.fetchQuery({ ...o, retry: false, staleTime: 0 }) + q.backups, q.dataset, q.budget, q.demoBudget, q.jobs, q.job('j1')] + for (const query of all) await query.refetch() expect(mock).toHaveBeenCalledTimes(all.length) + expect(all.every((query) => query.state.error === null)).toBe(true) }) - it('refreshAfterJob invalidates everything', async () => { - stubApi({ 'GET /api/server': { stale: false } }) - const obs = new QueryObserver(queryClient, { ...q.server, refetchInterval: false }) - const unsub = obs.subscribe(() => {}) - await vi.waitFor(() => expect(obs.getCurrentResult().isSuccess).toBe(true)) - const spy = vi.spyOn(queryClient, 'invalidateQueries') + it('polls jobs fast while one is active, slowly otherwise', async () => { + const jobs = [makeJob({ state: 'running' })] + const mock = stubApi({ 'GET /api/jobs': () => jobs }) + q.jobs.subscribe(() => undefined) + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(2000) + expect(mock).toHaveBeenCalledTimes(2) + jobs[0] = makeJob({ state: 'failed' }) + await vi.advanceTimersByTimeAsync(2000) + expect(mock).toHaveBeenCalledTimes(3) + await vi.advanceTimersByTimeAsync(14_000) + expect(mock).toHaveBeenCalledTimes(3) + await vi.advanceTimersByTimeAsync(1000) + expect(mock).toHaveBeenCalledTimes(4) + }) + + it('polls jobs slowly while nothing has loaded', async () => { + const mock = stubApi({ 'GET /api/jobs': apiError(500, 'op_failed') }) + q.jobs.subscribe(() => undefined) + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(14_999) + expect(mock).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(mock).toHaveBeenCalledTimes(2) + }) + + it('polls one job until it ends', async () => { + let job = makeJob({ state: 'running' }) + const mock = stubApi({ 'GET /api/jobs/j9': () => job }) + q.job('j9').subscribe(() => undefined) + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(2000) + expect(mock).toHaveBeenCalledTimes(2) + job = makeJob({ state: 'succeeded' }) + await vi.advanceTimersByTimeAsync(2000) + expect(mock).toHaveBeenCalledTimes(3) + await vi.advanceTimersByTimeAsync(60_000) + expect(mock).toHaveBeenCalledTimes(3) + }) + + it('keeps polling a job whose first answer failed', async () => { + const mock = stubApi({ 'GET /api/jobs/j8': apiError(404, 'not_found') }) + q.job('j8').subscribe(() => undefined) + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(2000) + expect(mock).toHaveBeenCalledTimes(2) + }) + + it('refreshAfterJob refetches every watched query', async () => { + const mock = stubApi({ 'GET /api/server': { stale: false } }) + q.server.subscribe(() => undefined) + await vi.advanceTimersByTimeAsync(0) refreshAfterJob() - expect(spy).toHaveBeenCalled() - unsub() + await vi.advanceTimersByTimeAsync(0) + expect(mock).toHaveBeenCalledTimes(2) + }) + + describe('budget polling', () => { + const setHidden = (hidden: boolean) => { + Object.defineProperty(document, 'hidden', { value: hidden, configurable: true }) + } + afterEach(() => { + setHidden(false) + }) + + it('polls the budget every 5 s, the demo run from its own URL', async () => { + const mock = stubApi({ 'GET /api/budget': makeBudget(), 'GET /api/budget?demo=1': makeBudget() }) + q.budget.subscribe(() => undefined) + q.demoBudget.subscribe(() => undefined) + await vi.advanceTimersByTimeAsync(0) + expect(mock.mock.calls.map(([p]) => p)).toEqual(['/api/budget', '/api/budget?demo=1']) + await vi.advanceTimersByTimeAsync(5000) + expect(mock).toHaveBeenCalledTimes(4) + expect(q.budget.state.data?.today?.gaming_day).toBe('2026-08-28') + }) + + it('polls while the tab is hidden', async () => { + setHidden(true) + const mock = stubApi({ 'GET /api/budget': makeBudget() }) + q.budget.subscribe(() => undefined) + await vi.advanceTimersByTimeAsync(10_000) + expect(mock).toHaveBeenCalledTimes(3) + }) + + it('keeps the last snapshot when a poll fails', async () => { + const routes: Record = { 'GET /api/budget': makeBudget() } + stubApi(routes) + q.budget.subscribe(() => undefined) + await vi.advanceTimersByTimeAsync(0) + routes['GET /api/budget'] = new Reply(500, undefined, 'oops') + await vi.advanceTimersByTimeAsync(5000) + expect(q.budget.state.data?.ok).toBe(true) + expect(q.budget.state.error).toEqual(new Error('API returned 500 Error')) + }) + + it('stops polling after the last subscriber leaves', async () => { + const mock = stubApi({ 'GET /api/budget': makeBudget() }) + const off = q.budget.subscribe(() => undefined) + await vi.advanceTimersByTimeAsync(0) + off() + await vi.advanceTimersByTimeAsync(20_000) + expect(mock).toHaveBeenCalledTimes(1) + }) }) }) diff --git a/web/src/api/queries.ts b/web/src/api/queries.ts index 964fee4..6efabde 100644 --- a/web/src/api/queries.ts +++ b/web/src/api/queries.ts @@ -1,51 +1,33 @@ -// TanStack Query bindings. Polling queries set `refetchIntervalInBackground` -// because automation tabs (and a Chrome --app window behind another) report -// hidden, and a paused poll there shows frozen state. +// The app's cached server reads (see lib/query.ts), one per endpoint. -import { queryOptions, QueryClient } from '@tanstack/react-query' import { fetchBudget, fetchDataset } from '../api' +import { family, invalidateAll, Query } from '../lib/query' import { api } from './client' import type { Job } from './jobContract' -export const queryClient = new QueryClient({ - defaultOptions: { - queries: { staleTime: 5_000, retry: 1, refetchOnWindowFocus: true }, - }, -}) - -const live = (ms: number) => ({ refetchInterval: ms, refetchIntervalInBackground: true }) - const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']) -export const isActive = (j: Job) => !TERMINAL.has(j.state) +export const isActive = (j: Job): boolean => !TERMINAL.has(j.state) export const q = { - status: queryOptions({ queryKey: ['status'], queryFn: api.status, ...live(15_000) }), - stats: queryOptions({ queryKey: ['stats'], queryFn: api.stats }), - installed: queryOptions({ queryKey: ['installed'], queryFn: api.installed }), - library: queryOptions({ queryKey: ['library'], queryFn: api.library, staleTime: 60_000 }), - commands: queryOptions({ queryKey: ['commands'], queryFn: api.commands, ...live(30_000) }), - setup: queryOptions({ queryKey: ['setup'], queryFn: api.setup }), - daemon: queryOptions({ queryKey: ['daemon'], queryFn: api.daemon, ...live(3_000) }), - server: queryOptions({ queryKey: ['server'], queryFn: api.server, ...live(20_000) }), - backups: queryOptions({ queryKey: ['backups'], queryFn: api.backups }), - dataset: queryOptions({ queryKey: ['dataset'], queryFn: fetchDataset, staleTime: 60_000 }), - budget: queryOptions({ queryKey: ['budget'], queryFn: () => fetchBudget(false), ...live(5_000) }), - jobs: queryOptions({ - queryKey: ['jobs'], - queryFn: api.jobs, - refetchInterval: (query) => (query.state.data?.some(isActive) ? 2_000 : 15_000), - refetchIntervalInBackground: true, - }), - job: (id: string) => - queryOptions({ - queryKey: ['jobs', id], - queryFn: () => api.job(id), - refetchInterval: (query) => (query.state.data && !isActive(query.state.data) ? false : 2_000), - refetchIntervalInBackground: true, - }), + status: new Query(api.status, { intervalMs: 15_000 }), + stats: new Query(api.stats), + installed: new Query(api.installed), + library: new Query(api.library, { staleMs: 60_000 }), + commands: new Query(api.commands, { intervalMs: 30_000 }), + setup: new Query(api.setup), + daemon: new Query(api.daemon, { intervalMs: 3_000 }), + server: new Query(api.server, { intervalMs: 20_000 }), + backups: new Query(api.backups), + dataset: new Query(fetchDataset, { staleMs: 60_000 }), + budget: new Query(() => fetchBudget(false), { intervalMs: 5_000 }), + demoBudget: new Query(() => fetchBudget(true), { intervalMs: 5_000 }), + jobs: new Query(api.jobs, { intervalMs: (data) => (data?.some(isActive) ? 2_000 : 15_000) }), + job: family((id) => + new Query(() => api.job(id), { intervalMs: (data) => (data && !isActive(data) ? false : 2_000) }), + ), } /** After a job ends, anything it may have changed is stale. */ export function refreshAfterJob(): void { - void queryClient.invalidateQueries() + invalidateAll() } diff --git a/web/src/commands/CommandCard.test.tsx b/web/src/commands/CommandCard.test.tsx deleted file mode 100644 index d8b0eba..0000000 --- a/web/src/commands/CommandCard.test.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { renderHook, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { makeSpec } from '../test/fixtures' -import { renderRouted, stubApi } from '../test/harness' -import { CommandButton, CommandCard } from './CommandCard' -import { RunnerContext, useRunner } from './runnerContext' - -const run = vi.fn() -const withRunner = (ui: React.ReactNode) => ( - {} }}>{ui} -) - -describe('CommandCard', () => { - afterEach(() => { - run.mockClear() - vi.unstubAllGlobals() - }) - - it('shows the label, server description and a run button', async () => { - stubApi({ 'GET /api/commands': [makeSpec('scan', { description: 'Scans achievements.' })] }) - renderRouted(withRunner()) - expect(await screen.findByText('Scans achievements.')).toBeInTheDocument() - expect(screen.getByRole('heading', { name: 'Scan' })).toBeInTheDocument() - await userEvent.click(screen.getByRole('button', { name: 'Run scan' })) - expect(run).toHaveBeenCalledWith('scan', { app_id: 4 }) - }) - - it('marks privileged and countdown commands', async () => { - stubApi({ - 'GET /api/commands': [ - makeSpec('gaming-reset', { - privileged: true, - friction: { phrase_template: 'x', countdown_seconds: 300 }, - }), - ], - }) - renderRouted(withRunner()) - expect(await screen.findByText('root')).toBeInTheDocument() - expect(screen.getByText('countdown')).toBeInTheDocument() - }) - - it('shows no countdown pill for a friction command without one', async () => { - stubApi({ 'GET /api/commands': [makeSpec('unblock', { friction: { phrase_template: 'x' } })] }) - renderRouted(withRunner()) - await screen.findByText('Does unblock.') - expect(screen.queryByText('countdown')).toBeNull() - }) - - it('stays focusable but inert while locked, and says why', async () => { - stubApi({ 'GET /api/commands': [makeSpec('pick', { locked_reason: 'Locked by a manual pick' })] }) - renderRouted(withRunner()) - const button = await screen.findByRole('button', { name: 'Run pick' }) - expect(screen.getByText('Locked by a manual pick', { selector: '.cmd-desc' })).toBeInTheDocument() - expect(button).toHaveAttribute('aria-disabled', 'true') - expect(button).toHaveAttribute('title', 'Locked by a manual pick') - expect(document.querySelector('article')).toHaveClass('locked') - await userEvent.click(button) - expect(run).not.toHaveBeenCalled() - }) - - it('is inert until the catalog has loaded', async () => { - stubApi({ 'GET /api/commands': [] }) - renderRouted(withRunner()) - const button = await screen.findByRole('button', { name: 'Open Scan' }) - expect(screen.getByText('…')).toBeInTheDocument() - expect(button).toHaveAttribute('aria-disabled', 'true') - await userEvent.click(button) - expect(run).not.toHaveBeenCalled() - }) - - it('links to the owning screen for a non-job command', async () => { - stubApi({ 'GET /api/commands': [makeSpec('status', { kind: 'view' })] }) - const { router } = renderRouted(withRunner()) - const link = await screen.findByRole('link', { name: 'Open Status' }) - expect(link).toHaveClass('btn-primary') - await userEvent.click(link) - expect(router.state.location.pathname).toBe('/') - }) - - it('uses the given label and tone', async () => { - stubApi({ 'GET /api/commands': [makeSpec('scan')] }) - renderRouted(withRunner()) - expect(await screen.findByRole('button', { name: 'Scan now' })).toHaveClass('btn-danger') - }) -}) - -describe('runner context', () => { - it('defaults to no-ops without a provider', () => { - const { result } = renderHook(() => useRunner()) - expect(result.current.run('scan')).toBeUndefined() - expect(result.current.openPalette()).toBeUndefined() - }) -}) diff --git a/web/src/commands/CommandCard.tsx b/web/src/commands/CommandCard.tsx deleted file mode 100644 index c75bc11..0000000 --- a/web/src/commands/CommandCard.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { Link } from '@tanstack/react-router' -import type { CommandName } from '../api/contract' -import { q } from '../api/queries' -import { COMMAND_HOME, commandLabel, findCommand } from './catalog' -import { useRunner } from './runnerContext' - -interface Props { - name: CommandName - preset?: Record - /** Override the button text (defaults to the command name). */ - label?: string - tone?: 'primary' | 'ghost' | 'danger' -} - -/** - * A command as a card: name, server description, lock reason. The button - * stays focusable while locked (aria-disabled), so a keyboard user can still - * reach it and read why. - */ -export function CommandCard({ name, preset, label, tone }: Props) { - const { data: commands } = useQuery(q.commands) - const spec = findCommand(commands, name) - const locked = spec?.locked_reason ?? null - const btn = tone ?? 'ghost' - return ( -
-

- {commandLabel(name)} - {spec?.privileged && root} - {spec?.friction?.countdown_seconds ? countdown : null} -

-

{locked ?? spec?.description ?? '…'}

- -
- ) -} - -export function CommandButton({ name, preset, label, tone = 'ghost' }: Props) { - const { run } = useRunner() - const { data: commands } = useQuery(q.commands) - const spec = findCommand(commands, name) - const locked = spec?.locked_reason ?? null - const text = label ?? (spec?.kind === 'job' ? `Run ${name}` : `Open ${commandLabel(name)}`) - if (spec && spec.kind !== 'job') { - return ( - - {text} - - ) - } - return ( - - ) -} diff --git a/web/src/commands/CommandPalette.test.tsx b/web/src/commands/CommandPalette.test.tsx deleted file mode 100644 index 183f08b..0000000 --- a/web/src/commands/CommandPalette.test.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { act, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { apiError, renderRouted, stubApi } from '../test/harness' -import { makeSpec } from '../test/fixtures' -import { CommandPalette } from './CommandPalette' - -const SPECS = [ - makeSpec('scan', { description: 'Scan achievements.' }), - makeSpec('unblock', { description: 'Open the store.', friction: { phrase_template: 'x' } }), - makeSpec('status', { kind: 'view', description: 'Show status.' }), - makeSpec('pick', { description: 'Pick next.', locked_reason: 'Locked by a pick' }), -] - -function open() { - const onClose = vi.fn() - const onRun = vi.fn() - const view = renderRouted() - return { onClose, onRun, ...view } -} -const box = () => screen.getByRole('combobox', { name: 'Search commands' }) -const opt = (title: string) => - screen.getAllByRole('option').find((o) => o.querySelector('.palette-title')?.firstChild?.textContent === title)! -const active = () => screen.getAllByRole('option').findIndex((o) => o.getAttribute('aria-selected') === 'true') - -describe('CommandPalette', () => { - afterEach(() => vi.unstubAllGlobals()) - - it('lists every command, then the sections', async () => { - stubApi({ 'GET /api/commands': SPECS }) - open() - expect(await screen.findByText('Scan achievements.')).toBeInTheDocument() - expect(opt('Scan')).toHaveTextContent('run') - expect(opt('Unblock')).toHaveTextContent('run · confirm') - expect(opt('Status')).toHaveTextContent('open /') - expect(opt('Pick')).toHaveTextContent('locked') - expect(opt('Pick')).toHaveTextContent('Locked by a pick') // the reason replaces the description - expect(screen.getByText('Go to Dashboard')).toBeInTheDocument() - expect(box()).toHaveFocus() - }) - - it('shows loading and then an error', async () => { - let fail: (r: unknown) => void = () => {} - stubApi({ 'GET /api/commands': () => new Promise((r) => (fail = r)) }) - open() - expect(await screen.findByText(/Loading commands/)).toBeInTheDocument() - await act(async () => fail(apiError(500, 'op_failed', 'catalog broken'))) - expect(await screen.findByText('catalog broken')).toBeInTheDocument() - expect(screen.queryByText(/Loading commands/)).toBeNull() - }) - - it('filters by text and says when nothing matches', async () => { - stubApi({ 'GET /api/commands': SPECS }) - open() - await screen.findByText('Scan achievements.') - await userEvent.type(box(), 'store') - expect(screen.getAllByRole('option')).toHaveLength(2) // unblock + "Go to Store" - await userEvent.clear(box()) - await userEvent.type(box(), 'zzz') - expect(screen.getByText('No command matches “zzz”.')).toBeInTheDocument() - expect(box()).not.toHaveAttribute('aria-activedescendant') - }) - - it('moves with arrows and pages, clamped, and announces the active option', async () => { - stubApi({ 'GET /api/commands': SPECS }) - open() - await screen.findByText('Scan achievements.') - await userEvent.keyboard('{ArrowDown}{ArrowDown}') - expect(active()).toBe(2) - expect(box().getAttribute('aria-activedescendant')).toBe(screen.getAllByRole('option')[2].id) - await userEvent.keyboard('{ArrowUp}{ArrowUp}{ArrowUp}') - expect(active()).toBe(0) - await userEvent.keyboard('{PageDown}') - expect(active()).toBe(8) - await userEvent.keyboard('{PageDown}') - expect(active()).toBe(11) // 4 commands + 8 sections - 1 - await userEvent.keyboard('{PageUp}') - expect(active()).toBe(3) - await userEvent.keyboard('{PageUp}') - expect(active()).toBe(0) - }) - - it('the pointer moves the highlight too', async () => { - stubApi({ 'GET /api/commands': SPECS }) - open() - await screen.findByText('Scan achievements.') - await userEvent.hover(screen.getAllByRole('option')[1]) - expect(active()).toBe(1) - }) - - it('Enter runs a job command', async () => { - stubApi({ 'GET /api/commands': SPECS }) - const { onRun } = open() - await screen.findByText('Scan achievements.') - await userEvent.keyboard('{Enter}') - expect(onRun).toHaveBeenCalledWith('scan') - }) - - it('Enter on a view command navigates and closes', async () => { - stubApi({ 'GET /api/commands': SPECS }) - const { onClose, onRun, router } = open() - await screen.findByText('Scan achievements.') - await userEvent.type(box(), 'show status') - await userEvent.keyboard('{Enter}') - expect(onRun).not.toHaveBeenCalled() - expect(onClose).toHaveBeenCalled() - expect(router.state.location.pathname).toBe('/') - }) - - it('clicking a section navigates', async () => { - stubApi({ 'GET /api/commands': SPECS }) - const { onClose, router } = open() - await userEvent.click(await screen.findByText('Go to Jobs')) - expect(onClose).toHaveBeenCalled() - expect(router.state.location.pathname).toBe('/jobs') - }) - - it('Enter with nothing shown does nothing', async () => { - stubApi({ 'GET /api/commands': SPECS }) - const { onRun, onClose } = open() - await screen.findByText('Scan achievements.') - await userEvent.type(box(), 'zzz{Enter}') - expect(onRun).not.toHaveBeenCalled() - expect(onClose).not.toHaveBeenCalled() - }) - - it('ignores other keys', async () => { - stubApi({ 'GET /api/commands': SPECS }) - const { onRun } = open() - await screen.findByText('Scan achievements.') - await userEvent.keyboard('{Tab}{Shift}') - expect(onRun).not.toHaveBeenCalled() - }) -}) diff --git a/web/src/commands/CommandPalette.tsx b/web/src/commands/CommandPalette.tsx deleted file mode 100644 index ca76555..0000000 --- a/web/src/commands/CommandPalette.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { useNavigate } from '@tanstack/react-router' -import { useId, useState, type KeyboardEvent } from 'react' -import type { CommandName } from '../api/contract' -import { q } from '../api/queries' -import { NAV } from '../shell/nav' -import { Dialog } from '../ui/Dialog' -import { ErrorNotice, Loading } from '../ui/Notice' -import { COMMAND_HOME, commandLabel, type SectionPath } from './catalog' - -interface Entry { - key: string - title: string - detail: string - hint: string - locked: string | null - act: () => void -} - -/** - * Ctrl/Cmd+K: every command from `GET /api/commands`, plus the sections. - * A combobox + listbox pair: arrows move, Enter runs, Escape closes, and the - * active option is announced via aria-activedescendant. - */ -export function CommandPalette({ onClose, onRun }: { onClose: () => void; onRun: (n: CommandName) => void }) { - const id = useId() - const navigate = useNavigate() - const { data: commands, error } = useQuery(q.commands) - const [text, setText] = useState('') - const [active, setActive] = useState(0) - - const go = (to: SectionPath | '/jobs') => { - onClose() - void navigate({ to }) - } - const entries: Entry[] = [ - ...(commands ?? []).map((c) => ({ - key: c.name, - title: commandLabel(c.name), - detail: c.description, - hint: c.kind === 'job' ? (c.friction ? 'run · confirm' : 'run') : `open ${COMMAND_HOME[c.name]}`, - locked: c.locked_reason, - act: () => (c.kind === 'job' ? onRun(c.name) : go(COMMAND_HOME[c.name])), - })), - ...NAV.map((n) => ({ key: `nav:${n.to}`, title: `Go to ${n.label}`, detail: n.blurb, hint: 'navigate', locked: null, act: () => go(n.to) })), - ] - const needle = text.trim().toLowerCase() - const shown = needle - ? entries.filter((e) => `${e.key} ${e.title} ${e.detail}`.toLowerCase().includes(needle)) - : entries - const current = Math.min(active, Math.max(0, shown.length - 1)) - - const onKey = (e: KeyboardEvent) => { - const last = shown.length - 1 - const moves: Record = { - ArrowDown: Math.min(last, current + 1), - ArrowUp: Math.max(0, current - 1), - PageDown: Math.min(last, current + 8), - PageUp: Math.max(0, current - 8), - } - if (e.key in moves) { - e.preventDefault() - setActive(moves[e.key]) - document.getElementById(`${id}-${moves[e.key]}`)?.scrollIntoView({ block: 'nearest' }) - } else if (e.key === 'Enter' && shown[current]) { - e.preventDefault() - shown[current].act() - } - } - - return ( - - { - setText(e.target.value) - setActive(0) - }} - onKeyDown={onKey} - /> - {error && } - {!commands && !error && } -
    - {shown.map((e, i) => ( -
  • setActive(i)} - onClick={e.act} - > - - {e.title} - {e.locked && locked} - - {e.locked ?? e.detail} - {e.hint} -
  • - ))} - {shown.length === 0 &&
  • No command matches “{text}”.
  • } -
-

↑↓ to move · Enter to run · Esc to close

-
- ) -} diff --git a/web/src/commands/ParamsForm.test.tsx b/web/src/commands/ParamsForm.test.tsx deleted file mode 100644 index 4101c06..0000000 --- a/web/src/commands/ParamsForm.test.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { useState } from 'react' -import { describe, expect, it } from 'vitest' -import type { ParamSpec } from '../api/contract' -import { ParamsForm } from './ParamsForm' -import type { ParamValues } from './params' - -const SPECS: ParamSpec[] = [ - { name: 'app_id', label: 'Game', type: 'app_id', required: true }, - { name: 'days', label: 'Days', type: 'int', required: false, min: 1, max: 9, help: 'How long.' }, - { name: 'note', label: 'Note', type: 'string', required: false }, -] -const GAMES = [{ app_id: 5, name: 'Five' }] - -function Harness({ showErrors = false, disabled = false }) { - const [values, setValues] = useState({ app_id: '', days: '', note: '' }) - return -} - -describe('ParamsForm', () => { - it('renders one labelled field per spec, marking the required ones', () => { - render() - expect(screen.getByLabelText(/Game/)).toHaveAttribute('list') - expect(screen.getByLabelText('Days')).toHaveAttribute('type', 'number') - expect(screen.getByLabelText('Days')).toHaveAttribute('min', '1') - expect(screen.getByLabelText('Note')).toHaveAttribute('type', 'text') - expect(screen.getByLabelText('Note')).not.toHaveAttribute('inputmode') - expect(screen.getByLabelText(/Game/)).toHaveFocus() - expect(screen.getByText('Type an app id or pick a game from the list.')).toBeInTheDocument() - expect(screen.getByText('How long.')).toBeInTheDocument() - }) - - it('offers the known games in a datalist and names the chosen one', async () => { - const { container } = render() - expect(container.querySelector('datalist option')).toHaveValue('5') - await userEvent.type(screen.getByLabelText(/Game/), '5') - expect(container.querySelector('.field-help')).toHaveTextContent('Five') - }) - - it('shows errors only once asked to', async () => { - const { rerender } = render() - expect(screen.queryByText('Game is required.')).toBeNull() - rerender() - expect(screen.getByText('Game is required.')).toBeInTheDocument() - expect(screen.getByLabelText(/Game/)).toHaveClass('input-bad') - expect(screen.getByLabelText(/Game/)).toHaveAttribute('aria-invalid', 'true') - }) - - it('treats a value the form has not set yet as empty', () => { - render( {}} games={GAMES} showErrors={false} disabled={false} />) - expect(screen.getByLabelText('Note')).toHaveValue('') - }) - - it('disables every field', () => { - render() - expect(screen.getByLabelText('Note')).toBeDisabled() - }) - - it('renders a bool as an unchecked checkbox that toggles 0/1', async () => { - const box: ParamSpec[] = [{ name: 'replace', label: 'Replace', type: 'bool', required: false, default: 0 }] - function BoxHarness() { - const [values, setValues] = useState({ replace: '0' }) - return - } - render() - const input = screen.getByLabelText('Replace') - expect(input).toHaveAttribute('type', 'checkbox') - expect(input).not.toBeChecked() - await userEvent.click(input) - expect(input).toBeChecked() - await userEvent.click(input) - expect(input).not.toBeChecked() - }) -}) diff --git a/web/src/commands/ParamsForm.tsx b/web/src/commands/ParamsForm.tsx deleted file mode 100644 index 93dd10b..0000000 --- a/web/src/commands/ParamsForm.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { useId } from 'react' -import type { ParamSpec } from '../api/contract' -import { paramError, type GameOption, type ParamValues } from './params' - -interface Props { - specs: ParamSpec[] - values: ParamValues - onChange: (v: ParamValues) => void - games: GameOption[] - showErrors: boolean - disabled: boolean -} - -export function ParamsForm({ specs, values, onChange, games, showErrors, disabled }: Props) { - const base = useId() - return ( - <> - {specs.map((p, i) => { - const id = `${base}-${p.name}` - const raw = values[p.name] ?? '' - if (p.type === 'bool') { - return ( -
- -

- {p.help ?? ' '} -

-
- ) - } - const err = paramError(p, raw, games) - const game = p.type === 'app_id' ? games.find((g) => g.app_id === Number(raw)) : undefined - return ( -
- - onChange({ ...values, [p.name]: e.target.value })} - /> - {p.type === 'app_id' && ( - - {games.map((g) => ( - - ))} - - )} -

- {showErrors && err ? err : game ? game.name : (p.help ?? (p.type === 'app_id' ? 'Type an app id or pick a game from the list.' : ' '))} -

-
- ) - })} - - ) -} diff --git a/web/src/commands/PendingCountdown.test.tsx b/web/src/commands/PendingCountdown.test.tsx deleted file mode 100644 index d230e8c..0000000 --- a/web/src/commands/PendingCountdown.test.tsx +++ /dev/null @@ -1,193 +0,0 @@ -import { act, fireEvent, render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { QueryClientProvider } from '@tanstack/react-query' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { queryClient } from '../api/queries' -import { makeJob, makePending } from '../test/fixtures' -import { apiError, stubApi } from '../test/harness' -import { PendingCountdown } from './PendingCountdown' - -const T0 = Date.parse('2026-10-10T10:00:00Z') -const pending = (over = {}) => - makePending({ armed_at: '2026-10-10T10:00:00Z', ready_at: '2026-10-10T10:00:30Z', heartbeat_interval_seconds: 10, ...over }) - -function mount(over = {}) { - const cbs = { onCommitted: vi.fn(), onCancel: vi.fn(), onRearm: vi.fn() } - const p = pending(over) - const view = render( - - - , - ) - return { ...cbs, ...view } -} -const tick = (ms: number) => act(async () => { await vi.advanceTimersByTimeAsync(ms) }) -const user = () => userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) - -describe('PendingCountdown', () => { - beforeEach(() => { - vi.useFakeTimers({ now: T0 }) - // Testing Library only advances fake timers for a `jest` global; without - // this shim its async helpers wait on a timer that never fires. - vi.stubGlobal('jest', { advanceTimersByTime: vi.advanceTimersByTime }) - }) - afterEach(() => { - vi.useRealTimers() - vi.unstubAllGlobals() - }) - - it('counts down on the server’s clock', async () => { - stubApi({ 'POST /api/pending/p1/heartbeat': pending() }) - mount() - // One second past ready_at: a slightly fast browser clock must not offer - // a commit the daemon refuses. - expect(screen.getByText('0:31')).toBeInTheDocument() - expect(screen.getByText('until commit unlocks')).toBeInTheDocument() - await tick(10_000) - expect(screen.getByText('0:21')).toBeInTheDocument() - expect(screen.getByRole('progressbar', { name: 'Countdown' })).toHaveAttribute('aria-valuenow', '32') - expect(screen.getByText(/Closing this dialog/)).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Cancel countdown' })).toHaveFocus() - }) - - it('keeps the commit locked until a second after the server’s ready time', async () => { - stubApi({ 'POST /api/pending/p1/heartbeat': pending() }) - mount() - await tick(30_000) - expect(screen.getByText('until commit unlocks')).toBeInTheDocument() - await tick(1_000) - expect(screen.getByText('Ready to commit')).toBeInTheDocument() - }) - - it('heartbeats at half the interval, and takes the server’s new ready time', async () => { - const mock = stubApi({ 'POST /api/pending/p1/heartbeat': () => pending({ ready_at: '2026-10-10T10:01:00Z' }) }) - mount() - await tick(4_999) - expect(mock).not.toHaveBeenCalled() - await tick(1) - expect(mock).toHaveBeenCalledTimes(1) - expect(screen.getByText(/Heartbeat every 5 s · last 0 s ago/)).toBeInTheDocument() - expect(screen.getByText('0:56')).toBeInTheDocument() // pushed out by the server - }) - - it('never heartbeats faster than once a second', async () => { - const mock = stubApi({ 'POST /api/pending/p1/heartbeat': pending({ heartbeat_interval_seconds: 0.5 }) }) - mount({ heartbeat_interval_seconds: 0.5 }) - await tick(999) - expect(mock).not.toHaveBeenCalled() - await tick(1) - expect(mock).toHaveBeenCalledTimes(1) - }) - - it('reports a failing heartbeat and recovers when it works again', async () => { - const routes: Record = { 'POST /api/pending/p1/heartbeat': apiError(503, 'daemon_unreachable') } - stubApi(routes) - mount() - await tick(5_000) - expect(screen.getByText(/heartbeat failing, retrying/)).toBeInTheDocument() - routes['POST /api/pending/p1/heartbeat'] = pending() - await tick(5_000) - expect(screen.queryByText(/heartbeat failing/)).toBeNull() - }) - - it('offers to arm again once the server says the countdown lapsed', async () => { - stubApi({ 'POST /api/pending/p1/heartbeat': apiError(410, 'pending_lapsed', 'missed a beat') }) - const { onRearm } = mount() - await tick(5_000) - expect(screen.getByText('missed a beat')).toBeInTheDocument() - await user().click(screen.getByRole('button', { name: 'Arm again' })) - expect(onRearm).toHaveBeenCalled() - }) - - it('drops the action with a keepalive request when the page is hidden', async () => { - const mock = stubApi({ 'DELETE /api/pending/p1': undefined }) - mount() - window.dispatchEvent(new Event('pagehide')) - expect(mock).toHaveBeenCalledWith('/api/pending/p1', expect.objectContaining({ method: 'DELETE', keepalive: true })) - }) - - it('swallows a failed keepalive drop', async () => { - stubApi({ 'DELETE /api/pending/p1': apiError(500, 'op_failed') }) - mount() - window.dispatchEvent(new Event('pagehide')) - await tick(0) - expect(screen.getByText('0:31')).toBeInTheDocument() - }) - - it('stops heartbeating and listening on unmount', async () => { - const mock = stubApi({ 'POST /api/pending/p1/heartbeat': pending(), 'DELETE /api/pending/p1': undefined }) - const { unmount } = mount() - unmount() - await tick(60_000) - window.dispatchEvent(new Event('pagehide')) - expect(mock).not.toHaveBeenCalled() - }) - - it('cancels, whatever the server answers', async () => { - stubApi({ 'DELETE /api/pending/p1': apiError(404, 'not_found') }) - const { onCancel } = mount() - await user().click(screen.getByRole('button', { name: 'Cancel countdown' })) - await tick(0) - expect(onCancel).toHaveBeenCalled() - }) - - it('shows Cancelling… while the request runs', async () => { - stubApi({ 'DELETE /api/pending/p1': () => new Promise(() => {}) }) - mount() - await user().click(screen.getByRole('button', { name: 'Cancel countdown' })) - expect(screen.getByRole('button', { name: 'Cancelling…' })).toBeInTheDocument() - }) - - describe('once ready', () => { - const ready = async (over = {}) => { - const m = mount(over) - await tick(31_000) - return m - } - - it('asks for the phrase again, and commits only when it matches', async () => { - const job = makeJob({ id: 'jc' }) - stubApi({ 'POST /api/pending/p1/heartbeat': pending(), 'POST /api/pending/p1/commit': job }) - const { onCommitted } = await ready() - expect(screen.getByText('Ready to commit')).toBeInTheDocument() - expect(screen.getByRole('textbox')).toHaveFocus() - const commit = screen.getByRole('button', { name: 'Commit' }) - expect(commit).toBeDisabled() - fireEvent.submit(commit.closest('form')!) // Enter with the wrong phrase - expect(onCommitted).not.toHaveBeenCalled() - await user().type(screen.getByRole('textbox'), 'reset gaming now') - expect(commit).toBeEnabled() - await user().click(commit) - await tick(0) - expect(onCommitted.mock.calls[0][0]).toEqual(job) - }) - - it('shows a refused commit', async () => { - stubApi({ - 'POST /api/pending/p1/heartbeat': pending(), - 'POST /api/pending/p1/commit': apiError(409, 'countdown_running', 'not yet'), - }) - await ready() - await user().type(screen.getByRole('textbox'), 'reset gaming now') - await user().click(screen.getByRole('button', { name: 'Commit' })) - await tick(0) - expect(screen.getByText('not yet')).toBeInTheDocument() - }) - - it('shows Committing… while it is in flight', async () => { - stubApi({ 'POST /api/pending/p1/heartbeat': pending(), 'POST /api/pending/p1/commit': () => new Promise(() => {}) }) - await ready() - await user().type(screen.getByRole('textbox'), 'reset gaming now') - await user().click(screen.getByRole('button', { name: 'Commit' })) - expect(screen.getByRole('button', { name: 'Committing…' })).toBeDisabled() - }) - - it('can still be cancelled', async () => { - stubApi({ 'POST /api/pending/p1/heartbeat': pending(), 'DELETE /api/pending/p1': undefined }) - const { onCancel } = await ready() - await user().click(screen.getByRole('button', { name: 'Cancel' })) - await tick(0) - expect(onCancel).toHaveBeenCalled() - }) - }) -}) diff --git a/web/src/commands/PendingCountdown.tsx b/web/src/commands/PendingCountdown.tsx deleted file mode 100644 index 04f5250..0000000 --- a/web/src/commands/PendingCountdown.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { useMutation } from '@tanstack/react-query' -import { useEffect, useState } from 'react' -import { api, ApiFailure } from '../api/client' -import type { Job, PendingAction } from '../api/jobContract' -import { ProgressBar } from '../jobs/ProgressBar' -import { clock } from '../ui/format' -import { ErrorNotice, Notice } from '../ui/Notice' -import { useNow } from '../ui/useNow' -import { phraseMatches } from './catalog' -import { PhraseField } from './PhraseField' - -/** - * The daemon checks `ready_at` on its own clock; unlocking the commit a - * second late keeps a slightly fast browser clock from offering a commit the - * daemon refuses (seen in vmbox: refused 34 ms early, countdown_running). - */ -const CLOCK_SLACK_MS = 1000 - -interface Props { - pending: PendingAction - onCommitted: (job: Job) => void - onCancel: () => void - onRearm: () => void -} - -/** - * Armed countdown (gaming-reset): heartbeats at half the server's interval so - * one slow request never lapses it, a countdown driven by the server's - * `ready_at` (not a local timer), then the phrase is retyped to commit. - * Closing the page drops the action with a keepalive DELETE. - */ -export function PendingCountdown({ pending: initial, onCommitted, onCancel, onRearm }: Props) { - const [pending, setPending] = useState(initial) - const [lastBeat, setLastBeat] = useState(() => Date.now()) - const [beatError, setBeatError] = useState(null) - const [phrase, setPhrase] = useState('') - const now = useNow(250) - - useEffect(() => { - const everyMs = Math.max(1000, (initial.heartbeat_interval_seconds * 1000) / 2) - const id = setInterval(() => { - api.heartbeat(initial.id).then( - (p) => { - setPending(p) - setLastBeat(Date.now()) - setBeatError(null) - }, - (e: unknown) => setBeatError(e), - ) - }, everyMs) - const drop = () => void api.dropPending(initial.id, true).catch(() => {}) - window.addEventListener('pagehide', drop) - return () => { - clearInterval(id) - window.removeEventListener('pagehide', drop) - } - }, [initial.id, initial.heartbeat_interval_seconds]) - - const commit = useMutation({ mutationFn: () => api.commit(pending.id, phrase), onSuccess: onCommitted }) - const cancel = useMutation({ mutationFn: () => api.dropPending(pending.id), onSettled: onCancel }) - - const armed = Date.parse(pending.armed_at) - const ready = Date.parse(pending.ready_at) + CLOCK_SLACK_MS - const left = Math.max(0, (ready - now) / 1000) - const isReady = left <= 0 - const lapsed = beatError instanceof ApiFailure && beatError.code === 'pending_lapsed' - const sinceBeat = Math.round((now - lastBeat) / 1000) - - if (lapsed) { - return ( -
- -
- -
-
- ) - } - - return ( -
-
- {clock(left)} - {isReady ? 'Ready to commit' : 'until commit unlocks'} -
- -

- Keep this window open. Heartbeat every {Math.round(pending.heartbeat_interval_seconds / 2)} s · last {sinceBeat} s ago - {beatError !== null && · heartbeat failing, retrying} -

-

- {isReady ? 'Countdown finished. Retype the phrase to commit.' : ''} -

- {isReady ? ( -
{ - e.preventDefault() - if (phraseMatches(pending.phrase, phrase)) commit.mutate() - }} - > - - {commit.error && } -
- - -
- - ) : ( - <> - Closing this dialog, navigating away or closing the tab cancels the countdown. -
- -
- - )} -
- ) -} diff --git a/web/src/commands/PhraseField.test.tsx b/web/src/commands/PhraseField.test.tsx deleted file mode 100644 index 2f27fd4..0000000 --- a/web/src/commands/PhraseField.test.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { useState } from 'react' -import { describe, expect, it } from 'vitest' -import { PhraseField } from './PhraseField' - -function Harness({ initial = '', autoFocus = false }: { initial?: string; autoFocus?: boolean }) { - const [v, setV] = useState(initial) - return -} - -describe('PhraseField', () => { - it('counts characters while the prefix is right', async () => { - render() - await userEvent.type(screen.getByLabelText('Type the phrase to confirm'), 'res') - expect(screen.getByText('3 of 8 characters')).toBeInTheDocument() - expect(screen.getByRole('textbox')).toHaveAttribute('aria-invalid', 'false') - }) - - it('marks a mismatch', async () => { - render() - await userEvent.type(screen.getByRole('textbox'), 'rx') - expect(screen.getByText(/Does not match/)).toBeInTheDocument() - expect(screen.getByRole('textbox')).toHaveClass('input-bad') - expect(screen.getByRole('textbox')).toHaveAttribute('aria-invalid', 'true') - }) - - it('confirms the full phrase, ignoring surrounding whitespace', () => { - render() - expect(screen.getByText('Matches.')).toBeInTheDocument() - expect(screen.getByRole('textbox')).toHaveClass('input-ok') - }) - - it('colours each target character by whether it was typed right', () => { - const { container } = render() - const spans = [...container.querySelectorAll('.phrase-target span')] - expect(spans[0]).toHaveClass('ok') - expect(spans[1]).toHaveClass('bad') - expect(spans[2]).not.toHaveClass('ok') - }) - - it('blocks paste and drop', () => { - render() - const input = screen.getByRole('textbox') - expect(fireEvent.paste(input)).toBe(false) - expect(fireEvent.drop(input)).toBe(false) - }) - - it('focuses itself on request and shows the phrase to screen readers', () => { - render() - expect(screen.getByRole('textbox')).toHaveFocus() - expect(screen.getByText('Phrase: reset it')).toBeInTheDocument() - }) -}) diff --git a/web/src/commands/PhraseField.tsx b/web/src/commands/PhraseField.tsx deleted file mode 100644 index 2c7cbb2..0000000 --- a/web/src/commands/PhraseField.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { useId } from 'react' -import { phraseMatches } from './catalog' - -interface Props { - phrase: string - value: string - onChange: (v: string) => void - autoFocus?: boolean - label?: string -} - -/** - * Typed-phrase friction. Shows the exact sentence, then live per-character - * feedback on the typed text. Paste is blocked: retyping is the friction. - * The server re-checks — this field only helps the user get it right. - */ -export function PhraseField({ phrase, value, onChange, autoFocus = false, label = 'Type the phrase to confirm' }: Props) { - const id = useId() - const typed = value.trim() - const ok = phraseMatches(phrase, value) - const prefixOk = phrase.startsWith(typed) - const status = ok ? 'Matches.' : prefixOk ? `${typed.length} of ${phrase.length} characters` : 'Does not match — check case and spacing.' - - return ( -
- - - e.preventDefault()} - onDrop={(e) => e.preventDefault()} - onChange={(e) => onChange(e.target.value)} - /> -

Phrase: {phrase}

-

- {status} -

-
- ) -} diff --git a/web/src/commands/RunnerDialog.countdown.test.tsx b/web/src/commands/RunnerDialog.countdown.test.tsx deleted file mode 100644 index a750b4f..0000000 --- a/web/src/commands/RunnerDialog.countdown.test.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { routes } from '../test/dialogRoutes' -import { makeJob, makePending, makeSpec } from '../test/fixtures' -import { apiError, bodiesOf, installEventSource, renderRouted, stubApi } from '../test/harness' -import { RunnerDialog } from './RunnerDialog' - -const SPEC = makeSpec('gaming-reset', { - privileged: true, - friction: { phrase_template: 'reset gaming now', countdown_seconds: 300 }, -}) -const READY = makePending({ ready_at: '2026-10-10T09:00:00Z', armed_at: '2026-10-10T08:55:00Z' }) -const FAR = makePending({ ready_at: '2099-01-01T00:00:00Z', armed_at: '2026-10-10T08:55:00Z' }) - -function open(extra: Record) { - const mock = stubApi(routes([SPEC], extra)) - const onClose = vi.fn() - const view = renderRouted() - return { mock, onClose, ...view } -} -const arm = async () => { - await userEvent.type(await screen.findByLabelText('Type the phrase to confirm'), 'reset gaming now') - await userEvent.click(screen.getByRole('button', { name: 'Arm countdown' })) -} - -describe('RunnerDialog countdown commands', () => { - beforeEach(installEventSource) - afterEach(() => vi.unstubAllGlobals()) - - it('sends the phrase to arm, then commits with a retyped phrase and follows the job', async () => { - const { mock } = open({ 'POST /api/jobs': READY, 'POST /api/pending/p1/commit': makeJob({ id: 'j1', command: 'gaming-reset' }) }) - await arm() - expect(bodiesOf(mock, 'POST /api/jobs')).toEqual([ - { command: 'gaming-reset', params: {}, confirm_phrase: 'reset gaming now' }, - ]) - await userEvent.type(await screen.findByLabelText('Retype the phrase to commit'), 'reset gaming now') - await userEvent.click(screen.getByRole('button', { name: 'Commit' })) - expect(await screen.findByRole('region', { name: /^Job/ })).toBeInTheDocument() - expect(bodiesOf(mock, 'POST /api/pending/p1/commit')).toEqual([{ confirm_phrase: 'reset gaming now' }]) - }) - - it('cancelling the countdown closes the dialog and the server drops it', async () => { - const { mock, onClose } = open({ 'POST /api/jobs': FAR, 'DELETE /api/pending/p1': undefined }) - await arm() - await userEvent.click(await screen.findByRole('button', { name: 'Cancel countdown' })) - await waitFor(() => expect(onClose).toHaveBeenCalled()) - expect(mock).toHaveBeenCalledWith('/api/pending/p1', expect.objectContaining({ method: 'DELETE' })) - }) - - it('closing the dialog while armed drops the countdown', async () => { - const { mock, onClose } = open({ 'POST /api/jobs': FAR, 'DELETE /api/pending/p1': undefined }) - await arm() - await userEvent.click(await screen.findByRole('button', { name: 'Close' })) - expect(onClose).toHaveBeenCalled() - expect(mock).toHaveBeenCalledWith('/api/pending/p1', expect.objectContaining({ method: 'DELETE' })) - }) - - it('navigating away while armed drops it too, even if the server refuses', async () => { - const { mock, onClose, router } = open({ 'POST /api/jobs': FAR, 'DELETE /api/pending/p1': apiError(404, 'not_found') }) - await arm() - await screen.findByRole('button', { name: 'Cancel countdown' }) - await router.navigate({ to: '/jobs' }) - await waitFor(() => expect(onClose).toHaveBeenCalled()) - expect(mock).toHaveBeenCalledWith('/api/pending/p1', expect.objectContaining({ method: 'DELETE' })) - }) - - it('after a commit, closing no longer drops anything', async () => { - const { mock, onClose } = open({ 'POST /api/jobs': READY, 'POST /api/pending/p1/commit': makeJob({ id: 'j1' }) }) - await arm() - await userEvent.type(await screen.findByLabelText('Retype the phrase to commit'), 'reset gaming now') - await userEvent.click(screen.getByRole('button', { name: 'Commit' })) - await userEvent.click(await screen.findByRole('button', { name: 'Close (job keeps running)' })) - expect(onClose).toHaveBeenCalled() - expect(bodiesOf(mock, 'DELETE /api/pending/p1')).toEqual([]) - }) - - it('arming again after a lapse returns to an empty form', async () => { - const lapsing = makePending({ ...FAR, heartbeat_interval_seconds: 0.001 }) - open({ 'POST /api/jobs': lapsing, 'POST /api/pending/p1/heartbeat': apiError(410, 'pending_lapsed', 'missed') }) - await arm() - await userEvent.click(await screen.findByRole('button', { name: 'Arm again' }, { timeout: 3000 })) - expect(await screen.findByLabelText('Type the phrase to confirm')).toHaveValue('') - expect(screen.getByRole('button', { name: 'Arm countdown' })).toBeDisabled() - }) -}) diff --git a/web/src/commands/RunnerDialog.test.tsx b/web/src/commands/RunnerDialog.test.tsx deleted file mode 100644 index e32ada2..0000000 --- a/web/src/commands/RunnerDialog.test.tsx +++ /dev/null @@ -1,226 +0,0 @@ -import { act, fireEvent, screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { queryClient } from '../api/queries' -import { routes } from '../test/dialogRoutes' -import { makeJob, makeSpec } from '../test/fixtures' -import { apiError, bodiesOf, installEventSource, renderRouted, Reply, stubApi } from '../test/harness' -import { RunnerDialog } from './RunnerDialog' - -function open(name: Parameters[0]['name'], preset?: Record) { - const onClose = vi.fn() - const view = renderRouted() - return { onClose, ...view } -} -const run = (label: string | RegExp) => screen.findByRole('button', { name: label }) - -describe('RunnerDialog', () => { - beforeEach(installEventSource) - afterEach(() => vi.unstubAllGlobals()) - - it('shows loading, then the load error', async () => { - let fail: (r: unknown) => void = () => {} - stubApi({ 'GET /api/commands': () => new Promise((r) => (fail = r)) }) - open('scan') - expect(await screen.findByText(/Loading commands/)).toBeInTheDocument() - await act(async () => fail(apiError(500, 'op_failed', 'catalog broken'))) - expect(await screen.findByText('catalog broken')).toBeInTheDocument() - }) - - it('says so when the server does not offer the command', async () => { - stubApi(routes([makeSpec('done')])) - open('scan') - expect(await screen.findByText('Not in the server catalog')).toBeInTheDocument() - expect(screen.getByText(/does not offer "scan"/)).toBeInTheDocument() - }) - - it('describes the command with its tags', async () => { - stubApi(routes([makeSpec('enforce', { description: 'Enforce.', privileged: true, mutating: false, cancellable: true, friction: { phrase_template: 'x', countdown_seconds: 300 } })])) - open('enforce') - expect(await screen.findByText('Enforce.')).toBeInTheDocument() - expect(screen.getByText('Runs as root via the daemon')).toBeInTheDocument() - expect(screen.getByText('Read-only')).toBeInTheDocument() - expect(screen.getByText('Cancellable')).toBeInTheDocument() - expect(screen.getByText('5 min countdown')).toBeInTheDocument() - expect(await run('Arm countdown')).toBeInTheDocument() - }) - - it('runs the enforce preset it was opened for, with no mode field to change it', async () => { - const demo = { name: 'demo', label: 'Demo mode', type: 'int' as const, required: false, default: 0, min: 0, max: 1 } - const mock = stubApi(routes([makeSpec('enforce', { kind: 'screen', params: [demo], privileged: true, cancellable: true })], { 'POST /api/jobs': makeJob({ id: 'j1' }) })) - open('enforce', { demo: 1 }) - const button = await run('Run enforce') - expect(screen.getByText('Cancellable')).toBeInTheDocument() - expect(screen.queryByLabelText(/Demo mode/)).toBeNull() - expect(screen.queryByText('Runs as root via the daemon')).toBeNull() - await userEvent.click(button) - expect(bodiesOf(mock, 'POST /api/jobs')).toEqual([{ command: 'enforce', params: { demo: 1 } }]) - }) - - it('tags a state-changing command, with no countdown for an immediate one', async () => { - stubApi(routes([makeSpec('scan', { friction: { phrase_template: 'x' } })])) - open('scan') - expect(await screen.findByText('Changes state')).toBeInTheDocument() - expect(screen.queryByText(/countdown/)).toBeNull() - }) - - it('runs a parameterless command and follows its job', async () => { - const mock = stubApi(routes([makeSpec('scan')], { 'POST /api/jobs': makeJob({ id: 'j1' }) })) - const { onClose, router } = open('scan') - const button = await run('Run scan') - expect(button).toHaveFocus() - await userEvent.click(button) - expect(bodiesOf(mock, 'POST /api/jobs')).toEqual([{ command: 'scan', params: {} }]) - expect(await screen.findByRole('region', { name: 'Job Scan' })).toBeInTheDocument() - await userEvent.click(screen.getByRole('link', { name: 'Open in Jobs' })) - expect(router.state.location.pathname).toBe('/jobs/j1') - await waitFor(() => expect(onClose).toHaveBeenCalled()) // navigating away closes it - }) - - it('"Close" leaves the job running', async () => { - stubApi(routes([makeSpec('scan')], { 'POST /api/jobs': makeJob({ id: 'j1' }) })) - const { onClose } = open('scan') - await userEvent.click(await run('Run scan')) - await userEvent.click(await run('Close (job keeps running)')) - expect(onClose).toHaveBeenCalled() - }) - - it('Cancel closes the form', async () => { - stubApi(routes([makeSpec('scan')])) - const { onClose } = open('scan') - await userEvent.click(await run('Cancel')) - expect(onClose).toHaveBeenCalledTimes(1) - }) - - describe('parameters', () => { - const spec = makeSpec('abandon-pick', { - params: [{ name: 'app_id', label: 'Game', type: 'app_id', required: true }], - }) - - it('blocks an invalid submit and shows why', async () => { - const mock = stubApi(routes([spec])) - open('abandon-pick') - // The button is disabled while invalid, so only a forced submit (e.g. a - // script) reaches the validation display. - expect(await run('Run abandon-pick')).toBeDisabled() - fireEvent.submit(screen.getByRole('button', { name: 'Run abandon-pick' }).closest('form')!) - expect(await screen.findByText('Game is required.')).toBeInTheDocument() - expect(bodiesOf(mock, 'POST /api/jobs')).toEqual([]) - }) - - it('starts from a preset and sends typed values', async () => { - const mock = stubApi(routes([spec], { 'POST /api/jobs': makeJob({ id: 'j1' }) })) - open('abandon-pick', { app_id: 7 }) - expect(await screen.findByLabelText(/Game/)).toHaveValue('7') - await waitFor(() => expect(document.querySelector('.field-help')).toHaveTextContent('Seven')) // useGameLookup - await userEvent.click(await run('Run abandon-pick')) - expect(bodiesOf(mock, 'POST /api/jobs')).toEqual([{ command: 'abandon-pick', params: { app_id: 7 } }]) - }) - }) - - it('shows a parameter that appears when the catalog refreshes under an open form', async () => { - const alphaSpec = makeSpec('hide', { params: [{ name: 'a', label: 'Alpha', type: 'int', required: false }] }) - const table = routes([alphaSpec]) - stubApi(table) - open('hide') - await screen.findByLabelText('Alpha') - table['GET /api/commands'] = [ - makeSpec('hide', { - params: [ - { name: 'a', label: 'Alpha', type: 'int', required: false }, - { name: 'b', label: 'Beta', type: 'int', required: true }, - ], - }), - ] - await act(async () => { - await queryClient.invalidateQueries({ queryKey: ['commands'] }) - }) - expect(await screen.findByLabelText(/Beta/)).toHaveValue(null) - expect(await run('Run hide')).toBeDisabled() // Beta is required and empty - }) - - describe('friction', () => { - const spec = makeSpec('uninstall', { - params: [{ name: 'app_id', label: 'Game', type: 'app_id', required: true }], - friction: { phrase_template: 'uninstall {game_name} and {count} others' }, - }) - - it('shows the exact phrase once its fields are known, and sends it', async () => { - const mock = stubApi(routes([spec], { 'POST /api/jobs': makeJob({ id: 'j1' }) })) - open('uninstall') - expect(await screen.findByText(/Fill in the fields above to see the exact phrase \(game_name\)/)).toBeInTheDocument() - await userEvent.type(screen.getByLabelText(/Game/), '7') - const submit = await run('Run uninstall') - expect(submit).toBeDisabled() - expect(submit).toHaveClass('btn-danger') - await userEvent.type(screen.getByLabelText('Type the phrase to confirm'), 'uninstall Seven and 1 others') - expect(submit).toBeEnabled() - await userEvent.click(submit) - expect(bodiesOf(mock, 'POST /api/jobs')).toEqual([ - { command: 'uninstall', params: { app_id: 7 }, confirm_phrase: 'uninstall Seven and 1 others' }, - ]) - }) - - it('focuses the phrase straight away when there are no fields', async () => { - stubApi(routes([makeSpec('reset', { friction: { phrase_template: 'reset everything' } })])) - open('reset') - expect(await screen.findByLabelText('Type the phrase to confirm')).toHaveFocus() - }) - - it('does not submit a wrong phrase on Enter', async () => { - const mock = stubApi(routes([makeSpec('reset', { friction: { phrase_template: 'reset everything' } })])) - open('reset') - await userEvent.type(await screen.findByLabelText('Type the phrase to confirm'), 'nope{Enter}') - expect(bodiesOf(mock, 'POST /api/jobs')).toEqual([]) - }) - }) - - it('is read-only when locked, and explains', async () => { - stubApi(routes([makeSpec('pick', { locked_reason: 'Manual pick active', friction: { phrase_template: 'x' }, params: [{ name: 'n', label: 'N', type: 'int', required: false }] })])) - open('pick') - expect(await screen.findByText('Manual pick active')).toBeInTheDocument() - expect(screen.getByLabelText('N')).toBeDisabled() - expect(screen.queryByLabelText('Type the phrase to confirm')).toBeNull() - expect(await run('Run pick')).toBeDisabled() - }) - - describe('start failures', () => { - it('shows the server error', async () => { - stubApi(routes([makeSpec('scan')], { 'POST /api/jobs': apiError(409, 'busy', 'job 4 running') })) - open('scan') - await userEvent.click(await run('Run scan')) - expect(await screen.findByText('job 4 running')).toBeInTheDocument() - expect(screen.queryByText(/terminal instead/)).toBeNull() - }) - - it('offers the sudo fallback when the daemon is down', async () => { - stubApi(routes([makeSpec('gaming-unblock')], { 'POST /api/jobs': apiError(502, 'daemon_unreachable') })) - open('gaming-unblock') - await userEvent.click(await run('Run gaming-unblock')) - expect(await screen.findByText('Run it from a terminal instead:')).toBeInTheDocument() - expect(screen.getByText('sudo ./run.sh gaming-unblock')).toBeInTheDocument() - }) - - it('has no fallback for a command without one', async () => { - stubApi(routes([makeSpec('scan')], { 'POST /api/jobs': apiError(502, 'daemon_unreachable') })) - open('scan') - await userEvent.click(await run('Run scan')) - expect(await screen.findByText('Root daemon unreachable')).toBeInTheDocument() - expect(screen.queryByText(/terminal instead/)).toBeNull() - }) - - it('copes with a garbled reply', async () => { - stubApi(routes([makeSpec('scan')], { 'POST /api/jobs': new Reply(200, undefined, '{garbled') })) - open('scan') - await userEvent.click(await run('Run scan')) - expect(await screen.findByText('Unexpected server error')).toBeInTheDocument() - }) - - it('shows Starting… while waiting', async () => { - stubApi(routes([makeSpec('scan')], { 'POST /api/jobs': () => new Promise(() => {}) })) - open('scan') - await userEvent.click(await run('Run scan')) - expect(await run('Starting…')).toBeDisabled() - }) - }) -}) diff --git a/web/src/commands/RunnerDialog.tsx b/web/src/commands/RunnerDialog.tsx deleted file mode 100644 index be881ba..0000000 --- a/web/src/commands/RunnerDialog.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import { useMutation, useQuery } from '@tanstack/react-query' -import { Link, useRouter } from '@tanstack/react-router' -import { useCallback, useEffect, useRef, useState } from 'react' -import { api, ApiFailure, startJob } from '../api/client' -import type { CommandName, CommandSpec } from '../api/contract' -import type { PendingAction } from '../api/jobContract' -import { q, queryClient } from '../api/queries' -import { JobProgress } from '../jobs/JobProgress' -import { Dialog } from '../ui/Dialog' -import { CommandLine, ErrorNotice, Loading, Notice } from '../ui/Notice' -import { commandLabel, fillPhrase, findCommand, phraseMatches } from './catalog' -import { enforcePreset } from './enforcePreset' -import { PendingCountdown } from './PendingCountdown' -import { ParamsForm } from './ParamsForm' -import { initialValues, paramError, toParams } from './params' -import { PhraseField } from './PhraseField' -import { useGameLookup } from './useGameLookup' - -type Phase = { k: 'form' } | { k: 'pending'; pending: PendingAction } | { k: 'job'; id: string } - -/** Recovery commands the UI offers when the root daemon is down. */ -const FALLBACK: Partial> = { - 'gaming-unblock': 'sudo ./run.sh gaming-unblock', -} - -interface Props { - name: CommandName - preset?: Record - onClose: () => void -} - -export function RunnerDialog({ name, preset, onClose }: Props) { - const router = useRouter() - const { data: commands, error: loadError } = useQuery(q.commands) - const spec = findCommand(commands, name) - // An armed countdown dies with the dialog: close, Escape and any - // navigation all drop it server-side. - const armed = useRef(null) - const close = useCallback(() => { - if (armed.current) void api.dropPending(armed.current).catch(() => {}) - armed.current = null - onClose() - }, [onClose]) - useEffect(() => router.subscribe('onBeforeNavigate', close), [router, close]) - - return ( - - {spec ? ( - (armed.current = id)} /> - ) : loadError ? ( - - ) : commands ? ( - The server does not offer "{name}". - ) : ( - - )} - - ) -} - -interface RunnerProps { - spec: CommandSpec - /** Params sent as-is, outside the form (see enforcePreset). */ - fixed: Record - preset?: Record - onClose: () => void - onArmed: (pendingId: string | null) => void -} - -function Runner({ spec, fixed, preset, onClose, onArmed }: RunnerProps) { - const { games, ctx } = useGameLookup() - const [values, setValues] = useState(() => initialValues(spec.params, preset)) - const [typed, setTyped] = useState('') - const [showErrors, setShowErrors] = useState(false) - const [phase, setPhase] = useState({ k: 'form' }) - - const start = useMutation({ - mutationFn: startJob, - onSuccess: (out) => { - void queryClient.invalidateQueries({ queryKey: ['jobs'] }) - onArmed(out.kind === 'pending' ? out.pending.id : null) - setPhase(out.kind === 'job' ? { k: 'job', id: out.job.id } : { k: 'pending', pending: out.pending }) - }, - }) - - const params = { ...fixed, ...toParams(spec.params, values) } - const errors = spec.params.map((p) => paramError(p, values[p.name] ?? '', games)).filter(Boolean) - const phrase = spec.friction ? fillPhrase(spec.friction.phrase_template, params, ctx) : null - const countdown = spec.friction?.countdown_seconds - const locked = spec.locked_reason !== null - const canSubmit = - !locked && errors.length === 0 && (!phrase || (phrase.missing.length === 0 && phraseMatches(phrase.text, typed))) - - const submit = () => { - setShowErrors(true) - if (!canSubmit) return - // Every friction command sends its phrase here; a countdown one - // (gaming-reset) needs it to arm and is asked again at commit. - start.mutate({ command: spec.name, params, ...(phrase ? { confirm_phrase: typed } : {}) }) - } - - if (phase.k === 'job') { - return ( -
- -
- - - Open in Jobs - -
-
- ) - } - - if (phase.k === 'pending') { - return ( - { - onArmed(null) - setPhase({ k: 'job', id: job.id }) - }} - onCancel={() => { - onArmed(null) - onClose() - }} - onRearm={() => { - onArmed(null) - setTyped('') - setPhase({ k: 'form' }) - }} - /> - ) - } - - const failure = start.error instanceof ApiFailure ? start.error : null - return ( -
{ - e.preventDefault() - submit() - }} - > -

{spec.description}

-
- {spec.privileged && Runs as root via the daemon} - {spec.mutating ? Changes state : Read-only} - {spec.cancellable && Cancellable} - {countdown ? {Math.round(countdown / 60)} min countdown : null} -
- {locked && {spec.locked_reason}} - - {phrase && phrase.missing.length > 0 && ( - Fill in the fields above to see the exact phrase ({phrase.missing.join(', ')}). - )} - {phrase && phrase.missing.length === 0 && !locked && ( - - )} - {start.error && ( - - {failure?.code === 'daemon_unreachable' && FALLBACK[spec.name] && ( - <> -

Run it from a terminal instead:

- - - )} -
- )} -
- - -
- - ) -} diff --git a/web/src/commands/RunnerProvider.test.tsx b/web/src/commands/RunnerProvider.test.tsx deleted file mode 100644 index 979f96b..0000000 --- a/web/src/commands/RunnerProvider.test.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { routes } from '../test/dialogRoutes' -import { makeSpec } from '../test/fixtures' -import { renderRouted, stubApi } from '../test/harness' -import { RunnerProvider } from './RunnerProvider' -import { useRunner } from './runnerContext' - -function Launcher() { - const { run, openPalette } = useRunner() - return ( - <> - - - - - ) -} - -function mount() { - stubApi(routes([makeSpec('scan'), makeSpec('done')])) - return renderRouted( - - - , - ) -} - -describe('RunnerProvider', () => { - afterEach(() => vi.unstubAllGlobals()) - - it('opens the runner dialog for a command, and closes it', async () => { - mount() - await userEvent.click(await screen.findByRole('button', { name: 'run scan' })) - expect(await screen.findByRole('dialog', { name: 'Scan' })).toBeInTheDocument() - await userEvent.click(await screen.findByRole('button', { name: 'Cancel' })) - await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) - }) - - it('starts a fresh form each time the same command is run again', async () => { - mount() - await userEvent.click(await screen.findByRole('button', { name: 'run done' })) - expect(await screen.findByRole('dialog', { name: 'Done' })).toBeInTheDocument() - await userEvent.click(await screen.findByRole('button', { name: 'Cancel' })) - await userEvent.click(screen.getByRole('button', { name: 'run done' })) - expect(await screen.findByRole('dialog', { name: 'Done' })).toBeInTheDocument() - }) - - it('opens the palette from a button and from Ctrl+K, which toggles it', async () => { - mount() - await userEvent.click(await screen.findByRole('button', { name: 'open palette' })) - expect(await screen.findByRole('dialog', { name: 'Command palette' })).toBeInTheDocument() - await userEvent.keyboard('{Control>}k{/Control}') - await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) - await userEvent.keyboard('{Meta>}K{/Meta}') - expect(await screen.findByRole('dialog', { name: 'Command palette' })).toBeInTheDocument() - }) - - it('closes the palette from its own close button', async () => { - mount() - await userEvent.click(await screen.findByRole('button', { name: 'open palette' })) - await screen.findByRole('dialog', { name: 'Command palette' }) - await userEvent.click(screen.getByRole('button', { name: 'Close' })) - await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) - }) - - it('ignores a plain k, and Ctrl+K while a wide dialog owns the screen', async () => { - mount() - await screen.findByRole('button', { name: 'run scan' }) - await userEvent.keyboard('k') - expect(screen.queryByRole('dialog')).toBeNull() - await userEvent.click(screen.getByRole('button', { name: 'run scan' })) - await screen.findByRole('dialog', { name: 'Scan' }) - await userEvent.keyboard('{Control>}k{/Control}') - expect(screen.queryByRole('dialog', { name: 'Command palette' })).toBeNull() - expect(screen.getByRole('dialog', { name: 'Scan' })).toBeInTheDocument() - }) - - it('running a command from the palette swaps the palette for the runner', async () => { - mount() - await userEvent.click(await screen.findByRole('button', { name: 'open palette' })) - await userEvent.click(await screen.findByText('Does scan.')) - expect(await screen.findByRole('dialog', { name: 'Scan' })).toBeInTheDocument() - expect(screen.queryByRole('dialog', { name: 'Command palette' })).toBeNull() - }) - - it('stops listening for Ctrl+K when unmounted', async () => { - const view = mount() - await screen.findByRole('button', { name: 'run scan' }) - view.unmount() - await userEvent.keyboard('{Control>}k{/Control}') - expect(screen.queryByRole('dialog')).toBeNull() - }) -}) diff --git a/web/src/commands/RunnerProvider.tsx b/web/src/commands/RunnerProvider.tsx deleted file mode 100644 index 564f0b0..0000000 --- a/web/src/commands/RunnerProvider.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react' -import type { CommandName } from '../api/contract' -import { CommandPalette } from './CommandPalette' -import { RunnerContext } from './runnerContext' -import { RunnerDialog } from './RunnerDialog' - -interface Open { - name: CommandName - preset?: Record - /** Changes per open so re-running the same command starts a fresh form. */ - nonce: number -} - -/** Owns the one runner dialog and the command palette (Ctrl/Cmd+K). */ -export function RunnerProvider({ children }: { children: ReactNode }) { - const [open, setOpen] = useState(null) - const [palette, setPalette] = useState(false) - - const run = useCallback((name: CommandName, preset?: Record) => { - setPalette(false) - setOpen({ name, preset, nonce: Date.now() }) - }, []) - const openPalette = useCallback(() => setPalette(true), []) - const close = useCallback(() => setOpen(null), []) - const api = useMemo(() => ({ run, openPalette }), [run, openPalette]) - - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') { - e.preventDefault() - // A modal runner keeps focus; the palette waits until it closes. - if (!document.querySelector('dialog[open].dialog-wide')) setPalette((p) => !p) - } - } - window.addEventListener('keydown', onKey) - return () => window.removeEventListener('keydown', onKey) - }, []) - - return ( - - {children} - {palette && setPalette(false)} onRun={run} />} - {open && } - - ) -} diff --git a/web/src/commands/catalog.ts b/web/src/commands/catalog.ts index bafe34e..cc4b803 100644 --- a/web/src/commands/catalog.ts +++ b/web/src/commands/catalog.ts @@ -80,7 +80,7 @@ export function fillPhrase( const text = template.replace(/\{(\w+)\}/g, (whole, key: string) => { let value: string | null = null if (key in params && params[key] !== '') value = String(params[key]) - else if (key === 'game_name' && params.app_id !== undefined) value = ctx.gameName(Number(params.app_id)) + else if (key === 'game_name' && 'app_id' in params) value = ctx.gameName(Number(params.app_id)) else if (key === 'count' && ctx.installed) { value = String(ctx.installed.filter((g) => !g.assigned && !g.protected).length) } diff --git a/web/src/commands/commandButton.test.ts b/web/src/commands/commandButton.test.ts new file mode 100644 index 0000000..d64e61a --- /dev/null +++ b/web/src/commands/commandButton.test.ts @@ -0,0 +1,143 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { CommandSpec } from '../api/contract' +import { q } from '../api/queries' +import { runner } from '../lib/runner' +import { makeSpec } from '../test/fixtures' +import { mount, stubApi } from '../test/harness' +import { commandButton } from './commandButton' +import { must } from '../test/events' + +const loaded = (specs: CommandSpec[]) => { + stubApi({ 'GET /api/commands': specs }) +} +const requests: unknown[] = [] +let off = (): void => undefined + +beforeEach(() => { + off = runner.onRun((r) => requests.push(r)) +}) +afterEach(() => { + off() + requests.length = 0 +}) + +describe('', () => { + it('runs a job command with its preset when clicked', async () => { + loaded([makeSpec('scan')]) + const el = mount(``) + await vi.waitFor(() => { + expect(el.querySelector('button')).toHaveTextContent('Run scan') + }) + const btn = must(el.querySelector('button')) + expect(btn).toHaveAttribute('aria-disabled', 'false') + expect(btn.className).toBe('btn btn-ghost') + btn.click() + expect(requests).toEqual([{ name: 'scan', preset: { app_id: 4 } }]) + }) + + it('is inert until the catalog has loaded', async () => { + loaded([]) + const el = mount('') + const btn = must(el.querySelector('button')) + expect(btn).toHaveTextContent('Open Status') + expect(btn).toHaveAttribute('aria-disabled', 'true') + btn.click() + await vi.waitFor(() => { + expect(q.commands.state.loading).toBe(false) + }) + expect(requests).toEqual([]) + }) + + it('stays focusable but inert while locked, and says why', async () => { + loaded([makeSpec('pick', { locked_reason: 'Locked by a manual pick' })]) + const el = mount('') + const btn = must(el.querySelector('button')) + expect(btn).toHaveTextContent('Pick it') + await vi.waitFor(() => { + expect(el.querySelector('button')).toHaveAttribute('title', 'Locked by a manual pick') + }) + expect(el.querySelector('button')).toHaveAttribute('aria-disabled', 'true') + expect(el.querySelector('button')).toHaveClass('btn-danger') + el.querySelector('button')?.click() + expect(requests).toEqual([]) + }) + + it('links to the owning page for a non-job command', async () => { + loaded([makeSpec('status', { kind: 'view' })]) + const el = mount('') + await vi.waitFor(() => { + expect(el.querySelector('a')).not.toBeNull() + }) + const link = must(el.querySelector('a')) + expect(link).toHaveTextContent('Open Status') + expect(link).toHaveAttribute('href', '/') + expect(link).toHaveClass('btn-primary') + }) + + it('runs without a preset when the attribute is absent', async () => { + loaded([makeSpec('scan')]) + const el = mount('') + await vi.waitFor(() => { + expect(el.querySelector('button')).toHaveTextContent('Run scan') + }) + el.querySelector('button')?.click() + expect(requests).toEqual([{ name: 'scan' }]) + }) +}) + +describe('commandButton()', () => { + it('defaults to the ghost tone', () => { + expect(commandButton({ name: 'scan' }).className).toBe('btn btn-ghost') + }) +}) + +describe('', () => { + it('shows the label, server description and a run button', async () => { + loaded([makeSpec('scan', { description: 'Scans achievements.' })]) + const el = mount(``) + await vi.waitFor(() => { + expect(el).toHaveTextContent('Scans achievements.') + }) + expect(el.querySelector('h3')).toHaveTextContent('Scan') + el.querySelector('button')?.click() + expect(requests).toEqual([{ name: 'scan', preset: { app_id: 4 } }]) + expect(el.querySelector('article')).toHaveClass('cmd-card') + expect(el.querySelector('article')).not.toHaveClass('locked') + }) + + it('marks privileged and countdown commands', async () => { + loaded([makeSpec('gaming-reset', { privileged: true, friction: { phrase_template: 'x', countdown_seconds: 300 } })]) + const el = mount('') + await vi.waitFor(() => { + expect(el).toHaveTextContent('root') + }) + expect(el).toHaveTextContent('countdown') + }) + + it('shows no countdown pill for a friction command without one', async () => { + loaded([makeSpec('unblock', { friction: { phrase_template: 'x' } })]) + const el = mount('') + await vi.waitFor(() => { + expect(el).toHaveTextContent('Does unblock.') + }) + expect(el).not.toHaveTextContent('countdown') + expect(el).not.toHaveTextContent('root') + }) + + it('shows the lock reason in place of the description', async () => { + loaded([makeSpec('pick', { locked_reason: 'Locked by a manual pick' })]) + const el = mount('') + await vi.waitFor(() => { + expect(el.querySelector('.cmd-desc')).toHaveTextContent('Locked by a manual pick') + }) + expect(el.querySelector('article')).toHaveClass('locked') + expect(el.querySelector('button')).toHaveAttribute('aria-disabled', 'true') + }) + + it('shows a placeholder until the catalog has loaded', () => { + loaded([]) + const el = mount('') + expect(el.querySelector('.cmd-desc')).toHaveTextContent('…') + expect(el.querySelector('button')).toHaveAttribute('aria-disabled', 'true') + }) +}) diff --git a/web/src/commands/commandButton.ts b/web/src/commands/commandButton.ts new file mode 100644 index 0000000..5611494 --- /dev/null +++ b/web/src/commands/commandButton.ts @@ -0,0 +1,92 @@ +// `` and +// `` (was CommandCard.tsx): a catalog command as a button or +// a card with its server description and lock reason. A locked button stays +// focusable (aria-disabled), so a keyboard user can still reach it and read +// why. Non-job commands (views) link to the page that shows them. + +import type { CommandName } from '../api/contract' +import { q } from '../api/queries' +import { define, h, jsonAttr } from '../lib/dom' +import { LiveElement } from '../lib/element' +import { pageHref, runner, type Preset } from '../lib/runner' +import { pill } from '../ui/build' +import { COMMAND_HOME, commandLabel, findCommand } from './catalog' + +export interface ButtonProps { + name: CommandName + preset?: Preset + label?: string + tone?: string +} + +function props(el: Element): ButtonProps { + const preset = jsonAttr(el, 'preset', undefined) + const label = el.getAttribute('label') + return { + name: (el.getAttribute('name') ?? 'status') as CommandName, + ...(preset ? { preset } : {}), + ...(label ? { label } : {}), + tone: el.getAttribute('tone') ?? 'ghost', + } +} + +/** The button for one command, from the current catalog. */ +export function commandButton({ name, preset, label, tone = 'ghost' }: ButtonProps): HTMLElement { + const spec = findCommand(q.commands.state.data, name) + const locked = spec?.locked_reason ?? null + const text = label ?? (spec?.kind === 'job' ? `Run ${name}` : `Open ${commandLabel(name)}`) + if (spec && spec.kind !== 'job') return h('a', { href: pageHref(COMMAND_HOME[name]), class: `btn btn-${tone}` }, text) + return h( + 'button', + { + type: 'button', + class: `btn btn-${tone}`, + 'aria-disabled': String(locked !== null || !spec), + title: locked, + onclick: () => { + if (spec && !locked) runner.run(name, preset) + }, + }, + text, + ) +} + +export class CommandButtonElement extends LiveElement { + protected override setup(): void { + this.watch(q.commands) + } + + protected update(): void { + this.replaceChildren(commandButton(props(this))) + } +} + +export class CommandCardElement extends LiveElement { + protected override setup(): void { + this.watch(q.commands) + } + + protected update(): void { + const p = props(this) + const spec = findCommand(q.commands.state.data, p.name) + const locked = spec?.locked_reason ?? null + this.replaceChildren( + h( + 'article', + { class: locked ? 'cmd-card locked' : 'cmd-card' }, + h( + 'h3', + { class: 'cmd-name' }, + commandLabel(p.name), + spec?.privileged ? pill('neutral', 'root') : null, + spec?.friction?.countdown_seconds ? pill('warning', 'countdown') : null, + ), + h('p', { class: 'cmd-desc' }, locked ?? spec?.description ?? '…'), + commandButton(p), + ), + ) + } +} + +define('command-button', CommandButtonElement) +define('command-card', CommandCardElement) diff --git a/web/src/commands/gameLookup.test.ts b/web/src/commands/gameLookup.test.ts new file mode 100644 index 0000000..f5c9d78 --- /dev/null +++ b/web/src/commands/gameLookup.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { makeDataset, makeGame } from '../test/factories' +import { makeInstalled, makeStatus } from '../test/fixtures' +import { gameLookup } from './gameLookup' + +describe('gameLookup', () => { + it('is empty until the sources load', () => { + const { games, ctx } = gameLookup(undefined, undefined, undefined) + expect(games).toEqual([]) + expect(ctx.gameName(1)).toBeNull() + expect(ctx.installed).toBeNull() + }) + + it('merges backlog, installed, manual picks and the assignment, sorted by name', () => { + const { games, ctx } = gameLookup( + makeDataset([makeGame({ app_id: 1, name: 'Zeta' }), makeGame({ app_id: 2, name: 'Old name' })]), + { games: [makeInstalled({ app_id: 2, name: 'Beta' })] }, + makeStatus({ current_app_id: 4, current_game_name: 'Delta', manual_picks: [{ app_id: 3, name: 'Gamma', age_days: 1 }] }), + ) + expect(games.map((g) => g.name)).toEqual(['Beta', 'Delta', 'Gamma', 'Zeta']) + expect(ctx.gameName(2)).toBe('Beta') + expect(ctx.installed).toHaveLength(1) + }) + + it('skips an unassigned status', () => { + const base = [makeGame({ app_id: 1, name: 'Zeta' })] + const none = gameLookup(makeDataset(base), undefined, makeStatus({ current_app_id: null, current_game_name: null })) + expect(none.games).toHaveLength(1) + const nameless = gameLookup(makeDataset(base), undefined, makeStatus({ current_app_id: 9, current_game_name: '' })) + expect(nameless.games).toHaveLength(1) + }) +}) diff --git a/web/src/commands/gameLookup.ts b/web/src/commands/gameLookup.ts new file mode 100644 index 0000000..e6b1a76 --- /dev/null +++ b/web/src/commands/gameLookup.ts @@ -0,0 +1,21 @@ +// Every game the UI knows a name for (backlog snapshot, installed list, +// current assignment, manual picks), for app_id pickers and for filling +// `{game_name}` / `{count}` in phrase previews (was useGameLookup.ts). + +import type { InstalledPayload, StatusPayload, WebDataset } from '../api/contract' +import type { PhraseContext } from './catalog' +import type { GameOption } from './params' + +export function gameLookup( + dataset: WebDataset | undefined, + installed: InstalledPayload | undefined, + status: StatusPayload | undefined, +): { games: GameOption[]; ctx: PhraseContext } { + const byId = new Map() + for (const g of dataset?.games ?? []) byId.set(g.app_id, g.name) + for (const g of installed?.games ?? []) byId.set(g.app_id, g.name) + for (const p of status?.manual_picks ?? []) byId.set(p.app_id, p.name) + if (status?.current_app_id && status.current_game_name) byId.set(status.current_app_id, status.current_game_name) + const games = [...byId].map(([app_id, name]) => ({ app_id, name })).sort((a, b) => a.name.localeCompare(b.name)) + return { games, ctx: { gameName: (id) => byId.get(id) ?? null, installed: installed?.games ?? null } } +} diff --git a/web/src/commands/palette.test.ts b/web/src/commands/palette.test.ts new file mode 100644 index 0000000..5672c2b --- /dev/null +++ b/web/src/commands/palette.test.ts @@ -0,0 +1,216 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { NAV } from '../shell/nav' +import { runner, type RunRequest } from '../lib/runner' +import { must, setValue } from '../test/events' +import { makeSpec } from '../test/fixtures' +import { apiError, stubApi } from '../test/harness' +import { CommandPaletteElement, go } from './palette' + +const SPECS = [ + makeSpec('scan', { description: 'Scan achievements.' }), + makeSpec('unblock', { description: 'Open the store.', friction: { phrase_template: 'x' } }), + makeSpec('status', { kind: 'view', description: 'Show status.' }), + makeSpec('pick', { description: 'Pick next.', locked_reason: 'Locked by a pick' }), +] +const TOTAL = SPECS.length + NAV.length + +const assign = vi.fn() +const runs: RunRequest[] = [] +let offRun = (): void => undefined + +function open(specs = SPECS) { + stubApi({ 'GET /api/commands': specs }) + const el = new CommandPaletteElement() + document.body.append(el) + el.open() + return el +} +const box = () => must(document.querySelector('[role=combobox]')) +const options = () => [...document.querySelectorAll('[role=option]')] +const active = () => options().findIndex((o) => o.getAttribute('aria-selected') === 'true') +const opt = (title: string) => must(options().find((o) => o.querySelector('.palette-title')?.firstChild?.textContent === title)) +const key = (k: string) => box().dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true })) +const loaded = () => vi.waitFor(() => { + expect(options().length).toBeGreaterThan(0) + expect(document.body).not.toHaveTextContent('Loading commands') +}) + +describe('CommandPaletteElement', () => { + beforeEach(() => { + vi.stubGlobal('location', { assign }) + offRun = runner.onRun((r) => runs.push(r)) + }) + afterEach(() => { + offRun() + runs.length = 0 + assign.mockClear() + }) + + it('lists every command, then the sections, and focuses the box', async () => { + const el = open() + await loaded() + expect(el.isOpen).toBe(true) + expect(options()).toHaveLength(TOTAL) + expect(opt('Scan')).toHaveTextContent('run') + expect(opt('Unblock')).toHaveTextContent('run · confirm') + expect(opt('Status')).toHaveTextContent('open /') + expect(opt('Pick')).toHaveTextContent('locked') + expect(opt('Pick')).toHaveTextContent('Locked by a pick') + expect(opt('Pick')).not.toHaveTextContent('Pick next.') + expect(document.body).toHaveTextContent('Go to Dashboard') + expect(document.activeElement).toBe(box()) + }) + + it('opens from the runner bus, closes when a run starts, and cleans up on removal', () => { + stubApi({ 'GET /api/commands': SPECS }) + const el = new CommandPaletteElement() + document.body.append(el) + runner.openPalette() + expect(el.isOpen).toBe(true) + runner.run('scan') + expect(el.isOpen).toBe(false) + runner.openPalette() + el.remove() + expect(el.isOpen).toBe(false) + runner.openPalette() + expect(el.isOpen).toBe(false) + }) + + it('toggles and ignores a second open', async () => { + const el = open() + await loaded() + el.open() + expect(options()).toHaveLength(TOTAL) + el.toggle() + expect(el.isOpen).toBe(false) + el.toggle() + expect(el.isOpen).toBe(true) + }) + + it('closes on Escape and on its close button', () => { + const el = open() + el.querySelector('dialog')?.dispatchEvent(new Event('cancel', { cancelable: true })) + expect(el.isOpen).toBe(false) + el.open() + el.querySelector('.dialog-close')?.click() + expect(el.isOpen).toBe(false) + }) + + it('shows loading, then the load error', async () => { + let fail: (r: unknown) => void = () => undefined + stubApi({ 'GET /api/commands': () => new Promise((r) => { fail = r }) }) + const el = new CommandPaletteElement() + document.body.append(el) + el.open() + expect(document.body).toHaveTextContent('Loading commands') + fail(apiError(500, 'op_failed', 'catalog broken')) + await vi.waitFor(() => { + expect(document.body).toHaveTextContent('catalog broken') + }) + expect(document.body).not.toHaveTextContent('Loading commands') + expect(options()).toHaveLength(NAV.length) + }) + + it('filters by text and says when nothing matches', async () => { + open() + await loaded() + setValue(box(), 'store') + expect(options()).toHaveLength(2) + setValue(box(), 'zzz') + expect(document.body).toHaveTextContent('No command matches “zzz”.') + expect(box()).not.toHaveAttribute('aria-activedescendant') + expect(active()).toBe(-1) + }) + + it('reopens with an empty box and the first entry active', async () => { + const el = open() + await loaded() + setValue(box(), 'store') + el.close() + el.open() + expect(box()).toHaveValue('') + expect(options()).toHaveLength(TOTAL) + expect(active()).toBe(0) + }) + + it('moves with arrows and pages, clamped, and announces the active option', async () => { + open() + await loaded() + expect(key('ArrowDown')).toBe(false) + key('ArrowDown') + expect(active()).toBe(2) + expect(box().getAttribute('aria-activedescendant')).toBe(options()[2]?.id) + key('ArrowUp') + key('ArrowUp') + key('ArrowUp') + expect(active()).toBe(0) + key('PageDown') + expect(active()).toBe(8) + key('PageDown') + expect(active()).toBe(TOTAL - 1) + key('ArrowDown') + expect(active()).toBe(TOTAL - 1) + key('PageUp') + expect(active()).toBe(TOTAL - 9) + key('PageUp') + key('PageUp') + expect(active()).toBe(0) + }) + + it('the pointer moves the highlight, and a typed edit resets it', async () => { + open() + await loaded() + const second = options()[1] + second.dispatchEvent(new MouseEvent('mousemove')) + expect(active()).toBe(1) + expect(second).toHaveClass('active') + second.dispatchEvent(new MouseEvent('mousemove')) + expect(active()).toBe(1) + setValue(box(), 's') + expect(active()).toBe(0) + }) + + it('Enter runs a job command', async () => { + open() + await loaded() + expect(key('Enter')).toBe(false) + expect(runs).toEqual([{ name: 'scan' }]) + }) + + it('Enter on a view command navigates and closes', async () => { + const el = open() + await loaded() + setValue(box(), 'show status') + key('Enter') + expect(runs).toEqual([]) + expect(assign).toHaveBeenCalledWith('/') + expect(el.isOpen).toBe(false) + }) + + it('clicking a section navigates and closes', async () => { + const el = open() + await loaded() + opt('Go to Jobs').click() + expect(assign).toHaveBeenCalledWith('/jobs/') + expect(el.isOpen).toBe(false) + }) + + it('Enter with nothing shown does nothing, and other keys are ignored', async () => { + const el = open() + await loaded() + setValue(box(), 'zzz') + expect(key('Enter')).toBe(true) + expect(key('Tab')).toBe(true) + expect(runs).toEqual([]) + expect(el.isOpen).toBe(true) + }) +}) + +describe('go', () => { + it('loads the section as a static page', () => { + vi.stubGlobal('location', { assign }) + go('/library') + expect(assign).toHaveBeenCalledWith('/library/') + assign.mockClear() + }) +}) diff --git a/web/src/commands/palette.ts b/web/src/commands/palette.ts new file mode 100644 index 0000000..790f5d4 --- /dev/null +++ b/web/src/commands/palette.ts @@ -0,0 +1,197 @@ +// `` (Ctrl/Cmd+K): every command from `GET /api/commands` +// plus the sections (was CommandPalette.tsx). A combobox + listbox pair: +// arrows move, Enter runs, Escape closes, and the active option is announced +// via aria-activedescendant. + +import { q } from '../api/queries' +import { define, h } from '../lib/dom' +import { uid } from '../lib/ids' +import { pageHref, runner } from '../lib/runner' +import { NAV } from '../shell/nav' +import { errorNotice, loading, pill } from '../ui/build' +import { Modal } from '../ui/dialog' +import { COMMAND_HOME, commandLabel } from './catalog' + +interface Entry { + key: string + title: string + detail: string + hint: string + locked: string | null + act: () => void +} + +/** Navigate to a section page (a full page load: this is a static site). */ +export function go(to: string): void { + window.location.assign(pageHref(to)) +} + +export class CommandPaletteElement extends HTMLElement { + private readonly modal = new Modal({ title: 'Command palette', onClose: () => { this.close() } }) + private readonly listId = uid('palette') + private readonly input: HTMLInputElement + private readonly list: HTMLUListElement + private readonly status = h('div', { class: 'contents' }) + private active = 0 + private shown: Entry[] = [] + private unsubs: (() => void)[] = [] + private unwatch: (() => void) | null = null + + constructor() { + super() + this.input = h('input', { + class: 'input palette-input', + role: 'combobox', + 'aria-expanded': 'true', + 'aria-controls': `${this.listId}-list`, + 'aria-label': 'Search commands', + placeholder: 'Type a command or section…', + autofocus: true, + }) + this.list = h('ul', { id: `${this.listId}-list`, role: 'listbox', class: 'palette-list', 'aria-label': 'Commands' }) + this.input.addEventListener('input', () => { + this.active = 0 + this.renderList() + }) + this.input.addEventListener('keydown', (e) => { + this.onKey(e) + }) + this.modal.render(this.input, this.status, this.list, h('p', { class: 'field-help' }, '↑↓ to move · Enter to run · Esc to close')) + } + + get isOpen(): boolean { + return this.modal.isOpen + } + + connectedCallback(): void { + this.append(this.modal.el) + this.unsubs.push(runner.onPalette(() => { + this.open() + })) + this.unsubs.push(runner.onRun(() => { + this.close() + })) + } + + disconnectedCallback(): void { + this.close() + for (const u of this.unsubs.splice(0)) u() + } + + toggle(): void { + if (this.isOpen) this.close() + else this.open() + } + + open(): void { + if (this.isOpen) return + this.input.value = '' + this.active = 0 + this.unwatch = q.commands.subscribe(() => { + this.renderList() + }) + this.renderList() + this.modal.show() + this.input.focus() + } + + close(): void { + this.unwatch?.() + this.unwatch = null + this.modal.hide() + } + + private entries(): Entry[] { + return [ + ...(q.commands.state.data ?? []).map((c) => ({ + key: c.name, + title: commandLabel(c.name), + detail: c.description, + hint: c.kind === 'job' ? (c.friction ? 'run · confirm' : 'run') : `open ${COMMAND_HOME[c.name]}`, + locked: c.locked_reason, + act: () => { + if (c.kind === 'job') runner.run(c.name) + else { + this.close() + go(COMMAND_HOME[c.name]) + } + }, + })), + ...NAV.map((n) => ({ + key: `nav:${n.to}`, + title: `Go to ${n.label}`, + detail: n.blurb, + hint: 'navigate', + locked: null, + act: () => { + this.close() + go(n.to) + }, + })), + ] + } + + private renderList(): void { + const { data, error } = q.commands.state + this.status.replaceChildren(...(error ? [errorNotice(error)] : !data ? [loading('commands')] : [])) + const needle = this.input.value.trim().toLowerCase() + const all = this.entries() + this.shown = needle ? all.filter((e) => `${e.key} ${e.title} ${e.detail}`.toLowerCase().includes(needle)) : all + this.active = Math.min(this.active, Math.max(0, this.shown.length - 1)) + this.list.replaceChildren( + ...this.shown.map((e, i) => { + const li = h( + 'li', + { id: `${this.listId}-${i}`, role: 'option', 'aria-selected': String(i === this.active), class: i === this.active ? 'palette-item active' : 'palette-item' }, + h('span', { class: 'palette-title' }, e.title, e.locked ? pill('warning', 'locked') : null), + h('span', { class: 'palette-detail' }, e.locked ?? e.detail), + h('span', { class: 'palette-hint' }, e.hint), + ) + li.addEventListener('mousemove', () => { + if (this.active !== i) this.setActive(i) + }) + li.addEventListener('click', e.act) + return li + }), + ) + if (this.shown.length === 0) this.list.append(h('li', { class: 'empty' }, `No command matches “${this.input.value}”.`)) + this.syncActive() + } + + private setActive(i: number): void { + this.active = i + this.list.querySelectorAll('[role=option]').forEach((li, j) => { + li.setAttribute('aria-selected', String(j === i)) + li.className = j === i ? 'palette-item active' : 'palette-item' + }) + this.syncActive() + } + + private syncActive(): void { + if (this.shown.length > 0) this.input.setAttribute('aria-activedescendant', `${this.listId}-${this.active}`) + else this.input.removeAttribute('aria-activedescendant') + } + + private onKey(e: KeyboardEvent): void { + const last = this.shown.length - 1 + const moves = new Map([ + ['ArrowDown', Math.min(last, this.active + 1)], + ['ArrowUp', Math.max(0, this.active - 1)], + ['PageDown', Math.min(last, this.active + 8)], + ['PageUp', Math.max(0, this.active - 8)], + ]) + const to = moves.get(e.key) + if (to !== undefined) { + e.preventDefault() + this.setActive(to) + document.getElementById(`${this.listId}-${to}`)?.scrollIntoView({ block: 'nearest' }) + } else if (e.key === 'Enter') { + const entry = this.shown.at(this.active) + if (!entry) return + e.preventDefault() + entry.act() + } + } +} + +define('command-palette', CommandPaletteElement) diff --git a/web/src/commands/params.ts b/web/src/commands/params.ts index e6c1e1b..973519d 100644 --- a/web/src/commands/params.ts +++ b/web/src/commands/params.ts @@ -43,5 +43,5 @@ export function toParams(specs: ParamSpec[], values: ParamValues): Record = {}): ParamValues { - return Object.fromEntries(specs.map((p) => [p.name, String(preset[p.name] ?? p.default ?? '')])) + return Object.fromEntries(specs.map((p) => [p.name, String((p.name in preset ? preset[p.name] : undefined) ?? p.default ?? '')])) } diff --git a/web/src/commands/paramsForm.test.ts b/web/src/commands/paramsForm.test.ts new file mode 100644 index 0000000..b1d8bba --- /dev/null +++ b/web/src/commands/paramsForm.test.ts @@ -0,0 +1,134 @@ +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import type { ParamSpec } from '../api/contract' +import type { ParamValues } from './params' +import { ParamsForm } from './paramsForm' +import { must } from '../test/events' + +const SPECS: ParamSpec[] = [ + { name: 'app_id', label: 'Game', type: 'app_id', required: true }, + { name: 'days', label: 'Days', type: 'int', required: false, min: 1, max: 9, help: 'How long.' }, + { name: 'note', label: 'Note', type: 'string', required: false }, +] +const GAMES = [{ app_id: 5, name: 'Five' }] + +function make(over: { specs?: ParamSpec[]; values?: ParamValues; disabled?: boolean } = {}) { + const onChange = vi.fn() + const form = new ParamsForm({ + specs: over.specs ?? SPECS, + values: over.values ?? { app_id: '', days: '', note: '' }, + disabled: over.disabled ?? false, + onChange, + }) + document.body.append(...form.nodes) + const input = (label: RegExp | string) => must(document.querySelector(`#${labelFor(label)}`)) + return { form, onChange, input } +} + +function labelFor(text: RegExp | string): string { + const label = [...document.querySelectorAll('label')].find((l) => (typeof text === 'string' ? l.textContent === text : text.test(l.textContent))) + return label?.getAttribute('for') ?? '' +} + +describe('ParamsForm', () => { + it('renders one labelled field per spec, marking the required ones', () => { + const { input } = make() + expect(input(/Game/)).toHaveAttribute('list') + expect(input(/Game/)).toHaveAttribute('inputmode', 'numeric') + expect(input(/Game/)).toHaveAttribute('autofocus') + expect(input(/Game/)).toBeRequired() + expect(document.body).toHaveTextContent('Game *') + expect(input('Days')).toHaveAttribute('type', 'number') + expect(input('Days')).toHaveAttribute('min', '1') + expect(input('Days')).toHaveAttribute('max', '9') + expect(input('Days')).not.toHaveAttribute('autofocus') + expect(input('Note')).toHaveAttribute('type', 'text') + expect(input('Note')).not.toHaveAttribute('inputmode') + expect(input('Note')).not.toHaveAttribute('list') + expect(document.body).toHaveTextContent('Type an app id or pick a game from the list.') + expect(document.body).toHaveTextContent('How long.') + }) + + it('offers the known games in a datalist and names the chosen one', async () => { + const { form, input } = make() + form.refresh(GAMES) + expect(document.querySelector('datalist option')).toHaveValue('5') + expect(document.querySelector('datalist option')).toHaveTextContent('Five') + await userEvent.type(input(/Game/), '5') + expect(document.querySelector('.field-help')).toHaveTextContent('Five') + form.refresh(GAMES) + expect(document.querySelectorAll('datalist option')).toHaveLength(1) + }) + + it('shows errors only once asked to', () => { + const { form, input } = make() + expect(document.body).not.toHaveTextContent('Game is required.') + form.refresh(GAMES, true) + expect(document.body).toHaveTextContent('Game is required.') + expect(input(/Game/)).toHaveClass('input-bad') + expect(input(/Game/)).toHaveAttribute('aria-invalid', 'true') + expect(document.querySelector('.field-help')).toHaveClass('text-danger') + form.refresh(GAMES, false) + expect(input(/Game/)).not.toHaveClass('input-bad') + expect(input(/Game/)).toHaveAttribute('aria-invalid', 'false') + }) + + it('keeps the error setting across a games-only refresh', () => { + const { form } = make() + form.refresh(GAMES, true) + form.refresh([{ app_id: 6, name: 'Six' }]) + expect(document.body).toHaveTextContent('Game is required.') + form.refresh() + expect(document.body).toHaveTextContent('Game is required.') + }) + + it('treats a value the form has not set yet as empty', () => { + const { input, form } = make({ values: {} }) + expect(input('Note')).toHaveValue('') + form.refresh([], true) + expect(document.body).toHaveTextContent('Game is required.') + }) + + it('reports every edit with the full value set', async () => { + const { input, onChange } = make() + await userEvent.type(input('Note'), 'hi') + expect(onChange).toHaveBeenLastCalledWith({ app_id: '', days: '', note: 'hi' }) + await userEvent.type(input('Days'), '3') + expect(onChange).toHaveBeenLastCalledWith({ app_id: '', days: '3', note: 'hi' }) + }) + + it('disables every field', () => { + const { input } = make({ disabled: true }) + expect(input('Note')).toBeDisabled() + expect(input(/Game/)).toBeDisabled() + }) + + it('renders a bool as a checkbox that toggles 0/1', async () => { + const box: ParamSpec[] = [{ name: 'replace', label: 'Replace', type: 'bool', required: false, default: 0 }] + const { input, onChange, form } = make({ specs: box, values: { replace: '0' } }) + form.refresh([], true) + const check = input('Replace') + expect(check).toHaveAttribute('type', 'checkbox') + expect(check).not.toBeChecked() + expect(check).not.toHaveAttribute('autofocus') + await userEvent.click(check) + expect(check).toBeChecked() + expect(onChange).toHaveBeenLastCalledWith({ replace: '1' }) + await userEvent.click(check) + expect(onChange).toHaveBeenLastCalledWith({ replace: '0' }) + }) + + it('starts a bool checked from "1" and shows its help', () => { + const box: ParamSpec[] = [{ name: 'on', label: 'On', type: 'bool', required: false, help: 'Be on.' }] + const { input } = make({ specs: box, values: { on: '1' } }) + expect(input('On')).toBeChecked() + expect(document.body).toHaveTextContent('Be on.') + }) + + it('leaves a bool without help blank, and a missing bool value unchecked', () => { + const box: ParamSpec[] = [{ name: 'on', label: 'On', type: 'bool', required: false }] + const { input } = make({ specs: box, values: {} }) + expect(input('On')).not.toBeChecked() + expect(document.querySelector('.field-help')?.textContent).toBe(' ') + }) +}) diff --git a/web/src/commands/paramsForm.ts b/web/src/commands/paramsForm.ts new file mode 100644 index 0000000..7acde9c --- /dev/null +++ b/web/src/commands/paramsForm.ts @@ -0,0 +1,121 @@ +// The runner's parameter fields (was ParamsForm.tsx). Inputs are built once +// per spec list; `refresh()` only updates validity and help text, so typing +// never re-creates an input. + +import type { ParamSpec } from '../api/contract' +import { h } from '../lib/dom' +import { uid } from '../lib/ids' +import { paramError, type GameOption, type ParamValues } from './params' + +interface Field { + wrap: HTMLDivElement + spec: ParamSpec + input: HTMLInputElement + help: HTMLParagraphElement + datalist: HTMLDataListElement | null +} + +export interface ParamsFormOptions { + specs: ParamSpec[] + values: ParamValues + disabled: boolean + onChange: (values: ParamValues) => void +} + +export class ParamsForm { + readonly nodes: HTMLDivElement[] + private readonly fields: Field[] + private values: ParamValues + private games: GameOption[] = [] + private showErrors = false + + constructor({ specs, values, disabled, onChange }: ParamsFormOptions) { + this.values = { ...values } + const base = uid('param') + this.fields = specs.map((spec, i) => this.field(spec, `${base}-${spec.name}`, i === 0, disabled, onChange)) + this.nodes = this.fields.map((f) => f.wrap) + this.refresh() + } + + /** New game list (app_id pickers) and/or error visibility. */ + refresh(games: GameOption[] = this.games, showErrors: boolean = this.showErrors): void { + this.games = games + this.showErrors = showErrors + for (const f of this.fields) this.refreshField(f) + } + + private field( + spec: ParamSpec, + id: string, + first: boolean, + disabled: boolean, + onChange: (values: ParamValues) => void, + ): Field { + const raw = this.values[spec.name] ?? '' + const help = h('p', { id: `${id}-help`, class: 'field-help' }) + const emit = (value: string) => { + this.values = { ...this.values, [spec.name]: value } + this.refresh() + onChange(this.values) + } + if (spec.type === 'bool') { + const input = h('input', { id, type: 'checkbox', disabled, 'aria-describedby': `${id}-help` }) + input.checked = raw === '1' + input.addEventListener('change', () => { + emit(input.checked ? '1' : '0') + }) + const wrap = h('div', { class: 'form-field' }, h('label', { class: 'check', for: id }, input, spec.label), help) + return { wrap, spec, input, help, datalist: null } + } + const numeric = spec.type !== 'string' + const datalist = spec.type === 'app_id' ? h('datalist', { id: `${id}-games` }) : null + const input = h('input', { + id, + class: numeric ? 'input input-num' : 'input', + type: spec.type === 'int' ? 'number' : 'text', + inputmode: numeric ? 'numeric' : null, + min: spec.min, + max: spec.max, + list: datalist ? `${id}-games` : null, + required: spec.required, + disabled, + autofocus: first, + 'aria-describedby': `${id}-help`, + }) + input.value = raw + input.addEventListener('input', () => { + emit(input.value) + }) + const wrap = h( + 'div', + { class: 'form-field' }, + h('label', { for: id, class: 'field-label' }, spec.label, spec.required ? h('span', { 'aria-hidden': 'true' }, ' *') : null), + input, + datalist, + help, + ) + return { wrap, spec, input, help, datalist } + } + + private refreshField({ spec, input, help, datalist }: Field): void { + if (spec.type === 'bool') { + help.textContent = spec.help ?? ' ' + return + } + const raw = this.values[spec.name] ?? '' + const err = paramError(spec, raw, this.games) + const bad = this.showErrors && err !== null + input.classList.toggle('input-bad', bad) + input.setAttribute('aria-invalid', String(bad)) + help.className = bad ? 'field-help text-danger' : 'field-help' + const game = spec.type === 'app_id' ? this.games.find((g) => g.app_id === Number(raw)) : undefined + help.textContent = bad + ? err + : game + ? game.name + : (spec.help ?? (spec.type === 'app_id' ? 'Type an app id or pick a game from the list.' : ' ')) + if (datalist && datalist.childElementCount !== this.games.length) { + datalist.replaceChildren(...this.games.map((g) => h('option', { value: g.app_id }, g.name))) + } + } +} diff --git a/web/src/commands/pendingCountdown.test.ts b/web/src/commands/pendingCountdown.test.ts new file mode 100644 index 0000000..8efe9cc --- /dev/null +++ b/web/src/commands/pendingCountdown.test.ts @@ -0,0 +1,213 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makeJob, makePending } from '../test/fixtures' +import { apiError, bodiesOf, flush, stubApi, type Handler } from '../test/harness' +import { button, must, setValue, submit } from '../test/events' +import { PendingCountdown } from './pendingCountdown' + +const T0 = Date.parse('2026-10-10T10:00:00Z') +const pending = (over = {}) => + makePending({ armed_at: '2026-10-10T10:00:00Z', ready_at: '2026-10-10T10:00:30Z', heartbeat_interval_seconds: 10, ...over }) + +const made: PendingCountdown[] = [] + +function make(over = {}) { + const cbs = { onCommitted: vi.fn(), onCancel: vi.fn(), onRearm: vi.fn() } + const countdown = new PendingCountdown(pending(over), cbs) + made.push(countdown) + document.body.append(countdown.el) + return { ...cbs, countdown, el: countdown.el } +} +const tick = (ms: number) => vi.advanceTimersByTimeAsync(ms) +const text = () => document.body.textContent +const phraseInput = () => must(document.querySelector('.phrase input')) + +describe('PendingCountdown', () => { + beforeEach(() => { + vi.useFakeTimers({ now: T0 }) + }) + afterEach(() => { + // The pagehide listener lives on window: release every instance. + for (const c of made.splice(0)) c.dispose() + }) + + it('counts down on the server clock, one second past ready_at', async () => { + stubApi({ 'POST /api/pending/p1/heartbeat': pending() }) + const { el } = make() + expect(text()).toContain('0:31') + expect(text()).toContain('until commit unlocks') + await tick(10_000) + expect(text()).toContain('0:21') + expect(el.querySelector('[role=progressbar]')).toHaveAttribute('aria-valuenow', '32') + expect(text()).toContain('Closing this dialog') + expect(button(el, 'Cancel countdown')).toHaveAttribute('autofocus') + }) + + it('keeps the commit locked until a second after the ready time', async () => { + stubApi({ 'POST /api/pending/p1/heartbeat': pending() }) + make() + await tick(30_000) + expect(text()).toContain('until commit unlocks') + await tick(1_000) + expect(text()).toContain('Ready to commit') + }) + + it('heartbeats at half the interval and takes the server ready time', async () => { + const mock = stubApi({ 'POST /api/pending/p1/heartbeat': () => pending({ ready_at: '2026-10-10T10:01:00Z' }) }) + make() + await tick(4_999) + expect(mock).not.toHaveBeenCalled() + await tick(1) + expect(mock).toHaveBeenCalledTimes(1) + await tick(250) + expect(text()).toMatch(/Heartbeat every 5 s · last 0 s ago/) + expect(text()).toContain('0:56') + }) + + it('never heartbeats faster than once a second', async () => { + const mock = stubApi({ 'POST /api/pending/p1/heartbeat': pending({ heartbeat_interval_seconds: 0.5 }) }) + make({ heartbeat_interval_seconds: 0.5 }) + await tick(999) + expect(mock).not.toHaveBeenCalled() + await tick(1) + expect(mock).toHaveBeenCalledTimes(1) + }) + + it('reports a failing heartbeat and recovers when it works again', async () => { + const routes: Record = { 'POST /api/pending/p1/heartbeat': apiError(503, 'daemon_unreachable') } + stubApi(routes) + make() + await tick(5_000) + expect(text()).toContain('heartbeat failing, retrying') + routes['POST /api/pending/p1/heartbeat'] = pending() + await tick(5_000) + expect(text()).not.toContain('heartbeat failing') + }) + + it('offers to arm again once the server says the countdown lapsed', async () => { + const mock = stubApi({ 'POST /api/pending/p1/heartbeat': apiError(410, 'pending_lapsed', 'missed a beat') }) + const { onRearm, el } = make() + await tick(5_000) + expect(text()).toContain('missed a beat') + expect(el.className).toBe('stack') + button(el, 'Arm again').click() + expect(onRearm).toHaveBeenCalled() + await tick(60_000) + expect(mock).toHaveBeenCalledTimes(1) + }) + + it('drops the action with a keepalive request when the page is hidden', () => { + const mock = stubApi({ 'DELETE /api/pending/p1': undefined }) + make() + window.dispatchEvent(new Event('pagehide')) + expect(mock).toHaveBeenCalledWith('/api/pending/p1', expect.objectContaining({ method: 'DELETE', keepalive: true })) + }) + + it('swallows a failed keepalive drop', async () => { + stubApi({ 'DELETE /api/pending/p1': apiError(500, 'op_failed') }) + make() + window.dispatchEvent(new Event('pagehide')) + await tick(0) + expect(text()).toContain('0:31') + }) + + it('stops heartbeating and listening once disposed', async () => { + const mock = stubApi({ 'POST /api/pending/p1/heartbeat': pending(), 'DELETE /api/pending/p1': undefined }) + const { countdown } = make() + countdown.dispose() + await tick(60_000) + window.dispatchEvent(new Event('pagehide')) + expect(mock).not.toHaveBeenCalled() + }) + + it('cancels, whatever the server answers', async () => { + stubApi({ 'DELETE /api/pending/p1': apiError(404, 'not_found') }) + const { onCancel, el } = make() + button(el, 'Cancel countdown').click() + await tick(0) + expect(onCancel).toHaveBeenCalled() + }) + + it('shows Cancelling… while the request runs', async () => { + stubApi({ 'DELETE /api/pending/p1': () => new Promise(() => undefined) }) + const { el, onCancel } = make() + button(el, 'Cancel countdown').click() + await flush() + expect(button(el, 'Cancelling…')).toBeInTheDocument() + expect(onCancel).not.toHaveBeenCalled() + }) + + describe('once ready', () => { + const ready = async () => { + const m = make() + await tick(31_000) + return m + } + + it('asks for the phrase again, and commits only when it matches', async () => { + const job = makeJob({ id: 'jc' }) + const mock = stubApi({ 'POST /api/pending/p1/heartbeat': pending(), 'POST /api/pending/p1/commit': job }) + const { onCommitted, el } = await ready() + expect(text()).toContain('Ready to commit') + expect(el.querySelector('.sr-only[aria-live]')).toHaveTextContent('Countdown finished') + const commit = button(el, 'Commit') + expect(commit).toBeDisabled() + submit(commit) + await flush() + expect(onCommitted).not.toHaveBeenCalled() + setValue(phraseInput(), 'reset gaming now') + expect(commit).toBeEnabled() + submit(commit) + await tick(0) + expect(onCommitted).toHaveBeenCalledWith(job) + expect(bodiesOf(mock, 'POST /api/pending/p1/commit')).toEqual([{ confirm_phrase: 'reset gaming now' }]) + }) + + it('keeps the same input while typing', async () => { + stubApi({ 'POST /api/pending/p1/heartbeat': pending() }) + await ready() + const input = phraseInput() + setValue(input, 'res') + await tick(250) + expect(phraseInput()).toBe(input) + expect(input).toHaveValue('res') + }) + + it('shows a refused commit and stays open', async () => { + stubApi({ + 'POST /api/pending/p1/heartbeat': pending(), + 'POST /api/pending/p1/commit': apiError(409, 'countdown_running', 'not yet'), + }) + const { el, onCommitted } = await ready() + setValue(phraseInput(), 'reset gaming now') + submit(button(el, 'Commit')) + await tick(0) + expect(text()).toContain('not yet') + expect(onCommitted).not.toHaveBeenCalled() + }) + + it('shows Committing… while it is in flight', async () => { + stubApi({ 'POST /api/pending/p1/heartbeat': pending(), 'POST /api/pending/p1/commit': () => new Promise(() => undefined) }) + const { el } = await ready() + setValue(phraseInput(), 'reset gaming now') + submit(button(el, 'Commit')) + await flush() + expect(button(el, 'Committing…')).toBeDisabled() + }) + + it('can still be cancelled', async () => { + stubApi({ 'POST /api/pending/p1/heartbeat': pending(), 'DELETE /api/pending/p1': undefined }) + const { el, onCancel } = await ready() + button(el, 'Cancel').click() + await tick(0) + expect(onCancel).toHaveBeenCalled() + }) + + it('can be cancelled in flight and keeps the phrase form', async () => { + stubApi({ 'POST /api/pending/p1/heartbeat': pending(), 'DELETE /api/pending/p1': () => new Promise(() => undefined) }) + const { el } = await ready() + button(el, 'Cancel').click() + await flush() + expect(phraseInput()).toBeInTheDocument() + }) + }) +}) diff --git a/web/src/commands/pendingCountdown.ts b/web/src/commands/pendingCountdown.ts new file mode 100644 index 0000000..552f6ab --- /dev/null +++ b/web/src/commands/pendingCountdown.ts @@ -0,0 +1,185 @@ +// Armed countdown (gaming-reset), was PendingCountdown.tsx: heartbeats at +// half the server's interval so one slow request never lapses it, a +// countdown driven by the server's `ready_at` (not a local timer), then the +// phrase is retyped to commit. Closing the page drops the action with a +// keepalive DELETE. + +import { api, ApiFailure } from '../api/client' +import type { Job, PendingAction } from '../api/jobContract' +import { progressBar } from '../jobs/progress' +import { Action } from '../lib/action' +import { h } from '../lib/dom' +import { errorNotice, notice } from '../ui/build' +import { clock } from '../ui/format' +import { phraseMatches } from './catalog' +import { PhraseField } from './phraseField' + +/** + * The daemon checks `ready_at` on its own clock; unlocking the commit a + * second late keeps a slightly fast browser clock from offering a commit the + * daemon refuses (seen in vmbox: refused 34 ms early, countdown_running). + */ +export const CLOCK_SLACK_MS = 1000 + +export interface CountdownCallbacks { + onCommitted: (job: Job) => void + onCancel: () => void + onRearm: () => void +} + +export class PendingCountdown { + readonly el = h('div', { class: 'stack countdown' }) + private pending: PendingAction + private lastBeat = Date.now() + private beatError: unknown = null + private phrase: PhraseField | null = null + private readonly commit: Action<[string], Job> + private readonly cancel: Action<[], void> + private readonly cb: CountdownCallbacks + private readonly timers: ReturnType[] = [] + private readonly drop = () => { + void api.dropPending(this.pending.id, true).catch(() => undefined) + } + private shownReady: boolean | null = null + private commitForm: { submit: HTMLButtonElement; errors: HTMLDivElement } | null = null + private readonly parts = { + num: h('span', { class: 'countdown-num' }), + caption: h('span', { class: 'muted' }), + bar: h('div', { class: 'contents' }), + beat: h('p', { class: 'field-help' }), + live: h('p', { class: 'sr-only', 'aria-live': 'polite' }), + body: h('div', { class: 'contents' }), + } + + constructor(pending: PendingAction, cb: CountdownCallbacks) { + this.pending = pending + this.cb = cb + this.commit = new Action((phrase: string) => api.commit(this.pending.id, phrase), () => { + this.renderBody(true) + }) + this.cancel = new Action(() => api.dropPending(this.pending.id), () => { + this.renderBody(this.shownReady === true) + }) + const everyMs = Math.max(1000, (pending.heartbeat_interval_seconds * 1000) / 2) + this.timers.push( + setInterval(() => { + api.heartbeat(pending.id).then( + (p) => { + this.pending = p + this.lastBeat = Date.now() + this.beatError = null + }, + (e: unknown) => { + this.beatError = e + this.tick() + }, + ) + }, everyMs), + setInterval(() => { + this.tick() + }, 250), + ) + window.addEventListener('pagehide', this.drop) + this.el.append( + h('div', { class: 'countdown-clock', 'aria-live': 'off' }, this.parts.num, this.parts.caption), + this.parts.bar, + this.parts.beat, + this.parts.live, + this.parts.body, + ) + this.tick() + } + + /** Stop heartbeats and timers (the dialog closed or moved on). */ + dispose(): void { + for (const t of this.timers) clearInterval(t) + window.removeEventListener('pagehide', this.drop) + } + + private tick(): void { + if (this.beatError instanceof ApiFailure && this.beatError.code === 'pending_lapsed') { + this.dispose() + this.el.className = 'stack' + this.el.replaceChildren( + errorNotice(this.beatError), + h('div', { class: 'actions' }, h('button', { type: 'button', class: 'btn btn-primary', autofocus: true, onclick: this.cb.onRearm }, 'Arm again')), + ) + this.el.querySelector('[autofocus]')?.focus() + return + } + const now = Date.now() + const armed = Date.parse(this.pending.armed_at) + const ready = Date.parse(this.pending.ready_at) + CLOCK_SLACK_MS + const left = Math.max(0, (ready - now) / 1000) + const isReady = left <= 0 + this.parts.num.textContent = clock(left) + this.parts.caption.textContent = isReady ? 'Ready to commit' : 'until commit unlocks' + this.parts.bar.replaceChildren(progressBar(Math.min(1, (now - armed) / (ready - armed)), 'Countdown', isReady ? 'success' : 'warning')) + this.parts.beat.replaceChildren( + `Keep this window open. Heartbeat every ${Math.round(this.pending.heartbeat_interval_seconds / 2)} s · last ${Math.round((now - this.lastBeat) / 1000)} s ago`, + this.beatError !== null ? h('span', { class: 'text-danger' }, ' · heartbeat failing, retrying') : '', + ) + this.parts.live.textContent = isReady ? 'Countdown finished. Retype the phrase to commit.' : '' + if (isReady !== this.shownReady) this.renderBody(isReady) + } + + private renderBody(isReady: boolean): void { + const first = this.shownReady !== isReady + this.shownReady = isReady + if (!isReady) { + this.parts.body.replaceChildren( + notice('warning', null, 'Closing this dialog, navigating away or closing the tab cancels the countdown.'), + h( + 'div', + { class: 'actions' }, + h('button', { type: 'button', class: 'btn btn-ghost', autofocus: true, onclick: () => void this.doCancel() }, this.cancel.pending ? 'Cancelling…' : 'Cancel countdown'), + ), + ) + return + } + if (first || !this.phrase) { + const submitBtn = h('button', { type: 'submit', class: 'btn btn-danger' }) + const errorSlot = h('div', { class: 'contents' }) + this.phrase = new PhraseField({ + phrase: this.pending.phrase, + autofocus: true, + label: 'Retype the phrase to commit', + onChange: () => { + this.renderBody(true) + }, + }) + const form = h( + 'form', + { class: 'stack' }, + this.phrase.el, + errorSlot, + h('div', { class: 'actions' }, submitBtn, h('button', { type: 'button', class: 'btn btn-ghost', onclick: () => void this.doCancel() }, 'Cancel')), + ) + form.addEventListener('submit', (e) => { + e.preventDefault() + if (this.phrase && phraseMatches(this.pending.phrase, this.phrase.value)) void this.doCommit(this.phrase.value) + }) + this.parts.body.replaceChildren(form) + this.commitForm = { submit: submitBtn, errors: errorSlot } + this.phrase.input.focus() + } + const { submit, errors } = this.commitForm as { submit: HTMLButtonElement; errors: HTMLDivElement } + submit.disabled = !phraseMatches(this.pending.phrase, this.phrase.value) || this.commit.pending + submit.textContent = this.commit.pending ? 'Committing…' : 'Commit' + errors.replaceChildren(...(this.commit.error ? [errorNotice(this.commit.error)] : [])) + } + + private async doCommit(phrase: string): Promise { + const out = await this.commit.run(phrase) + if (out.ok) { + this.dispose() + this.cb.onCommitted(out.value) + } + } + + private async doCancel(): Promise { + await this.cancel.run() + this.dispose() + this.cb.onCancel() + } +} diff --git a/web/src/commands/phraseField.test.ts b/web/src/commands/phraseField.test.ts new file mode 100644 index 0000000..7f0dd19 --- /dev/null +++ b/web/src/commands/phraseField.test.ts @@ -0,0 +1,65 @@ +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { PhraseField } from './phraseField' + +function make(over: { value?: string; label?: string; autofocus?: boolean } = {}) { + const onChange = vi.fn() + const field = new PhraseField({ phrase: 'reset it', onChange, ...over }) + document.body.append(field.el) + return { field, onChange, input: field.input, status: () => field.el.querySelector('[aria-live]') } +} + +describe('PhraseField', () => { + it('counts characters while the prefix is right', async () => { + const { input, status, onChange } = make() + expect(status()).toHaveTextContent('0 of 8 characters') + await userEvent.type(input, 'res') + expect(status()).toHaveTextContent('3 of 8 characters') + expect(input).toHaveAttribute('aria-invalid', 'false') + expect(input.className).toBe('input mono') + expect(onChange).toHaveBeenLastCalledWith('res') + }) + + it('marks a mismatch', async () => { + const { input, status } = make() + await userEvent.type(input, 'rx') + expect(status()).toHaveTextContent(/Does not match/) + expect(status()).toHaveClass('text-danger') + expect(input).toHaveClass('input-bad') + expect(input).toHaveAttribute('aria-invalid', 'true') + }) + + it('confirms the full phrase, ignoring surrounding whitespace', () => { + const { input, status, field } = make({ value: ' reset it ' }) + expect(status()).toHaveTextContent('Matches.') + expect(status()).toHaveClass('text-success') + expect(input).toHaveClass('input-ok') + expect(field.value).toBe(' reset it ') + }) + + it('colours each target character by whether it was typed right', () => { + const { field } = make({ value: 'rx' }) + const spans = [...field.el.querySelectorAll('.phrase-target span')] + expect(spans).toHaveLength(8) + expect(spans[0]).toHaveClass('ok') + expect(spans[1]).toHaveClass('bad') + expect(spans[2]).not.toHaveClass('ok') + expect(spans[2]).not.toHaveClass('bad') + }) + + it('blocks paste and drop', () => { + const { input } = make() + expect(input.dispatchEvent(new Event('paste', { cancelable: true }))).toBe(false) + expect(input.dispatchEvent(new Event('drop', { cancelable: true }))).toBe(false) + }) + + it('labels the input, shows the phrase to screen readers, and can ask for focus', () => { + const { field, input } = make({ autofocus: true }) + expect(field.el.querySelector('label')).toHaveTextContent('Type the phrase to confirm') + expect(field.el.querySelector('label')).toHaveAttribute('for', input.id) + expect(field.el.querySelector('.sr-only')).toHaveTextContent('Phrase: reset it') + expect(input).toHaveAttribute('autofocus') + expect(make({ label: 'Again' }).field.el.querySelector('label')).toHaveTextContent('Again') + expect(make().input).not.toHaveAttribute('autofocus') + }) +}) diff --git a/web/src/commands/phraseField.ts b/web/src/commands/phraseField.ts new file mode 100644 index 0000000..cc20697 --- /dev/null +++ b/web/src/commands/phraseField.ts @@ -0,0 +1,80 @@ +// Typed-phrase friction (was PhraseField.tsx). Shows the exact sentence, then +// live per-character feedback on the typed text. Paste is blocked: retyping +// is the friction. The server re-checks; this field only helps get it right. +// The input is built once, so typing never loses focus or caret position. + +import { h } from '../lib/dom' +import { uid } from '../lib/ids' +import { phraseMatches } from './catalog' + +export interface PhraseFieldOptions { + phrase: string + value?: string + label?: string + autofocus?: boolean + onChange: (value: string) => void +} + +export class PhraseField { + readonly el: HTMLDivElement + readonly input: HTMLInputElement + private readonly target: HTMLParagraphElement + private readonly status: HTMLParagraphElement + private readonly phrase: string + + constructor({ phrase, value = '', label = 'Type the phrase to confirm', autofocus = false, onChange }: PhraseFieldOptions) { + this.phrase = phrase + const id = uid('phrase') + this.target = h('p', { class: 'phrase-target', 'aria-hidden': 'true' }) + this.status = h('p', { id: `${id}-status`, 'aria-live': 'polite' }) + this.input = h('input', { + id, + value, + autocomplete: 'off', + spellcheck: 'false', + autofocus, + 'aria-describedby': `${id}-status`, + }) + this.input.value = value + const block = (e: Event) => { + e.preventDefault() + } + this.input.addEventListener('paste', block) + this.input.addEventListener('drop', block) + this.input.addEventListener('input', () => { + this.refresh() + onChange(this.input.value) + }) + this.el = h( + 'div', + { class: 'phrase' }, + h('label', { for: id, class: 'field-label' }, label), + this.target, + this.input, + h('p', { class: 'sr-only' }, `Phrase: ${phrase}`), + this.status, + ) + this.refresh() + } + + get value(): string { + return this.input.value + } + + private refresh(): void { + const typed = this.input.value.trim() + const ok = phraseMatches(this.phrase, this.input.value) + const prefixOk = this.phrase.startsWith(typed) + this.target.replaceChildren( + ...Array.from(this.phrase).map((ch, i) => h('span', { class: i < typed.length ? (typed[i] === ch ? 'ok' : 'bad') : null }, ch)), + ) + this.input.className = `input mono ${ok ? 'input-ok' : prefixOk ? '' : 'input-bad'}`.trim() + this.input.setAttribute('aria-invalid', String(!prefixOk)) + this.status.className = `field-help ${ok ? 'text-success' : prefixOk ? '' : 'text-danger'}`.trim() + this.status.textContent = ok + ? 'Matches.' + : prefixOk + ? `${typed.length} of ${this.phrase.length} characters` + : 'Does not match — check case and spacing.' + } +} diff --git a/web/src/commands/runnerContext.ts b/web/src/commands/runnerContext.ts deleted file mode 100644 index 61d4370..0000000 --- a/web/src/commands/runnerContext.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { createContext, useContext } from 'react' -import type { CommandName } from '../api/contract' - -export interface RunnerApi { - /** Open the generic runner for a job command, optionally pre-filled. */ - run: (name: CommandName, preset?: Record) => void - openPalette: () => void -} - -export const RunnerContext = createContext({ run: () => {}, openPalette: () => {} }) - -export function useRunner(): RunnerApi { - return useContext(RunnerContext) -} diff --git a/web/src/commands/runnerDialog.countdown.test.ts b/web/src/commands/runnerDialog.countdown.test.ts new file mode 100644 index 0000000..56b0f0d --- /dev/null +++ b/web/src/commands/runnerDialog.countdown.test.ts @@ -0,0 +1,112 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makeJob, makePending, makeSpec } from '../test/fixtures' +import { button, setValue, submit } from '../test/events' +import { apiError, bodiesOf, flush } from '../test/harness' +import { found, openRunner, phraseInput, text } from '../test/runnerHelpers' + +const SPEC = makeSpec('gaming-reset', { + privileged: true, + friction: { phrase_template: 'reset gaming now', countdown_seconds: 300 }, +}) +const READY = makePending({ ready_at: '2026-10-10T09:00:00Z', armed_at: '2026-10-10T08:55:00Z' }) +const FAR = makePending({ ready_at: '2099-01-01T00:00:00Z', armed_at: '2026-10-10T08:55:00Z' }) +const DELETE = 'DELETE /api/pending/p1' + +function open(extra: Record) { + return openRunner([SPEC], 'gaming-reset', { extra }) +} +const arm = async () => { + await vi.waitFor(() => { expect(phraseInput()).toBeInTheDocument() }) + setValue(phraseInput(), 'reset gaming now') + submit(await found('Arm countdown')) +} +const waitDeletes = (mock: ReturnType['mock'], n: number) => + vi.waitFor(() => { expect(bodiesOf(mock, DELETE)).toHaveLength(n) }) + +describe(' countdown commands', () => { + it('sends the phrase to arm, then commits with a retyped phrase and follows the job', async () => { + const { mock, el } = open({ 'POST /api/jobs': READY, 'POST /api/pending/p1/commit': makeJob({ id: 'j1', command: 'gaming-reset' }) }) + await arm() + await found('Commit') + expect(bodiesOf(mock, 'POST /api/jobs')).toEqual([{ command: 'gaming-reset', params: {}, confirm_phrase: 'reset gaming now' }]) + setValue(phraseInput(), 'reset gaming now') + submit(button(document.body, 'Commit')) + await found('Close (job keeps running)') + expect(bodiesOf(mock, 'POST /api/pending/p1/commit')).toEqual([{ confirm_phrase: 'reset gaming now' }]) + expect(el.querySelector('job-progress')).toHaveAttribute('job-id', 'j1') + }) + + it('cancelling the countdown closes the dialog and the server drops it', async () => { + const { mock, el } = open({ 'POST /api/jobs': FAR, [DELETE]: undefined }) + await arm() + ;(await found('Cancel countdown')).click() + await waitDeletes(mock, 1) + await vi.waitFor(() => { expect(el.querySelector('dialog')).not.toHaveAttribute('open') }) + expect(mock).toHaveBeenCalledWith('/api/pending/p1', expect.objectContaining({ method: 'DELETE' })) + }) + + it('closing the dialog while armed drops the countdown', async () => { + const { mock, el } = open({ 'POST /api/jobs': FAR, [DELETE]: undefined }) + await arm() + await found('Cancel countdown') + el.querySelector('.dialog-close')?.click() + await waitDeletes(mock, 1) + expect(el.querySelector('dialog')).not.toHaveAttribute('open') + }) + + it('closing while armed swallows a refused drop', async () => { + const { mock, el } = open({ 'POST /api/jobs': FAR, [DELETE]: apiError(404, 'not_found') }) + await arm() + await found('Cancel countdown') + el.querySelector('.dialog-close')?.click() + await waitDeletes(mock, 1) + await flush() + expect(el.querySelector('dialog')).not.toHaveAttribute('open') + }) + + it('leaving the page while armed drops it with a keepalive request, even if refused', async () => { + const { mock } = open({ 'POST /api/jobs': FAR, [DELETE]: apiError(404, 'not_found') }) + await arm() + await found('Cancel countdown') + window.dispatchEvent(new Event('pagehide')) + // Both the dialog and the countdown listen: one drop each. + await waitDeletes(mock, 2) + expect(mock).toHaveBeenCalledWith('/api/pending/p1', expect.objectContaining({ method: 'DELETE', keepalive: true })) + await flush() + }) + + it('after a commit, closing no longer drops anything', async () => { + const { mock, el } = open({ 'POST /api/jobs': READY, 'POST /api/pending/p1/commit': makeJob({ id: 'j1' }) }) + await arm() + await found('Commit') + setValue(phraseInput(), 'reset gaming now') + submit(button(document.body, 'Commit')) + ;(await found('Close (job keeps running)')).click() + expect(el.querySelector('dialog')).not.toHaveAttribute('open') + window.dispatchEvent(new Event('pagehide')) + expect(bodiesOf(mock, DELETE)).toEqual([]) + }) +}) + +describe(' lapsed countdown', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('arming again after a lapse returns to an empty form', async () => { + const lapsing = makePending({ ...FAR, heartbeat_interval_seconds: 0.001 }) + open({ 'POST /api/jobs': lapsing, 'POST /api/pending/p1/heartbeat': apiError(410, 'pending_lapsed', 'missed') }) + await vi.advanceTimersByTimeAsync(50) + await arm() + await vi.advanceTimersByTimeAsync(50) + await vi.advanceTimersByTimeAsync(1000) + button(document.body, 'Arm again').click() + await vi.advanceTimersByTimeAsync(50) + expect(phraseInput()).toHaveValue('') + expect(button(document.body, 'Arm countdown')).toBeDisabled() + expect(text()).not.toContain('Arm again') + }) +}) diff --git a/web/src/commands/runnerDialog.failures.test.ts b/web/src/commands/runnerDialog.failures.test.ts new file mode 100644 index 0000000..cc592eb --- /dev/null +++ b/web/src/commands/runnerDialog.failures.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from 'vitest' +import { q } from '../api/queries' +import { makeSpec } from '../test/fixtures' +import { submit } from '../test/events' +import { apiError, Reply } from '../test/harness' +import { field, found, openRunner, text } from '../test/runnerHelpers' + +const waitText = (s: string | RegExp) => vi.waitFor(() => { + expect(text()).toMatch(s) +}) + +describe(' locked', () => { + it('is read-only when locked, and explains', async () => { + const spec = makeSpec('pick', { + locked_reason: 'Manual pick active', + friction: { phrase_template: 'x' }, + params: [{ name: 'n', label: 'N', type: 'int', required: false }], + }) + openRunner([spec], 'pick') + const run = await found('Run pick') + expect(text()).toContain('Manual pick active') + expect(field('N')).toBeDisabled() + expect(document.querySelector('.phrase')).toBeNull() + expect(run).toBeDisabled() + submit(run) + expect(text()).not.toContain('Starting…') + }) +}) + +describe(' start failures', () => { + it('shows the server error', async () => { + openRunner([makeSpec('scan')], 'scan', { extra: { 'POST /api/jobs': apiError(409, 'busy', 'job 4 running') } }) + submit(await found('Run scan')) + await waitText('job 4 running') + expect(text()).not.toContain('terminal instead') + }) + + it('offers the sudo fallback when the daemon is down', async () => { + openRunner([makeSpec('gaming-unblock')], 'gaming-unblock', { extra: { 'POST /api/jobs': apiError(502, 'daemon_unreachable') } }) + submit(await found('Run gaming-unblock')) + await waitText('Run it from a terminal instead:') + expect(text()).toContain('sudo ./run.sh gaming-unblock') + }) + + it('has no fallback for a command without one', async () => { + openRunner([makeSpec('scan')], 'scan', { extra: { 'POST /api/jobs': apiError(502, 'daemon_unreachable') } }) + submit(await found('Run scan')) + await waitText('Root daemon unreachable') + expect(text()).not.toContain('terminal instead') + }) + + it('copes with a garbled reply', async () => { + openRunner([makeSpec('scan')], 'scan', { extra: { 'POST /api/jobs': new Reply(200, undefined, '{garbled') } }) + submit(await found('Run scan')) + await waitText('Unexpected server error') + }) + + it('shows Starting… while waiting', async () => { + openRunner([makeSpec('scan')], 'scan', { extra: { 'POST /api/jobs': () => new Promise(() => undefined) } }) + submit(await found('Run scan')) + expect(await found('Starting…')).toBeDisabled() + }) +}) + +describe(' lifecycle', () => { + it('close() on a dialog that is not open does nothing', () => { + const { el } = openRunner([makeSpec('scan')], 'scan') + const dialog = el as unknown as { close: () => void } + dialog.close() + expect(() => { + dialog.close() + }).not.toThrow() + }) + + it('ignores a catalog answer that arrives after another listener closed it', async () => { + // Query.emit walks a snapshot, so a listener removed mid-emit still runs once. + let el: HTMLElement | null = null + const off = q.commands.subscribe(() => { + ;(el as unknown as { close: () => void } | null)?.close() + }) + el = openRunner([makeSpec('scan')], 'scan').el + await vi.waitFor(() => { expect(q.commands.state.loading).toBe(false) }) + expect(el.querySelector('form')).toBeNull() + expect(el.querySelector('dialog')).not.toHaveAttribute('open') + off() + }) + + it('closes itself and stops listening when removed', async () => { + const { el } = openRunner([makeSpec('scan')], 'scan') + await found('Run scan') + el.remove() + expect(el.querySelector('dialog')).not.toHaveAttribute('open') + }) + + it('Escape and the close button both close it', async () => { + const { el } = openRunner([makeSpec('scan')], 'scan') + await found('Run scan') + el.querySelector('dialog')?.dispatchEvent(new Event('cancel', { cancelable: true })) + expect(el.querySelector('dialog')).not.toHaveAttribute('open') + }) + + it('does nothing on pagehide when no countdown is armed', async () => { + const { mock } = openRunner([makeSpec('scan')], 'scan') + await found('Run scan') + const calls = mock.mock.calls.length + window.dispatchEvent(new Event('pagehide')) + expect(mock.mock.calls).toHaveLength(calls) + }) +}) diff --git a/web/src/commands/runnerDialog.test.ts b/web/src/commands/runnerDialog.test.ts new file mode 100644 index 0000000..1242af6 --- /dev/null +++ b/web/src/commands/runnerDialog.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it, vi } from 'vitest' +import { q } from '../api/queries' +import { makeJob, makeSpec } from '../test/fixtures' +import { button, setValue, submit } from '../test/events' +import { apiError, bodiesOf, stubApi } from '../test/harness' +import { field, found, openRunner, phraseInput, text } from '../test/runnerHelpers' +import { runner } from '../lib/runner' +import { RunnerDialogElement } from './runnerDialog' + +const waitText = (s: string | RegExp) => vi.waitFor(() => { + expect(text()).toMatch(s) +}) + +describe(' catalog states', () => { + it('shows loading, then the load error', async () => { + let fail: (r: unknown) => void = () => undefined + stubApi({ 'GET /api/commands': () => new Promise((r) => { fail = r }) }) + document.body.append(document.createElement('runner-dialog')) + runner.run('scan') + expect(text()).toContain('Loading commands') + fail(apiError(500, 'op_failed', 'catalog broken')) + await waitText('catalog broken') + }) + + it('says so when the server does not offer the command', async () => { + openRunner([makeSpec('done')], 'scan') + await waitText('Not in the server catalog') + expect(text()).toContain('does not offer "scan"') + }) + + it('titles the dialog with the command label', async () => { + const { el } = openRunner([makeSpec('pick-manual')], 'pick-manual') + await found('Run pick-manual') + expect(el.querySelector('dialog')).toHaveAttribute('aria-label', 'Pick manual') + expect(el.querySelector('dialog')).toHaveAttribute('open') + }) +}) + +describe(' form', () => { + it('describes the command with its tags', async () => { + openRunner( + [makeSpec('enforce', { description: 'Enforce.', privileged: true, mutating: false, cancellable: true, friction: { phrase_template: 'x', countdown_seconds: 300 } })], + 'enforce', + ) + await found('Arm countdown') + for (const t of ['Enforce.', 'Runs as root via the daemon', 'Read-only', 'Cancellable', '5 min countdown']) expect(text()).toContain(t) + }) + + it('tags a state-changing command, with no countdown for an immediate one', async () => { + openRunner([makeSpec('scan', { friction: { phrase_template: 'x' } })], 'scan') + await waitText('Changes state') + expect(text()).not.toMatch(/countdown/) + expect(text()).not.toContain('Cancellable') + }) + + it('runs the enforce preset it was opened for, with no mode field to change it', async () => { + const demo = { name: 'demo', label: 'Demo mode', type: 'int' as const, required: false, default: 0, min: 0, max: 1 } + const spec = makeSpec('enforce', { kind: 'screen', params: [demo], privileged: true, cancellable: true }) + const { mock } = openRunner([spec], 'enforce', { preset: { demo: 1 }, extra: { 'POST /api/jobs': makeJob({ id: 'j1' }) } }) + const run = await found('Run enforce') + expect(text()).toContain('Cancellable') + expect(text()).not.toContain('Demo mode') + expect(text()).not.toContain('Runs as root via the daemon') + submit(run) + await vi.waitFor(() => { expect(bodiesOf(mock, 'POST /api/jobs')).toHaveLength(1) }) + expect(bodiesOf(mock, 'POST /api/jobs')).toEqual([{ command: 'enforce', params: { demo: 1 } }]) + }) + + it('runs a parameterless command and follows its job', async () => { + const { mock, el } = openRunner([makeSpec('scan')], 'scan', { extra: { 'POST /api/jobs': makeJob({ id: 'j1' }) } }) + const run = await found('Run scan') + expect(run).toHaveAttribute('autofocus') + expect(run.className).toBe('btn btn-primary') + submit(run) + await found('Close (job keeps running)') + expect(bodiesOf(mock, 'POST /api/jobs')).toEqual([{ command: 'scan', params: {} }]) + expect(el.querySelector('job-progress')).toHaveAttribute('job-id', 'j1') + expect(el.querySelector('a')).toHaveAttribute('href', '/jobs/?id=j1') + }) + + it('"Close" leaves the job running, and Cancel closes the form', async () => { + const { el } = openRunner([makeSpec('scan')], 'scan', { extra: { 'POST /api/jobs': makeJob({ id: 'j1' }) } }) + submit(await found('Run scan')) + ;(await found('Close (job keeps running)')).click() + expect(el.querySelector('dialog')).not.toHaveAttribute('open') + runner.run('scan') + ;(await found('Cancel')).click() + expect(el.querySelector('dialog')).not.toHaveAttribute('open') + }) + + it('ignores a catalog refresh under an open form', async () => { + const { el } = openRunner([makeSpec('scan')], 'scan') + await found('Run scan') + const form = el.querySelector('form') + q.commands.invalidate() + await vi.waitFor(() => { expect(q.commands.state.loading).toBe(false) }) + expect(el.querySelector('form')).toBe(form) + }) + + it('re-opening replaces the previous request', async () => { + const { el } = openRunner([makeSpec('scan'), makeSpec('done')], 'scan') + await found('Run scan') + runner.run('done') + await found('Run done') + expect(el.querySelector('dialog')).toHaveAttribute('aria-label', 'Done') + expect((el as RunnerDialogElement).querySelectorAll('form')).toHaveLength(1) + }) +}) + +describe(' parameters', () => { + const spec = makeSpec('abandon-pick', { params: [{ name: 'app_id', label: 'Game', type: 'app_id', required: true }] }) + + it('blocks an invalid submit and shows why', async () => { + const { mock } = openRunner([spec], 'abandon-pick') + const run = await found('Run abandon-pick') + expect(run).toBeDisabled() + submit(run) + await waitText('Game is required.') + expect(bodiesOf(mock, 'POST /api/jobs')).toEqual([]) + }) + + it('starts from a preset, names the game, and sends typed values', async () => { + const { mock } = openRunner([spec], 'abandon-pick', { preset: { app_id: 7 }, extra: { 'POST /api/jobs': makeJob({ id: 'j1' }) } }) + const run = await found('Run abandon-pick') + expect(field(/Game/)).toHaveValue('7') + await vi.waitFor(() => { expect(document.querySelector('.field-help')).toHaveTextContent('Seven') }) + expect(run).toBeEnabled() + submit(run) + await vi.waitFor(() => { expect(bodiesOf(mock, 'POST /api/jobs')).toHaveLength(1) }) + expect(bodiesOf(mock, 'POST /api/jobs')).toEqual([{ command: 'abandon-pick', params: { app_id: 7 } }]) + }) + + it('does not refresh the game names once the form is gone', async () => { + openRunner([spec], 'abandon-pick', { preset: { app_id: 7 }, extra: { 'POST /api/jobs': makeJob({ id: 'j1' }) } }) + submit(await found('Run abandon-pick')) + await found('Close (job keeps running)') + q.dataset.invalidate() + await vi.waitFor(() => { expect(q.dataset.state.loading).toBe(false) }) + expect(text()).toContain('Open in Jobs') + }) +}) + +describe(' friction', () => { + const spec = makeSpec('uninstall', { + params: [{ name: 'app_id', label: 'Game', type: 'app_id', required: true }], + friction: { phrase_template: 'uninstall {game_name} and {count} others' }, + }) + + it('shows the exact phrase once its fields are known, and sends it', async () => { + const { mock } = openRunner([spec], 'uninstall', { extra: { 'POST /api/jobs': makeJob({ id: 'j1' }) } }) + const run = await found('Run uninstall') + expect(text()).toMatch(/Fill in the fields above to see the exact phrase \(game_name\)/) + setValue(field(/Game/), '7') + await vi.waitFor(() => { expect(phraseInput()).toBeInTheDocument() }) + expect(run).toBeDisabled() + expect(run).toHaveClass('btn-danger') + setValue(phraseInput(), 'uninstall Seven and 1 others') + expect(run).toBeEnabled() + submit(run) + await vi.waitFor(() => { expect(bodiesOf(mock, 'POST /api/jobs')).toHaveLength(1) }) + expect(bodiesOf(mock, 'POST /api/jobs')).toEqual([ + { command: 'uninstall', params: { app_id: 7 }, confirm_phrase: 'uninstall Seven and 1 others' }, + ]) + }) + + it('keeps the typed phrase while unrelated fields refresh, and resets it when the sentence changes', async () => { + openRunner([spec], 'uninstall', { preset: { app_id: 7 } }) + await vi.waitFor(() => { expect(phraseInput()).toBeInTheDocument() }) + const input = phraseInput() + setValue(input, 'uninst') + q.status.invalidate() + await vi.waitFor(() => { expect(q.status.state.loading).toBe(false) }) + expect(phraseInput()).toBe(input) + expect(input).toHaveValue('uninst') + setValue(field(/Game/), '8') + await vi.waitFor(() => { expect(phraseInput()).toHaveValue('') }) + }) + + it('focuses the phrase straight away when there are no fields', async () => { + openRunner([makeSpec('reset', { friction: { phrase_template: 'reset everything' } })], 'reset') + await vi.waitFor(() => { expect(phraseInput()).toHaveAttribute('autofocus') }) + }) + + it('does not submit a wrong phrase on Enter', async () => { + const { mock } = openRunner([makeSpec('reset', { friction: { phrase_template: 'reset everything' } })], 'reset') + await vi.waitFor(() => { expect(phraseInput()).toBeInTheDocument() }) + setValue(phraseInput(), 'nope') + submit(phraseInput()) + expect(bodiesOf(mock, 'POST /api/jobs')).toEqual([]) + expect(button(document.body, 'Run reset')).toBeDisabled() + }) +}) diff --git a/web/src/commands/runnerDialog.ts b/web/src/commands/runnerDialog.ts new file mode 100644 index 0000000..4d146ed --- /dev/null +++ b/web/src/commands/runnerDialog.ts @@ -0,0 +1,127 @@ +// ``: the one generic job runner (was RunnerDialog.tsx + +// RunnerProvider.tsx). Opens on runner.run(); phases form → (countdown) → +// live job. An armed countdown dies with the dialog: close, Escape and +// leaving the page all drop it server-side. + +import { api } from '../api/client' +import { q } from '../api/queries' +import { define, h, hx } from '../lib/dom' +import { runner, jobHref, type RunRequest } from '../lib/runner' +import { errorNotice, loading, notice } from '../ui/build' +import { Modal } from '../ui/dialog' +import { commandLabel, findCommand } from './catalog' +import type { PendingCountdown } from './pendingCountdown' +import { Runner, type NextPhase } from './runnerForm' + +type Phase = { k: 'loading' } | { k: 'form'; runner: Runner } | { k: 'pending'; countdown: PendingCountdown } | { k: 'job'; id: string } + +export class RunnerDialogElement extends HTMLElement { + private modal = new Modal({ title: '', wide: true, onClose: () => { this.close() } }) + private request: RunRequest | null = null + private phase: Phase = { k: 'loading' } + private armed: string | null = null + private unsubs: (() => void)[] = [] + private readonly onPageHide = () => { + if (this.armed) void api.dropPending(this.armed, true).catch(() => undefined) + } + + connectedCallback(): void { + this.append(this.modal.el) + this.unsubs.push(runner.onRun((req) => { + this.open(req) + })) + window.addEventListener('pagehide', this.onPageHide) + } + + disconnectedCallback(): void { + this.close() + for (const u of this.unsubs.splice(0)) u() + window.removeEventListener('pagehide', this.onPageHide) + } + + open(req: RunRequest): void { + this.close() + this.request = req + this.modal.setTitle(commandLabel(req.name)) + // The catalog is polled; the form is built once from the first answer. + this.unsubs.push(q.commands.subscribe(() => { + this.render() + })) + for (const query of [q.dataset, q.installed, q.status]) { + this.unsubs.push(query.subscribe(() => { + if (this.phase.k === 'form') this.phase.runner.refreshGames() + })) + } + this.phase = { k: 'loading' } + this.render() + this.modal.show() + } + + close(): void { + if (!this.request) return + if (this.armed) void api.dropPending(this.armed).catch(() => undefined) + this.armed = null + if (this.phase.k === 'pending') this.phase.countdown.dispose() + this.phase = { k: 'loading' } + this.request = null + // Keep the runner.onRun subscription (index 0); drop the per-open ones. + for (const u of this.unsubs.splice(1)) u() + this.modal.render() + this.modal.hide() + } + + private render(): void { + const req = this.request + if (!req) return + if (this.phase.k !== 'loading') return + const { data: commands, error } = q.commands.state + const spec = findCommand(commands, req.name) + if (spec) { + const run = new Runner(spec, req.preset, { + onClose: () => { + this.close() + }, + onArmed: (id) => { + this.armed = id + }, + onPhase: (next) => { + this.setPhase(next) + }, + }) + this.phase = { k: 'form', runner: run } + this.modal.render(run.el) + this.modal.focusFirst() + return + } + if (error) this.modal.render(errorNotice(error)) + else if (commands) this.modal.render(notice('danger', 'Not in the server catalog', `The server does not offer "${req.name}".`)) + else this.modal.render(loading('commands')) + } + + private setPhase(next: NextPhase): void { + this.phase = next + if (next.k === 'pending') { + this.modal.render(next.countdown.el) + } else if (next.k === 'job') { + this.modal.render( + h( + 'div', + { class: 'stack' }, + hx('job-progress', { 'job-id': next.id }), + h( + 'div', + { class: 'actions' }, + h('button', { type: 'button', class: 'btn btn-ghost', onclick: () => { this.close() } }, 'Close (job keeps running)'), + h('a', { href: jobHref(next.id), class: 'btn btn-ghost' }, 'Open in Jobs'), + ), + ), + ) + } else { + this.modal.render(next.runner.el) + } + this.modal.focusFirst() + } +} + + +define('runner-dialog', RunnerDialogElement) diff --git a/web/src/commands/runnerForm.ts b/web/src/commands/runnerForm.ts new file mode 100644 index 0000000..3600e1b --- /dev/null +++ b/web/src/commands/runnerForm.ts @@ -0,0 +1,213 @@ +// The runner dialog's form phase (was the Runner half of RunnerDialog.tsx): +// params, the typed phrase, submit, then hand the next phase (countdown or +// live job) back to through RunnerCallbacks. + +import { ApiFailure, startJob } from '../api/client' +import type { CommandName, CommandSpec } from '../api/contract' +import { q } from '../api/queries' +import { Action } from '../lib/action' +import { h } from '../lib/dom' +import type { Preset } from '../lib/runner' +import { commandLine, errorNotice, notice, pill } from '../ui/build' +import { fillPhrase, phraseMatches } from './catalog' +import { enforcePreset } from './enforcePreset' +import { gameLookup } from './gameLookup' +import { initialValues, paramError, toParams, type ParamValues } from './params' +import { ParamsForm } from './paramsForm' +import { PendingCountdown } from './pendingCountdown' +import { PhraseField } from './phraseField' + +/** Recovery commands the UI offers when the root daemon is down. */ +const FALLBACK: Partial> = { + 'gaming-unblock': 'sudo ./run.sh gaming-unblock', +} + + +export type NextPhase = { k: 'pending'; countdown: PendingCountdown } | { k: 'job'; id: string } | { k: 'form'; runner: Runner } + +export interface RunnerCallbacks { + onClose: () => void + onArmed: (pendingId: string | null) => void + onPhase: (next: NextPhase) => void +} + +/** The form phase: params, typed phrase, submit. */ +export class Runner { + readonly el: HTMLFormElement + private readonly spec: CommandSpec + private readonly fixed: Preset + private readonly preset: Preset | undefined + private readonly cb: RunnerCallbacks + private values: ParamValues + private typed = '' + private showErrors = false + private readonly form: ParamsForm + private readonly start: Action<[Parameters[0]], Awaited>> + private readonly phraseSlot = h('div', { class: 'contents' }) + private readonly errorSlot = h('div', { class: 'contents' }) + private readonly submitBtn: HTMLButtonElement + private phraseKey: string | null = null + + constructor(fullSpec: CommandSpec, preset: Preset | undefined, cb: RunnerCallbacks) { + const { spec, fixed } = enforcePreset(fullSpec, preset) + this.spec = spec + this.fixed = fixed + this.preset = preset + this.cb = cb + this.values = initialValues(spec.params, preset) + const locked = spec.locked_reason !== null + this.form = new ParamsForm({ + specs: spec.params, + values: this.values, + disabled: locked, + onChange: (v) => { + this.values = v + this.refresh() + }, + }) + this.start = new Action(startJob, () => { + this.refresh() + }) + const countdown = spec.friction?.countdown_seconds + this.submitBtn = h('button', { + type: 'submit', + class: 'btn btn-primary', + autofocus: spec.params.every((p) => p.type === 'bool') && !spec.friction, + }) + this.el = h( + 'form', + { class: 'stack', novalidate: true }, + h('p', { class: 'm0' }, spec.description), + h( + 'div', + { class: 'tags' }, + spec.privileged ? pill('neutral', 'Runs as root via the daemon') : null, + pill('neutral', spec.mutating ? 'Changes state' : 'Read-only'), + spec.cancellable ? pill('neutral', 'Cancellable') : null, + countdown ? pill('warning', `${Math.round(countdown / 60)} min countdown`) : null, + ), + spec.locked_reason !== null ? notice('warning', 'Locked', spec.locked_reason) : null, + this.form.nodes, + this.phraseSlot, + this.errorSlot, + h( + 'div', + { class: 'actions' }, + this.submitBtn, + h('button', { type: 'button', class: 'btn btn-ghost', onclick: () => { cb.onClose() } }, 'Cancel'), + ), + ) + this.el.addEventListener('submit', (e) => { + e.preventDefault() + void this.submit() + }) + this.refreshGames() + } + + refreshGames(): void { + this.form.refresh(this.lookup().games, this.showErrors) + this.refresh() + } + + private lookup() { + return gameLookup(q.dataset.state.data, q.installed.state.data, q.status.state.data) + } + + private params(): Preset { + return { ...this.fixed, ...toParams(this.spec.params, this.values) } + } + + private phrase() { + return this.spec.friction ? fillPhrase(this.spec.friction.phrase_template, this.params(), this.lookup().ctx) : null + } + + private canSubmit(): boolean { + const games = this.lookup().games + const errors = this.spec.params.map((p) => paramError(p, this.values[p.name], games)).filter(Boolean) + const phrase = this.phrase() + return ( + this.spec.locked_reason === null && + errors.length === 0 && + (!phrase || (phrase.missing.length === 0 && phraseMatches(phrase.text, this.typed))) + ) + } + + private refresh(): void { + const phrase = this.phrase() + const locked = this.spec.locked_reason !== null + // The phrase field is rebuilt only when its sentence changes. + const key = phrase ? `${phrase.missing.join(',')}|${phrase.text}` : null + if (key !== this.phraseKey) { + this.phraseKey = key + if (phrase && phrase.missing.length > 0) { + this.phraseSlot.replaceChildren(notice('info', null, `Fill in the fields above to see the exact phrase (${phrase.missing.join(', ')}).`)) + } else if (phrase && !locked) { + this.typed = '' + const field = new PhraseField({ + phrase: phrase.text, + autofocus: this.spec.params.length === 0, + onChange: (v) => { + this.typed = v + this.refresh() + }, + }) + this.phraseSlot.replaceChildren(field.el) + } else { + this.phraseSlot.replaceChildren() + } + } + const err = this.start.error + const failure = err instanceof ApiFailure ? err : null + const fallback = FALLBACK[this.spec.name] + this.errorSlot.replaceChildren( + ...(err + ? [ + errorNotice( + err, + failure?.code === 'daemon_unreachable' && fallback + ? [h('p', { class: 'm0' }, 'Run it from a terminal instead:'), commandLine(fallback)] + : null, + ), + ] + : []), + ) + const countdown = this.spec.friction?.countdown_seconds + this.submitBtn.className = phrase ? 'btn btn-danger' : 'btn btn-primary' + this.submitBtn.disabled = !this.canSubmit() || this.start.pending + this.submitBtn.textContent = this.start.pending ? 'Starting…' : countdown ? 'Arm countdown' : `Run ${this.spec.name}` + } + + private async submit(): Promise { + this.showErrors = true + this.form.refresh(this.lookup().games, true) + if (!this.canSubmit()) return + const phrase = this.phrase() + // Every friction command sends its phrase here; a countdown one + // (gaming-reset) needs it to arm and is asked again at commit. + const out = await this.start.run({ command: this.spec.name, params: this.params(), ...(phrase ? { confirm_phrase: this.typed } : {}) }) + if (!out.ok) return + q.jobs.invalidate() + const started = out.value + if (started.kind === 'job') { + this.cb.onArmed(null) + this.cb.onPhase({ k: 'job', id: started.job.id }) + return + } + this.cb.onArmed(started.pending.id) + const countdown: PendingCountdown = new PendingCountdown(started.pending, { + onCommitted: (job) => { + this.cb.onArmed(null) + this.cb.onPhase({ k: 'job', id: job.id }) + }, + onCancel: () => { + this.cb.onArmed(null) + this.cb.onClose() + }, + onRearm: () => { + this.cb.onArmed(null) + this.cb.onPhase({ k: 'form', runner: new Runner(this.spec, this.preset, this.cb) }) + }, + }) + this.cb.onPhase({ k: 'pending', countdown }) + } +} diff --git a/web/src/commands/useGameLookup.test.tsx b/web/src/commands/useGameLookup.test.tsx deleted file mode 100644 index 3fc05a8..0000000 --- a/web/src/commands/useGameLookup.test.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { QueryClientProvider } from '@tanstack/react-query' -import { renderHook, waitFor } from '@testing-library/react' -import type { ReactNode } from 'react' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { queryClient } from '../api/queries' -import { makeDataset, makeGame } from '../test/factories' -import { makeInstalled, makeStatus } from '../test/fixtures' -import { stubApi } from '../test/harness' -import { useGameLookup } from './useGameLookup' - -const wrapper = ({ children }: { children: ReactNode }) => ( - {children} -) - -describe('useGameLookup', () => { - afterEach(() => vi.unstubAllGlobals()) - - it('is empty until the sources load', () => { - stubApi({ 'GET /api/dataset': makeDataset([]), 'GET /api/installed': { games: [] }, 'GET /api/status': makeStatus({ current_app_id: null }) }) - const { result } = renderHook(() => useGameLookup(), { wrapper }) - expect(result.current.games).toEqual([]) - expect(result.current.ctx.gameName(1)).toBeNull() - expect(result.current.ctx.installed).toBeNull() - }) - - it('merges backlog, installed, manual picks and the assignment, sorted by name', async () => { - stubApi({ - 'GET /api/dataset': makeDataset([makeGame({ app_id: 1, name: 'Zeta' }), makeGame({ app_id: 2, name: 'Old name' })]), - 'GET /api/installed': { games: [makeInstalled({ app_id: 2, name: 'Beta' })] }, - 'GET /api/status': makeStatus({ - current_app_id: 4, - current_game_name: 'Delta', - manual_picks: [{ app_id: 3, name: 'Gamma', age_days: 1 }], - }), - }) - const { result } = renderHook(() => useGameLookup(), { wrapper }) - await waitFor(() => expect(result.current.games).toHaveLength(4)) - expect(result.current.games.map((g) => g.name)).toEqual(['Beta', 'Delta', 'Gamma', 'Zeta']) - expect(result.current.ctx.gameName(2)).toBe('Beta') - expect(result.current.ctx.installed).toHaveLength(1) - }) - - it('skips an unassigned status', async () => { - stubApi({ - 'GET /api/dataset': makeDataset([makeGame({ app_id: 1, name: 'Zeta' })]), - 'GET /api/installed': { games: [] }, - 'GET /api/status': makeStatus({ current_app_id: null, current_game_name: null }), - }) - const { result } = renderHook(() => useGameLookup(), { wrapper }) - await waitFor(() => expect(result.current.games).toHaveLength(1)) - }) -}) diff --git a/web/src/commands/useGameLookup.ts b/web/src/commands/useGameLookup.ts deleted file mode 100644 index b9bc6ca..0000000 --- a/web/src/commands/useGameLookup.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { q } from '../api/queries' -import type { PhraseContext } from './catalog' -import type { GameOption } from './params' - -/** - * Every game the UI knows a name for (backlog snapshot, installed list, - * current assignment, manual picks), for app_id pickers and for filling - * `{game_name}` / `{count}` in phrase previews. - */ -export function useGameLookup(): { games: GameOption[]; ctx: PhraseContext } { - const dataset = useQuery(q.dataset).data - const installed = useQuery(q.installed).data - const status = useQuery(q.status).data - - const byId = new Map() - for (const g of dataset?.games ?? []) byId.set(g.app_id, g.name) - for (const g of installed?.games ?? []) byId.set(g.app_id, g.name) - for (const p of status?.manual_picks ?? []) byId.set(p.app_id, p.name) - if (status?.current_app_id && status.current_game_name) byId.set(status.current_app_id, status.current_game_name) - - const games = [...byId].map(([app_id, name]) => ({ app_id, name })).sort((a, b) => a.name.localeCompare(b.name)) - return { - games, - ctx: { gameName: (id) => byId.get(id) ?? null, installed: installed?.games ?? null }, - } -} diff --git a/web/src/components/Actions.astro b/web/src/components/Actions.astro new file mode 100644 index 0000000..aa06c18 --- /dev/null +++ b/web/src/components/Actions.astro @@ -0,0 +1,12 @@ +--- +// A titled grid of s (was ui/Page.tsx Actions). +interface Props { + title?: string +} +const { title = 'Actions' } = Astro.props +--- + +
+

{title}

+
+
diff --git a/web/src/components/BudgetHistoryChart.test.tsx b/web/src/components/BudgetHistoryChart.test.tsx deleted file mode 100644 index 0784747..0000000 --- a/web/src/components/BudgetHistoryChart.test.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { render, screen } from '@testing-library/react' -import { describe, expect, it } from 'vitest' -import type { BudgetHistoryDay, BudgetLegendEntry } from '../types' -import { BudgetHistoryChart } from './BudgetHistoryChart' - -const LEGEND: BudgetLegendEntry[] = [ - { key: 'app:475150', label: 'Titan Quest' }, - { key: 'proc:osu-lazer', label: 'osu!lazer' }, - { key: 'unattributed', label: 'Unattributed' }, -] - -function day(over: Partial = {}): BudgetHistoryDay { - return { - day: '2026-08-28', - seconds: 14400, - segments: [{ key: 'app:475150', seconds: 14400 }], - ...over, - } -} - -describe('BudgetHistoryChart', () => { - it('explains itself when nothing has been recorded', () => { - render() - expect(screen.getByText(/No budget history recorded yet/)).toBeInTheDocument() - expect(document.querySelector('svg')).toBeNull() - }) - - it('draws a single day without filling the plot', () => { - render() - const bars = document.querySelectorAll('rect.seg') - expect(bars).toHaveLength(1) - // Scaled against the budget, half a budget is half the plot height. - expect(Number(bars[0].getAttribute('height'))).toBeLessThan( - Number(document.querySelector('svg')?.getAttribute('viewBox')?.split(' ')[3]) / 2, - ) - expect(screen.getByText('08-28')).toBeInTheDocument() - }) - - it('stacks one band per game and colours it by legend position', () => { - render( - , - ) - expect(document.querySelectorAll('rect.seg')).toHaveLength(2) - expect(document.querySelector('rect.cat-1')).not.toBeNull() - expect(document.querySelector('rect.cat-2')).not.toBeNull() - // Bands abut: the second starts exactly where the first ends. - const [first, second] = document.querySelectorAll('rect.seg') - const firstTop = Number(first.getAttribute('y')) - const secondBottom = - Number(second.getAttribute('y')) + Number(second.getAttribute('height')) - expect(secondBottom).toBeCloseTo(firstTop, 5) - }) - - it('renders an unattributed day in its own neutral band', () => { - render( - , - ) - expect(document.querySelectorAll('rect.seg.unattributed')).toHaveLength(1) - // No ramp hue: an absence of information must not read as another game. - expect(document.querySelector('rect.cat-1')).toBeNull() - }) - - it('labels each band with its game in the tooltip and the legend', () => { - render() - expect(document.querySelector('rect.seg title')?.textContent).toContain('Titan Quest') - expect(document.querySelectorAll('.chart-legend li')).toHaveLength(LEGEND.length) - }) - - it('falls back to the raw key when the legend omits it', () => { - render( - , - ) - expect(document.querySelector('rect.seg title')?.textContent).toContain('app:999') - }) - - it('survives a zero budget and a day that billed nothing', () => { - render( - , - ) - // A day with no billed time draws no bands, but the axes still render. - expect(document.querySelectorAll('rect.seg')).toHaveLength(0) - expect(document.querySelector('svg')).not.toBeNull() - }) -}) diff --git a/web/src/components/BudgetHistoryChart.tsx b/web/src/components/BudgetHistoryChart.tsx deleted file mode 100644 index e8cbd5f..0000000 --- a/web/src/components/BudgetHistoryChart.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { seriesClass } from '../budgetSeries' -import { fmtDuration } from '../format' -import type { BudgetHistoryDay, BudgetLegendEntry } from '../types' - -interface Props { - days: BudgetHistoryDay[] - legend: BudgetLegendEntry[] - budgetSeconds: number -} - -const W = 820 -const H = 220 -const PAD = { top: 16, right: 20, bottom: 36, left: 56 } - -export function BudgetHistoryChart({ days, legend, budgetSeconds }: Props) { - if (days.length === 0) { - return ( -
-

Last 14 days

-

- No budget history recorded yet — a bar appears for each gaming day from now on. -

-
- ) - } - - // Scaled against the budget rather than the tallest bar, so a single day - // reads as "most of the allowance" instead of a lone full-height bar that - // means nothing on its own. - const maxSeconds = - Math.max(budgetSeconds, ...days.map((d) => d.seconds)) || 1 - const plotW = W - PAD.left - PAD.right - const plotH = H - PAD.top - PAD.bottom - const sy = (seconds: number) => PAD.top + plotH - (seconds / maxSeconds) * plotH - const slot = plotW / days.length - const barW = slot * 0.7 - - const yTicks = [0, 0.5, 1].map((f) => f * maxSeconds) - const labelFor = new Map(legend.map((e) => [e.key, e.label])) - const indexFor = new Map(legend.map((e, i) => [e.key, i])) - - return ( -
-

Last 14 days

- - {yTicks.map((s) => ( - - - - {fmtDuration(s)} - - - ))} - - {days.map((d, i) => { - // Bands stack upwards from the axis in legend order, so a game keeps - // the same colour and the same relative position in every bar. - let base = 0 - return d.segments.map((seg) => { - const top = base + seg.seconds - const y = sy(top) - const height = sy(base) - y - base = top - return ( - - - {`${d.day} · ${labelFor.get(seg.key) ?? seg.key}: ${fmtDuration(seg.seconds)}`} - - - ) - }) - })} - {days.map((d, i) => ( - - {d.day.slice(5)} - - ))} - -
    - {legend.map((entry, i) => ( -
  • -
  • - ))} -
-

- Billed gaming time per day, split by game. The dashed line is the daily budget; - today's bar can still move. -

-
- ) -} diff --git a/web/src/components/BudgetLiveCard.test.tsx b/web/src/components/BudgetLiveCard.test.tsx deleted file mode 100644 index a7d278f..0000000 --- a/web/src/components/BudgetLiveCard.test.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { render, screen } from '@testing-library/react' -import { describe, expect, it } from 'vitest' -import { makeBudgetSession } from '../test/factories' -import { BudgetLiveCard } from './BudgetLiveCard' - -describe('BudgetLiveCard', () => { - it('reports an engaged session with its game', () => { - render() - expect(screen.getByText(/Billing — you are playing/)).toBeInTheDocument() - expect(screen.getByText(/Assigned game/)).toBeInTheDocument() - // Engaged, so the credited game is described in the present tense. - expect(screen.getByText(/Billing to/)).toBeInTheDocument() - expect(screen.getAllByText('Hollow Knight')).toHaveLength(2) - expect(screen.getByText(/3 qualifying processes/)).toBeInTheDocument() - }) - - it('falls back to the raw verdict when unrecognised', () => { - render() - expect(screen.getByText('weird')).toBeInTheDocument() - }) - - it('omits the game line when nothing qualifies', () => { - render( - , - ) - expect(screen.getByText(/no game running/)).toBeInTheDocument() - expect(screen.queryByText(/qualifying processes/)).toBeNull() - }) - - it('handles a qualifying session with no assigned game name', () => { - render( - , - ) - expect(screen.getByText(/3 qualifying processes/)).toBeInTheDocument() - expect(screen.queryByText('Hollow Knight')).toBeNull() - }) - - it('says when no verdict has been logged', () => { - render() - expect(screen.getByText(/No verdict has been logged yet/)).toBeInTheDocument() - }) -}) diff --git a/web/src/components/BudgetLiveCard.tsx b/web/src/components/BudgetLiveCard.tsx deleted file mode 100644 index e326a04..0000000 --- a/web/src/components/BudgetLiveCard.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { fmtAgo } from '../format' -import type { BudgetSession } from '../types' - -interface Props { - session: BudgetSession -} - -const VERDICT_LABELS: Record = { - engaged: 'Billing — you are playing', - not_applicable: 'Not billing — no game running', -} - -export function BudgetLiveCard({ session }: Props) { - if (!session.available) { - return ( -
-

Right now

-

No verdict has been logged yet.

-
- ) - } - - return ( -
-

Right now

-

- - {VERDICT_LABELS[session.state] ?? session.state} - -

- {session.qualifying_count > 0 && ( -

- {session.game_name ? ( - <> - Assigned game {session.game_name} ·{' '} - - ) : null} - {session.qualifying_count} qualifying processes -

- )} - {session.billing_label && ( -

- {/* The assignment above is what the enforcer told you to play; this - is what the budget charged. A counted non-Steam game is never the - assignment, so the two legitimately differ. The key is the *last* - one credited, so it must not claim to be live once nothing - qualifies any more. */} - {session.state === 'engaged' ? 'Billing to ' : 'Last billed to '} - {session.billing_label} -

- )} -

- {/* The daemon logs on change or a 5-minute heartbeat, so this reading - can legitimately be minutes old. Saying so beats implying "now". */} - as of {fmtAgo(session.observed_at)} -

-
- ) -} diff --git a/web/src/components/BudgetPanel.test.tsx b/web/src/components/BudgetPanel.test.tsx deleted file mode 100644 index 9c05001..0000000 --- a/web/src/components/BudgetPanel.test.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { render, screen, waitFor } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { makeBudget } from '../test/factories' -import { BudgetPanel } from './BudgetPanel' - -function stubBudget(snapshot: unknown) { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => snapshot })) -} - -describe('BudgetPanel', () => { - beforeEach(() => { - vi.restoreAllMocks() - }) - afterEach(() => { - vi.unstubAllGlobals() - }) - - it('shows a loading line before the first response', () => { - stubBudget(makeBudget()) - render() - expect(screen.getByText(/Reading budget state/)).toBeInTheDocument() - }) - - it('renders all four sections once loaded', async () => { - stubBudget(makeBudget()) - render() - await waitFor(() => - expect(screen.getByRole('heading', { name: 'Today' })).toBeInTheDocument(), - ) - expect(screen.getByRole('heading', { name: 'Right now' })).toBeInTheDocument() - expect(screen.getByRole('heading', { name: 'Last 14 days' })).toBeInTheDocument() - expect(screen.getByRole('heading', { name: 'Rules in effect' })).toBeInTheDocument() - }) - - it('surfaces a transport failure', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ ok: false, status: 500, statusText: 'Server Error' }), - ) - render() - await waitFor(() => - expect(screen.getByText(/Could not read budget state/)).toBeInTheDocument(), - ) - }) - - it('names the permission problem and still shows the rest', async () => { - stubBudget( - makeBudget({ - readable: false, - state_status: 'denied', - error: 'Cannot read the budget state file.', - today: null, - }), - ) - render() - await waitFor(() => - expect(screen.getByText(/Cannot read the budget state file/)).toBeInTheDocument(), - ) - // The log, history and config are all still readable — the panel must not - // go blank just because one file is locked down. - expect(screen.getByRole('heading', { name: 'Right now' })).toBeInTheDocument() - expect(screen.getByRole('heading', { name: 'Rules in effect' })).toBeInTheDocument() - }) - - it('falls back to a generic message when the backend sends none', async () => { - stubBudget(makeBudget({ readable: false, state_status: 'denied', error: null, today: null })) - render() - await waitFor(() => - expect(screen.getByText(/Budget state unavailable/)).toBeInTheDocument(), - ) - }) -}) diff --git a/web/src/components/BudgetPanel.tsx b/web/src/components/BudgetPanel.tsx deleted file mode 100644 index 4554fda..0000000 --- a/web/src/components/BudgetPanel.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { useBudgetPoll } from '../useBudgetPoll' -import { BudgetHistoryChart } from './BudgetHistoryChart' -import { BudgetLiveCard } from './BudgetLiveCard' -import { BudgetRulesCard } from './BudgetRulesCard' -import { BudgetTodayCard } from './BudgetTodayCard' - -interface Props { - demo: boolean -} - -export function BudgetPanel({ demo }: Props) { - const { data, error } = useBudgetPoll(demo) - - if (error !== null) { - return ( -
-

Could not read budget state: {error}

-

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

-
- ) - } - - if (data === null) { - return ( -
-

Reading budget state…

-
- ) - } - - return ( -
- {/* A state file we cannot read must say so and still show everything we - can read — the log, the history and the config are all reachable. */} - {data.today === null ? ( -
-

Today

-

{data.error ?? 'Budget state unavailable.'}

-
- ) : ( - - )} - - - -
- ) -} diff --git a/web/src/components/BudgetRulesCard.test.tsx b/web/src/components/BudgetRulesCard.test.tsx deleted file mode 100644 index 28528e7..0000000 --- a/web/src/components/BudgetRulesCard.test.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { render, screen } from '@testing-library/react' -import { describe, expect, it } from 'vitest' -import { makeBudgetRules } from '../test/factories' -import { BudgetRulesCard } from './BudgetRulesCard' - -describe('BudgetRulesCard', () => { - it('lists the production policy', () => { - render() - expect(screen.getByText('8h 0m')).toBeInTheDocument() - expect(screen.getByText('on')).toBeInTheDocument() - expect(screen.getByText('yes')).toBeInTheDocument() - expect(screen.getByText(/1h 0m, 30m, 10m, 5m remaining/)).toBeInTheDocument() - expect(screen.queryByText(/demo/)).toBeNull() - expect(screen.queryByText('Masked now')).toBeNull() - }) - - it('shows the demo mode and masked launchers when they apply', () => { - render( - , - ) - expect(screen.getByText(/demo \(short budget/)).toBeInTheDocument() - expect(screen.getByText('/usr/bin/steam')).toBeInTheDocument() - expect(screen.getByText('no')).toBeInTheDocument() - }) -}) diff --git a/web/src/components/BudgetRulesCard.tsx b/web/src/components/BudgetRulesCard.tsx deleted file mode 100644 index a4787fe..0000000 --- a/web/src/components/BudgetRulesCard.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { fmtDuration } from '../format' -import type { BudgetRules } from '../types' - -interface Props { - rules: BudgetRules -} - -function onOff(value: boolean): string { - return value ? 'on' : 'off' -} - -export function BudgetRulesCard({ rules }: Props) { - return ( -
-

Rules in effect

-
-
Daily budget
-
{fmtDuration(rules.budget_seconds)}
-
Enforcement
-
{onOff(rules.enforcement)}
-
Counts launchers
-
{rules.counts_launchers ? 'yes' : 'no'}
-
Warnings at
-
{rules.warn_at.map((s) => fmtDuration(s)).join(', ')} remaining
- {rules.demo && ( - <> -
Mode
-
demo (short budget, separate state)
- - )} - {rules.masked_launchers.length > 0 && ( - <> -
Masked now
-
{rules.masked_launchers.join(', ')}
- - )} -
-
- ) -} diff --git a/web/src/components/BudgetTodayCard.test.tsx b/web/src/components/BudgetTodayCard.test.tsx deleted file mode 100644 index 8ac067c..0000000 --- a/web/src/components/BudgetTodayCard.test.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { render, screen } from '@testing-library/react' -import { describe, expect, it } from 'vitest' -import { makeBudgetToday } from '../test/factories' -import { BudgetTodayCard } from './BudgetTodayCard' - -describe('BudgetTodayCard', () => { - it('shows remaining time, usage and the next warning', () => { - render() - expect(screen.getByText('7h 0m')).toBeInTheDocument() - expect(screen.getByText(/1h 0m of 8h 0m used/)).toBeInTheDocument() - expect(screen.getByText(/Next warning at 1h 0m remaining/)).toBeInTheDocument() - expect(document.querySelector('.budget-meter--ok')).not.toBeNull() - }) - - it('turns the meter amber once a warning has fired', () => { - render( - , - ) - expect(document.querySelector('.budget-meter--warn')).not.toBeNull() - }) - - it('says so when no warnings remain', () => { - render( - , - ) - expect(screen.getByText(/No further warnings today/)).toBeInTheDocument() - expect(document.querySelector('.budget-meter--warn')).not.toBeNull() - }) - - const blockedToday = () => - makeBudgetToday({ - blocked: true, - blocked_at: 1, - seconds_used: 28800, - seconds_remaining: 0, - fraction_used: 1, - next_warning_seconds: null, - }) - - it('shows the cutoff badge when blocked', () => { - render() - expect(screen.getByText(/Cutoff engaged — launchers are masked/)).toBeInTheDocument() - expect(document.querySelector('.budget-meter--blocked')).not.toBeNull() - }) - - it('does not claim a mask that is not in place', () => { - render() - expect( - screen.getByText(/Cutoff engaged — launchers are not currently masked/), - ).toBeInTheDocument() - }) -}) diff --git a/web/src/components/BudgetTodayCard.tsx b/web/src/components/BudgetTodayCard.tsx deleted file mode 100644 index f79ec3a..0000000 --- a/web/src/components/BudgetTodayCard.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { seriesClass } from '../budgetSeries' -import { fmtDuration } from '../format' -import type { BudgetLegendEntry, BudgetToday } from '../types' - -interface Props { - today: BudgetToday - legend: BudgetLegendEntry[] - maskedCount: number -} - -/** Which colour the meter takes: normal, into the warning band, or spent. - * - * Keyed on warnings already fired rather than on `next_warning_seconds`: that - * field is by construction always *below* the remaining time, so comparing the - * two could never turn the meter amber. "The enforcer has warned you" is also - * the thing the colour is actually trying to say. - */ -function meterState(today: BudgetToday): string { - if (today.blocked) return 'blocked' - return today.warned_seconds.length > 0 ? 'warn' : 'ok' -} - -export function BudgetTodayCard({ today, legend, maskedCount }: Props) { - // Colour by the chart's legend position so a game reads the same in both - // places; today can hold games the capped 14-day legend left out, and those - // fall back to their own row order. - const indexFor = new Map(legend.map((e, i) => [e.key, i])) - - return ( -
-

Today

-

- {fmtDuration(today.seconds_remaining)} left -

-
-
-
-

- {fmtDuration(today.seconds_used)} of {fmtDuration(today.budget_seconds)} used - {' · '} - {Math.round(today.fraction_used * 100)}%{' · '} - gaming day {today.gaming_day} (starts {today.day_starts_at}) -

-

- {today.blocked ? ( - - {/* Read the live mount count rather than inferring it from - `blocked`: the total-block branch releases every mount while - leaving `blocked_at` set, so the two can genuinely disagree — - and a badge claiming a mask that is not there is the one thing - this panel must never do. */} - {maskedCount > 0 - ? 'Cutoff engaged — launchers are masked' - : 'Cutoff engaged — launchers are not currently masked'} - - ) : today.next_warning_seconds === null ? ( - 'No further warnings today.' - ) : ( - `Next warning at ${fmtDuration(today.next_warning_seconds)} remaining.` - )} -

- {today.games.length > 0 && ( - <> -

Today by game

-
    - {today.games.map((game, i) => ( -
  • -
  • - ))} -
- - )} -
- ) -} diff --git a/web/src/components/Card.astro b/web/src/components/Card.astro new file mode 100644 index 0000000..b8c26b5 --- /dev/null +++ b/web/src/components/Card.astro @@ -0,0 +1,21 @@ +--- +// A titled box (was ui/Page.tsx Card); `aside` slot sits beside the title. +interface Props { + title?: string + class?: string +} +const { title, class: extra = '' } = Astro.props +const hasHead = Boolean(title) || Astro.slots.has('aside') +--- + +
+ { + hasHead && ( +
+ {title &&

{title}

} + +
+ ) + } + +
diff --git a/web/src/components/DevToken.astro b/web/src/components/DevToken.astro new file mode 100644 index 0000000..77ba3a9 --- /dev/null +++ b/web/src/components/DevToken.astro @@ -0,0 +1,25 @@ +--- +// Dev only: the token meta the Python server injects into every built page. +// `pnpm dev:mock` sets SBE_DEV_TOKEN; `pnpm dev` reads the running server's +// token file on each request, so a restarted server's token arrives on reload. +// In `astro build` this renders nothing: the server injects the real one. +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { TOKEN_META } from '../api/contract' + +function devToken(): string | null { + if (!import.meta.env.DEV) return null + if (process.env.SBE_DEV_TOKEN) return process.env.SBE_DEV_TOKEN + const port = process.env.SBE_PORT ?? '8000' + const runtime = process.env.XDG_RUNTIME_DIR ?? `/run/user/${String(process.getuid?.() ?? 0)}` + const file = join(runtime, 'steam-backlog-enforcer', `web-${port}.token`) + try { + return readFileSync(file, 'utf8').trim() + } catch { + console.warn(`[sbe] no token at ${file}: is ./run.sh serve running on port ${port}? Mutating requests will fail.`) + return null + } +} +const token = devToken() +--- +{token && } diff --git a/web/src/components/FilterPanel.test.tsx b/web/src/components/FilterPanel.test.tsx deleted file mode 100644 index 19100b6..0000000 --- a/web/src/components/FilterPanel.test.tsx +++ /dev/null @@ -1,122 +0,0 @@ -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 deleted file mode 100644 index db71748..0000000 --- a/web/src/components/FilterPanel.tsx +++ /dev/null @@ -1,241 +0,0 @@ -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 deleted file mode 100644 index cc4b1b9..0000000 --- a/web/src/components/GameTable.test.tsx +++ /dev/null @@ -1,170 +0,0 @@ -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 deleted file mode 100644 index 0a375a6..0000000 --- a/web/src/components/GameTable.tsx +++ /dev/null @@ -1,159 +0,0 @@ -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/PageHead.astro b/web/src/components/PageHead.astro new file mode 100644 index 0000000..ac424f3 --- /dev/null +++ b/web/src/components/PageHead.astro @@ -0,0 +1,19 @@ +--- +// Page title, optional subtitle (text prop or `sub` slot) and action buttons +// (default slot). Was ui/Page.tsx PageHead. +interface Props { + title: string + sub?: string +} +const { title, sub } = Astro.props +const hasActions = Astro.slots.has('default') +--- + +
+
+

{title}

+ {sub &&

{sub}

} + {Astro.slots.has('sub') &&

} +
+ {hasActions &&
} +
diff --git a/web/src/components/PlannerView.test.tsx b/web/src/components/PlannerView.test.tsx deleted file mode 100644 index 0a5ef1e..0000000 --- a/web/src/components/PlannerView.test.tsx +++ /dev/null @@ -1,83 +0,0 @@ -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 { makeDataset, makeGame, makeState } from '../test/factories' -import { PlannerView } from './PlannerView' - -function stubFetch(impl: ReturnType) { - vi.stubGlobal('fetch', impl) -} - -describe('PlannerView', () => { - beforeEach(() => { - vi.restoreAllMocks() - }) - afterEach(() => { - vi.unstubAllGlobals() - }) - - it('renders the planner after data loads', async () => { - const ds = makeDataset([makeGame({ app_id: 1, name: 'Alpha' })]) - stubFetch(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 () => { - stubFetch(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 () => { - stubFetch(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, - }), - }, - ) - stubFetch(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/components/PlannerView.tsx b/web/src/components/PlannerView.tsx deleted file mode 100644 index 535484f..0000000 --- a/web/src/components/PlannerView.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { useEffect, useMemo, useState } from 'react' -import { fetchDataset } from '../api' -import { applyFilters } from '../estimate' -import type { Filters, WebDataset, WebDefaults } from '../types' -import { FilterPanel } from './FilterPanel' -import { GameTable } from './GameTable' -import { SummaryCards } from './SummaryCards' -import { TimelineChart } from './TimelineChart' - -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: '', - } -} - -/** The backlog-completion planner: the app's original single screen. */ -export function PlannerView() { - 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 ( -
-

Could not load data: {error}

-

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

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

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_since_start} games finished since{' '} - {dataset.state.enforcement_started_at.slice(0, 10) || '—'} ·{' '} - {dataset.games.length} candidates -

-
-
- -
- setFilters(defaultFilters(dataset.defaults))} - /> - -
- - - update({ search: s })} - onToggleExclude={toggleExclude} - /> -
-
- - ) -} diff --git a/web/src/components/SummaryCards.test.tsx b/web/src/components/SummaryCards.test.tsx deleted file mode 100644 index c3822c1..0000000 --- a/web/src/components/SummaryCards.test.tsx +++ /dev/null @@ -1,92 +0,0 @@ -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, makePaceVsHltb, makeState } from '../test/factories' -import { SummaryCards } from './SummaryCards' - -function renderCards(filtersOver: Partial = {}, statePace = 0, paceVsHltb = null) { - const filters = makeFilters(filtersOver) - const dataset = makeDataset([makeGame({ app_id: 1 })], { pace_vs_hltb: paceVsHltb }) - const result = applyFilters(dataset, 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) - }) -}) - -describe('PlayerSpeedInsight', () => { - it('shows empty state message when paceVsHltb is null', () => { - renderCards({}, 0, null) - expect(screen.getByText(/Your Play Style vs HLTB/i)).toBeInTheDocument() - expect(screen.getByText(/No calibration data yet/i)).toBeInTheDocument() - }) - - it('shows calibration stats when paceVsHltb is provided', () => { - renderCards({}, 0, makePaceVsHltb()) - expect(screen.getByText(/Calibration games/i)).toBeInTheDocument() - expect(screen.getByText(/vs Rush speed/i)).toBeInTheDocument() - expect(screen.getByText('Play style')).toBeInTheDocument() - expect(screen.getByText(/Between rush and leisure/i)).toBeInTheDocument() - }) - - it('shows estimated total when calibration data is present', () => { - // rushTotal = 10, leisureTotal = 20, t = 0.05 → 10 + 0.05 * 10 = 10.5 h - renderCards({}, 0, makePaceVsHltb({ interpolation_t: 0.05 })) - expect(screen.getByText(/Estimated total at your pace/i)).toBeInTheDocument() - }) - - it('hides leisure ratio when ratio_vs_leisure is -1', () => { - renderCards({}, 0, makePaceVsHltb({ ratio_vs_leisure: -1, interpolation_t: -1 })) - expect(screen.queryByText(/vs Leisure speed/i)).not.toBeInTheDocument() - }) - - it('marks the rush ratio as fast when at or below 1', () => { - renderCards({}, 0, makePaceVsHltb({ ratio_vs_rush: 0.9 })) - expect(screen.getByText('0.90×')).toHaveClass('player-insight-fast') - }) - - it('falls back to the raw player_style key when it has no display label', () => { - renderCards({}, 0, makePaceVsHltb({ player_style: 'totally_unknown' })) - expect(screen.getByText('totally_unknown')).toBeInTheDocument() - }) - - it('shows N/A presets when the estimated pace total is not positive', () => { - renderCards({ excluded: new Set([1]) }, 0, makePaceVsHltb()) - expect(screen.getByText(/Estimated total at your pace/i)).toBeInTheDocument() - expect(screen.getAllByText('N/A').length).toBeGreaterThan(0) - }) -}) diff --git a/web/src/components/SummaryCards.tsx b/web/src/components/SummaryCards.tsx deleted file mode 100644 index 7c3819c..0000000 --- a/web/src/components/SummaryCards.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import { basisTotal, etaDays, paceDays, playerEstimatedTotal } from '../estimate' -import type { EstimateResult } from '../estimate' -import { fmtEta, fmtHours } from '../format' -import type { EstimateBasis, Filters, PaceVsHLTB, WebStateInfo } from '../types' - -interface Props { - result: EstimateResult - filters: Filters - state: WebStateInfo - presets: number[] - defaultQualifying: number - paceVsHltb: PaceVsHLTB | null -} - -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' }, -] - -const STYLE_LABELS: Record = { - faster_than_rush: 'Faster than rush', - rush_to_leisure: 'Between rush and leisure', - slower_than_leisure: 'Slower than leisure', - unknown: 'Unknown', -} - -function TargetBanner({ result, filters }: Props) { - if (!filters.targetDate) return null - const now = new Date() - const target = new Date(filters.targetDate) - const days = Math.max(1, Math.ceil((target.getTime() - now.getTime()) / 86400000)) - 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}. -
- ) -} - -function PlayerSpeedInsight({ - result, - paceVsHltb, - presets, -}: Pick) { - const pace = paceVsHltb - const estimated = playerEstimatedTotal(result.rushTotal, result.leisureTotal, pace) - - if (!pace || pace.calibration_count === 0) { - return ( -
-
Your Play Style vs HLTB
-

- No calibration data yet. Finish games (100% achievements) and re-run{' '} - stats to see your pace estimate. -

-
- ) - } - - return ( -
-
Your Play Style vs HLTB
-
- Calibration games - {pace.calibration_count} - - {pace.ratio_vs_rush !== -1 && ( - <> - vs Rush speed - - {pace.ratio_vs_rush.toFixed(2)}× - - - )} - - {pace.ratio_vs_leisure !== -1 && ( - <> - vs Leisure speed - {pace.ratio_vs_leisure.toFixed(2)}× - - )} - - {pace.interpolation_t !== -1 && ( - <> - Interpolation t - - {pace.interpolation_t.toFixed(3)} - - - )} - - Play style - - {STYLE_LABELS[pace.player_style] ?? pace.player_style} - -
- - {estimated !== null && ( -
-
- Estimated total at your pace:{' '} - {fmtHours(estimated)} -
-
- {presets.map((h) => { - const days = estimated > 0 ? Math.floor(estimated / h) : null - return ( -
- {h} h/day - {fmtEta(days)} -
- ) - })} -
-
- )} -
- ) -} - -export function SummaryCards(props: Props) { - const { result, filters, state, presets, paceVsHltb } = 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 deleted file mode 100644 index 422dc8c..0000000 --- a/web/src/components/TimelineChart.test.tsx +++ /dev/null @@ -1,64 +0,0 @@ -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 deleted file mode 100644 index cc9ed4f..0000000 --- a/web/src/components/TimelineChart.tsx +++ /dev/null @@ -1,102 +0,0 @@ -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/elements/shell.test.ts b/web/src/elements/shell.test.ts new file mode 100644 index 0000000..5ef5eef --- /dev/null +++ b/web/src/elements/shell.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it, vi } from 'vitest' + +const boot = vi.hoisted(() => vi.fn()) +vi.mock('../shell/boot', () => ({ boot })) + +describe('shell entry', () => { + it('registers the shell elements and boots the page', async () => { + await import('./shell') + for (const tag of ['command-button', 'command-card', 'command-palette', 'runner-dialog', 'job-progress', 'game-browser', 'health-badges', 'stale-banner', 'job-indicator']) { + expect(customElements.get(tag), tag).toBeDefined() + } + expect(boot).toHaveBeenCalledOnce() + }) +}) diff --git a/web/src/elements/shell.ts b/web/src/elements/shell.ts new file mode 100644 index 0000000..1858592 --- /dev/null +++ b/web/src/elements/shell.ts @@ -0,0 +1,11 @@ +// Registers every element the shell uses, then wires page-wide behaviour. +import '../commands/commandButton' +import '../commands/palette' +import '../commands/runnerDialog' +import '../jobs/jobProgress' +import '../library/gameBrowser' +import '../shell/health' +import '../shell/jobIndicator' +import { boot } from '../shell/boot' + +boot() diff --git a/web/src/env.d.ts b/web/src/env.d.ts new file mode 100644 index 0000000..acef35f --- /dev/null +++ b/web/src/env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/web/src/jobs/GamePrompt.test.tsx b/web/src/jobs/GamePrompt.test.tsx deleted file mode 100644 index bc6c593..0000000 --- a/web/src/jobs/GamePrompt.test.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { act, fireEvent, screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { queryClient } from '../api/queries' -import { makeLibGame } from '../test/fixtures' -import { apiError, bodiesOf, renderClient, stubApi } from '../test/harness' -import { GamePrompt } from './GamePrompt' -import { hub, type PromptEvent } from './hub' -import { PromptForm } from './PromptForm' - -const GAMES = [ - makeLibGame({ app_id: 1, name: 'Hollow Knight' }), - makeLibGame({ app_id: 2, name: 'The Witcher 2' }), - makeLibGame({ app_id: 3, name: 'Age of Empires III', ineligible_reason: 'Not owned for long enough' }), -] -const prompt = (over: Partial = {}): PromptEvent => ({ - seq: 1, ts: 't', type: 'prompt', prompt_id: 'g1', kind: 'game', message: 'Pick a game', app_ids: [1, 2], ...over, -}) -const ANSWER = 'POST /api/jobs/j/answer' -const card = (name: string) => screen.getByRole('button', { name: new RegExp(`^${name}`) }) - -describe('GamePrompt', () => { - afterEach(() => vi.unstubAllGlobals()) - - it('is rendered by PromptForm for a game prompt', async () => { - stubApi({ 'GET /api/library': { games: GAMES } }) - renderClient() - expect(await screen.findByRole('form', { name: 'Job question' })).toHaveTextContent('2 games can be picked') - }) - - it('selecting a card never submits; only the Pick button does', async () => { - const mock = stubApi({ 'GET /api/library': { games: GAMES }, [ANSWER]: undefined }) - const answered = vi.spyOn(hub, 'answered') - renderClient() - await screen.findByText('3 of 3 games') - expect(screen.getByRole('button', { name: 'Select a game' })).toBeDisabled() - await userEvent.click(card('The Witcher 2')) - await userEvent.dblClick(card('The Witcher 2')) - expect(bodiesOf(mock, ANSWER)).toEqual([]) - await userEvent.click(screen.getByRole('button', { name: 'Pick The Witcher 2' })) - await waitFor(() => expect(bodiesOf(mock, ANSWER)).toEqual([{ prompt_id: 'g1', value: '2' }])) - await waitFor(() => expect(answered).toHaveBeenCalledWith('j', 'g1')) - }) - - it('only offers the app ids the job sent', async () => { - const mock = stubApi({ 'GET /api/library': { games: GAMES }, [ANSWER]: undefined }) - renderClient() - await screen.findByText('3 of 3 games') - await userEvent.click(card('Age of Empires III')) - expect(screen.getByRole('button', { name: 'Select a game' })).toBeDisabled() - expect(card('Age of Empires III')).toHaveTextContent('Not owned for long enough') - expect(card('Hollow Knight')).not.toHaveAttribute('aria-disabled') - expect(bodiesOf(mock, ANSWER)).toEqual([]) - }) - - it('"Back to the list" answers an empty string', async () => { - const mock = stubApi({ 'GET /api/library': { games: GAMES }, [ANSWER]: undefined }) - renderClient() - await screen.findByText('3 of 3 games') - await userEvent.click(screen.getByRole('button', { name: 'Back to the list' })) - await waitFor(() => expect(bodiesOf(mock, ANSWER)).toEqual([{ prompt_id: 'g1', value: '' }])) - }) - - it('Enter in the form submits the selection, and does nothing without one', async () => { - const mock = stubApi({ 'GET /api/library': { games: GAMES }, [ANSWER]: undefined }) - renderClient() - await screen.findByText('3 of 3 games') - const form = screen.getByRole('form', { name: 'Job question' }) - fireEvent.submit(form) - expect(bodiesOf(mock, ANSWER)).toEqual([]) - await userEvent.click(card('Hollow Knight')) - fireEvent.submit(form) - await waitFor(() => expect(bodiesOf(mock, ANSWER)).toEqual([{ prompt_id: 'g1', value: '1' }])) - }) - - it('names the game by id if it vanished from the library meanwhile', async () => { - const routes = { 'GET /api/library': { games: GAMES }, [ANSWER]: undefined } - stubApi(routes) - renderClient() - await screen.findByText('3 of 3 games') - await userEvent.click(card('Hollow Knight')) - routes['GET /api/library'] = { games: [GAMES[1]] } - await act(async () => { - await queryClient.invalidateQueries({ queryKey: ['library'] }) - }) - expect(await screen.findByRole('button', { name: 'Pick 1' })).toBeInTheDocument() - }) - - it('copes with a prompt that carries no app ids', async () => { - stubApi({ 'GET /api/library': { games: GAMES } }) - renderClient() - expect(await screen.findByText(/0 games can be picked/)).toBeInTheDocument() - }) - - it('shows the error when the answer is refused, and Sending… meanwhile', async () => { - let fail = false - let release: () => void = () => {} - stubApi({ - 'GET /api/library': { games: GAMES }, - [ANSWER]: () => (fail ? apiError(409, 'busy', 'job moved on') : new Promise((r) => (release = r))), - }) - renderClient() - await screen.findByText('3 of 3 games') - await userEvent.click(card('Hollow Knight')) - await userEvent.click(screen.getByRole('button', { name: 'Pick Hollow Knight' })) - expect(await screen.findByRole('button', { name: 'Sending…' })).toBeDisabled() - expect(screen.getByRole('button', { name: 'Back to the list' })).toBeDisabled() - fail = true - await act(async () => release()) - await userEvent.click(screen.getByRole('button', { name: 'Back to the list' })) - expect(await screen.findByText('job moved on')).toBeInTheDocument() - }) -}) diff --git a/web/src/jobs/GamePrompt.tsx b/web/src/jobs/GamePrompt.tsx deleted file mode 100644 index d3da1d9..0000000 --- a/web/src/jobs/GamePrompt.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { useMutation, useQuery } from '@tanstack/react-query' -import { useState } from 'react' -import { api } from '../api/client' -import { q } from '../api/queries' -import { GameBrowser } from '../library/GameBrowser' -import { ErrorNotice } from '../ui/Notice' -import { hub, type PromptEvent } from './hub' - -/** - * A `game` prompt: the library browser in pick mode. Only the ids the job - * sent are selectable (the job re-checks the answer). "Back to the list" - * answers empty, which returns the job to its previous question. - */ -export function GamePrompt({ jobId, prompt }: { jobId: string; prompt: PromptEvent }) { - const [appIds] = useState(() => new Set(prompt.app_ids ?? [])) - const [selected, setSelected] = useState(null) - const { data } = useQuery(q.library) - const send = useMutation({ - mutationFn: (v: string) => api.answer(jobId, { prompt_id: prompt.prompt_id, value: v }), - onSuccess: () => hub.answered(jobId, prompt.prompt_id), - }) - const name = data?.games.find((g) => g.app_id === selected)?.name ?? String(selected) - const submit = (id: number) => send.mutate(String(id)) - - return ( -
{ - e.preventDefault() - if (selected !== null) submit(selected) - }} - > -

- {prompt.message} · {appIds.size} games can be picked -

- - {send.error && } -
- - -
- - ) -} diff --git a/web/src/jobs/JobProgress.nolive.test.tsx b/web/src/jobs/JobProgress.nolive.test.tsx deleted file mode 100644 index f866901..0000000 --- a/web/src/jobs/JobProgress.nolive.test.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { screen } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' -import { renderClient, stubApi } from '../test/harness' -import { JobProgress } from './JobProgress' - -// useLiveJob returns null only for a null id, which JobProgress never passes. -// JobProgress still guards the type, so pin that guard. -vi.mock('./useLiveJob', () => ({ useLiveJob: () => null })) - -describe('JobProgress without a live stream handle', () => { - it('renders nothing', () => { - stubApi({ 'GET /api/jobs/x': {}, 'GET /api/commands': [] }) - renderClient() - expect(screen.queryByRole('region')).toBeNull() - expect(document.querySelector('section')).toBeNull() - }) -}) diff --git a/web/src/jobs/JobProgress.test.tsx b/web/src/jobs/JobProgress.test.tsx deleted file mode 100644 index ae17c6b..0000000 --- a/web/src/jobs/JobProgress.test.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import { act, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { makeJob, makeSpec } from '../test/fixtures' -import { apiError, FakeEventSource, installEventSource, renderClient, stubApi } from '../test/harness' -import { JobProgress } from './JobProgress' - -let n = 0 -const ts = '2026-10-10T10:00:00Z' - -/** Mount a JobProgress for a fresh job id; `send` pushes SSE events into it. */ -function mount(opts: { job?: Parameters[0]; cancellable?: boolean; compact?: boolean; extra?: Record } = {}) { - const id = `jp-${++n}` - const routes: Record = { - [`GET /api/jobs/${id}`]: makeJob({ id, command: 'scan', ...opts.job }), - 'GET /api/commands': [makeSpec('scan', { cancellable: opts.cancellable ?? false })], - ...opts.extra, - } - const mock = stubApi(routes) - renderClient() - const es = FakeEventSource.last - let seq = 0 - const send = (type: string, body: Record) => act(() => es.emit(type, { seq: ++seq, ts, type, ...body })) - return { id, es, send, mock, routes } -} - -describe('JobProgress', () => { - beforeEach(installEventSource) - afterEach(() => { - vi.useRealTimers() - vi.unstubAllGlobals() - }) - - it('starts from the fetched job and shows "Starting…" before any progress', async () => { - mount({ job: { state: 'running' } }) - expect(screen.getByText('Starting…')).toBeInTheDocument() - expect(screen.getByText('Queued')).toBeInTheDocument() // until the job loads - expect(await screen.findByText('Running')).toBeInTheDocument() - expect(await screen.findByRole('heading', { name: 'Scan' })).toBeInTheDocument() - expect(screen.getByRole('progressbar')).not.toHaveAttribute('aria-valuenow') - }) - - it('shows step, item and the job’s own estimate', async () => { - const { send } = mount() - await send('progress', { step: 3, total: 10, label: 'Scanning library', item: 'Hollow Knight', eta_seconds: 9 }) - expect(screen.getByText('Scanning library', { selector: '.job-label' })).toBeInTheDocument() - expect(screen.getByText('Step 3 of 10')).toBeInTheDocument() - expect(screen.getByText('Hollow Knight')).toBeInTheDocument() - expect(screen.getByText('about 0:09 left')).toBeInTheDocument() - expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '30') - }) - - it('says "working…" for an unbounded phase', async () => { - const { send } = mount() - await send('progress', { step: 4, total: null, label: 'Reading' }) - expect(screen.getByText('working…')).toBeInTheDocument() - expect(screen.queryByText(/^Step/)).toBeNull() - expect(screen.getByRole('progressbar')).not.toHaveAttribute('aria-valuenow') - }) - - it('shows nothing for an empty total and no estimate yet', async () => { - const { send } = mount() - await send('progress', { step: 0, total: 0, label: 'Preparing' }) - expect(document.querySelector('.job-eta')).toHaveTextContent('') - expect(screen.getByRole('progressbar')).not.toHaveAttribute('aria-valuenow') - }) - - it('extrapolates the remaining time from the pace when the job gives none', async () => { - vi.useFakeTimers({ now: Date.parse(ts) }) - const { send } = mount() - await send('progress', { step: 1, total: 5, label: 'Working' }) - await act(async () => { - await vi.advanceTimersByTimeAsync(10_000) - }) - await send('progress', { step: 2, total: 5, label: 'Working' }) - // 10 s for 2 steps, 3 to go: 15 s. - expect(screen.getByText('about 0:15 left')).toBeInTheDocument() - }) - - it('has no estimate for the first step of zero', async () => { - const { send } = mount() - await send('progress', { step: 0, total: 5, label: 'Working' }) - expect(document.querySelector('.job-eta')).toHaveTextContent('') - }) - - it('falls back to the fetched job’s progress, with a null estimate ignored', async () => { - mount({ job: { progress: { step: 2, total: 4, label: 'From server', eta_seconds: null as unknown as undefined } } }) - expect(await screen.findByText('From server', { selector: '.job-label' })).toBeInTheDocument() - expect(document.querySelector('.job-eta')).toHaveTextContent('') // no pace data either - }) - - it('shows the connection state', async () => { - const { es } = mount() - expect(screen.getByText('Reconnecting')).toBeInTheDocument() - act(() => es.onopen?.()) - expect(screen.getByText('Live')).toBeInTheDocument() - }) - - describe('waiting for input', () => { - it('shows the question, pauses, and lets even a non-cancellable job be cancelled', async () => { - const { send, mock } = mount({ extra: { 'POST /api/jobs/jp-1/cancel': undefined } }) - mock.mockClear() - await send('prompt', { prompt_id: 'q', kind: 'text', message: 'Name?' }) - await send('state', { state: 'waiting_input' }) - expect(screen.getByText('Waiting for your answer')).toBeInTheDocument() - expect(screen.getByText('paused')).toBeInTheDocument() - expect(screen.getByRole('form', { name: 'Job question' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Cancel job' })).toBeInTheDocument() - }) - - it('cancels through the API and shows a failure', async () => { - const { send, routes, id } = mount({ cancellable: true }) - routes[`POST /api/jobs/${id}/cancel`] = apiError(409, 'not_cancellable', 'too late') - await send('state', { state: 'running' }) - await userEvent.click(await screen.findByRole('button', { name: 'Cancel job' })) - expect(await screen.findByText('too late')).toBeInTheDocument() - }) - - it('shows Cancelling… while the request runs', async () => { - let release: () => void = () => {} - const { send, routes, id } = mount({ cancellable: true }) - routes[`POST /api/jobs/${id}/cancel`] = () => new Promise((r) => (release = r)) - await send('state', { state: 'running' }) - await userEvent.click(await screen.findByRole('button', { name: 'Cancel job' })) - expect(await screen.findByRole('button', { name: 'Cancelling…' })).toBeDisabled() - await act(async () => release()) - }) - }) - - describe('when finished', () => { - it('reports success with the summary, full bar and an open log', async () => { - const { send } = mount({ cancellable: true }) - await send('state', { state: 'succeeded' }) - await send('result', { ok: true, summary: 'All 12 scanned' }) - expect(screen.getByText('Completed')).toBeInTheDocument() - expect(screen.getByText('Done')).toBeInTheDocument() - expect(screen.getByText('All 12 scanned')).toBeInTheDocument() - expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '100') - expect(screen.queryByRole('button', { name: 'Cancel job' })).toBeNull() - expect(document.querySelector('details')).toHaveAttribute('open') - expect(screen.getByText('No output.')).toBeInTheDocument() - expect(document.querySelector('.job-sub')).toBeNull() - }) - - it('reports failure and cancellation', async () => { - const failed = mount() - await failed.send('state', { state: 'failed' }) - await failed.send('result', { ok: false, summary: 'boom' }) - expect(screen.getByText('Did not complete')).toBeInTheDocument() - expect(screen.getByText('Failed', { selector: '.job-label' })).toBeInTheDocument() - expect(screen.getByRole('progressbar').className).toContain('bar-danger') - const cancelled = mount() - await cancelled.send('state', { state: 'cancelled' }) - expect(screen.getByText('Cancelled', { selector: '.job-label' })).toBeInTheDocument() - }) - - it('uses the success tone', async () => { - const { send } = mount() - await send('state', { state: 'succeeded' }) - expect(screen.getByRole('progressbar').className).toContain('bar-success') - }) - }) - - describe('log', () => { - it('lists lines with their time and counts them', async () => { - const { send } = mount() - await send('log', { level: 'info', message: 'hello' }) - await send('log', { level: 'warning', message: 'careful' }) - expect(screen.getByText('Log (2 lines)')).toBeInTheDocument() - expect(screen.getByText('hello')).toBeInTheDocument() - expect(screen.getAllByText('10:00:00')).toHaveLength(2) - expect(document.querySelector('details')).not.toHaveAttribute('open') - expect(screen.queryByText('No output yet.')).toBeNull() - }) - - it('opens by itself on an error line, but not in compact mode', async () => { - const full = mount() - await full.send('log', { level: 'error', message: 'bad' }) - expect(document.querySelector('details')).toHaveAttribute('open') - }) - - it('stays closed in compact mode', async () => { - const { send } = mount({ compact: true }) - await send('log', { level: 'error', message: 'bad' }) - expect(document.querySelector('details')).not.toHaveAttribute('open') - expect(document.querySelector('section')).toHaveClass('job-compact') - }) - - it('says nothing has been printed yet', () => { - mount() - expect(screen.getByText('No output yet.')).toBeInTheDocument() - }) - }) -}) diff --git a/web/src/jobs/JobProgress.tsx b/web/src/jobs/JobProgress.tsx deleted file mode 100644 index f952861..0000000 --- a/web/src/jobs/JobProgress.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { useMutation, useQuery } from '@tanstack/react-query' -import { useEffect, useRef } from 'react' -import { api } from '../api/client' -import type { JobProgress as Progress, JobState } from '../api/jobContract' -import { q } from '../api/queries' -import { commandLabel, findCommand } from '../commands/catalog' -import { clock, duration } from '../ui/format' -import { ErrorNotice, Notice } from '../ui/Notice' -import { useNow } from '../ui/useNow' -import type { LiveJob } from './hub' -import { ProgressBar, StateBadge } from './ProgressBar' -import { PromptForm } from './PromptForm' -import { useLiveJob } from './useLiveJob' - -/** Remaining seconds: the job's own estimate, else extrapolated from pace. */ -function eta(p: Progress, live: LiveJob, now: number): number | null { - if (p.eta_seconds !== undefined && p.eta_seconds !== null) return p.eta_seconds - if (p.total === null || p.step <= 0 || live.startedProgressAt === null) return null - const perStep = (now / 1000 - live.startedProgressAt) / p.step - return perStep * (p.total - p.step) -} - -const DONE: ReadonlySet = new Set(['succeeded', 'failed', 'cancelled']) -const FINISHED_LABEL: Partial> = { succeeded: 'Completed', failed: 'Failed', cancelled: 'Cancelled' } - -export function JobProgress({ jobId, compact = false }: { jobId: string; compact?: boolean }) { - const live = useLiveJob(jobId) - const { data: job } = useQuery(q.job(jobId)) - const { data: commands } = useQuery(q.commands) - const now = useNow(1000) - const logRef = useRef(null) - const cancel = useMutation({ mutationFn: () => api.cancelJob(jobId) }) - - const logCount = live?.logs.length ?? 0 - useEffect(() => { - const el = logRef.current - if (el) el.scrollTop = el.scrollHeight - }, [logCount]) - - if (!live) return null - const state: JobState = live.state ?? job?.state ?? 'queued' - const progress = live.progress ?? job?.progress ?? null - const finished = DONE.has(state) - const spec = job ? findCommand(commands, job.command) : undefined - const fraction = finished - ? 1 - : progress && progress.total !== null && progress.total > 0 - ? progress.step / progress.total - : null - const tone = state === 'failed' ? 'danger' : state === 'succeeded' ? 'success' : state === 'waiting_input' ? 'warning' : 'accent' - const waiting = state === 'waiting_input' - const remaining = progress && !finished && !waiting ? eta(progress, live, now) : null - const hasErrors = live.logs.some((l) => l.level === 'error') - - return ( -
-
-

{job ? commandLabel(job.command) : 'Job'}

- - - {job && duration(job.started_at, job.ended_at, now)} - {!finished && ( - - {live.connected ? 'Live' : 'Reconnecting'} - - )} - -
- -
- {finished ? FINISHED_LABEL[state] : waiting ? 'Waiting for your answer' : (progress?.label ?? 'Starting…')} - {!finished && progress && progress.total !== null && ( - - Step {progress.step} of {progress.total} - - )} -
- - {!finished && ( -
- {progress?.item ?? ' '} - {remaining !== null ? `about ${clock(remaining)} left` : waiting ? 'paused' : progress?.total === null ? 'working…' : ''} -
- )} - - {live.prompt && state === 'waiting_input' && } - - {live.result && ( - - {live.result.summary} - - )} - - {cancel.error && } - {(spec?.cancellable || waiting) && !finished && ( -
- -
- )} - -
- Log ({logCount} lines) -
    - {live.logs.map((l) => ( -
  1. - {l.message} -
  2. - ))} - {logCount === 0 &&
  3. {finished ? 'No output.' : 'No output yet.'}
  4. } -
-
-
- ) -} diff --git a/web/src/jobs/ProgressBar.test.tsx b/web/src/jobs/ProgressBar.test.tsx deleted file mode 100644 index 52587f8..0000000 --- a/web/src/jobs/ProgressBar.test.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { render, screen } from '@testing-library/react' -import { describe, expect, it } from 'vitest' -import type { JobState } from '../api/jobContract' -import { ProgressBar, StateBadge } from './ProgressBar' - -describe('StateBadge', () => { - it.each<[JobState, string, string]>([ - ['queued', 'Queued', 'neutral'], - ['running', 'Running', 'accent'], - ['waiting_input', 'Needs your input', 'warning'], - ['succeeded', 'Succeeded', 'success'], - ['failed', 'Failed', 'danger'], - ['cancelled', 'Cancelled', 'neutral'], - ])('%s reads %s in the %s tone', (state, text, tone) => { - render() - expect(screen.getByText(text)).toHaveClass(`pill-${tone}`) - }) -}) - -describe('ProgressBar', () => { - it('is a determinate progressbar with a percentage', () => { - render() - const bar = screen.getByRole('progressbar', { name: 'Scan' }) - expect(bar).toHaveAttribute('aria-valuenow', '26') - expect(bar).toHaveAttribute('aria-valuetext', 'Scan: 26%') - expect(bar).toHaveClass('bar-accent') - expect(bar).not.toHaveClass('bar-indeterminate', 'bar-small') - expect(bar.firstElementChild).toHaveStyle({ width: '26%' }) - }) - - it('is indeterminate without a fraction', () => { - render() - const bar = screen.getByRole('progressbar') - expect(bar).not.toHaveAttribute('aria-valuenow') - expect(bar).toHaveAttribute('aria-valuetext', 'Scan: working') - expect(bar).toHaveClass('bar-indeterminate', 'bar-small', 'bar-warning') - }) - - it('clamps out-of-range fractions', () => { - const { rerender } = render() - expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '100') - rerender() - expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '0') - }) -}) diff --git a/web/src/jobs/ProgressBar.tsx b/web/src/jobs/ProgressBar.tsx deleted file mode 100644 index d88be7b..0000000 --- a/web/src/jobs/ProgressBar.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import type { JobState } from '../api/jobContract' - -const STATE_LABEL: Record = { - queued: 'Queued', - running: 'Running', - waiting_input: 'Needs your input', - succeeded: 'Succeeded', - failed: 'Failed', - cancelled: 'Cancelled', -} - -const STATE_TONE: Record = { - queued: 'neutral', - running: 'accent', - waiting_input: 'warning', - succeeded: 'success', - failed: 'danger', - cancelled: 'neutral', -} - -export function StateBadge({ state }: { state: JobState }) { - return {STATE_LABEL[state]} -} - -interface BarProps { - /** 0..1, or null for an indeterminate phase. */ - fraction: number | null - label: string - tone?: 'accent' | 'success' | 'danger' | 'warning' - small?: boolean -} - -/** - * Determinate bar with a real `progressbar` role, or an indeterminate sweep - * when the job reported `total: null`. Width transitions use the motion - * tokens, so reduced-motion users get instant jumps. - */ -export function ProgressBar({ fraction, label, tone = 'accent', small = false }: BarProps) { - const pct = fraction === null ? null : Math.round(Math.min(1, Math.max(0, fraction)) * 100) - return ( -
-
-
- ) -} diff --git a/web/src/jobs/PromptForm.test.tsx b/web/src/jobs/PromptForm.test.tsx deleted file mode 100644 index 4df74d5..0000000 --- a/web/src/jobs/PromptForm.test.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { act, screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { apiError, bodiesOf, renderClient, stubApi } from '../test/harness' -import { hub, type PromptEvent } from './hub' -import { PromptForm } from './PromptForm' - -const prompt = (over: Partial = {}): PromptEvent => ({ - seq: 1, ts: 't', type: 'prompt', prompt_id: 'q1', kind: 'text', message: 'Which one?', ...over, -}) - -describe('PromptForm', () => { - afterEach(() => vi.unstubAllGlobals()) - - describe('choice', () => { - const options = [ - { value: 'a', label: 'Alpha', detail: 'the first' }, - { value: 'b', label: 'Beta' }, - ] - - it('is a radio group, first option focused, nothing preselected', () => { - renderClient() - expect(screen.getByRole('radio', { name: /Alpha/ })).toHaveFocus() - expect(screen.getByText('the first')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Answer' })).toBeDisabled() - }) - - it('sends the chosen value and hides the prompt', async () => { - const mock = stubApi({ 'POST /api/jobs/j/answer': undefined }) - const answered = vi.spyOn(hub, 'answered') - renderClient() - await userEvent.click(screen.getByRole('radio', { name: /Beta/ })) - expect(screen.getByText('Beta').closest('label')).toHaveClass('selected') - await userEvent.click(screen.getByRole('button', { name: 'Answer' })) - await waitFor(() => expect(answered).toHaveBeenCalledWith('j', 'q1')) - expect(bodiesOf(mock, 'POST /api/jobs/j/answer')).toEqual([{ prompt_id: 'q1', value: 'b' }]) - }) - - it('honours a default and tolerates missing options', () => { - renderClient() - expect(screen.getByRole('button', { name: 'Answer' })).toBeEnabled() - expect(screen.queryAllByRole('radio')).toHaveLength(0) - }) - }) - - describe('text', () => { - it('submits on Enter, even empty', async () => { - const mock = stubApi({ 'POST /api/jobs/j/answer': undefined }) - renderClient() - expect(screen.getByRole('textbox', { name: 'Which one?' })).toHaveFocus() - await userEvent.type(screen.getByRole('textbox'), 'y{Enter}') - await waitFor(() => expect(bodiesOf(mock, 'POST /api/jobs/j/answer')).toEqual([{ prompt_id: 'q1', value: 'xy' }])) - }) - }) - - describe('confirm', () => { - it('answers yes or no, yes focused by default', async () => { - const mock = stubApi({ 'POST /api/jobs/j/answer': undefined }) - renderClient() - expect(screen.getByRole('button', { name: 'Yes' })).toHaveFocus() - await userEvent.click(screen.getByRole('button', { name: 'Yes' })) - await userEvent.click(screen.getByRole('button', { name: 'No' })) - await waitFor(() => - expect(bodiesOf(mock, 'POST /api/jobs/j/answer').map((b) => (b as { value: string }).value)).toEqual(['yes', 'no']), - ) - }) - - it('focuses No when that is the default', () => { - renderClient() - expect(screen.getByRole('button', { name: 'No' })).toHaveFocus() - }) - }) - - describe('phrase', () => { - it('stays disabled until the phrase is typed exactly', async () => { - const mock = stubApi({ 'POST /api/jobs/j/answer': undefined }) - renderClient() - const answer = screen.getByRole('button', { name: 'Answer' }) - await userEvent.type(screen.getByRole('textbox'), 'do i') - expect(answer).toBeDisabled() - await userEvent.type(screen.getByRole('textbox'), 't') - expect(answer).toBeEnabled() - await userEvent.click(answer) - await waitFor(() => expect(bodiesOf(mock, 'POST /api/jobs/j/answer')).toEqual([{ prompt_id: 'q1', value: 'do it' }])) - }) - - it('copes with a prompt that lost its phrase', () => { - renderClient() - expect(screen.getByRole('button', { name: 'Answer' })).toBeEnabled() // '' matches '' - }) - }) - - it('shows the server error when the answer is refused', async () => { - stubApi({ 'POST /api/jobs/j/answer': apiError(404, 'not_found', 'job is gone') }) - renderClient() - await userEvent.click(screen.getByRole('button', { name: 'Answer' })) - expect(await screen.findByText('job is gone')).toBeInTheDocument() - }) - - it('disables the buttons while sending', async () => { - let release: () => void = () => {} - stubApi({ 'POST /api/jobs/j/answer': () => new Promise((r) => (release = r)) }) - renderClient() - await userEvent.click(screen.getByRole('button', { name: 'Yes' })) - expect(screen.getByRole('button', { name: 'Yes' })).toBeDisabled() - expect(screen.getByRole('button', { name: 'No' })).toBeDisabled() - await act(async () => release()) - }) - - it('shows Sending… on the answer button', async () => { - let release: () => void = () => {} - stubApi({ 'POST /api/jobs/j/answer': () => new Promise((r) => (release = r)) }) - renderClient() - await userEvent.click(screen.getByRole('button', { name: 'Answer' })) - expect(await screen.findByRole('button', { name: 'Sending…' })).toBeDisabled() - await act(async () => release()) - }) -}) diff --git a/web/src/jobs/PromptForm.tsx b/web/src/jobs/PromptForm.tsx deleted file mode 100644 index 118e20f..0000000 --- a/web/src/jobs/PromptForm.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { useMutation } from '@tanstack/react-query' -import { useState, type FormEvent } from 'react' -import { api } from '../api/client' -import { phraseMatches } from '../commands/catalog' -import { PhraseField } from '../commands/PhraseField' -import { ErrorNotice } from '../ui/Notice' -import { GamePrompt } from './GamePrompt' -import { hub, type PromptEvent } from './hub' - -/** - * Answer a job's prompt. Every kind is a real form: Enter submits, the first - * control is focused, and choices are radio buttons (arrow keys move). - */ -export function PromptForm({ jobId, prompt }: { jobId: string; prompt: PromptEvent }) { - if (prompt.kind === 'game') return - return -} - -function AnswerForm({ jobId, prompt }: { jobId: string; prompt: PromptEvent }) { - const [value, setValue] = useState(prompt.default ?? '') - const send = useMutation({ - mutationFn: (v: string) => api.answer(jobId, { prompt_id: prompt.prompt_id, value: v }), - onSuccess: () => hub.answered(jobId, prompt.prompt_id), - }) - - const submit = (e: FormEvent, v: string = value) => { - e.preventDefault() - send.mutate(v) - } - - const phraseOk = prompt.kind !== 'phrase' || phraseMatches(prompt.phrase ?? '', value) - const ready = prompt.kind === 'text' || (prompt.kind === 'choice' ? value !== '' : phraseOk) - - return ( -
-

{prompt.message}

- {prompt.kind === 'choice' && ( -
- {prompt.message} - {prompt.options?.map((o, i) => ( - - ))} -
- )} - {prompt.kind === 'text' && ( - setValue(e.target.value)} - /> - )} - {prompt.kind === 'phrase' && ( - - )} - {send.error && } -
- {prompt.kind === 'confirm' ? ( - <> - - - - ) : ( - - )} -
- - ) -} diff --git a/web/src/jobs/hub.test.ts b/web/src/jobs/hub.test.ts index 339c574..65ee105 100644 --- a/web/src/jobs/hub.test.ts +++ b/web/src/jobs/hub.test.ts @@ -14,7 +14,7 @@ describe('JobHub', () => { afterEach(() => { vi.useRealTimers() vi.unstubAllGlobals() - hub.onTerminal = () => {} + hub.onTerminal = () => undefined }) it('reports an empty job before anything arrives', () => { diff --git a/web/src/jobs/hub.ts b/web/src/jobs/hub.ts index a285dfd..ad8e354 100644 --- a/web/src/jobs/hub.ts +++ b/web/src/jobs/hub.ts @@ -72,7 +72,7 @@ class JobHub { private live = new Map() private streams = new Map() private listeners = new Set<() => void>() - onTerminal: (id: string) => void = () => {} + onTerminal: (id: string) => void = () => undefined subscribe = (listener: () => void): (() => void) => { this.listeners.add(listener) @@ -110,7 +110,9 @@ class JobHub { const url = `${ENDPOINTS.jobEvents(id)}?token=${encodeURIComponent(sessionToken())}&after=${live.lastSeq}` const source = new EventSource(url) s.source = source - const onEvent = (msg: MessageEvent) => this.ingest(id, msg.data) + const onEvent = (msg: MessageEvent) => { + this.ingest(id, msg.data) + } source.onmessage = onEvent for (const t of EVENT_TYPES) source.addEventListener(t, onEvent) source.onopen = () => { @@ -124,7 +126,9 @@ class JobHub { const state = this.get(id).state if (state && TERMINAL.has(state)) return if (s.refs > 0) { - s.retry = setTimeout(() => this.open(id, s), s.backoff) + s.retry = setTimeout(() => { + this.open(id, s) + }, s.backoff) s.backoff = Math.min(s.backoff * 2, 10_000) } } diff --git a/web/src/jobs/jobProgress.log.test.ts b/web/src/jobs/jobProgress.log.test.ts new file mode 100644 index 0000000..602c395 --- /dev/null +++ b/web/src/jobs/jobProgress.log.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { installEventSource } from '../test/harness' +import { mountJob } from '../test/jobHelpers' + +beforeEach(installEventSource) + +const sel = (el: Element, s: string) => el.querySelector(s) + +describe(' log', () => { + it('lists lines with their time and counts them', () => { + const { el, send } = mountJob() + send('log', { level: 'info', message: 'hello' }) + send('log', { level: 'warning', message: 'careful' }) + expect(sel(el, 'summary')).toHaveTextContent('Log (2 lines)') + expect(el.querySelectorAll('.log li')).toHaveLength(2) + expect(sel(el, '.log-info')).toHaveTextContent('10:00:00 hello') + expect(sel(el, '.log-warning')).toHaveTextContent('careful') + expect(sel(el, 'details')).not.toHaveAttribute('open') + }) + + it('only appends new lines, keeping the ones already shown', () => { + const { el, send } = mountJob() + send('log', { level: 'info', message: 'one' }) + const first = sel(el, '.log li') + send('log', { level: 'info', message: 'two' }) + expect(sel(el, '.log li')).toBe(first) + expect(el.querySelectorAll('.log li')).toHaveLength(2) + }) + + it('opens by itself on an error line, once, and leaves the toggle to the user', () => { + const { el, send } = mountJob() + send('log', { level: 'error', message: 'bad' }) + const details = sel(el, 'details') as HTMLDetailsElement + expect(details.open).toBe(true) + details.open = false + send('log', { level: 'error', message: 'worse' }) + expect(details.open).toBe(false) + }) + + it('stays closed in compact mode', () => { + const { el, send } = mountJob({ compact: true }) + send('log', { level: 'error', message: 'bad' }) + expect(sel(el, 'details')).not.toHaveAttribute('open') + expect(sel(el, 'section')).toHaveClass('job-compact') + }) + + it('says nothing has been printed yet', () => { + const { el } = mountJob() + expect(sel(el, '.log')).toHaveTextContent('No output yet.') + }) + + it('rebuilds the list when the newest-lines window slides', () => { + const { el, send } = mountJob() + for (let i = 0; i < 502; i++) send('log', { level: 'info', message: `line ${i}` }) + expect(el.querySelectorAll('.log li')).toHaveLength(500) + expect(sel(el, '.log li')).toHaveTextContent('line 2') + expect(sel(el, '.log li:last-child')).toHaveTextContent('line 501') + }) +}) + +describe(' lifecycle', () => { + it('re-renders on a timer, and stops when removed', async () => { + vi.useFakeTimers({ now: Date.parse('2026-10-10T10:00:05Z') }) + const { el, es } = mountJob({ job: { started_at: '2026-10-10T10:00:00Z' } }) + await vi.advanceTimersByTimeAsync(10) + expect(sel(el, '.job-meta')).toHaveTextContent('0:05') + await vi.advanceTimersByTimeAsync(3000) + expect(sel(el, '.job-meta')).toHaveTextContent('0:08') + el.remove() + expect(es.closed).toBe(true) + await vi.advanceTimersByTimeAsync(3000) + expect(sel(el, '.job-meta')).toHaveTextContent('0:08') + }) + + it('builds a fresh frame and log state when reattached', () => { + const { el, send } = mountJob() + send('log', { level: 'error', message: 'bad' }) + const section = sel(el, 'section') + el.remove() + document.body.append(el) + expect(sel(el, 'section')).not.toBe(section) + expect(sel(el, 'summary')).toHaveTextContent('Log (1 lines)') + expect(sel(el, 'details')).toHaveAttribute('open') + }) +}) diff --git a/web/src/jobs/jobProgress.test.ts b/web/src/jobs/jobProgress.test.ts new file mode 100644 index 0000000..ccfc890 --- /dev/null +++ b/web/src/jobs/jobProgress.test.ts @@ -0,0 +1,170 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apiError, installEventSource } from '../test/harness' +import { button } from '../test/events' +import { mountJob } from '../test/jobHelpers' +import type { LiveJob } from './hub' +import { eta } from './jobProgress' + +beforeEach(installEventSource) + +const text = (el: Element) => el.textContent +const sel = (el: Element, s: string) => el.querySelector(s) + +describe('eta', () => { + const live = (startedProgressAt: number | null) => ({ startedProgressAt }) as LiveJob + it('prefers the job estimate, then extrapolates from the pace', () => { + expect(eta({ step: 1, total: 5, label: 'x', eta_seconds: 9 }, live(null), 0)).toBe(9) + expect(eta({ step: 2, total: 5, label: 'x' }, live(0), 10_000)).toBe(15) + }) + it('has no estimate without a total, a first step or a start time', () => { + expect(eta({ step: 2, total: null, label: 'x' }, live(0), 1000)).toBeNull() + expect(eta({ step: 0, total: 5, label: 'x' }, live(0), 1000)).toBeNull() + expect(eta({ step: 2, total: 5, label: 'x' }, live(null), 1000)).toBeNull() + expect(eta({ step: 2, total: 5, label: 'x', eta_seconds: undefined }, live(null), 1000)).toBeNull() + }) +}) + +describe('', () => { + it('starts from the fetched job and shows "Starting…" before any progress', async () => { + const { el } = mountJob({ job: { state: 'running' } }) + expect(text(el)).toContain('Starting…') + expect(text(el)).toContain('Queued') + expect(sel(el, 'h3')).toHaveTextContent('Job') + await vi.waitFor(() => { expect(text(el)).toContain('Running') }) + expect(sel(el, 'h3')).toHaveTextContent('Scan') + expect(sel(el, '[role=progressbar]')).not.toHaveAttribute('aria-valuenow') + expect(sel(el, 'section')).toHaveAttribute('aria-label', 'Job Scan') + }) + + it('names the section after the id until the job loads', () => { + const { el, id } = mountJob() + expect(sel(el, 'section')).toHaveAttribute('aria-label', `Job ${id}`) + }) + + it('shows step, item and the job’s own estimate', () => { + const { el, send } = mountJob() + send('progress', { step: 3, total: 10, label: 'Scanning library', item: 'Hollow Knight', eta_seconds: 9 }) + expect(sel(el, '.job-label')).toHaveTextContent('Scanning library') + expect(text(el)).toContain('Step 3 of 10') + expect(sel(el, '.job-item')).toHaveTextContent('Hollow Knight') + expect(sel(el, '.job-eta')).toHaveTextContent('about 0:09 left') + expect(sel(el, '[role=progressbar]')).toHaveAttribute('aria-valuenow', '30') + }) + + it('says "working…" for an unbounded phase', () => { + const { el, send } = mountJob() + send('progress', { step: 4, total: null, label: 'Reading' }) + expect(sel(el, '.job-eta')).toHaveTextContent('working…') + expect(text(el)).not.toMatch(/Step \d/) + expect(sel(el, '[role=progressbar]')).not.toHaveAttribute('aria-valuenow') + }) + + it('shows no estimate for an empty total', () => { + const { el, send } = mountJob() + send('progress', { step: 0, total: 0, label: 'Preparing' }) + expect(sel(el, '.job-eta')?.textContent).toBe('') + expect(sel(el, '[role=progressbar]')).not.toHaveAttribute('aria-valuenow') + }) + + it('extrapolates the remaining time from the pace when the job gives none', async () => { + vi.useFakeTimers({ now: Date.parse('2026-10-10T10:00:00Z') }) + const { el, send } = mountJob() + send('progress', { step: 1, total: 5, label: 'Working' }) + await vi.advanceTimersByTimeAsync(10_000) + send('progress', { step: 2, total: 5, label: 'Working' }) + expect(sel(el, '.job-eta')).toHaveTextContent('about 0:15 left') + }) + + it('falls back to the fetched job’s progress', async () => { + const { el } = mountJob({ job: { progress: { step: 2, total: 4, label: 'From server' } } }) + await vi.waitFor(() => { expect(sel(el, '.job-label')).toHaveTextContent('From server') }) + expect(sel(el, '.job-eta')?.textContent).toBe('') + }) + + it('shows the connection state', () => { + const { el, es } = mountJob() + expect(text(el)).toContain('Reconnecting') + es.onopen?.() + expect(text(el)).toContain('Live') + expect(sel(el, '.dot')).toHaveClass('dot-live') + }) + + it('works without a job-id attribute', () => { + document.body.innerHTML = '' + expect(document.body.firstElementChild?.querySelector('h3')).toHaveTextContent('Job') + }) +}) + +describe(' waiting for input', () => { + it('shows the question, pauses, and lets even a non-cancellable job be cancelled', () => { + const { el, send } = mountJob() + send('prompt', { prompt_id: 'q', kind: 'text', message: 'Name?' }) + expect(sel(el, 'form')).toBeNull() + send('state', { state: 'waiting_input' }) + expect(text(el)).toContain('Waiting for your answer') + expect(sel(el, '.job-eta')).toHaveTextContent('paused') + expect(sel(el, 'form')).toHaveAttribute('aria-label', 'Job question') + expect(button(el, 'Cancel job')).toBeInTheDocument() + expect(sel(el, '[role=progressbar]')?.className).toContain('bar-warning') + }) + + it('keeps one form per prompt, swaps it for a new prompt and drops it when answered', () => { + const { el, send } = mountJob() + send('state', { state: 'waiting_input' }) + send('prompt', { prompt_id: 'q1', kind: 'text', message: 'One?' }) + const first = sel(el, 'form') + send('log', { level: 'info', message: 'noise' }) + expect(sel(el, 'form')).toBe(first) + send('prompt', { prompt_id: 'q2', kind: 'confirm', message: 'Two?' }) + expect(sel(el, 'form')).not.toBe(first) + send('state', { state: 'running' }) + expect(sel(el, 'form')).toBeNull() + }) + + it('cancels through the API and shows a failure', async () => { + const { el, send, routes, id } = mountJob({ cancellable: true }) + routes[`POST /api/jobs/${id}/cancel`] = apiError(409, 'not_cancellable', 'too late') + await vi.waitFor(() => { expect(sel(el, '.actions')).not.toBeNull() }) + send('state', { state: 'running' }) + button(el, 'Cancel job').click() + await vi.waitFor(() => { expect(text(el)).toContain('too late') }) + }) + + it('shows Cancelling… while the request runs', async () => { + const { el, routes, id } = mountJob({ cancellable: true }) + routes[`POST /api/jobs/${id}/cancel`] = () => new Promise(() => undefined) + await vi.waitFor(() => { expect(sel(el, '.actions')).not.toBeNull() }) + button(el, 'Cancel job').click() + await vi.waitFor(() => { expect(button(el, 'Cancelling…')).toBeDisabled() }) + }) +}) + +describe(' when finished', () => { + it('reports success with the summary, full bar and an open log', () => { + const { el, send } = mountJob({ cancellable: true }) + send('state', { state: 'succeeded' }) + send('result', { ok: true, summary: 'All 12 scanned' }) + expect(sel(el, '.job-label')).toHaveTextContent('Completed') + expect(text(el)).toContain('Done') + expect(text(el)).toContain('All 12 scanned') + expect(sel(el, '[role=progressbar]')).toHaveAttribute('aria-valuenow', '100') + expect(sel(el, '[role=progressbar]')?.className).toContain('bar-success') + expect(text(el)).not.toContain('Cancel job') + expect(sel(el, 'details')).toHaveAttribute('open') + expect(text(el)).toContain('No output.') + expect(sel(el, '.job-sub')).toBeNull() + expect(sel(el, '.dot')).toBeNull() + }) + + it('reports failure and cancellation', () => { + const failed = mountJob() + failed.send('state', { state: 'failed' }) + failed.send('result', { ok: false, summary: 'boom' }) + expect(text(failed.el)).toContain('Did not complete') + expect(sel(failed.el, '.job-label')).toHaveTextContent('Failed') + expect(sel(failed.el, '[role=progressbar]')?.className).toContain('bar-danger') + const cancelled = mountJob() + cancelled.send('state', { state: 'cancelled' }) + expect(sel(cancelled.el, '.job-label')).toHaveTextContent('Cancelled') + }) +}) diff --git a/web/src/jobs/jobProgress.ts b/web/src/jobs/jobProgress.ts new file mode 100644 index 0000000..99f5739 --- /dev/null +++ b/web/src/jobs/jobProgress.ts @@ -0,0 +1,233 @@ +// ``: one job, live from its SSE stream +// (hub.ts) with the REST copy as fallback (was JobProgress.tsx). The frame is +// built once; each update refreshes the parts, appends only new log lines, +// and swaps the prompt form only when a new prompt arrives, so typing an +// answer or reading the log is never interrupted by a progress event. + +import { api } from '../api/client' +import type { JobProgress as Progress, JobState } from '../api/jobContract' +import { q } from '../api/queries' +import { commandLabel, findCommand } from '../commands/catalog' +import { Action } from '../lib/action' +import { define, h } from '../lib/dom' +import { LiveElement } from '../lib/element' +import { errorNotice, notice } from '../ui/build' +import { clock, duration } from '../ui/format' +import { hub, type LiveJob } from './hub' +import { progressBar, stateBadge } from './progress' +import { promptForm } from './promptForm' + +/** Remaining seconds: the job's own estimate, else extrapolated from pace. */ +export function eta(p: Progress, live: LiveJob, now: number): number | null { + if (typeof p.eta_seconds === 'number') return p.eta_seconds + if (p.total === null || p.step <= 0 || live.startedProgressAt === null) return null + const perStep = (now / 1000 - live.startedProgressAt) / p.step + return perStep * (p.total - p.step) +} + +const DONE: ReadonlySet = new Set(['succeeded', 'failed', 'cancelled']) +const FINISHED_LABEL: Record<'succeeded' | 'failed' | 'cancelled', string> = { + succeeded: 'Completed', + failed: 'Failed', + cancelled: 'Cancelled', +} +const isDone = (s: JobState): s is keyof typeof FINISHED_LABEL => DONE.has(s) + +export class JobProgressElement extends LiveElement { + private cancel = new Action(() => api.cancelJob(this.jobId), () => { + this.update() + }) + private frame: { + section: HTMLElement + title: HTMLHeadingElement + badge: HTMLSpanElement + meta: HTMLSpanElement + phase: HTMLDivElement + bar: HTMLDivElement + sub: HTMLDivElement + prompt: HTMLDivElement + result: HTMLDivElement + actions: HTMLDivElement + log: HTMLOListElement + details: HTMLDetailsElement + summary: HTMLElement + } | null = null + private promptId: string | null = null + private logCount = -1 + private autoOpened = false + + get jobId(): string { + return this.getAttribute('job-id') ?? '' + } + + protected override setup(): void { + const id = this.jobId + this.onCleanup(hub.watch(id)) + this.onCleanup(hub.subscribe(() => { + this.update() + })) + this.watch(q.job(id)) + this.watch(q.commands) + this.tick(1000) + } + + override disconnectedCallback(): void { + super.disconnectedCallback() + this.frame = null + this.promptId = null + this.logCount = -1 + this.autoOpened = false + } + + private build(): NonNullable { + const f = { + title: h('h3', { class: 'job-title' }), + badge: h('span'), + meta: h('span', { class: 'job-meta' }), + phase: h('div', { class: 'job-phase', 'aria-live': 'polite' }), + bar: h('div', { class: 'contents' }), + sub: h('div', { class: 'contents' }), + prompt: h('div', { class: 'contents' }), + result: h('div', { class: 'contents' }), + actions: h('div', { class: 'contents' }), + log: h('ol', { class: 'log', tabindex: 0, 'aria-label': 'Job log' }), + summary: h('summary'), + details: h('details', { class: 'job-log' }), + section: h('section'), + } + f.details.append(f.summary, f.log) + f.section.append( + h('div', { class: 'job-head' }, f.title, f.badge, f.meta), + f.phase, + f.bar, + f.sub, + f.prompt, + f.result, + f.actions, + f.details, + ) + this.replaceChildren(f.section) + return f + } + + protected update(): void { + const id = this.jobId + const live = hub.get(id) + const job = q.job(id).state.data + const f = (this.frame ??= this.build()) + const compact = this.hasAttribute('compact') + const now = Date.now() + const state: JobState = live.state ?? job?.state ?? 'queued' + const progress = live.progress ?? job?.progress ?? null + const finished = DONE.has(state) + const waiting = state === 'waiting_input' + const spec = job ? findCommand(q.commands.state.data, job.command) : undefined + const fraction = finished ? 1 : progress && progress.total !== null && progress.total > 0 ? progress.step / progress.total : null + const tone = state === 'failed' ? 'danger' : state === 'succeeded' ? 'success' : waiting ? 'warning' : 'accent' + const remaining = progress && !finished && !waiting ? eta(progress, live, now) : null + const title = job ? commandLabel(job.command) : 'Job' + + f.section.className = compact ? 'job job-compact' : 'job' + f.section.setAttribute('aria-label', `Job ${job ? commandLabel(job.command) : id}`) + f.title.textContent = title + f.badge.replaceChildren(stateBadge(state)) + f.meta.replaceChildren( + job ? duration(job.started_at, job.ended_at, now) : '', + finished + ? '' + : h( + 'span', + { class: live.connected ? 'dot dot-live' : 'dot dot-off', title: live.connected ? 'Live' : 'Reconnecting…' }, + h('span', { class: 'sr-only' }, live.connected ? 'Live' : 'Reconnecting'), + ), + ) + f.phase.replaceChildren( + h( + 'span', + { class: 'job-label' }, + isDone(state) ? FINISHED_LABEL[state] : waiting ? 'Waiting for your answer' : (progress?.label ?? 'Starting…'), + ), + !finished && progress && progress.total !== null + ? h('span', { class: 'job-step' }, `Step ${progress.step} of ${progress.total}`) + : '', + ) + f.bar.replaceChildren(progressBar(fraction, progress?.label ?? 'Progress', tone)) + f.sub.replaceChildren( + ...(finished + ? [] + : [ + h( + 'div', + { class: 'job-sub' }, + h('span', { class: 'job-item' }, progress?.item ?? ' '), + h( + 'span', + { class: 'job-eta' }, + remaining !== null ? `about ${clock(remaining)} left` : waiting ? 'paused' : progress?.total === null ? 'working…' : '', + ), + ), + ]), + ) + this.updatePrompt(f, live, state) + f.result.replaceChildren( + ...(live.result + ? [notice(live.result.ok ? 'success' : 'danger', live.result.ok ? 'Done' : 'Did not complete', live.result.summary)] + : []), + ) + f.actions.replaceChildren( + ...(this.cancel.error ? [errorNotice(this.cancel.error)] : []), + ...((spec?.cancellable || waiting) && !finished + ? [ + h( + 'div', + { class: 'actions' }, + h( + 'button', + { type: 'button', class: 'btn btn-ghost', disabled: this.cancel.pending, onclick: () => void this.cancel.run() }, + this.cancel.pending ? 'Cancelling…' : 'Cancel job', + ), + ), + ] + : []), + ) + this.updateLog(f, live, finished, compact) + } + + private updatePrompt(f: NonNullable, live: LiveJob, state: JobState): void { + const prompt = live.prompt && state === 'waiting_input' ? live.prompt : null + const id = prompt?.prompt_id ?? null + if (id === this.promptId) return + this.promptId = id + f.prompt.replaceChildren(...(prompt ? [promptForm(this.jobId, prompt)] : [])) + f.prompt.querySelector('[autofocus]')?.focus() + } + + private updateLog(f: NonNullable, live: LiveJob, finished: boolean, compact: boolean): void { + const logs = live.logs + f.summary.textContent = `Log (${logs.length} lines)` + // Open once on errors or at the end; after that the user owns the toggle. + if (!this.autoOpened && !compact && (finished || logs.some((l) => l.level === 'error'))) { + f.details.open = true + this.autoOpened = true + } + if (logs.length === 0) { + f.log.replaceChildren(h('li', { class: 'muted' }, finished ? 'No output.' : 'No output yet.')) + this.logCount = 0 + return + } + // The hub keeps the newest MAX_LOGS lines; rebuild when the window slid. + const first = f.log.firstElementChild as HTMLElement | null + const slid = this.logCount <= 0 || first?.dataset.seq !== String(logs[0]?.seq) + const fresh = slid ? logs : logs.slice(this.logCount) + if (slid) f.log.replaceChildren() + for (const l of fresh) { + const li = h('li', { class: `log-${l.level}` }, h('time', { datetime: l.ts }, l.ts.slice(11, 19)), ` ${l.message}`) + li.dataset.seq = String(l.seq) + f.log.append(li) + } + if (fresh.length) f.log.scrollTop = f.log.scrollHeight + this.logCount = logs.length + } +} + +define('job-progress', JobProgressElement) diff --git a/web/src/jobs/progress.test.ts b/web/src/jobs/progress.test.ts new file mode 100644 index 0000000..d9b37ca --- /dev/null +++ b/web/src/jobs/progress.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import type { JobState } from '../api/jobContract' +import { progressBar, stateBadge } from './progress' + +describe('stateBadge', () => { + it.each<[JobState, string, string]>([ + ['queued', 'Queued', 'neutral'], + ['running', 'Running', 'accent'], + ['waiting_input', 'Needs your input', 'warning'], + ['succeeded', 'Succeeded', 'success'], + ['failed', 'Failed', 'danger'], + ['cancelled', 'Cancelled', 'neutral'], + ])('labels %s', (state, label, tone) => { + const el = stateBadge(state) + expect(el.textContent).toBe(label) + expect(el.className).toBe(`pill pill-${tone}`) + }) +}) + +describe('progressBar', () => { + it('is a determinate progressbar with a rounded percentage', () => { + const el = progressBar(0.456, 'Scan') + expect(el).toHaveAttribute('role', 'progressbar') + expect(el).toHaveAttribute('aria-valuenow', '46') + expect(el).toHaveAttribute('aria-valuemin', '0') + expect(el).toHaveAttribute('aria-valuemax', '100') + expect(el).toHaveAttribute('aria-valuetext', 'Scan: 46%') + expect(el).toHaveAttribute('aria-label', 'Scan') + expect(el.className).toBe('bar bar-accent') + expect((el.firstElementChild as HTMLElement).style.width).toBe('46%') + }) + + it('is an indeterminate sweep without a fraction', () => { + const el = progressBar(null, 'Scan') + expect(el).toHaveClass('bar-indeterminate') + expect(el).not.toHaveAttribute('aria-valuenow') + expect(el).toHaveAttribute('aria-valuetext', 'Scan: working') + expect((el.firstElementChild as HTMLElement).style.width).toBe('') + }) + + it('clamps out-of-range fractions', () => { + expect(progressBar(2, 'x')).toHaveAttribute('aria-valuenow', '100') + expect(progressBar(-1, 'x')).toHaveAttribute('aria-valuenow', '0') + }) + + it('takes a tone and a small size', () => { + const el = progressBar(0.5, 'x', 'danger', true) + expect(el.className).toBe('bar bar-danger bar-small') + }) +}) diff --git a/web/src/jobs/progress.ts b/web/src/jobs/progress.ts new file mode 100644 index 0000000..3589307 --- /dev/null +++ b/web/src/jobs/progress.ts @@ -0,0 +1,52 @@ +// Job state pill and progress bar (was jobs/ProgressBar.tsx). + +import type { JobState } from '../api/jobContract' +import { h } from '../lib/dom' + +const STATE_LABEL: Record = { + queued: 'Queued', + running: 'Running', + waiting_input: 'Needs your input', + succeeded: 'Succeeded', + failed: 'Failed', + cancelled: 'Cancelled', +} + +const STATE_TONE: Record = { + queued: 'neutral', + running: 'accent', + waiting_input: 'warning', + succeeded: 'success', + failed: 'danger', + cancelled: 'neutral', +} + +export function stateBadge(state: JobState): HTMLSpanElement { + return h('span', { class: `pill pill-${STATE_TONE[state]}` }, STATE_LABEL[state]) +} + +export type BarTone = 'accent' | 'success' | 'danger' | 'warning' + +/** + * Determinate bar with a real `progressbar` role, or an indeterminate sweep + * when the job reported `total: null` (`fraction` null). Width transitions + * use the motion tokens, so reduced-motion users get instant jumps. + */ +export function progressBar(fraction: number | null, label: string, tone: BarTone = 'accent', small = false): HTMLDivElement { + const pct = fraction === null ? null : Math.round(Math.min(1, Math.max(0, fraction)) * 100) + const fill = h('div', { class: 'bar-fill' }) + if (pct !== null) fill.style.width = `${pct}%` + return h( + 'div', + { + class: `bar bar-${tone}${small ? ' bar-small' : ''}${pct === null ? ' bar-indeterminate' : ''}`, + role: 'progressbar', + 'aria-label': label, + 'aria-valuemin': 0, + 'aria-valuemax': 100, + 'aria-valuenow': pct, + 'aria-valuetext': pct === null ? `${label}: working` : `${label}: ${pct}%`, + }, + fill, + ) +} diff --git a/web/src/jobs/promptForm.test.ts b/web/src/jobs/promptForm.test.ts new file mode 100644 index 0000000..041898c --- /dev/null +++ b/web/src/jobs/promptForm.test.ts @@ -0,0 +1,250 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { q } from '../api/queries' +import type { PickMode } from '../library/pick' +import { button, must, setValue, submit } from '../test/events' +import { makeLibGame } from '../test/fixtures' +import { apiError, bodiesOf, flush, stubApi } from '../test/harness' +import { hub, type PromptEvent } from './hub' +import { promptForm } from './promptForm' + +// The real belongs to library/; the prompt only needs a `pick` slot. +class StubBrowser extends HTMLElement { + pick: PickMode | null = null +} +if (!customElements.get('game-browser')) customElements.define('game-browser', StubBrowser) + +const ANSWER = 'POST /api/jobs/j/answer' +const prompt = (over: Partial = {}): PromptEvent => ({ + seq: 1, ts: 't', type: 'prompt', prompt_id: 'q1', kind: 'text', message: 'Which one?', ...over, +}) +function show(over: Partial = {}) { + const form = promptForm('j', prompt(over)) + document.body.append(form) + return form +} +const inputs = (form: HTMLElement) => [...form.querySelectorAll('input')] +const answerBodies = (mock: ReturnType) => bodiesOf(mock, ANSWER) + +afterEach(vi.restoreAllMocks) + +describe('choice prompt', () => { + const options = [ + { value: 'a', label: 'Alpha', detail: 'the first' }, + { value: 'b', label: 'Beta' }, + ] + + it('is a radio group, first option focusable first, nothing preselected', () => { + const form = show({ kind: 'choice', options }) + const radios = inputs(form) + expect(radios).toHaveLength(2) + expect(radios[0]).toHaveAttribute('autofocus') + expect(radios[1]).not.toHaveAttribute('autofocus') + expect(form).toHaveTextContent('the first') + expect(form.querySelectorAll('.choice-detail')).toHaveLength(1) + expect(button(form, 'Answer')).toBeDisabled() + expect(form).toHaveAttribute('aria-label', 'Job question') + }) + + it('sends the chosen value and hides the prompt', async () => { + const mock = stubApi({ [ANSWER]: undefined }) + const answered = vi.spyOn(hub, 'answered') + const form = show({ kind: 'choice', options }) + inputs(form)[1]?.click() + expect(form.querySelector('[data-value=b]')).toHaveClass('selected') + expect(form.querySelector('[data-value=a]')).not.toHaveClass('selected') + button(form, 'Answer').click() + await vi.waitFor(() => { expect(answered).toHaveBeenCalledWith('j', 'q1') }) + expect(answerBodies(mock)).toEqual([{ prompt_id: 'q1', value: 'b' }]) + }) + + it('honours a default and tolerates missing options', () => { + const form = show({ kind: 'choice', default: 'a' }) + expect(button(form, 'Answer')).toBeEnabled() + expect(inputs(form)).toHaveLength(0) + }) + + it('preselects the default option', () => { + const form = show({ kind: 'choice', options, default: 'b' }) + expect(inputs(form)[1]?.checked).toBe(true) + expect(form.querySelector('[data-value=b]')).toHaveClass('selected') + }) + + it('ignores Enter until something is chosen', async () => { + const mock = stubApi({ [ANSWER]: undefined }) + submit(show({ kind: 'choice', options })) + await flush() + expect(answerBodies(mock)).toEqual([]) + }) +}) + +describe('text prompt', () => { + it('submits on Enter, even empty, starting from the default', async () => { + const mock = stubApi({ [ANSWER]: undefined }) + const form = show({ default: 'x' }) + const input = inputs(form)[0] + expect(input).toHaveAttribute('autofocus') + expect(input).toHaveAttribute('aria-label', 'Which one?') + expect(input).toHaveValue('x') + setValue(input, 'xy') + submit(input) + await vi.waitFor(() => { expect(answerBodies(mock)).toEqual([{ prompt_id: 'q1', value: 'xy' }]) }) + }) + + it('sends an empty answer', async () => { + const mock = stubApi({ [ANSWER]: undefined }) + submit(show()) + await vi.waitFor(() => { expect(answerBodies(mock)).toEqual([{ prompt_id: 'q1', value: '' }]) }) + }) +}) + +describe('confirm prompt', () => { + it('answers yes or no, yes focused by default', async () => { + const mock = stubApi({ [ANSWER]: undefined }) + const form = show({ kind: 'confirm' }) + expect(button(form, 'Yes')).toHaveAttribute('autofocus') + expect(button(form, 'No')).not.toHaveAttribute('autofocus') + button(form, 'Yes').click() + await vi.waitFor(() => { expect(button(form, 'No')).toBeEnabled() }) + button(form, 'No').click() + await vi.waitFor(() => { expect(answerBodies(mock)).toHaveLength(2) }) + expect(answerBodies(mock).map((b) => (b as { value: string }).value)).toEqual(['yes', 'no']) + }) + + it('focuses No when that is the default', () => { + const form = show({ kind: 'confirm', default: 'no' }) + expect(button(form, 'No')).toHaveAttribute('autofocus') + expect(button(form, 'Yes')).not.toHaveAttribute('autofocus') + }) + + it('disables both buttons while sending', async () => { + let release: () => void = () => undefined + stubApi({ [ANSWER]: () => new Promise((r) => { release = r }) }) + const form = show({ kind: 'confirm' }) + button(form, 'Yes').click() + await flush() + expect(button(form, 'Yes')).toBeDisabled() + expect(button(form, 'No')).toBeDisabled() + release() + await vi.waitFor(() => { expect(button(form, 'Yes')).toBeEnabled() }) + }) +}) + +describe('phrase prompt', () => { + it('stays disabled until the phrase is typed exactly', async () => { + const mock = stubApi({ [ANSWER]: undefined }) + const form = show({ kind: 'phrase', phrase: 'do it' }) + const answer = button(form, 'Answer') + const input = inputs(form)[0] + expect(input).toHaveAttribute('autofocus') + setValue(input, 'do i') + expect(answer).toBeDisabled() + setValue(input, 'do it') + expect(answer).toBeEnabled() + answer.click() + submit(answer) + await vi.waitFor(() => { expect(answerBodies(mock)).toContainEqual({ prompt_id: 'q1', value: 'do it' }) }) + }) + + it('starts from the default, and copes with a prompt that lost its phrase', () => { + expect(button(show({ kind: 'phrase', phrase: 'do it', default: 'do it' }), 'Answer')).toBeEnabled() + expect(button(show({ kind: 'phrase' }), 'Answer')).toBeEnabled() + }) +}) + +describe('answering', () => { + it('shows the server error when the answer is refused', async () => { + stubApi({ [ANSWER]: apiError(404, 'not_found', 'job is gone') }) + const form = show() + button(form, 'Answer').click() + await vi.waitFor(() => { expect(form).toHaveTextContent('job is gone') }) + }) + + it('shows Sending… on the answer button', async () => { + stubApi({ [ANSWER]: () => new Promise(() => undefined) }) + const form = show() + button(form, 'Answer').click() + await flush() + expect(button(form, 'Sending…')).toBeDisabled() + }) +}) + +describe('game prompt', () => { + const gprompt = (over: Partial = {}) => prompt({ prompt_id: 'g1', kind: 'game', message: 'Pick a game', app_ids: [1, 2], ...over }) + function showGame(over: Partial = {}) { + const form = promptForm('j', gprompt(over)) + document.body.append(form) + const browser = must(form.querySelector('game-browser')) + return { form, browser, pick: () => must(browser.pick) } + } + + it('embeds the browser in pick mode with only the offered ids', () => { + const { form, pick } = showGame() + expect(form).toHaveTextContent('2 games can be picked') + expect(form).toHaveClass('prompt-game') + expect(pick().appIds).toEqual(new Set([1, 2])) + expect(pick().selected).toBeNull() + expect(button(form, 'Select a game')).toBeDisabled() + }) + + it('selecting never submits; only the Pick button does', async () => { + const mock = stubApi({ [ANSWER]: undefined }) + const answered = vi.spyOn(hub, 'answered') + q.library.set({ games: [makeLibGame({ app_id: 2, name: 'The Witcher 2' })] }) + const { form, pick } = showGame() + pick().onSelect(2) + expect(pick().selected).toBe(2) + expect(answerBodies(mock)).toEqual([]) + button(form, 'Pick The Witcher 2').click() + submit(form) + await vi.waitFor(() => { expect(answered).toHaveBeenCalledWith('j', 'g1') }) + expect(answerBodies(mock)[0]).toEqual({ prompt_id: 'g1', value: '2' }) + }) + + it('"Back to the list" answers an empty string', async () => { + const mock = stubApi({ [ANSWER]: undefined }) + const { form } = showGame() + button(form, 'Back to the list').click() + await vi.waitFor(() => { expect(answerBodies(mock)).toEqual([{ prompt_id: 'g1', value: '' }]) }) + }) + + it('Enter does nothing without a selection, and submits one', async () => { + const mock = stubApi({ [ANSWER]: undefined }) + const { form, pick } = showGame() + submit(form) + await flush() + expect(answerBodies(mock)).toEqual([]) + pick().onSelect(1) + submit(form) + await vi.waitFor(() => { expect(answerBodies(mock)).toEqual([{ prompt_id: 'g1', value: '1' }]) }) + }) + + it('names the game by id when it is not in the library', () => { + q.library.set({ games: [] }) + const { form, pick } = showGame() + pick().onSelect(1) + expect(button(form, 'Pick 1')).toBeEnabled() + }) + + it('copes with a prompt that carries no app ids', () => { + const { form, pick } = showGame({ app_ids: undefined }) + expect(form).toHaveTextContent('0 games can be picked') + expect(pick().appIds.size).toBe(0) + }) + + it('shows Sending… meanwhile, then the error when the answer is refused', async () => { + let fail = false + let release: () => void = () => undefined + stubApi({ [ANSWER]: () => (fail ? apiError(409, 'busy', 'job moved on') : new Promise((r) => { release = r })) }) + const { form, pick } = showGame() + pick().onSelect(1) + button(form, 'Pick 1').click() + await flush() + expect(button(form, 'Sending…')).toBeDisabled() + expect(button(form, 'Back to the list')).toBeDisabled() + fail = true + release() + await flush() + button(form, 'Back to the list').click() + await vi.waitFor(() => { expect(form).toHaveTextContent('job moved on') }) + }) +}) diff --git a/web/src/jobs/promptForm.ts b/web/src/jobs/promptForm.ts new file mode 100644 index 0000000..db0b946 --- /dev/null +++ b/web/src/jobs/promptForm.ts @@ -0,0 +1,154 @@ +// Answer a job's prompt (was PromptForm.tsx + GamePrompt.tsx). Every kind is +// a real form: Enter submits, the first control is focused, and choices are +// radio buttons (arrow keys move). Each form owns its state; the job view +// replaces it only when a new prompt arrives. + +import { api } from '../api/client' +import { q } from '../api/queries' +import { phraseMatches } from '../commands/catalog' +import { PhraseField } from '../commands/phraseField' +import { Action } from '../lib/action' +import { h, hx } from '../lib/dom' +import type { GameBrowserElement } from '../library/pick' +import { errorNotice } from '../ui/build' +import { hub, type PromptEvent } from './hub' + +function answerAction(jobId: string, prompt: PromptEvent, onChange: () => void): Action<[string], void> { + return new Action(async (value: string) => { + await api.answer(jobId, { prompt_id: prompt.prompt_id, value }) + hub.answered(jobId, prompt.prompt_id) + }, onChange) +} + +export function promptForm(jobId: string, prompt: PromptEvent): HTMLFormElement { + return prompt.kind === 'game' ? gamePrompt(jobId, prompt) : answerForm(jobId, prompt) +} + +function answerForm(jobId: string, prompt: PromptEvent): HTMLFormElement { + let value = prompt.default ?? '' + const errorSlot = h('div', { class: 'contents' }) + const actions = h('div', { class: 'actions' }) + const send = answerAction(jobId, prompt, () => { + refresh() + }) + const submit = (v: string = value) => { + void send.run(v) + } + const form = h('form', { class: 'prompt', 'aria-label': 'Job question' }, h('p', { class: 'prompt-msg' }, prompt.message)) + form.addEventListener('submit', (e) => { + e.preventDefault() + if (ready()) submit() + }) + + const ready = () => + prompt.kind === 'text' || (prompt.kind === 'choice' ? value !== '' : phraseMatches(prompt.phrase ?? '', value)) + + if (prompt.kind === 'choice') { + const labels: HTMLLabelElement[] = [] + const set = h('fieldset', { class: 'choices' }, h('legend', { class: 'sr-only' }, prompt.message)) + ;(prompt.options ?? []).forEach((o, i) => { + const radio = h('input', { type: 'radio', name: `prompt-${prompt.prompt_id}`, value: o.value, autofocus: i === 0 }) + radio.checked = value === o.value + radio.addEventListener('change', () => { + value = o.value + refresh() + }) + const label = h( + 'label', + { class: 'choice' }, + radio, + h('span', { class: 'choice-label' }, o.label), + o.detail ? h('span', { class: 'choice-detail' }, o.detail) : null, + ) + label.dataset.value = o.value + labels.push(label) + set.append(label) + }) + form.append(set) + const markSelected = () => { + for (const l of labels) l.classList.toggle('selected', l.dataset.value === value) + } + form.addEventListener('change', markSelected) + markSelected() + } else if (prompt.kind === 'text') { + const input = h('input', { class: 'input', 'aria-label': prompt.message, autofocus: true }) + input.value = value + input.addEventListener('input', () => { + value = input.value + }) + form.append(input) + } else if (prompt.kind === 'phrase') { + const field = new PhraseField({ + phrase: prompt.phrase ?? '', + value, + autofocus: true, + onChange: (v) => { + value = v + refresh() + }, + }) + form.append(field.el) + } + form.append(errorSlot, actions) + + function refresh(): void { + errorSlot.replaceChildren(...(send.error ? [errorNotice(send.error)] : [])) + if (prompt.kind === 'confirm') { + actions.replaceChildren( + h('button', { type: 'button', class: 'btn btn-primary', autofocus: prompt.default !== 'no', disabled: send.pending, onclick: () => { submit('yes') } }, 'Yes'), + h('button', { type: 'button', class: 'btn btn-ghost', autofocus: prompt.default === 'no', disabled: send.pending, onclick: () => { submit('no') } }, 'No'), + ) + return + } + const btn = actions.querySelector('button') ?? actions.appendChild(h('button', { type: 'submit', class: 'btn btn-primary' })) + btn.disabled = !ready() || send.pending + btn.textContent = send.pending ? 'Sending…' : 'Answer' + } + refresh() + return form +} + +/** + * A `game` prompt: the library browser in pick mode. Only the ids the job + * sent are selectable (the job re-checks the answer). "Back to the list" + * answers empty, which returns the job to its previous question. + */ +function gamePrompt(jobId: string, prompt: PromptEvent): HTMLFormElement { + const appIds = new Set(prompt.app_ids ?? []) + let selected: number | null = null + const send = answerAction(jobId, prompt, () => { + refresh() + }) + const browser = hx('game-browser') as GameBrowserElement + const pick = (id: number) => { + selected = id + browser.pick = { appIds, selected, onSelect: pick } + refresh() + } + browser.pick = { appIds, selected, onSelect: pick } + const pickBtn = h('button', { type: 'submit', class: 'btn btn-primary' }) + const backBtn = h('button', { type: 'button', class: 'btn btn-ghost', onclick: () => void send.run('') }, 'Back to the list') + const errorSlot = h('div', { class: 'contents' }) + const form = h( + 'form', + { class: 'prompt prompt-game', 'aria-label': 'Job question' }, + h('p', { class: 'prompt-msg' }, prompt.message, ' ', h('span', { class: 'muted' }, `· ${appIds.size} games can be picked`)), + browser, + errorSlot, + h('div', { class: 'actions prompt-game-bar' }, pickBtn, backBtn), + ) + form.addEventListener('submit', (e) => { + e.preventDefault() + if (selected !== null) void send.run(String(selected)) + }) + + function refresh(): void { + const name = q.library.state.data?.games.find((g) => g.app_id === selected)?.name ?? String(selected) + pickBtn.disabled = selected === null || send.pending + pickBtn.textContent = send.pending ? 'Sending…' : selected === null ? 'Select a game' : `Pick ${name}` + backBtn.disabled = send.pending + errorSlot.replaceChildren(...(send.error ? [errorNotice(send.error)] : [])) + } + refresh() + return form +} diff --git a/web/src/jobs/useLiveJob.test.tsx b/web/src/jobs/useLiveJob.test.tsx deleted file mode 100644 index 06ff456..0000000 --- a/web/src/jobs/useLiveJob.test.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { act, renderHook } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { FakeEventSource, installEventSource } from '../test/harness' -import { useLiveJob } from './useLiveJob' - -describe('useLiveJob', () => { - beforeEach(() => { - installEventSource() - document.head.innerHTML = '' - }) - - it('is null without a job and opens no stream', () => { - const { result } = renderHook(() => useLiveJob(null)) - expect(result.current).toBeNull() - expect(FakeEventSource.all).toHaveLength(0) - }) - - it('follows a job while mounted and re-renders on events', () => { - const { result, unmount } = renderHook(() => useLiveJob('live-1')) - expect(result.current?.state).toBeNull() - act(() => FakeEventSource.last.emit('state', { seq: 1, ts: 't', type: 'state', state: 'running' })) - expect(result.current?.state).toBe('running') - unmount() - expect(FakeEventSource.last.closed).toBe(true) - }) - - it('switches streams when the id changes', () => { - const { rerender } = renderHook(({ id }) => useLiveJob(id), { initialProps: { id: 'live-2' } }) - const first = FakeEventSource.last - rerender({ id: 'live-3' }) - expect(first.closed).toBe(true) - expect(FakeEventSource.last.url).toContain('/live-3/') - vi.clearAllMocks() - }) -}) diff --git a/web/src/jobs/useLiveJob.ts b/web/src/jobs/useLiveJob.ts deleted file mode 100644 index f884354..0000000 --- a/web/src/jobs/useLiveJob.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { useCallback, useEffect, useSyncExternalStore } from 'react' -import { hub, type LiveJob } from './hub' - -/** Follow a job's SSE stream for as long as the caller is mounted. */ -export function useLiveJob(id: string | null): LiveJob | null { - useEffect(() => (id ? hub.watch(id) : undefined), [id]) - const snapshot = useCallback(() => (id ? hub.get(id) : null), [id]) - return useSyncExternalStore(hub.subscribe, snapshot) -} diff --git a/web/src/layouts/Shell.astro b/web/src/layouts/Shell.astro new file mode 100644 index 0000000..b2607eb --- /dev/null +++ b/web/src/layouts/Shell.astro @@ -0,0 +1,83 @@ +--- +// Every page: sidebar nav, top bar (commands, running jobs, health), the +// stale-server banner, and the one command palette + runner dialog pair. +// All live parts are custom elements registered by src/elements/shell.ts. +import '@kuhyx/web-ui/tokens.css' +import '../styles/theme.css' +import '../index.css' +import '../styles/app.css' +import '../styles/controls.css' +import '../styles/screens.css' +import '../styles/library.css' +import DevToken from '../components/DevToken.astro' +import { BUILD_STAMP } from '../lib/buildStamp' +import { NAV } from '../shell/nav' + +interface Props { + title: string +} +const { title } = Astro.props +const here = Astro.url.pathname.replace(/\/$/, '') || '/' +const isActive = (to: string) => (to === '/' ? here === '/' : here === to || here.startsWith(`${to}/`)) +const href = (to: string) => (to === '/' ? '/' : `${to}/`) +--- + + + + + + + + + + {title} · Steam Backlog Enforcer + + + +
+ +
+
+ + + + +
+ +
+ +
+
+
+ + + + + diff --git a/web/src/lib/action.test.ts b/web/src/lib/action.test.ts new file mode 100644 index 0000000..3db0a44 --- /dev/null +++ b/web/src/lib/action.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest' +import { Action } from './action' + +describe('Action', () => { + it('reports pending around a successful run and resolves the value', async () => { + const seen: boolean[] = [] + let release = (): void => undefined + const gate = new Promise((r) => { + release = r + }) + const action = new Action<[number], number>( + async (n: number) => { + await gate + return n * 2 + }, + () => { + seen.push(action.pending) + }, + ) + const out = action.run(4) + expect(action.pending).toBe(true) + release() + await expect(out).resolves.toEqual({ ok: true, value: 8 }) + expect(seen).toEqual([true, false]) + expect(action.error).toBeNull() + }) + + it('keeps the error, then clears it on the next run', async () => { + const boom = new Error('boom') + let fail = true + const onChange = vi.fn() + const action = new Action(() => (fail ? Promise.reject(boom) : Promise.resolve('fine')), onChange) + await expect(action.run()).resolves.toEqual({ ok: false }) + expect(action.error).toBe(boom) + expect(action.pending).toBe(false) + fail = false + await expect(action.run()).resolves.toEqual({ ok: true, value: 'fine' }) + expect(action.error).toBeNull() + expect(onChange).toHaveBeenCalledTimes(4) + }) +}) diff --git a/web/src/lib/action.ts b/web/src/lib/action.ts new file mode 100644 index 0000000..63cc1a9 --- /dev/null +++ b/web/src/lib/action.ts @@ -0,0 +1,31 @@ +// A mutation without React Query: tracks pending/error for one async call +// and reports every change so the owning element can re-render. + +export class Action { + pending = false + error: unknown = null + private readonly fn: (...args: A) => Promise + private readonly onChange: () => void + + constructor(fn: (...args: A) => Promise, onChange: () => void) { + this.fn = fn + this.onChange = onChange + } + + /** Run once; resolves to the result, or undefined when it failed. */ + async run(...args: A): Promise<{ ok: true; value: R } | { ok: false }> { + this.pending = true + this.error = null + this.onChange() + try { + const value = await this.fn(...args) + return { ok: true, value } + } catch (e) { + this.error = e + return { ok: false } + } finally { + this.pending = false + this.onChange() + } + } +} diff --git a/web/src/lib/buildStamp.test.ts b/web/src/lib/buildStamp.test.ts new file mode 100644 index 0000000..b999012 --- /dev/null +++ b/web/src/lib/buildStamp.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { BUILD_STAMP, commitId } from './buildStamp' + +describe('build stamp', () => { + it('is the short commit plus a per-build time part', () => { + expect(BUILD_STAMP).toMatch(/^[0-9a-f]{7,}\.[0-9a-z]+$/) + }) + + it('falls back when git is unavailable', () => { + expect( + commitId(() => { + throw new Error('not a repo') + }), + ).toBe('unknown') + expect(commitId(() => 'abc1234\n')).toBe('abc1234') + }) +}) diff --git a/web/src/lib/buildStamp.ts b/web/src/lib/buildStamp.ts new file mode 100644 index 0000000..b252d0a --- /dev/null +++ b/web/src/lib/buildStamp.ts @@ -0,0 +1,18 @@ +// Build-time stamp for `` (rendered in Shell.astro's +// frontmatter, never shipped to the browser). The commit says which code is +// served (`curl … | grep $(git rev-parse --short HEAD)`); the time part makes +// every rebuild distinct, so an open tab reloads onto an uncommitted rebuild +// too. Computed once per process: a dev server keeps one stamp, so pages do +// not reload themselves every poll. + +import { execSync } from 'node:child_process' + +export function commitId(run: (cmd: string) => string = (cmd) => execSync(cmd, { encoding: 'utf8' })): string { + try { + return run('git rev-parse --short HEAD').trim() + } catch { + return 'unknown' + } +} + +export const BUILD_STAMP = `${commitId()}.${Date.now().toString(36)}` diff --git a/web/src/lib/charts/budgetHistory.test.ts b/web/src/lib/charts/budgetHistory.test.ts new file mode 100644 index 0000000..634949e --- /dev/null +++ b/web/src/lib/charts/budgetHistory.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import type { BudgetHistoryDay, BudgetLegendEntry } from '../../types' +import { budgetHistorySvg, esc, H } from './budgetHistory' + +const LEGEND: BudgetLegendEntry[] = [ + { key: 'app:475150', label: 'Titan Quest' }, + { key: 'proc:osu-lazer', label: 'osu!lazer' }, + { key: 'unattributed', label: 'Unattributed' }, +] + +function day(over: Partial = {}): BudgetHistoryDay { + return { day: '2026-08-28', seconds: 14400, segments: [{ key: 'app:475150', seconds: 14400 }], ...over } +} + +/** Parse the markup the way the page does, into the document. */ +function render(markup: string): SVGSVGElement { + document.body.innerHTML = markup + const svg = document.querySelector('svg') + if (!svg) throw new Error('no in the markup') + return svg +} + +const num = (el: Element | undefined, attr: string) => Number(el?.getAttribute(attr)) + +describe('budgetHistorySvg', () => { + it('draws a single day without filling the plot', () => { + const svg = render(budgetHistorySvg([day()], LEGEND, 28800)) + expect(svg.namespaceURI).toBe('http://www.w3.org/2000/svg') + const bars = svg.querySelectorAll('rect.seg') + expect(bars).toHaveLength(1) + // Scaled against the budget, half a budget is half the plot height. + expect(num(bars[0], 'height')).toBeLessThan(Number(svg.getAttribute('viewBox')?.split(' ')[3]) / 2) + expect(svg.querySelector('text.axis-label.mid')?.textContent).toBe('08-28') + expect(svg.querySelector('line.budget-line')).not.toBeNull() + expect(svg.querySelectorAll('line.grid')).toHaveLength(3) + }) + + it('stacks one band per game and colours it by legend position', () => { + const svg = render( + budgetHistorySvg( + [day({ seconds: 21600, segments: [{ key: 'app:475150', seconds: 14400 }, { key: 'proc:osu-lazer', seconds: 7200 }] })], + LEGEND, + 28800, + ), + ) + const [first, second] = svg.querySelectorAll('rect.seg') + expect(svg.querySelectorAll('rect.seg')).toHaveLength(2) + expect(svg.querySelector('rect.cat-1')).not.toBeNull() + expect(svg.querySelector('rect.cat-2')).not.toBeNull() + // Bands abut: the second starts exactly where the first ends. + expect(num(second, 'y') + num(second, 'height')).toBeCloseTo(num(first, 'y'), 5) + }) + + it('renders an unattributed day in its own neutral band', () => { + const svg = render(budgetHistorySvg([day({ day: '2026-08-24', seconds: 12000, segments: [{ key: 'unattributed', seconds: 12000 }] })], LEGEND, 28800)) + expect(svg.querySelectorAll('rect.seg.unattributed')).toHaveLength(1) + // No ramp hue: an absence of information must not read as another game. + expect(svg.querySelector('rect.cat-1')).toBeNull() + }) + + it('labels each band with its game in the tooltip, escaped', () => { + const svg = render(budgetHistorySvg([day({ segments: [{ key: 'b', seconds: 3600 }] })], [{ key: 'b', label: `Baldur's & "3"` }], 28800)) + expect(svg.querySelector('rect.seg title')?.textContent).toBe(`2026-08-28 · Baldur's & "3": 1h 0m`) + }) + + it('falls back to the raw key and the first hue when the legend omits it', () => { + const svg = render(budgetHistorySvg([day({ segments: [{ key: 'app:999', seconds: 3600 }] })], LEGEND, 28800)) + expect(svg.querySelector('rect.seg title')?.textContent).toContain('app:999') + expect(svg.querySelector('rect.cat-1')).not.toBeNull() + }) + + it('scales to the tallest day once one passes the budget', () => { + const svg = render(budgetHistorySvg([day({ seconds: 57600, segments: [{ key: 'app:475150', seconds: 57600 }] })], LEGEND, 28800)) + // The over-budget day sets the top tick; the budget is then the middle one. + expect([...svg.querySelectorAll('text.axis-label.end')].map((t) => t.textContent)).toEqual(['0s', '8h 0m', '16h 0m']) + }) + + it('survives a zero budget and a day that billed nothing', () => { + const svg = render(budgetHistorySvg([day({ seconds: 0, segments: [] })], [], 0)) + // A day with no billed time draws no bands, but the axes still render. + expect(svg.querySelectorAll('rect.seg')).toHaveLength(0) + expect(num(svg.querySelector('line.budget-line') ?? undefined, 'y1')).toBeLessThan(H) + expect(svg.innerHTML).not.toContain('NaN') + }) +}) + +describe('esc', () => { + it('escapes every markup-significant character', () => { + expect(esc(`Tom & Jerry's`)).toBe('<a href="x">Tom & Jerry's</a>') + }) +}) diff --git a/web/src/lib/charts/budgetHistory.ts b/web/src/lib/charts/budgetHistory.ts new file mode 100644 index 0000000..a9817ab --- /dev/null +++ b/web/src/lib/charts/budgetHistory.ts @@ -0,0 +1,74 @@ +// Stacked 14-day budget bars as an SVG string (was BudgetHistoryChart.tsx). +// A string rather than DOM nodes: `h()` builds HTML-namespace elements, which +// an does not render, while innerHTML parses SVG markup correctly. + +import { seriesClass } from '../../budgetSeries' +import { fmtDuration } from '../../format' +import type { BudgetHistoryDay, BudgetLegendEntry } from '../../types' + +export const W = 820 +export const H = 220 +const PAD = { top: 16, right: 20, bottom: 36, left: 56 } + +/** Escape text for SVG/HTML content and attribute values. */ +export function esc(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +/** Unrounded on purpose: stacked bands must abut exactly. */ +const n = (v: number): string => String(v) + +/** The chart for `days` (non-empty; the caller shows the empty state). */ +export function budgetHistorySvg(days: readonly BudgetHistoryDay[], legend: readonly BudgetLegendEntry[], budgetSeconds: number): string { + // Scaled against the budget rather than the tallest bar, so a single day + // reads as "most of the allowance" instead of a lone full-height bar that + // means nothing on its own. + const maxSeconds = Math.max(budgetSeconds, ...days.map((d) => d.seconds)) || 1 + const plotW = W - PAD.left - PAD.right + const plotH = H - PAD.top - PAD.bottom + const sy = (seconds: number) => PAD.top + plotH - (seconds / maxSeconds) * plotH + const slot = plotW / days.length + const barW = slot * 0.7 + const labelFor = new Map(legend.map((e) => [e.key, e.label])) + const indexFor = new Map(legend.map((e, i) => [e.key, i])) + + const yTicks = [0, 0.5, 1].map((f) => f * maxSeconds) + const grid = yTicks.map( + (s) => + `` + + `${esc(fmtDuration(s))}`, + ) + const budgetLine = `` + + const bars = days.flatMap((d, i) => { + // Bands stack upwards from the axis in legend order, so a game keeps the + // same colour and the same relative position in every bar. + let base = 0 + return d.segments.map((seg) => { + const top = base + seg.seconds + const y = sy(top) + const height = sy(base) - y + base = top + const title = `${d.day} · ${labelFor.get(seg.key) ?? seg.key}: ${fmtDuration(seg.seconds)}` + return ( + `${esc(title)}` + ) + }) + }) + + const xLabels = days.map( + (d, i) => `${esc(d.day.slice(5))}`, + ) + + return ( + `` + + [...grid, budgetLine, ...bars, ...xLabels].join('') + + '' + ) +} diff --git a/web/src/lib/charts/timeline.test.ts b/web/src/lib/charts/timeline.test.ts new file mode 100644 index 0000000..c20a0c4 --- /dev/null +++ b/web/src/lib/charts/timeline.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' +import { applyFilters } from '../../estimate' +import { makeDataset, makeFilters, makeGame, makeState } from '../../test/factories' +import type { EstimateBasis } from '../../types' +import { timelineChart, timelinePoints, timelineSvg } from './timeline' + +const NOW = new Date(2026, 0, 1) + +function chart(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 }) + return timelineChart(applyFilters(makeDataset(games), filters), filters, makeState({ pace_games_per_day: pace }), NOW) +} + +describe('timelinePoints', () => { + it('accumulates hours shortest first at the daily rate', () => { + const games = [makeGame({ app_id: 1, leisure_hours: 8 }), makeGame({ app_id: 2, leisure_hours: 4 })] + const filters = makeFilters({ dailyHours: 2 }) + expect(timelinePoints(applyFilters(makeDataset(games), filters), filters, makeState())).toEqual([ + { day: 2, games: 1 }, + { day: 6, games: 2 }, + ]) + }) + + it('spaces games by the pace for the pace basis, or gives up without one', () => { + const filters = makeFilters({ basis: 'pace' }) + const result = applyFilters(makeDataset([makeGame({ app_id: 1 }), makeGame({ app_id: 2 })]), filters) + expect(timelinePoints(result, filters, makeState({ pace_games_per_day: 0.5 }))).toEqual([ + { day: 2, games: 1 }, + { day: 4, games: 2 }, + ]) + expect(timelinePoints(result, filters, makeState())).toEqual([]) + }) +}) + +describe('timelineChart', () => { + it('shows a fallback message with fewer than two games', () => { + const box = chart(1) + expect(box.textContent).toContain('Not enough games in scope to draw a timeline.') + expect(box.querySelector('h2')?.textContent).toBe('Completion timeline') + expect(box.querySelector('svg')).toBeNull() + }) + + it('draws an SVG line for the hours basis', () => { + const box = chart(3, 'leisure') + expect(box.querySelector('h2')?.textContent).toBe('Completion timeline · leisure') + expect(box.querySelector('svg.chart-svg')).not.toBeNull() + expect(box.querySelector('path.line')?.getAttribute('d')).toMatch(/^M[\d.]+ [\d.]+ L/) + expect(box.querySelector('path.area')?.getAttribute('d')).toMatch(/ Z$/) + expect(box.querySelector('.hint')?.textContent).toMatch(/shortest games first/) + }) + + it('labels the y axis in games and the x axis in dates from now', () => { + const box = chart(4, 'leisure') + const labels = [...box.querySelectorAll('text')].map((t) => t.textContent) + expect(labels.slice(0, 5)).toEqual(['0', '1', '2', '3', '4']) + expect(labels[5]).toBe('2026-01-01') + expect(labels).toHaveLength(10) + }) + + it('draws an SVG line for the pace basis when pace is known', () => { + expect(chart(3, 'pace', 0.5).querySelector('svg.chart-svg')).not.toBeNull() + }) + + it('shows the fallback message for the pace basis with no pace', () => { + expect(chart(3, 'pace', 0).textContent).toMatch(/Not enough games/) + }) + + it('falls back to a one-day span when the last point is day 0', () => { + const svg = timelineSvg([{ day: -1, games: 1 }, { day: 0, games: 2 }], NOW) + expect(svg.querySelector('path.line')?.getAttribute('d')).not.toMatch(/NaN|Infinity/) + }) + + it('survives an empty series', () => { + expect(timelineSvg([], NOW).querySelector('path.line')?.getAttribute('d')).toBe('') + }) + + it('defaults the dates to today', () => { + const svg = timelineSvg([{ day: 0, games: 1 }, { day: 1, games: 2 }]) + expect(svg.querySelectorAll('text.axis-label.mid')).toHaveLength(5) + expect(chart(2).querySelector('svg')).not.toBeNull() + }) +}) diff --git a/web/src/lib/charts/timeline.ts b/web/src/lib/charts/timeline.ts new file mode 100644 index 0000000..571e606 --- /dev/null +++ b/web/src/lib/charts/timeline.ts @@ -0,0 +1,98 @@ +// The planner's "games finished by day N" chart (was TimelineChart.tsx) as a +// pure SVG element builder: points from the estimate, then one . + +import type { EstimateResult } from '../../estimate' +import { isoDate } from '../../format' +import type { Filters, WebStateInfo } from '../../types' + +const W = 820 +const H = 320 +const PAD = { top: 16, right: 20, bottom: 36, left: 48 } +const NS = 'http://www.w3.org/2000/svg' + +export interface TimelinePoint { + day: number + games: number +} + +/** The cumulative curve for the basis, shortest games first; [] when pace is unknown. */ +export function timelinePoints(result: EstimateResult, filters: Filters, state: WebStateInfo): TimelinePoint[] { + const rows = [...result.included].sort((a, b) => a.lengthHours - b.lengthHours) + if (filters.basis === 'pace') { + const pace = state.pace_games_per_day + if (pace <= 0) return [] + return rows.map((_, i) => ({ day: (i + 1) / pace, games: i + 1 })) + } + let cum = 0 + return rows.map((r, i) => { + cum += r.lengthHours + return { day: cum / filters.dailyHours, games: i + 1 } + }) +} + +function svg(tag: string, attrs: Record, text?: string): SVGElement { + const el = document.createElementNS(NS, tag) + for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v)) + if (text !== undefined) el.textContent = text + return el +} + +function dayToDate(day: number, now: Date): string { + const d = new Date(now) + d.setDate(d.getDate() + Math.round(day)) + return isoDate(d) +} + +/** The chart for at least two points; dates count from `now`. */ +export function timelineSvg(pts: readonly TimelinePoint[], now: Date = new Date()): SVGElement { + const maxDay = (pts.at(-1)?.day ?? 0) || 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 fractions = [0, 0.25, 0.5, 0.75, 1] + + const root = svg('svg', { viewBox: `0 0 ${W} ${H}`, class: 'chart-svg', role: 'img', 'aria-label': 'Completion timeline' }) + for (const g of fractions.map((f) => Math.round(f * maxGames))) { + const group = svg('g', {}) + group.append( + svg('line', { x1: PAD.left, y1: sy(g), x2: W - PAD.right, y2: sy(g), class: 'grid' }), + svg('text', { x: PAD.left - 8, y: sy(g) + 4, class: 'axis-label end' }, String(g)), + ) + root.append(group) + } + for (const d of fractions.map((f) => f * maxDay)) { + root.append(svg('text', { x: sx(d), y: H - 12, class: 'axis-label mid' }, dayToDate(d, now))) + } + root.append(svg('path', { d: area, class: 'area' }), svg('path', { d: path, class: 'line' })) + return root +} + +/** The whole `.chart` box: title, SVG (or why there is none) and caption. */ +export function timelineChart( + result: EstimateResult, + filters: Filters, + state: WebStateInfo, + now: Date = new Date(), +): HTMLDivElement { + const pts = timelinePoints(result, filters, state) + const box = document.createElement('div') + box.className = 'chart' + const title = document.createElement('h2') + const hint = document.createElement('p') + hint.className = 'hint' + if (pts.length < 2) { + title.textContent = 'Completion timeline' + hint.textContent = 'Not enough games in scope to draw a timeline.' + box.append(title, hint) + return box + } + title.textContent = `Completion timeline · ${filters.basis}` + hint.textContent = 'Cumulative games finished over time (shortest games first).' + box.append(title, timelineSvg(pts, now), hint) + return box +} diff --git a/web/src/lib/dom.test.ts b/web/src/lib/dom.test.ts new file mode 100644 index 0000000..853aa27 --- /dev/null +++ b/web/src/lib/dom.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest' +import { append, define, h, hx, jsonAttr } from './dom' + +describe('h', () => { + it('sets string and number attributes, and bare booleans', () => { + const el = h('input', { id: 'a', tabindex: 3, disabled: true }) + expect(el.id).toBe('a') + expect(el.getAttribute('tabindex')).toBe('3') + expect(el.getAttribute('disabled')).toBe('') + }) + + it('skips false, null and undefined attributes', () => { + const el = h('div', { a: false, b: null, c: undefined }) + expect(el.attributes).toHaveLength(0) + }) + + it('turns on* attributes into listeners', () => { + const onclick = vi.fn() + const el = h('button', { onclick }) + el.click() + expect(onclick).toHaveBeenCalledTimes(1) + }) + + it('flattens child arrays and skips empty children', () => { + const el = h('p', {}, 'a', ['b', ['c', 4]], null, undefined, false, h('i')) + expect(el.textContent).toBe('abc4') + expect(el.querySelector('i')).not.toBeNull() + }) +}) + +describe('append', () => { + it('ignores empties and appends text and nodes', () => { + const el = document.createElement('div') + append(el, null) + append(el, undefined) + append(el, false) + append(el, 7) + append(el, document.createElement('b')) + expect(el.innerHTML).toBe('7') + }) +}) + +describe('hx', () => { + it('builds a custom-tag element', () => { + const el = hx('x-thing', { a: '1' }, 'hi') + expect(el.tagName).toBe('X-THING') + expect(el.getAttribute('a')).toBe('1') + expect(el.textContent).toBe('hi') + }) +}) + +describe('define', () => { + it('registers once and ignores a second definition', () => { + class A extends HTMLElement {} + class B extends HTMLElement {} + define('x-dom-define', A) + define('x-dom-define', B) + expect(customElements.get('x-dom-define')).toBe(A) + }) +}) + +describe('jsonAttr', () => { + const el = h('div', { ok: '{"a":1}', bad: '{nope' }) + it('parses valid JSON', () => { + expect(jsonAttr(el, 'ok', null)).toEqual({ a: 1 }) + }) + it('falls back when absent or invalid', () => { + expect(jsonAttr(el, 'missing', 'fb')).toBe('fb') + expect(jsonAttr(el, 'bad', 'fb')).toBe('fb') + }) +}) diff --git a/web/src/lib/dom.ts b/web/src/lib/dom.ts new file mode 100644 index 0000000..e187c10 --- /dev/null +++ b/web/src/lib/dom.ts @@ -0,0 +1,52 @@ +// Tiny DOM builder shared by every custom element (after dufs-cloud's +// elements/dom.ts): `on*` attrs become listeners, `true` is a bare boolean +// attribute, `false`/null/undefined attrs and children are skipped, and +// child arrays are flattened so `rows.map(...)` can be passed directly. + +export type Attr = string | number | boolean | null | undefined | EventListener +export type Child = Node | string | number | null | undefined | false | readonly Child[] + +/** Append `child` (flattening arrays, skipping empties) to `el`. */ +export function append(el: Element, child: Child): void { + if (child === null || child === undefined || child === false) return + if (Array.isArray(child)) { + for (const c of child as readonly Child[]) append(el, c) + return + } + el.append(typeof child === 'number' ? String(child) : (child as Node | string)) +} + +export function h( + tag: K, + attrs: Record = {}, + ...children: Child[] +): HTMLElementTagNameMap[K] { + const el = document.createElement(tag) + for (const [key, value] of Object.entries(attrs)) { + if (typeof value === 'function') el.addEventListener(key.slice(2).toLowerCase(), value) + else if (value === true) el.setAttribute(key, '') + else if (value !== false && value !== null && value !== undefined) el.setAttribute(key, String(value)) + } + for (const child of children) append(el, child) + return el +} + +/** An element by tag name that is not in HTMLElementTagNameMap (custom elements). */ +export function hx(tag: string, attrs: Record = {}, ...children: Child[]): HTMLElement { + return h(tag as 'div', attrs, ...children) +} + +export function define(tag: string, ctor: CustomElementConstructor): void { + if (!customElements.get(tag)) customElements.define(tag, ctor) +} + +/** The parsed JSON of attribute `name`, or `fallback` when absent or invalid. */ +export function jsonAttr(el: Element, name: string, fallback: T): T { + const raw = el.getAttribute(name) + if (raw === null) return fallback + try { + return JSON.parse(raw) as T + } catch { + return fallback + } +} diff --git a/web/src/lib/element.test.ts b/web/src/lib/element.test.ts new file mode 100644 index 0000000..45d9191 --- /dev/null +++ b/web/src/lib/element.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { LiveElement } from './element' +import { Query } from './query' + +const query = new Query(() => Promise.resolve('v')) +const updates: string[] = [] + +class Probe extends LiveElement { + read: (() => { data: string | undefined }) | null = null + protected override setup(): void { + this.read = this.watch(query) + this.tick(100) + this.onCleanup(() => updates.push('cleanup')) + } + protected update(): void { + updates.push(this.read?.().data ?? 'none') + } +} +class Bare extends LiveElement { + runs = 0 + protected update(): void { + this.runs += 1 + } +} +customElements.define('x-probe', Probe) +customElements.define('x-bare', Bare) + +describe('LiveElement', () => { + afterEach(() => { + updates.length = 0 + }) + + it('runs setup then update on connect, and re-updates on query changes', async () => { + const el = new Probe() + document.body.append(el) + expect(updates).toEqual(['none']) + await query.refetch() + expect(updates.at(-1)).toBe('v') + }) + + it('ticks while connected and releases everything on disconnect', async () => { + vi.useFakeTimers() + const el = new Probe() + document.body.append(el) + updates.length = 0 + vi.advanceTimersByTime(250) + expect(updates).toHaveLength(2) + el.remove() + expect(updates.at(-1)).toBe('cleanup') + updates.length = 0 + vi.advanceTimersByTime(500) + await query.refetch() + expect(updates).toEqual([]) + }) + + it('works without overriding setup', () => { + const el = new Bare() + document.body.append(el) + expect(el.runs).toBe(1) + el.remove() + }) +}) diff --git a/web/src/lib/element.ts b/web/src/lib/element.ts new file mode 100644 index 0000000..7f4829a --- /dev/null +++ b/web/src/lib/element.ts @@ -0,0 +1,50 @@ +// Base class for the app's stateful custom elements. Subclasses declare the +// queries they read in `setup()` via `watch()` and rebuild their light-DOM +// content in `update()`, which runs once on connect and again whenever a +// watched query changes. Everything subscribed is released on disconnect. + +import type { Query, QueryState } from './query' + +export abstract class LiveElement extends HTMLElement { + private cleanups: (() => void)[] = [] + + connectedCallback(): void { + this.setup() + this.update() + } + + disconnectedCallback(): void { + for (const fn of this.cleanups.splice(0)) fn() + } + + /** Subscribe to `query`; `update()` re-runs on every change. */ + protected watch(query: Query): () => QueryState { + this.cleanups.push( + query.subscribe(() => { + this.update() + }), + ) + return () => query.state + } + + /** Run `fn` on disconnect (timers, window listeners). */ + protected onCleanup(fn: () => void): void { + this.cleanups.push(fn) + } + + /** Re-run `update()` every `ms` (countdowns, "x ago" labels). */ + protected tick(ms: number): void { + const id = setInterval(() => { + this.update() + }, ms) + this.onCleanup(() => { + clearInterval(id) + }) + } + + protected setup(): void { + // Default: nothing to watch. + } + + protected abstract update(): void +} diff --git a/web/src/lib/ids.test.ts b/web/src/lib/ids.test.ts new file mode 100644 index 0000000..efe0d42 --- /dev/null +++ b/web/src/lib/ids.test.ts @@ -0,0 +1,10 @@ +import { expect, it } from 'vitest' +import { uid } from './ids' + +it('hands out document-unique ids with a prefix', () => { + const a = uid() + const b = uid('phrase') + expect(a).toMatch(/^sbe-\d+$/) + expect(b).toMatch(/^phrase-\d+$/) + expect(a).not.toBe(b) +}) diff --git a/web/src/lib/ids.ts b/web/src/lib/ids.ts new file mode 100644 index 0000000..8d25af3 --- /dev/null +++ b/web/src/lib/ids.ts @@ -0,0 +1,7 @@ +let next = 0 + +/** A document-unique id prefix (was React's useId). */ +export function uid(prefix = 'sbe'): string { + next += 1 + return `${prefix}-${next}` +} diff --git a/web/src/lib/query.test.ts b/web/src/lib/query.test.ts new file mode 100644 index 0000000..f923fab --- /dev/null +++ b/web/src/lib/query.test.ts @@ -0,0 +1,176 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { family, invalidateAll, Query, resetAll } from './query' + +function counting(values: (() => T | Promise)[]) { + let calls = 0 + const fetcher = vi.fn(async () => { + const make = values[Math.min(calls, values.length - 1)] + calls += 1 + return make() + }) + return fetcher +} + +describe('Query', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + it('starts loading, fetches on first subscribe and notifies', async () => { + const q = new Query(counting([() => 'a'])) + expect(q.state).toEqual({ data: undefined, error: null, loading: true }) + const seen = vi.fn() + q.subscribe(seen) + await vi.advanceTimersByTimeAsync(0) + expect(q.state).toEqual({ data: 'a', error: null, loading: false }) + expect(seen).toHaveBeenCalledWith({ data: 'a', error: null, loading: false }) + }) + + it('shares one fetch between subscribers and joins an in-flight refetch', async () => { + const fetcher = counting([() => 'a']) + const q = new Query(fetcher) + q.subscribe(() => undefined) + q.subscribe(() => undefined) + void q.refetch() + await vi.advanceTimersByTimeAsync(0) + expect(fetcher).toHaveBeenCalledTimes(1) + }) + + it('keeps the last good data when a refetch fails', async () => { + const boom = new Error('x') + const q = new Query(counting([() => 'ok', () => Promise.reject(boom)])) + q.subscribe(() => undefined) + await vi.advanceTimersByTimeAsync(0) + await q.refetch() + expect(q.state).toEqual({ data: 'ok', error: boom, loading: false }) + }) + + it('polls at a fixed interval while subscribed, and stops after unsubscribe', async () => { + const fetcher = counting([() => 1]) + const q = new Query(fetcher, { intervalMs: 1000 }) + const off = q.subscribe(() => undefined) + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(1000) + expect(fetcher).toHaveBeenCalledTimes(2) + off() + await vi.advanceTimersByTimeAsync(5000) + expect(fetcher).toHaveBeenCalledTimes(2) + }) + + it('keeps polling while another subscriber remains', async () => { + const fetcher = counting([() => 1]) + const q = new Query(fetcher, { intervalMs: 100 }) + const first = q.subscribe(() => undefined) + q.subscribe(() => undefined) + await vi.advanceTimersByTimeAsync(0) + first() + await vi.advanceTimersByTimeAsync(100) + expect(fetcher).toHaveBeenCalledTimes(2) + }) + + it('takes the interval from a function of the data', async () => { + const fetcher = counting([() => 5]) + const q = new Query(fetcher, { intervalMs: (d) => (d === 5 ? 200 : 900) }) + q.subscribe(() => undefined) + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(200) + expect(fetcher).toHaveBeenCalledTimes(2) + }) + + it.each<[false | undefined]>([[false], [undefined]])('does not poll when the interval is %s', async (intervalMs) => { + const fetcher = counting([() => 1]) + const q = new Query(fetcher, { intervalMs }) + q.subscribe(() => undefined) + await vi.advanceTimersByTimeAsync(60_000) + expect(fetcher).toHaveBeenCalledTimes(1) + }) + + it('does not schedule when the last listener left during the fetch', async () => { + const fetcher = counting([() => 1]) + const q = new Query(fetcher, { intervalMs: 100 }) + q.subscribe(() => undefined)() + await vi.advanceTimersByTimeAsync(1000) + expect(fetcher).toHaveBeenCalledTimes(1) + }) + + it('reuses a fresh value on a new subscription, with the default or custom window', async () => { + const fetcher = counting([() => 1]) + const q = new Query(fetcher, { intervalMs: 10_000 }) + q.subscribe(() => undefined)() + await vi.advanceTimersByTimeAsync(0) + q.subscribe(() => undefined)() + expect(fetcher).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(5001) + q.subscribe(() => undefined) + expect(fetcher).toHaveBeenCalledTimes(2) + + const slow = counting([() => 1]) + const q2 = new Query(slow, { staleMs: 60_000 }) + q2.subscribe(() => undefined)() + await vi.advanceTimersByTimeAsync(30_000) + q2.subscribe(() => undefined) + expect(slow).toHaveBeenCalledTimes(1) + }) + + it('set() stores a value and notifies', () => { + const q = new Query(counting([() => 'a'])) + const seen = vi.fn() + q.subscribe(seen) + seen.mockClear() + q.set('b') + expect(q.state).toEqual({ data: 'b', error: null, loading: false }) + expect(seen).toHaveBeenCalledTimes(1) + }) + + it('invalidate() refetches only when someone watches', async () => { + const fetcher = counting([() => 1]) + const q = new Query(fetcher) + q.invalidate() + await vi.advanceTimersByTimeAsync(0) + expect(fetcher).not.toHaveBeenCalled() + q.subscribe(() => undefined) + await vi.advanceTimersByTimeAsync(0) + q.invalidate() + await vi.advanceTimersByTimeAsync(0) + expect(fetcher).toHaveBeenCalledTimes(2) + }) + + it('reset() forgets data, listeners and the poll timer', async () => { + const fetcher = counting([() => 1]) + const q = new Query(fetcher, { intervalMs: 100 }) + const seen = vi.fn() + q.subscribe(seen) + await vi.advanceTimersByTimeAsync(0) + q.reset() + seen.mockClear() + await vi.advanceTimersByTimeAsync(1000) + expect(fetcher).toHaveBeenCalledTimes(1) + expect(seen).not.toHaveBeenCalled() + expect(q.state).toEqual({ data: undefined, error: null, loading: true }) + }) +}) + +describe('family', () => { + it('makes one query per key', () => { + const make = vi.fn((key: string) => new Query(() => Promise.resolve(key))) + const by = family(make) + expect(by('a')).toBe(by('a')) + expect(by('b')).not.toBe(by('a')) + expect(make).toHaveBeenCalledTimes(2) + }) +}) + +describe('registry', () => { + it('invalidateAll refetches watched queries and resetAll clears them', async () => { + vi.useFakeTimers() + const fetcher = counting([() => 1]) + const q = new Query(fetcher) + q.subscribe(() => undefined) + await vi.advanceTimersByTimeAsync(0) + invalidateAll() + await vi.advanceTimersByTimeAsync(0) + expect(fetcher).toHaveBeenCalledTimes(2) + resetAll() + expect(q.state.loading).toBe(true) + }) +}) diff --git a/web/src/lib/query.ts b/web/src/lib/query.ts new file mode 100644 index 0000000..ab715cb --- /dev/null +++ b/web/src/lib/query.ts @@ -0,0 +1,141 @@ +// A small replacement for React Query: one cached value per fetcher, fetched +// on first subscription, polled while anyone is subscribed, and refetched +// when invalidated. Polling deliberately ignores `document.hidden`: +// automation tabs and a Chrome --app window behind another report hidden, +// and a paused poll there shows frozen state. + +export interface QueryState { + data: T | undefined + error: unknown + /** True until the first response (success or failure) arrives. */ + loading: boolean +} + +export interface QueryOptions { + /** Poll period while subscribed; a function of the data, `false` = no poll. */ + intervalMs?: number | false | ((data: T | undefined) => number | false) + /** A subscription within this long of the last fetch reuses it. */ + staleMs?: number +} + +type Listener = (state: QueryState) => void + +const registry = new Set>() + +export class Query { + private current: QueryState = { data: undefined, error: null, loading: true } + private listeners = new Set>() + private timer: ReturnType | null = null + private fetchedAt = 0 + private inflight: Promise | null = null + private readonly fetcher: () => Promise + private readonly opts: QueryOptions + + constructor(fetcher: () => Promise, opts: QueryOptions = {}) { + this.fetcher = fetcher + this.opts = opts + registry.add(this as Query) + } + + get state(): QueryState { + return this.current + } + + /** Listen for changes; the first listener starts fetching and polling. */ + subscribe(listener: Listener): () => void { + this.listeners.add(listener) + if (this.listeners.size === 1) { + const fresh = this.fetchedAt > 0 && Date.now() - this.fetchedAt < (this.opts.staleMs ?? 5_000) + if (fresh) this.schedule() + else void this.refetch() + } + return () => { + this.listeners.delete(listener) + if (this.listeners.size === 0) this.stop() + } + } + + /** Fetch now (joining one already in flight) and notify listeners. */ + refetch(): Promise { + this.inflight ??= this.run().finally(() => { + this.inflight = null + }) + return this.inflight + } + + /** Set the cached value locally, as a mutation's response does. */ + set(data: T): void { + this.current = { data, error: null, loading: false } + this.emit() + } + + /** Mark stale; refetch now if anyone is watching. */ + invalidate(): void { + this.fetchedAt = 0 + if (this.listeners.size > 0) void this.refetch() + } + + /** Forget everything (tests). */ + reset(): void { + this.stop() + this.listeners.clear() + this.current = { data: undefined, error: null, loading: true } + this.fetchedAt = 0 + this.inflight = null + } + + private async run(): Promise { + this.stop() + try { + const data = await this.fetcher() + this.current = { data, error: null, loading: false } + } catch (error) { + // Keep the last good data: a failed poll should not blank the screen. + this.current = { data: this.current.data, error, loading: false } + } + this.fetchedAt = Date.now() + this.emit() + this.schedule() + } + + private schedule(): void { + this.stop() + if (this.listeners.size === 0) return + const { intervalMs } = this.opts + const ms = typeof intervalMs === 'function' ? intervalMs(this.current.data) : intervalMs + if (ms === undefined || ms === false) return + this.timer = setTimeout(() => void this.refetch(), ms) + } + + private stop(): void { + if (this.timer !== null) clearTimeout(this.timer) + this.timer = null + } + + private emit(): void { + for (const l of [...this.listeners]) l(this.current) + } +} + +/** One Query per key, created on first use (e.g. a job by id). */ +export function family(make: (key: string) => Query): (key: string) => Query { + const made = new Map>() + return (key) => { + let query = made.get(key) + if (!query) { + query = make(key) + made.set(key, query) + } + return query + } +} + +/** After a job ends anything may have changed: refetch every watched query. */ +export function invalidateAll(): void { + for (const query of registry) query.invalidate() +} + +/** Reset every query (tests). */ +export function resetAll(): void { + for (const query of registry) query.reset() +} diff --git a/web/src/lib/runner.test.ts b/web/src/lib/runner.test.ts new file mode 100644 index 0000000..3781bc9 --- /dev/null +++ b/web/src/lib/runner.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest' +import { jobHref, pageHref, runner } from './runner' + +describe('runner bus', () => { + it('delivers a run request with and without a preset', () => { + const fn = vi.fn() + const off = runner.onRun(fn) + runner.run('scan') + runner.run('pick-manual', { app_id: 5 }) + expect(fn).toHaveBeenNthCalledWith(1, { name: 'scan' }) + expect(fn).toHaveBeenNthCalledWith(2, { name: 'pick-manual', preset: { app_id: 5 } }) + off() + runner.run('scan') + expect(fn).toHaveBeenCalledTimes(2) + }) + + it('delivers palette requests until unsubscribed', () => { + const fn = vi.fn() + const off = runner.onPalette(fn) + runner.openPalette() + expect(fn).toHaveBeenCalledTimes(1) + off() + runner.openPalette() + expect(fn).toHaveBeenCalledTimes(1) + }) +}) + +describe('hrefs', () => { + it('gives every section a trailing slash', () => { + expect(pageHref('/')).toBe('/') + expect(pageHref('/library')).toBe('/library/') + expect(pageHref('/library/')).toBe('/library/') + }) + + it('encodes the job id', () => { + expect(jobHref('a b&c')).toBe('/jobs/?id=a%20b%26c') + }) +}) diff --git a/web/src/lib/runner.ts b/web/src/lib/runner.ts new file mode 100644 index 0000000..f22473c --- /dev/null +++ b/web/src/lib/runner.ts @@ -0,0 +1,49 @@ +// Module-level replacement for RunnerProvider: any button anywhere asks for +// the runner or the palette here, and the one / +// in the shell listens. + +import type { CommandName } from '../api/contract' + +export type Preset = Record + +export interface RunRequest { + name: CommandName + preset?: Preset +} + +const bus = new EventTarget() + +export const runner = { + /** Open the generic runner for a job command, optionally pre-filled. */ + run(name: CommandName, preset?: Preset): void { + bus.dispatchEvent(new CustomEvent('run', { detail: preset ? { name, preset } : { name } })) + }, + openPalette(): void { + bus.dispatchEvent(new Event('palette')) + }, + onRun(fn: (req: RunRequest) => void): () => void { + const listener = (e: Event) => { + fn((e as CustomEvent).detail) + } + bus.addEventListener('run', listener) + return () => { + bus.removeEventListener('run', listener) + } + }, + onPalette(fn: () => void): () => void { + bus.addEventListener('palette', fn) + return () => { + bus.removeEventListener('palette', fn) + } + }, +} + +/** Where a section lives as a static page: `/library` → `/library/`. */ +export function pageHref(to: string): string { + return to === '/' ? '/' : `${to.replace(/\/$/, '')}/` +} + +/** The job detail page (static output cannot prerender unknown ids). */ +export function jobHref(id: string): string { + return `/jobs/?id=${encodeURIComponent(id)}` +} diff --git a/web/src/library/GameBrowser.lag.test.tsx b/web/src/library/GameBrowser.lag.test.tsx deleted file mode 100644 index f618e65..0000000 --- a/web/src/library/GameBrowser.lag.test.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { QueryClientProvider } from '@tanstack/react-query' -import { render, screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { describe, expect, it, vi } from 'vitest' -import { queryClient } from '../api/queries' -import { makeLibGame } from '../test/fixtures' -import { stubApi } from '../test/harness' -import { GameBrowser } from './GameBrowser' - -// The grid trails the search box by design (useDeferredValue). Pin the -// trailing value so the lag can be held open and released deterministically. -const lag = vi.hoisted(() => ({ value: undefined as string | undefined })) -vi.mock('react', async (importOriginal) => { - const real = await importOriginal() - return { ...real, useDeferredValue: (v: T): T => (lag.value ?? v) as T } -}) - -describe('GameBrowser while the grid lags the search box', () => { - it('focuses a suggested card once the grid shows it', async () => { - stubApi({ - 'GET /api/library': { - games: [makeLibGame({ app_id: 1, name: 'Hollow Knight' }), makeLibGame({ app_id: 2, name: 'The Witcher' })], - }, - }) - lag.value = 'hollow' - render( - - - , - ) - const box = await screen.findByRole('combobox', { name: 'Search your games' }) - await userEvent.type(box, 'witcher') - await userEvent.keyboard('{Enter}') - // The grid still shows only "Hollow Knight": nothing to focus yet. - expect(screen.queryByRole('button', { name: /The Witcher/ })).toBeNull() - lag.value = undefined - await userEvent.type(box, ' ') - await waitFor(() => expect(screen.getByRole('button', { name: /The Witcher/ })).toHaveFocus()) - }) -}) diff --git a/web/src/library/GameBrowser.test.tsx b/web/src/library/GameBrowser.test.tsx deleted file mode 100644 index fc54678..0000000 --- a/web/src/library/GameBrowser.test.tsx +++ /dev/null @@ -1,247 +0,0 @@ -import { QueryClientProvider } from '@tanstack/react-query' -import { fireEvent, 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 { queryClient } from '../api/queries' -import { makeLibGame } from '../test/fixtures' -import { apiError, stubApi } from '../test/harness' -import { GameBrowser, type PickMode } from './GameBrowser' - -const GAMES = [ - makeLibGame({ app_id: 1, name: 'The Witcher', achievements_unlocked: 10, last_played: 100, hltb_hours: 30 }), - makeLibGame({ app_id: 2, name: 'The Witcher 2', achievements_unlocked: 2, last_played: 300, hltb_hours: 20, installed: true }), - makeLibGame({ app_id: 3, name: 'The Witcher 3', achievements_unlocked: 5, last_played: null, hltb_hours: null, ineligible_reason: 'Already started' }), - makeLibGame({ app_id: 4, name: 'Age of Empires III', achievements_unlocked: 5, last_played: 200, hltb_hours: 10 }), - makeLibGame({ app_id: 5, name: 'Hollow Knight', achievements_unlocked: 1, last_played: 50, hltb_hours: 5 }), -] -const withLibrary = (games = GAMES) => stubApi({ 'GET /api/library': { games } }) - -function renderBrowser(pick?: PickMode) { - return render( - - - , - ) -} -const titles = () => screen.queryAllByRole('button').filter((b) => b.dataset.appId).map((b) => b.querySelector('.game-title')!.textContent) -const card = (name: string) => screen.getByRole('button', { name: new RegExp(name) }) -const search = () => screen.getByRole('combobox', { name: 'Search your games' }) - -describe('GameBrowser', () => { - beforeEach(() => { - withLibrary() - }) - afterEach(() => vi.unstubAllGlobals()) - - it('shows the library sorted by name, with a count', async () => { - renderBrowser() - expect(screen.getByText(/Loading your library/)).toBeInTheDocument() - await screen.findByText('5 of 5 games') - expect(titles()).toEqual(['Age of Empires III', 'Hollow Knight', 'The Witcher', 'The Witcher 2', 'The Witcher 3']) - }) - - it('explains a failed load', async () => { - stubApi({ 'GET /api/library': apiError(500, 'op_failed', 'cannot read library') }) - renderBrowser() - expect(await screen.findByText('cannot read library')).toBeInTheDocument() - expect(screen.queryByText(/Loading/)).toBeNull() - }) - - it('says so when nothing matches', async () => { - withLibrary([]) - renderBrowser() - expect(await screen.findByText('No game matches.')).toBeInTheDocument() - expect(screen.getByText('0 of 0 games')).toBeInTheDocument() - }) - - it('sorts by the chosen key', async () => { - renderBrowser() - await screen.findByText('5 of 5 games') - await userEvent.selectOptions(screen.getByRole('combobox', { name: 'Sort' }), 'recent') - expect(titles()[0]).toBe('The Witcher 2') - await userEvent.selectOptions(screen.getByRole('combobox', { name: 'Sort' }), 'hltb') - expect(titles()[0]).toBe('Hollow Knight') - }) - - it('filters', async () => { - renderBrowser() - await screen.findByText('5 of 5 games') - await userEvent.click(screen.getByRole('checkbox', { name: 'Installed only' })) - expect(titles()).toEqual(['The Witcher 2']) - await userEvent.click(screen.getByRole('checkbox', { name: 'Installed only' })) - await userEvent.click(screen.getByRole('checkbox', { name: 'Pickable only' })) - expect(titles()).not.toContain('The Witcher 3') - await userEvent.click(screen.getByRole('checkbox', { name: 'Hide completed' })) - expect(titles()).not.toContain('The Witcher') - }) - - it('"wicher" finds the Witcher titles by relevance and locks the sort', async () => { - renderBrowser() - await screen.findByText('5 of 5 games') - await userEvent.type(search(), 'wicher') - await screen.findByText(/by relevance/) - expect(titles()).toEqual(['The Witcher', 'The Witcher 2', 'The Witcher 3']) - expect(screen.getByRole('combobox', { name: 'Sort' })).toBeDisabled() - }) - - it('"witcher 3" does not return Age of Empires III', async () => { - renderBrowser() - await screen.findByText('5 of 5 games') - await userEvent.type(search(), 'witcher 3') - await waitFor(() => expect(titles()[0]).toBe('The Witcher 3')) - expect(titles()).not.toContain('Age of Empires III') - }) - - it('an app id finds that game', async () => { - renderBrowser() - await screen.findByText('5 of 5 games') - await userEvent.type(search(), '5') - await waitFor(() => expect(titles()[0]).toBe('Hollow Knight')) - }) - - it('Enter on a suggestion moves focus to its card', async () => { - renderBrowser() - await screen.findByText('5 of 5 games') - await userEvent.type(search(), 'hollow') - await userEvent.keyboard('{Enter}') - await waitFor(() => expect(card('Hollow Knight')).toHaveFocus()) - expect(card('Hollow Knight')).toHaveAttribute('tabindex', '0') - }) - - it('a missing card is focused once the grid catches up', async () => { - renderBrowser() - await screen.findByText('5 of 5 games') - // Choosing from the list while the grid still shows a stale result. - await userEvent.type(search(), 'hollow') - fireEvent.keyDown(search(), { key: 'Enter' }) - await waitFor(() => expect(document.activeElement).toBe(card('Hollow Knight'))) - }) - - describe('keyboard in the grid', () => { - beforeEach(() => { - withLibrary(Array.from({ length: 12 }, (_, i) => makeLibGame({ app_id: i + 1, name: `Game ${String(i).padStart(2, '0')}` }))) - // jsdom lays nothing out; only the grid is told it has three columns. - const real = window.getComputedStyle.bind(window) - vi.spyOn(window, 'getComputedStyle').mockImplementation((el, pseudo) => - el.classList.contains('game-grid') ? ({ gridTemplateColumns: '1fr 1fr 1fr' } as CSSStyleDeclaration) : real(el, pseudo), - ) - }) - afterEach(() => vi.restoreAllMocks()) - - const press = async (key: string) => { - fireEvent.keyDown(document.activeElement!, { key }) - await waitFor(() => expect(document.activeElement).toHaveAttribute('data-app-id')) - } - const at = () => (document.activeElement as HTMLElement).querySelector('.game-title')!.textContent - - it('moves by cell, row, page and to the ends (roving tabindex)', async () => { - renderBrowser() - await screen.findByText('12 of 12 games') - card('Game 00').focus() - await press('ArrowRight') - await waitFor(() => expect(at()).toBe('Game 01')) - await press('ArrowDown') - await waitFor(() => expect(at()).toBe('Game 04')) - await press('ArrowLeft') - await waitFor(() => expect(at()).toBe('Game 03')) - await press('ArrowUp') - await waitFor(() => expect(at()).toBe('Game 00')) - await press('PageDown') - await waitFor(() => expect(at()).toBe('Game 09')) - await press('PageUp') - await waitFor(() => expect(at()).toBe('Game 00')) - await press('End') - await waitFor(() => expect(at()).toBe('Game 11')) - expect(card('Game 11')).toHaveAttribute('tabindex', '0') - expect(card('Game 00')).toHaveAttribute('tabindex', '-1') - await press('Home') - await waitFor(() => expect(at()).toBe('Game 00')) - await press('ArrowUp') // clamps at the first card - await waitFor(() => expect(at()).toBe('Game 00')) - }) - - it('leaves other keys alone', async () => { - renderBrowser() - await screen.findByText('12 of 12 games') - const e = new KeyboardEvent('keydown', { key: 'a', bubbles: true, cancelable: true }) - card('Game 00').dispatchEvent(e) - expect(e.defaultPrevented).toBe(false) - }) - - it('ignores navigation keys when nothing is shown', async () => { - renderBrowser() - await screen.findByText('12 of 12 games') - await userEvent.type(search(), 'qqqqqq') - await screen.findByText('No game matches.') - const e = new KeyboardEvent('keydown', { key: 'End', bubbles: true, cancelable: true }) - document.querySelector('.game-grid')!.dispatchEvent(e) - expect(e.defaultPrevented).toBe(false) - }) - - it('a click makes that card the tab stop', async () => { - renderBrowser() - await screen.findByText('12 of 12 games') - await userEvent.click(card('Game 05')) - expect(card('Game 05')).toHaveAttribute('tabindex', '0') - }) - - it('falls back to one column when the layout is unknown', async () => { - vi.restoreAllMocks() // jsdom computes no grid columns - renderBrowser() - await screen.findByText('12 of 12 games') - card('Game 00').focus() - await press('ArrowDown') - await waitFor(() => expect(at()).toBe('Game 01')) - }) - }) - - describe('pick mode', () => { - const pickable = new Set([1, 2]) - const setup = (selected: number | null = null) => { - const onSelect = vi.fn() - renderBrowser({ appIds: pickable, selected, onSelect }) - return onSelect - } - - it('selects eligible cards only, and never submits', async () => { - const onSelect = setup() - await screen.findByText('5 of 5 games') - await userEvent.click(card('The Witcher 2')) - expect(onSelect).toHaveBeenCalledWith(2) - onSelect.mockClear() - await userEvent.click(card('Hollow Knight')) - await userEvent.click(card('The Witcher 3')) - expect(onSelect).not.toHaveBeenCalled() - }) - - it('shows why a game is not offered', async () => { - setup(2) - await screen.findByText('5 of 5 games') - expect(card('The Witcher 3')).toHaveTextContent('Already started') - expect(card('Hollow Knight')).toHaveTextContent('Not offered here') - expect(card('The Witcher 2')).toHaveAttribute('aria-pressed', 'true') - }) - - it('"pickable only" keeps just the ids the job sent', async () => { - setup() - await screen.findByText('5 of 5 games') - await userEvent.click(screen.getByRole('checkbox', { name: 'Pickable only' })) - expect(titles()).toEqual(['The Witcher', 'The Witcher 2']) - }) - - it('choosing a suggestion selects an eligible game but only focuses an ineligible one', async () => { - const onSelect = setup() - await screen.findByText('5 of 5 games') - await userEvent.type(search(), 'hollow') - await userEvent.keyboard('{Enter}') - expect(onSelect).not.toHaveBeenCalled() - await waitFor(() => expect(card('Hollow Knight')).toHaveFocus()) - await userEvent.clear(search()) - await userEvent.type(search(), 'witcher 2') - await userEvent.keyboard('{Enter}') - expect(onSelect).toHaveBeenCalledWith(2) - const grid = screen.getByRole('group', { name: 'Games' }) - await waitFor(() => expect(within(grid).getByRole('button', { name: /The Witcher 2/ })).toHaveFocus()) - }) - }) -}) diff --git a/web/src/library/GameBrowser.tsx b/web/src/library/GameBrowser.tsx deleted file mode 100644 index db43c13..0000000 --- a/web/src/library/GameBrowser.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { useDeferredValue, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' -import type { LibraryGame } from '../api/contract' -import { q } from '../api/queries' -import { ErrorNotice, Loading } from '../ui/Notice' -import { buildIndex, search } from './fuzzy' -import { GameCard } from './GameCard' -import { SearchBox } from './SearchBox' -import { filterGames, NO_FILTERS, SORTS, sortGames, type Filters, type SortKey } from './view' - -const SUGGESTIONS = 8 - -/** Pick mode: only `appIds` are selectable; the caller's button submits. */ -export interface PickMode { - appIds: ReadonlySet - selected: number | null - onSelect: (appId: number) => void -} - -const FILTER_LABELS: [keyof Filters, string][] = [ - ['hideCompleted', 'Hide completed'], - ['installedOnly', 'Installed only'], - ['pickableOnly', 'Pickable only'], -] - -/** Grid columns as laid out right now (for ↑/↓ moving one row). */ -function columns(grid: HTMLElement): number { - return Math.max(1, getComputedStyle(grid).gridTemplateColumns.split(' ').filter(Boolean).length) -} - -/** - * Every owned game as a cover grid: fuzzy search with suggestions, sort, - * filters. The grid is one tab stop; arrows, PageUp/PageDown and Home/End - * move between cards (roving tabindex). - */ -export function GameBrowser({ pick }: { pick?: PickMode }) { - const { data, error } = useQuery(q.library) - const games = useMemo(() => data?.games ?? [], [data]) - const index = useMemo(() => buildIndex(games), [games]) - const [query, setQuery] = useState('') - const [sort, setSort] = useState('name') - const [filters, setFilters] = useState(NO_FILTERS) - const [active, setActive] = useState(0) - const [focusId, setFocusId] = useState(null) - const grid = useRef(null) - const deferred = useDeferredValue(query) - - const reason = (g: LibraryGame): string | null => - pick ? (pick.appIds.has(g.app_id) ? null : (g.ineligible_reason ?? 'Not offered here')) : g.ineligible_reason - const filtered = filterGames(games, filters, (g) => reason(g) === null) - const visible = deferred.trim() ? search(filtered, index, deferred) : sortGames(filtered, sort) - const suggestions = query.trim() ? search(filtered, index, query).slice(0, SUGGESTIONS) : [] - const current = Math.min(active, Math.max(0, visible.length - 1)) - - useEffect(() => { - if (focusId === null) return - const card = grid.current?.querySelector(`[data-app-id="${focusId}"]`) - if (!card) return - setActive(Number(card.dataset.index)) - card.focus() - setFocusId(null) - }, [focusId, visible]) - - const choose = (g: LibraryGame) => { - if (pick && reason(g) === null) pick.onSelect(g.app_id) - setFocusId(g.app_id) - } - - const onGridKey = (e: KeyboardEvent) => { - const cols = columns(e.currentTarget) - const page = cols * 3 - const moves: Record = { - ArrowRight: 1, ArrowLeft: -1, ArrowDown: cols, ArrowUp: -cols, - PageDown: page, PageUp: -page, Home: -Infinity, End: Infinity, - } - if (!(e.key in moves) || visible.length === 0) return - e.preventDefault() - const next = Math.min(visible.length - 1, Math.max(0, current + moves[e.key])) - setFocusId(visible[next].app_id) - } - - return ( -
-
- - -
- Filters - {FILTER_LABELS.map(([key, label]) => ( - - ))} -
- - {data && `${visible.length} of ${games.length} games${deferred.trim() ? ' · by relevance' : ''}`} - -
- {error && } - {!data && !error && } - {data && visible.length === 0 &&

No game matches.

} -
- {visible.map((g, i) => ( - { - setActive(i) - // Select only: assigning can uninstall other games, so - // it takes the explicit "Pick …" button, never a double-click. - if (reason(g) === null) pick.onSelect(g.app_id) - } - : () => setActive(i) - } - /> - ))} -
-
- ) -} diff --git a/web/src/library/GameCard.test.tsx b/web/src/library/GameCard.test.tsx deleted file mode 100644 index dd3c480..0000000 --- a/web/src/library/GameCard.test.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { describe, expect, it, vi } from 'vitest' -import { makeLibGame } from '../test/fixtures' -import { GameCard } from './GameCard' - -function renderCard(over: Partial> = {}) { - const onActivate = vi.fn() - const props = { - game: makeLibGame({ app_id: 42, name: 'Hollow Knight' }), - index: 3, - reason: null, - selected: false, - focusable: true, - pickMode: false, - onActivate, - ...over, - } - render() - return { onActivate, card: screen.getByRole('button') } -} - -describe('GameCard', () => { - it('is a button carrying its id and index, with title and meta line', () => { - const { card } = renderCard() - expect(card).toHaveAttribute('data-app-id', '42') - expect(card).toHaveAttribute('data-index', '3') - expect(card).toHaveTextContent('Hollow Knight') - expect(card).toHaveTextContent('50%') - }) - - it('is a roving tab stop', () => { - expect(renderCard().card).toHaveAttribute('tabindex', '0') - }) - - it('is out of the tab order when not the active card', () => { - expect(renderCard({ focusable: false }).card).toHaveAttribute('tabindex', '-1') - }) - - it('exposes pressed state only in pick mode', () => { - expect(renderCard().card).not.toHaveAttribute('aria-pressed') - }) - - it('exposes pressed and disabled in pick mode, with the reason shown', () => { - const { card } = renderCard({ pickMode: true, selected: true, reason: 'Already finished' }) - expect(card).toHaveAttribute('aria-pressed', 'true') - expect(card).toHaveAttribute('aria-disabled', 'true') - expect(card).toHaveClass('selected', 'ineligible') - expect(card).toHaveTextContent('Already finished') - }) - - it('is not aria-disabled in pick mode when pickable', () => { - const { card } = renderCard({ pickMode: true }) - expect(card).not.toHaveAttribute('aria-disabled') - expect(card).toHaveAttribute('aria-pressed', 'false') - }) - - it('shows badges for assigned and installed', () => { - const { card } = renderCard({ game: makeLibGame({ assigned: true, installed: true }) }) - expect(card).toHaveTextContent('assigned') - expect(card).toHaveTextContent('installed') - }) - - it('shows only the badge that applies, and none otherwise', () => { - renderCard({ game: makeLibGame({ installed: true }) }) - expect(screen.getByText('installed')).toBeInTheDocument() - expect(screen.queryByText('assigned')).toBeNull() - }) - - it('has no badge row for a plain game', () => { - renderCard() - expect(document.querySelector('.game-badges')).toBeNull() - }) - - it('reports clicks', async () => { - const { card, onActivate } = renderCard() - await userEvent.click(card) - expect(onActivate).toHaveBeenCalledTimes(1) - }) - - it('lazy-loads the art and falls back to a title tile when it 404s', () => { - const { card } = renderCard() - const img = card.querySelector('img')! - expect(img).toHaveAttribute('src', '/api/art/42') - expect(img).toHaveAttribute('loading', 'lazy') - fireEvent.error(img) - expect(card.querySelector('img')).toBeNull() - expect(card.querySelector('.cover-tile')).toHaveTextContent('Hollow Knight') - }) -}) diff --git a/web/src/library/GameCard.tsx b/web/src/library/GameCard.tsx deleted file mode 100644 index d17844b..0000000 --- a/web/src/library/GameCard.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { useState } from 'react' -import { ENDPOINTS, type LibraryGame } from '../api/contract' -import { metaLine } from './view' - -/** Portrait cover, lazily loaded; a title tile when the server has none. */ -function Cover({ game }: { game: LibraryGame }) { - const [failed, setFailed] = useState(false) - if (failed) { - return ( - - ) - } - return ( - setFailed(true)} - /> - ) -} - -/** - * One tile of the grid. A button in both modes so it is a focus stop; in - * browse mode it only takes focus, in pick mode it selects. `reason` greys - * it out and says why it cannot be picked. - */ -export function GameCard({ - game, - index, - reason, - selected, - focusable, - pickMode, - onActivate, -}: { - game: LibraryGame - index: number - reason: string | null - selected: boolean - focusable: boolean - pickMode: boolean - onActivate: () => void -}) { - const cls = ['game-card', reason ? 'ineligible' : '', selected ? 'selected' : ''].filter(Boolean).join(' ') - return ( - - ) -} diff --git a/web/src/library/SearchBox.test.tsx b/web/src/library/SearchBox.test.tsx deleted file mode 100644 index 5095f74..0000000 --- a/web/src/library/SearchBox.test.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { useState } from 'react' -import { describe, expect, it, vi } from 'vitest' -import type { LibraryGame } from '../api/contract' -import { makeLibGame } from '../test/fixtures' -import { SearchBox } from './SearchBox' - -const A = makeLibGame({ app_id: 1, name: 'Alpha' }) -const B = makeLibGame({ app_id: 2, name: 'Beta', ineligible_reason: 'Already finished' }) -const C = makeLibGame({ app_id: 3, name: 'Gamma' }) - -function Harness({ suggestions = [A, B, C], onChoose = () => {} }: { suggestions?: LibraryGame[]; onChoose?: (g: LibraryGame) => void }) { - const [value, setValue] = useState('') - return ( - g.ineligible_reason} onChoose={onChoose} /> - ) -} - -const box = () => screen.getByRole('combobox', { name: 'Search your games' }) - -describe('SearchBox', () => { - it('lists suggestions once typing starts, with reason or meta line', async () => { - render() - expect(screen.queryByRole('option')).toBeNull() - await userEvent.type(box(), 'a') - const options = screen.getAllByRole('option') - expect(options).toHaveLength(3) - expect(options[1]).toHaveTextContent('Already finished') - expect(options[0]).toHaveTextContent('50%') - expect(box()).toHaveAttribute('aria-expanded', 'true') - }) - - it('stays closed when there are no suggestions', async () => { - render() - await userEvent.type(box(), 'zz') - expect(box()).toHaveAttribute('aria-expanded', 'false') - }) - - it('moves through the list with the arrows, wrapping, and announces the active one', async () => { - render() - await userEvent.type(box(), 'a') - await userEvent.keyboard('{ArrowDown}') - expect(box()).toHaveAttribute('aria-activedescendant', expect.stringMatching(/-0$/)) - await userEvent.keyboard('{ArrowDown}{ArrowDown}{ArrowDown}') - expect(screen.getAllByRole('option')[0]).toHaveAttribute('aria-selected', 'true') - await userEvent.keyboard('{ArrowUp}') - expect(screen.getAllByRole('option')[2]).toHaveAttribute('aria-selected', 'true') - }) - - it('starts from the last suggestion on ArrowUp, and reopens a closed list', async () => { - render() - await userEvent.type(box(), 'a') - await userEvent.keyboard('{Escape}') - expect(box()).toHaveAttribute('aria-expanded', 'false') - await userEvent.keyboard('{ArrowUp}') - expect(box()).toHaveAttribute('aria-expanded', 'true') - expect(screen.getAllByRole('option')[2]).toHaveAttribute('aria-selected', 'true') - }) - - it('ignores arrows with no suggestions', async () => { - render() - await userEvent.type(box(), 'a') - await userEvent.keyboard('{ArrowDown}{ArrowUp}') - expect(box()).not.toHaveAttribute('aria-activedescendant') - }) - - it('Enter chooses the highlighted suggestion, else the first, and closes the list', async () => { - const onChoose = vi.fn() - render() - await userEvent.type(box(), 'a') - await userEvent.keyboard('{Enter}') - expect(onChoose).toHaveBeenLastCalledWith(A) - expect(box()).toHaveAttribute('aria-expanded', 'false') - await userEvent.type(box(), 'b') - await userEvent.keyboard('{ArrowDown}{ArrowDown}{Enter}') - expect(onChoose).toHaveBeenLastCalledWith(B) - }) - - it('falls back to the first suggestion when the highlight is out of range', () => { - const onChoose = vi.fn() - const { rerender } = render() - fireEvent.change(box(), { target: { value: 'a' } }) - fireEvent.keyDown(box(), { key: 'ArrowUp' }) // highlights the last of three - rerender() - fireEvent.keyDown(box(), { key: 'Enter' }) - expect(onChoose).toHaveBeenCalledWith(A) - }) - - it('Enter does nothing while the list is hidden', async () => { - const onChoose = vi.fn() - render() - await userEvent.type(box(), '{Enter}') - expect(onChoose).not.toHaveBeenCalled() - }) - - it('Escape closes the list first, then clears the text, then lets the key go', async () => { - const outer = vi.fn() - render( -
- -
, - ) - await userEvent.type(box(), 'abc') - await userEvent.keyboard('{Escape}') - expect(box()).toHaveAttribute('aria-expanded', 'false') - expect(box()).toHaveValue('abc') - await userEvent.keyboard('{Escape}') - expect(box()).toHaveValue('') - outer.mockClear() - await userEvent.keyboard('{Escape}') - expect(outer).toHaveBeenCalled() - }) - - it('opens on focus and closes on blur', async () => { - render() - await userEvent.type(box(), 'a') - await userEvent.tab() - expect(box()).toHaveAttribute('aria-expanded', 'false') - await userEvent.click(box()) - expect(box()).toHaveAttribute('aria-expanded', 'true') - }) - - it('chooses a suggestion on mousedown, before blur can close the list', async () => { - const onChoose = vi.fn() - render() - await userEvent.type(box(), 'a') - fireEvent.mouseDown(screen.getByRole('option', { name: /Gamma/ })) - expect(onChoose).toHaveBeenCalledWith(C) - }) -}) diff --git a/web/src/library/SearchBox.tsx b/web/src/library/SearchBox.tsx deleted file mode 100644 index 9fd4fb3..0000000 --- a/web/src/library/SearchBox.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { useId, useState, type KeyboardEvent } from 'react' -import type { LibraryGame } from '../api/contract' -import { metaLine } from './view' - -/** - * Library search: an ARIA combobox. Typing filters the grid behind it and - * lists the top suggestions; ↑/↓ move through them, Enter chooses the - * highlighted one (or the first), Escape closes the list, then clears. - */ -export function SearchBox({ - value, - onChange, - suggestions, - reason, - onChoose, -}: { - value: string - onChange: (v: string) => void - suggestions: LibraryGame[] - /** Why a game cannot be picked here (pick mode differs from the library). */ - reason: (g: LibraryGame) => string | null - onChoose: (g: LibraryGame) => void -}) { - const listId = useId() - const [open, setOpen] = useState(false) - const [active, setActive] = useState(-1) - const shown = open && value.trim() !== '' && suggestions.length > 0 - - const choose = (g: LibraryGame) => { - setOpen(false) - setActive(-1) - onChoose(g) - } - - const onKeyDown = (e: KeyboardEvent) => { - const n = suggestions.length - if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { - if (n === 0) return - e.preventDefault() - setOpen(true) - const step = e.key === 'ArrowDown' ? 1 : -1 - setActive((i) => (i < 0 && step < 0 ? n - 1 : (i + step + n) % n)) - } else if (e.key === 'Enter') { - if (!shown) return - e.preventDefault() - choose(suggestions[active >= 0 && active < n ? active : 0]) - } else if (e.key === 'Escape') { - if (shown) { - e.preventDefault() - e.stopPropagation() - setOpen(false) - } else if (value) { - e.preventDefault() - e.stopPropagation() - onChange('') - } - } - } - - return ( -
- = 0 ? `${listId}-${active}` : undefined} - placeholder="Search your games (typos are fine)…" - value={value} - onChange={(e) => { - onChange(e.target.value) - setOpen(true) - setActive(-1) - }} - onFocus={() => setOpen(true)} - onBlur={() => setOpen(false)} - onKeyDown={onKeyDown} - /> - -
- ) -} diff --git a/web/src/library/browserState.test.ts b/web/src/library/browserState.test.ts new file mode 100644 index 0000000..64b69aa --- /dev/null +++ b/web/src/library/browserState.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { screen, waitFor } from '@testing-library/dom' +import userEvent from '@testing-library/user-event' +import { at, render, search, sort, titles, withLibrary } from '../test/browserHelpers' +import { stateFromHash, stateToHash } from './browserState' + +describe('hash state', () => { + afterEach(() => { + at('/') + }) + + it('round-trips search, sort and filters, dropping defaults', () => { + const s = { query: 'hollow', sort: 'hltb' as const, filters: { hideCompleted: true, installedOnly: false, pickableOnly: true } } + expect(stateToHash(s)).toEqual({ q: 'hollow', sort: 'hltb', f: 'completed,pickable' }) + at('/library/#q=hollow&sort=hltb&f=completed,pickable') + expect(stateFromHash()).toEqual(s) + expect(stateToHash({ query: '', sort: 'name', filters: stateFromHash().filters })).toMatchObject({ q: '', sort: null }) + }) + + it('falls back to the defaults for missing or unknown values', () => { + at('/library/#sort=bogus&f=nope') + expect(stateFromHash()).toEqual({ + query: '', + sort: 'name', + filters: { hideCompleted: false, installedOnly: false, pickableOnly: false }, + }) + }) +}) + +describe('GameBrowser URL hash (hash-state)', () => { + beforeEach(() => { + withLibrary() + }) + afterEach(() => { + at('/') + }) + + it('writes search, sort and filters to the hash as they change, without history entries', async () => { + at('/library/#view=installed') + const before = history.length + render(undefined, 'hash-state') + await screen.findByText('5 of 5 games') + await userEvent.selectOptions(sort(), 'hltb') + await userEvent.click(screen.getByRole('checkbox', { name: 'Installed only' })) + expect(location.hash).toBe('#view=installed&sort=hltb&f=installed') + await userEvent.type(search(), 'witcher 2') + expect(location.hash).toBe('#view=installed&sort=hltb&f=installed&q=witcher+2') + await userEvent.clear(search()) + await userEvent.selectOptions(sort(), 'name') + await userEvent.click(screen.getByRole('checkbox', { name: 'Installed only' })) + expect(location.hash).toBe('#view=installed') + expect(history.length).toBe(before) + }) + + it('clears the hash entirely once everything is back to default', async () => { + at('/library/') + render(undefined, 'hash-state') + await screen.findByText('5 of 5 games') + await userEvent.type(search(), 'x') + expect(location.hash).toBe('#q=x') + await userEvent.clear(search()) + expect(location.href).toMatch(/\/library\/$/) + }) + + it('restores the state from the hash on load (a reload)', async () => { + at('/library/#q=witcher&f=pickable') + render(undefined, 'hash-state') + await screen.findByText(/3 of 5 games · by relevance|2 of 5 games · by relevance/) + expect(search()).toHaveValue('witcher') + expect(search()).toHaveAttribute('aria-expanded', 'false') + expect(sort()).toBeDisabled() + expect(screen.getByRole('checkbox', { name: 'Pickable only' })).toBeChecked() + expect(titles()).toEqual(['The Witcher', 'The Witcher 2']) + }) + + it('restores the sort', async () => { + at('/library/#sort=recent') + render(undefined, 'hash-state') + await screen.findByText('5 of 5 games') + expect(sort()).toHaveValue('recent') + expect(titles()[0]).toBe('The Witcher 2') + }) + + it('follows a hand-edited hash, and stops listening once detached', async () => { + at('/library/') + const el = render(undefined, 'hash-state') + await screen.findByText('5 of 5 games') + at('/library/#q=hollow') + window.dispatchEvent(new HashChangeEvent('hashchange')) + await waitFor(() => { + expect(titles()).toEqual(['Hollow Knight']) + }) + expect(search()).toHaveValue('hollow') + window.dispatchEvent(new HashChangeEvent('hashchange')) // same query: input untouched + const input = search() + expect(input).toHaveValue('hollow') + el.remove() + at('/library/#q=witcher') + window.dispatchEvent(new HashChangeEvent('hashchange')) + expect(input).toHaveValue('hollow') + }) + + it('without hash-state never touches the URL', async () => { + at('/picks/#keep') + render() + await screen.findByText('5 of 5 games') + await userEvent.type(search(), 'hollow') + expect(location.hash).toBe('#keep') + }) +}) diff --git a/web/src/library/browserState.ts b/web/src/library/browserState.ts new file mode 100644 index 0000000..dffcd86 --- /dev/null +++ b/web/src/library/browserState.ts @@ -0,0 +1,40 @@ +// What `` shows: the search, sort and filters. With +// `hash-state` (the Library page only) they live in the URL hash, so a +// reload restores them. + +import { readHash } from './hashState' +import { NO_FILTERS, SORTS, type Filters, type SortKey } from './view' + +/** Filter key, checkbox label, and its token in the hash's `f=` list. */ +export const FILTERS: [keyof Filters, string, string][] = [ + ['hideCompleted', 'Hide completed', 'completed'], + ['installedOnly', 'Installed only', 'installed'], + ['pickableOnly', 'Pickable only', 'pickable'], +] + +export interface BrowserState { + query: string + sort: SortKey + filters: Filters +} + +const isSort = (s: string | null): s is SortKey => SORTS.some((o) => o.key === s) + +/** Search, sort and filters from `#q=…&sort=…&f=…`; unknown values fall back to the defaults. */ +export function stateFromHash(): BrowserState { + const p = readHash() + const sort = p.get('sort') + const tokens = (p.get('f') ?? '').split(',') + const filters = { ...NO_FILTERS } + for (const [key, , token] of FILTERS) filters[key] = tokens.includes(token) + return { query: p.get('q') ?? '', sort: isSort(sort) ? sort : 'name', filters } +} + +/** The hash keys for `s`; defaults are dropped so a fresh view has no hash. */ +export function stateToHash(s: BrowserState): Record { + return { + q: s.query, + sort: s.sort === 'name' ? null : s.sort, + f: FILTERS.filter(([key]) => s.filters[key]).map(([, , token]) => token).join(','), + } +} diff --git a/web/src/library/browserToolbar.test.ts b/web/src/library/browserToolbar.test.ts new file mode 100644 index 0000000..d6798cd --- /dev/null +++ b/web/src/library/browserToolbar.test.ts @@ -0,0 +1,90 @@ +import { screen, waitFor, within } from '@testing-library/dom' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it } from 'vitest' +import { render, search, sort, titles, withLibrary } from '../test/browserHelpers' + +describe('GameBrowser toolbar', () => { + beforeEach(() => { + withLibrary() + }) + + it('sorts by the chosen key', async () => { + render() + await screen.findByText('5 of 5 games') + await userEvent.selectOptions(sort(), 'recent') + await waitFor(() => { + expect(titles()[0]).toBe('The Witcher 2') + }) + await userEvent.selectOptions(sort(), 'hltb') + await waitFor(() => { + expect(titles()[0]).toBe('Hollow Knight') + }) + }) + + it('filters', async () => { + render() + await screen.findByText('5 of 5 games') + const box = (name: string) => screen.getByRole('checkbox', { name }) + await userEvent.click(box('Installed only')) + await waitFor(() => { + expect(titles()).toEqual(['The Witcher 2']) + }) + await userEvent.click(box('Installed only')) + await userEvent.click(box('Pickable only')) + await waitFor(() => { + expect(titles()).not.toContain('The Witcher 3') + }) + await userEvent.click(box('Hide completed')) + await waitFor(() => { + expect(titles()).not.toContain('The Witcher') + }) + }) + + it('"wicher" finds the Witcher titles by relevance and locks the sort', async () => { + render() + await screen.findByText('5 of 5 games') + const input = search() + await userEvent.type(input, 'wicher') + // The grid trails the box by a frame: wait for the final keystroke's grid. + await waitFor(() => { + expect(titles()).toEqual(['The Witcher', 'The Witcher 2', 'The Witcher 3']) + }) + screen.getByText(/by relevance/) + expect(sort()).toBeDisabled() + expect(search()).toBe(input) + expect(input).toHaveFocus() + expect(within(screen.getByRole('listbox')).getAllByRole('option')).toHaveLength(3) + }) + + it('"witcher 3" does not return Age of Empires III', async () => { + render() + await screen.findByText('5 of 5 games') + await userEvent.type(search(), 'witcher 3') + await waitFor(() => { + expect(titles()[0]).toBe('The Witcher 3') + }) + expect(titles()).not.toContain('Age of Empires III') + }) + + it('an app id finds that game', async () => { + render() + await screen.findByText('5 of 5 games') + await userEvent.type(search(), '5') + await waitFor(() => { + expect(titles()[0]).toBe('Hollow Knight') + }) + }) + + it('builds its toolbar once across a re-attach', async () => { + const el = render() + await screen.findByText('5 of 5 games') + const input = search() + el.remove() + document.body.append(el) + await waitFor(() => { + expect(titles()).toHaveLength(5) + }) + expect(search()).toBe(input) + expect(screen.getAllByRole('combobox', { name: 'Search your games' })).toHaveLength(1) + }) +}) diff --git a/web/src/library/browserToolbar.ts b/web/src/library/browserToolbar.ts new file mode 100644 index 0000000..d9171c8 --- /dev/null +++ b/web/src/library/browserToolbar.ts @@ -0,0 +1,80 @@ +// The `` toolbar: search box, sort, filter checkboxes and the +// result count. It is built once and never rebuilt, so typing keeps its +// caret; the browser only writes values into it and hears every edit back. + +import type { LibraryGame } from '../api/contract' +import { h } from '../lib/dom' +import { FILTERS, type BrowserState } from './browserState' +import { SearchBox, type SearchBoxOptions } from './searchBox' +import { SORTS, type Filters, type SortKey } from './view' + +export interface ToolbarOptions extends SearchBoxOptions { + onSort: (sort: SortKey) => void + onFilter: (key: keyof Filters, on: boolean) => void +} + +export class BrowserToolbar { + readonly el: HTMLElement + private readonly searchBox: SearchBox + private readonly sortSelect: HTMLSelectElement + private readonly checks: Map + private readonly count = h('span', { class: 'muted lib-count', 'aria-live': 'polite' }) + + constructor(opts: ToolbarOptions) { + this.searchBox = new SearchBox(opts) + this.sortSelect = h( + 'select', + { + class: 'input', + onchange: () => { + opts.onSort(this.sortSelect.value as SortKey) + }, + }, + SORTS.map((s) => h('option', { value: s.key }, s.label)), + ) + this.checks = new Map( + FILTERS.map(([key]) => [ + key, + h('input', { + type: 'checkbox', + onchange: (e: Event) => { + opts.onFilter(key, (e.target as HTMLInputElement).checked) + }, + }), + ]), + ) + this.el = h( + 'div', + { class: 'lib-toolbar' }, + this.searchBox.el, + h('label', { class: 'lib-sort' }, h('span', { class: 'field-label' }, 'Sort'), this.sortSelect), + h( + 'fieldset', + { class: 'lib-filters' }, + h('legend', { class: 'sr-only' }, 'Filters'), + FILTERS.map(([key, label]) => h('label', { class: 'check' }, this.checks.get(key), label)), + ), + this.count, + ) + } + + /** Put `s` into the controls (a reload, or the hash edited by hand). */ + applyState(s: BrowserState): void { + if (this.searchBox.value !== s.query) this.searchBox.value = s.query + this.sortSelect.value = s.sort + for (const [key, box] of this.checks) box.checked = s.filters[key] + } + + /** A search ranks by relevance, so the sort is off while there is one. */ + lockSort(locked: boolean): void { + this.sortSelect.disabled = locked + } + + setSuggestions(games: LibraryGame[]): void { + this.searchBox.setSuggestions(games) + } + + setCount(text: string): void { + this.count.textContent = text + } +} diff --git a/web/src/library/gameBrowser.lag.test.ts b/web/src/library/gameBrowser.lag.test.ts new file mode 100644 index 0000000..39b514a --- /dev/null +++ b/web/src/library/gameBrowser.lag.test.ts @@ -0,0 +1,81 @@ +import { screen, waitFor } from '@testing-library/dom' +import { q } from '../api/queries' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { makeLibGame } from '../test/fixtures' +import { mount, stubApi } from '../test/harness' +import './gameBrowser' + +// The grid trails the search box by one animation frame (as useDeferredValue +// did). Hold frames to keep the lag open, then release them deterministically. +function holdFrames() { + const held: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => held.push(cb)) + vi.stubGlobal('cancelAnimationFrame', () => undefined) + return () => { + for (const cb of held.splice(0)) cb(performance.now()) + } +} +const grid = () => screen.getByRole('group', { name: 'Games' }) +const loaded = () => + waitFor(() => { + expect(q.library.state.data).toBeDefined() + }) +const titles = () => [...grid().querySelectorAll('.game-title')].map((t) => t.textContent) + +describe('GameBrowser while the grid lags the search box', () => { + it('focuses a suggested card once the grid shows it', async () => { + stubApi({ + 'GET /api/library': { + games: [makeLibGame({ app_id: 1, name: 'Hollow Knight' }), makeLibGame({ app_id: 2, name: 'The Witcher' })], + }, + }) + const release = holdFrames() + mount('') + const box = await screen.findByRole('combobox', { name: 'Search your games' }) + await loaded() + await userEvent.type(box, 'hollow') + release() + expect(titles()).toEqual(['Hollow Knight']) + await userEvent.clear(box) + await userEvent.type(box, 'witcher') + await userEvent.keyboard('{Enter}') + // The grid still shows only "Hollow Knight": nothing to focus yet. + expect(titles()).toEqual(['Hollow Knight']) + expect(box).toHaveFocus() + release() + expect(titles()).toEqual(['The Witcher']) + expect(screen.getByRole('button', { name: /The Witcher/ })).toHaveFocus() + }) + + it('coalesces a burst of keystrokes into one grid update', async () => { + stubApi({ 'GET /api/library': { games: [makeLibGame({ app_id: 1, name: 'Hollow Knight' })] } }) + const release = holdFrames() + mount('') + const box = await screen.findByRole('combobox', { name: 'Search your games' }) + await loaded() + release() + const raf = vi.mocked(requestAnimationFrame) + const calls = vi.fn(raf) + vi.stubGlobal('requestAnimationFrame', calls) + await userEvent.type(box, 'hollow') + expect(calls).toHaveBeenCalledTimes(1) + }) + + it('filters a 3000-game library in the next frame', async () => { + const games = Array.from({ length: 3000 }, (_, i) => makeLibGame({ app_id: i + 1, name: `Game number ${i} of the backlog` })) + stubApi({ 'GET /api/library': { games } }) + const release = holdFrames() + mount('') + const box = await screen.findByRole('combobox', { name: 'Search your games' }) + await loaded() + release() // first paint builds the cards + ;(box as HTMLInputElement).value = 'number 12' + box.dispatchEvent(new Event('input', { bubbles: true })) + release() + // Wall-clock budgets are measured in real Chromium (live check), not in + // jsdom under a 10 % CPU cap where they only measure the scheduler. + expect(titles().length).toBeGreaterThan(0) + expect(titles()[0]).toBe('Game number 12 of the backlog') + }) +}) diff --git a/web/src/library/gameBrowser.pick.test.ts b/web/src/library/gameBrowser.pick.test.ts new file mode 100644 index 0000000..16462d1 --- /dev/null +++ b/web/src/library/gameBrowser.pick.test.ts @@ -0,0 +1,136 @@ +import { screen, waitFor } from '@testing-library/dom' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { define } from '../lib/dom' +import { card, grid, render, search, titles, withLibrary } from '../test/browserHelpers' +import { GameBrowser } from './gameBrowser' +import type { GameBrowserElement, PickMode } from './pick' + +describe('GameBrowser pick mode', () => { + beforeEach(() => { + withLibrary() + }) + + const pickable = new Set([1, 2]) + const setup = (selected: number | null = null) => { + const onSelect = vi.fn() + const el = render({ appIds: pickable, selected, onSelect }) + return { onSelect, el } + } + + it('selects eligible cards only, and never submits', async () => { + const { onSelect } = setup() + await screen.findByText('5 of 5 games') + await userEvent.click(card('The Witcher 2')) + expect(onSelect).toHaveBeenCalledWith(2) + onSelect.mockClear() + await userEvent.click(card('Hollow Knight')) + await userEvent.click(card('The Witcher 3')) + expect(onSelect).not.toHaveBeenCalled() + }) + + it('shows why a game is not offered', async () => { + setup(2) + await screen.findByText('5 of 5 games') + expect(card('The Witcher 3')).toHaveTextContent('Already started') + expect(card('Hollow Knight')).toHaveTextContent('Not offered here') + expect(card('The Witcher 2')).toHaveAttribute('aria-pressed', 'true') + expect(card('Hollow Knight')).toHaveAttribute('aria-disabled', 'true') + }) + + it('"pickable only" keeps just the ids the job sent', async () => { + setup() + await screen.findByText('5 of 5 games') + await userEvent.click(screen.getByRole('checkbox', { name: 'Pickable only' })) + await waitFor(() => { + expect(titles()).toEqual(['The Witcher', 'The Witcher 2']) + }) + }) + + it('choosing a suggestion selects an eligible game but only focuses an ineligible one', async () => { + const { onSelect } = setup() + await screen.findByText('5 of 5 games') + await userEvent.type(search(), 'hollow') + await userEvent.keyboard('{Enter}') + expect(onSelect).not.toHaveBeenCalled() + await waitFor(() => { + expect(card('Hollow Knight')).toHaveFocus() + }) + await userEvent.clear(search()) + await userEvent.type(search(), 'witcher 2') + await userEvent.keyboard('{Enter}') + expect(onSelect).toHaveBeenCalledWith(2) + await waitFor(() => { + expect(card('The Witcher 2')).toHaveFocus() + }) + }) + + it('a new `selected` moves the highlight without rebuilding the toolbar or grid', async () => { + const { el } = setup(1) + await screen.findByText('5 of 5 games') + const input = search() + const cards = [...grid().children] + const pick: PickMode = { appIds: pickable, selected: 1, onSelect: vi.fn() } + el.pick = pick + el.pick = { ...pick, selected: 2 } + expect(card('The Witcher 2')).toHaveAttribute('aria-pressed', 'true') + expect(card('The Witcher 2')).toHaveClass('selected') + expect(card('The Witcher')).toHaveAttribute('aria-pressed', 'false') + el.pick = { ...pick, selected: null } + expect(grid().querySelectorAll('.selected')).toHaveLength(0) + expect(search()).toBe(input) + expect([...grid().children]).toEqual(cards) + }) + + it('works as the job prompt drives it: onSelect sets `pick` re-entrantly', async () => { + const el = render() + const pick: PickMode = { + appIds: pickable, + selected: null, + onSelect: (id) => { + el.pick = { ...pick, selected: id } + }, + } + el.pick = pick + await screen.findByText('5 of 5 games') + await userEvent.click(card('The Witcher 2')) + expect(card('The Witcher 2')).toHaveAttribute('aria-pressed', 'true') + expect(el.pick).toMatchObject({ selected: 2 }) + }) + + it('new ids, or leaving pick mode, repaint every card', async () => { + const { el } = setup() + await screen.findByText('5 of 5 games') + el.pick = { appIds: new Set([5]), selected: 5, onSelect: vi.fn() } + await waitFor(() => { + expect(card('Hollow Knight')).toHaveAttribute('aria-pressed', 'true') + }) + expect(card('The Witcher 2')).toHaveTextContent('Not offered here') + el.pick = null + await waitFor(() => { + expect(card('Hollow Knight')).not.toHaveAttribute('aria-pressed') + }) + expect(card('The Witcher 2')).not.toHaveTextContent('Not offered here') + expect(card('The Witcher 3')).toHaveTextContent('Already started') + expect(el.pick).toBeNull() + }) + + it('accepts `pick` set before the element is connected', async () => { + const el = document.createElement('game-browser') as GameBrowserElement + el.pick = { appIds: pickable, selected: 1, onSelect: vi.fn() } + document.body.append(el) + await screen.findByText('5 of 5 games') + expect(card('The Witcher')).toHaveAttribute('aria-pressed', 'true') + }) + + it('accepts `pick` set before the tag is even defined', async () => { + const el = document.createElement('gb-late') as GameBrowserElement + el.pick = { appIds: pickable, selected: 2, onSelect: vi.fn() } + define('gb-late', class extends GameBrowser {}) + document.body.append(el) + await screen.findByText('5 of 5 games') + expect(el).toBeInstanceOf(GameBrowser) + expect(Object.hasOwn(el, 'pick')).toBe(false) + expect(card('The Witcher 2')).toHaveAttribute('aria-pressed', 'true') + }) +}) diff --git a/web/src/library/gameBrowser.test.ts b/web/src/library/gameBrowser.test.ts new file mode 100644 index 0000000..609abca --- /dev/null +++ b/web/src/library/gameBrowser.test.ts @@ -0,0 +1,54 @@ +import { fireEvent, screen } from '@testing-library/dom' +import { beforeEach, describe, expect, it } from 'vitest' +import { q } from '../api/queries' +import { apiError, stubApi } from '../test/harness' +import { GAMES, render, search, titles, withLibrary } from '../test/browserHelpers' + +describe('GameBrowser', () => { + beforeEach(() => { + withLibrary() + }) + + it('shows the library sorted by name, with a count', async () => { + render() + expect(screen.getByText(/Loading your library/)).toBeInTheDocument() + await screen.findByText('5 of 5 games') + expect(titles()).toEqual(['Age of Empires III', 'Hollow Knight', 'The Witcher', 'The Witcher 2', 'The Witcher 3']) + expect(screen.queryByText(/Loading/)).toBeNull() + expect(document.querySelector('game-browser')).toHaveClass('lib') + }) + + it('explains a failed load', async () => { + stubApi({ 'GET /api/library': apiError(500, 'op_failed', 'cannot read library') }) + render() + expect(await screen.findByText('cannot read library')).toBeInTheDocument() + expect(screen.queryByText(/Loading/)).toBeNull() + }) + + it('keeps the grid and shows the error when a refetch fails', async () => { + const routes: Record = { 'GET /api/library': { games: GAMES } } + stubApi(routes as never) + render() + await screen.findByText('5 of 5 games') + routes['GET /api/library'] = apiError(500, 'op_failed', 'refetch broke') + q.library.invalidate() + expect(await screen.findByText('refetch broke')).toBeInTheDocument() + expect(titles()).toHaveLength(5) + }) + + it('says so when nothing matches', async () => { + withLibrary([]) + render() + expect(await screen.findByText('No game matches.')).toBeInTheDocument() + expect(screen.getByText('0 of 0 games')).toBeInTheDocument() + }) + + it('drops a pending grid frame when detached', async () => { + const el = render() + await screen.findByText('5 of 5 games') + fireEvent.input(search(), { target: { value: 'hollow' } }) + el.remove() + await new Promise((r) => setTimeout(r, 40)) + expect(el.querySelectorAll('.game-card')).toHaveLength(5) + }) +}) diff --git a/web/src/library/gameBrowser.ts b/web/src/library/gameBrowser.ts new file mode 100644 index 0000000..1a7ba8f --- /dev/null +++ b/web/src/library/gameBrowser.ts @@ -0,0 +1,213 @@ +// `` (was GameBrowser.tsx): every owned game as a +// cover grid, with fuzzy search + suggestions, sort and filters. The +// toolbar (browserToolbar.ts) and the keyboard-driven grid (rovingGrid.ts) +// are built once; this element owns the state and decides what they show. +// +// A keystroke runs the search synchronously (suggestions, count) and the +// grid follows on the next animation frame, as useDeferredValue did: fast +// typing coalesces into one grid update. Cards are built once per library +// load and reused, so no cover is ever recreated by a search. +// +// `pick` (see pick.ts) puts it in pick mode for a job's game prompt. With +// `hash-state` its state lives in the URL hash (see browserState.ts). + +import type { LibraryGame } from '../api/contract' +import { q } from '../api/queries' +import { define, h } from '../lib/dom' +import { LiveElement } from '../lib/element' +import { errorNotice, loading } from '../ui/build' +import { stateFromHash, stateToHash, type BrowserState } from './browserState' +import { BrowserToolbar } from './browserToolbar' +import { buildIndex, search, type Searchable } from './fuzzy' +import { gameCard, paintCard, type CardPaint } from './gameCard' +import { writeHash } from './hashState' +import type { GameBrowserElement, PickMode } from './pick' +import { RovingGrid } from './rovingGrid' +import { filterGames, NO_FILTERS, sortGames } from './view' + +const SUGGESTIONS = 8 + +interface Built { + game: LibraryGame + card: HTMLButtonElement +} + +export class GameBrowser extends LiveElement implements GameBrowserElement { + private pickMode: PickMode | null = null + private built = false + private state: BrowserState = { query: '', sort: 'name', filters: { ...NO_FILTERS } } + private frame: number | null = null + private visible: LibraryGame[] = [] + private indexOf: readonly LibraryGame[] | null = null + private index = new Map() + private cardsOf: readonly LibraryGame[] | null = null + private cards = new Map() + + private readonly toolbar = new BrowserToolbar({ + onInput: (value) => { + this.state.query = value + this.changed() + }, + onChoose: (game) => { + this.choose(game) + }, + reason: (game) => this.reason(game), + onSort: (sort) => { + this.state.sort = sort + this.changed() + }, + onFilter: (key, on) => { + this.state.filters = { ...this.state.filters, [key]: on } + this.changed() + }, + }) + private readonly notices = h('div', { class: 'contents' }) + private readonly none = h('div', { class: 'contents' }) + private readonly grid = new RovingGrid() + + constructor() { + super() + // `browser.pick = …` before this tag was defined left an own property + // that would shadow the accessor: move it through the setter. + if (Object.hasOwn(this, 'pick')) { + const early = (this as { pick: PickMode | null }).pick + Reflect.deleteProperty(this, 'pick') + this.pick = early + } + } + + get pick(): PickMode | null { + return this.pickMode + } + + /** Same `appIds` with a new `selected` only moves the highlight. */ + set pick(next: PickMode | null) { + const prev = this.pickMode + this.pickMode = next + if (prev && prev.appIds === next?.appIds) { + for (const id of [prev.selected, next.selected]) { + const built = id === null ? undefined : this.cards.get(id) + if (built) paintCard(built.card, this.paintOf(built.game)) + } + return + } + this.cards = new Map() + if (this.built) this.refresh() + } + + protected override setup(): void { + if (!this.built) { + this.built = true + this.classList.add('lib') + if (this.hashState) this.applyState(stateFromHash()) + this.replaceChildren(this.toolbar.el, this.notices, this.none, this.grid.el) + } + this.watch(q.library) + if (this.hashState) { + const onHash = () => { + this.applyState(stateFromHash()) + this.refresh() + } + window.addEventListener('hashchange', onHash) + this.onCleanup(() => { + window.removeEventListener('hashchange', onHash) + }) + } + this.onCleanup(() => { + if (this.frame !== null) cancelAnimationFrame(this.frame) + this.frame = null + }) + } + + protected update(): void { + const { data, error } = q.library.state + this.notices.replaceChildren(...(error ? [errorNotice(error)] : data ? [] : [loading('your library')])) + this.refresh() + } + + private get hashState(): boolean { + return this.hasAttribute('hash-state') + } + + /** Adopt `s` and show it in the toolbar (a reload, or the hash edited by hand). */ + private applyState(s: BrowserState): void { + this.state = s + this.toolbar.applyState(s) + } + + private changed(): void { + if (this.hashState) writeHash(stateToHash(this.state)) + this.refresh() + } + + /** Why `g` cannot be picked here, or null. */ + private reason(g: LibraryGame): string | null { + const pick = this.pickMode + if (!pick) return g.ineligible_reason + return pick.appIds.has(g.app_id) ? null : (g.ineligible_reason ?? 'Not offered here') + } + + private paintOf(g: LibraryGame): CardPaint { + return { reason: this.reason(g), selected: this.pickMode?.selected === g.app_id, pickMode: this.pickMode !== null } + } + + /** Recompute the visible games and suggestions now; the grid follows next frame. */ + private refresh(): void { + const query = this.state.query.trim() + this.toolbar.lockSort(query !== '') + const games = q.library.state.data?.games + if (!games) return + if (this.indexOf !== games) { + this.indexOf = games + this.index = buildIndex(games) + } + const filtered = filterGames(games, this.state.filters, (g) => this.reason(g) === null) + this.visible = query ? search(filtered, this.index, this.state.query) : sortGames(filtered, this.state.sort) + this.toolbar.setSuggestions(query ? this.visible.slice(0, SUGGESTIONS) : []) + this.frame ??= requestAnimationFrame(() => { + this.frame = null + this.renderGrid(games) + }) + } + + private card(g: LibraryGame): HTMLButtonElement { + let built = this.cards.get(g.app_id) + if (!built) { + const card = gameCard(g) + card.addEventListener('click', () => { + this.activate(card, g) + }) + paintCard(card, this.paintOf(g)) + built = { game: g, card } + this.cards.set(g.app_id, built) + } + return built.card + } + + private renderGrid(games: readonly LibraryGame[]): void { + if (this.cardsOf !== games) { + this.cardsOf = games + this.cards = new Map() + } + this.grid.show(this.visible.map((g) => this.card(g))) + const query = this.state.query.trim() + this.toolbar.setCount(`${this.visible.length} of ${games.length} games${query ? ' · by relevance' : ''}`) + this.none.replaceChildren(...(this.visible.length === 0 ? [h('p', { class: 'muted' }, 'No game matches.')] : [])) + } + + /** A click: becomes the tab stop; in pick mode an eligible card is selected. */ + private activate(card: HTMLButtonElement, g: LibraryGame): void { + this.grid.setActive(card) + // Select only: assigning can uninstall other games, so it takes the + // prompt's explicit "Pick …" button, never a double-click. + if (this.pickMode && this.reason(g) === null) this.pickMode.onSelect(g.app_id) + } + + /** A suggestion chosen: select it if pickable, and move focus to its card. */ + private choose(g: LibraryGame): void { + if (this.pickMode && this.reason(g) === null) this.pickMode.onSelect(g.app_id) + this.grid.focusGame(g.app_id) + } +} + +define('game-browser', GameBrowser) diff --git a/web/src/library/gameCard.test.ts b/web/src/library/gameCard.test.ts new file mode 100644 index 0000000..4114222 --- /dev/null +++ b/web/src/library/gameCard.test.ts @@ -0,0 +1,90 @@ +import { fireEvent } from '@testing-library/dom' +import { describe, expect, it } from 'vitest' +import { makeLibGame } from '../test/fixtures' +import { gameCard, paintCard, placeCard, type CardPaint } from './gameCard' + +const PLAIN: CardPaint = { reason: null, selected: false, pickMode: false } + +function make(over: Parameters[0] = {}, paint: CardPaint = PLAIN) { + const card = gameCard(makeLibGame({ app_id: 42, name: 'Hollow Knight', ...over })) + paintCard(card, paint) + document.body.append(card) + return card +} + +describe('gameCard', () => { + it('is a button carrying its id, with title and meta line', () => { + const card = make() + expect(card).toHaveAttribute('type', 'button') + expect(card).toHaveAttribute('data-app-id', '42') + expect(card).toHaveTextContent('Hollow Knight') + expect(card).toHaveTextContent('50%') + }) + + it('is placed with its index, as the roving tab stop or out of the tab order', () => { + const card = make() + expect(card).toHaveAttribute('tabindex', '-1') + placeCard(card, 3, true) + expect(card).toHaveAttribute('data-index', '3') + expect(card).toHaveAttribute('tabindex', '0') + placeCard(card, 4, false) + expect(card).toHaveAttribute('tabindex', '-1') + }) + + it('exposes pressed state only in pick mode', () => { + expect(make()).not.toHaveAttribute('aria-pressed') + }) + + it('exposes pressed and disabled in pick mode, with the reason shown', () => { + const card = make({}, { pickMode: true, selected: true, reason: 'Already finished' }) + expect(card).toHaveAttribute('aria-pressed', 'true') + expect(card).toHaveAttribute('aria-disabled', 'true') + expect(card).toHaveClass('selected', 'ineligible') + expect(card).toHaveTextContent('Already finished') + }) + + it('is not aria-disabled in pick mode when pickable', () => { + const card = make({}, { pickMode: true, selected: false, reason: null }) + expect(card).not.toHaveAttribute('aria-disabled') + expect(card).toHaveAttribute('aria-pressed', 'false') + }) + + it('repaints in place: reason updated, then removed, pick attributes dropped', () => { + const card = make({}, { pickMode: true, selected: true, reason: 'A' }) + paintCard(card, { pickMode: true, selected: false, reason: 'B' }) + expect(card.querySelectorAll('.game-reason')).toHaveLength(1) + expect(card).toHaveTextContent('B') + paintCard(card, PLAIN) + expect(card.querySelector('.game-reason')).toBeNull() + expect(card).not.toHaveClass('selected') + expect(card).not.toHaveClass('ineligible') + expect(card).not.toHaveAttribute('aria-pressed') + expect(card).not.toHaveAttribute('aria-disabled') + }) + + it('shows badges for assigned and installed', () => { + const card = make({ assigned: true, installed: true }) + expect(card).toHaveTextContent('assigned') + expect(card).toHaveTextContent('installed') + }) + + it('shows only the badge that applies', () => { + const card = make({ installed: true }) + expect(card.querySelector('.game-badges')).toHaveTextContent(/^installed$/) + expect(make({ assigned: true }).querySelector('.game-badges')).toHaveTextContent(/^assigned$/) + }) + + it('has no badge row for a plain game', () => { + expect(make().querySelector('.game-badges')).toBeNull() + }) + + it('lazy-loads the art and falls back to a title tile when it 404s', () => { + const card = make() + const img = card.getElementsByTagName('img')[0] + expect(img).toHaveAttribute('src', '/api/art/42') + expect(img).toHaveAttribute('loading', 'lazy') + fireEvent.error(img) + expect(card.querySelector('img')).toBeNull() + expect(card.querySelector('.cover-tile')).toHaveTextContent('Hollow Knight') + }) +}) diff --git a/web/src/library/gameCard.ts b/web/src/library/gameCard.ts new file mode 100644 index 0000000..e5c160d --- /dev/null +++ b/web/src/library/gameCard.ts @@ -0,0 +1,72 @@ +// One tile of the library grid (was GameCard.tsx). Built once per game and +// reused across searches, so a keystroke never recreates a cover ; +// `paintCard` updates the parts that depend on the browser's state. + +import { ENDPOINTS, type LibraryGame } from '../api/contract' +import { h } from '../lib/dom' +import { metaLine } from './view' + +export interface CardPaint { + /** Why it cannot be picked here, or null. Greys the card out. */ + reason: string | null + selected: boolean + pickMode: boolean +} + +/** Portrait cover, lazily loaded; a title tile when the server has none. */ +function cover(game: LibraryGame): HTMLImageElement { + const img = h('img', { class: 'cover', src: ENDPOINTS.art(game.app_id), alt: '', loading: 'lazy', decoding: 'async' }) + img.addEventListener( + 'error', + () => { + img.replaceWith(h('div', { class: 'cover cover-tile', 'aria-hidden': 'true' }, h('span', {}, game.name))) + }, + { once: true }, + ) + return img +} + +/** + * A button in both modes so it is a focus stop; in browse mode it only takes + * focus, in pick mode it selects. Clicks are handled by the grid. + */ +export function gameCard(game: LibraryGame): HTMLButtonElement { + return h( + 'button', + { type: 'button', class: 'game-card', 'data-app-id': game.app_id, tabindex: -1 }, + cover(game), + h('span', { class: 'game-title' }, game.name), + h('span', { class: 'game-meta' }, metaLine(game)), + game.assigned || game.installed + ? h( + 'span', + { class: 'game-badges' }, + game.assigned ? h('span', { class: 'pill pill-accent' }, 'assigned') : null, + game.installed ? h('span', { class: 'pill pill-neutral' }, 'installed') : null, + ) + : null, + ) +} + +/** Apply pick state: selection, eligibility and the reason line. */ +export function paintCard(card: HTMLButtonElement, { reason, selected, pickMode }: CardPaint): void { + card.classList.toggle('ineligible', reason !== null) + card.classList.toggle('selected', selected) + if (pickMode) card.setAttribute('aria-pressed', String(selected)) + else card.removeAttribute('aria-pressed') + if (pickMode && reason !== null) card.setAttribute('aria-disabled', 'true') + else card.removeAttribute('aria-disabled') + let line = card.querySelector('.game-reason') + if (reason === null) { + line?.remove() + return + } + line ??= card.appendChild(h('span', { class: 'game-reason' })) + line.textContent = reason +} + +/** Place the card in the grid: its position and whether it is the tab stop. */ +export function placeCard(card: HTMLButtonElement, index: number, focusable: boolean): void { + card.dataset.index = String(index) + card.tabIndex = focusable ? 0 : -1 +} diff --git a/web/src/library/hashState.ts b/web/src/library/hashState.ts new file mode 100644 index 0000000..56a5c2c --- /dev/null +++ b/web/src/library/hashState.ts @@ -0,0 +1,20 @@ +// Page state kept in the URL hash (`#q=wicher&sort=recent&view=installed`), +// so a reload or a shared link restores it. Writes use replaceState: typing +// must not push one history entry per keystroke, and replaceState fires no +// `hashchange`, so a page never re-applies its own writes. + +/** The current hash as search params. */ +export function readHash(): URLSearchParams { + return new URLSearchParams(location.hash.slice(1)) +} + +/** Set (or, for null / empty, drop) `values`, leaving other keys alone. */ +export function writeHash(values: Record): void { + const params = readHash() + for (const [key, value] of Object.entries(values)) { + if (value === null || value === '') params.delete(key) + else params.set(key, value) + } + const hash = params.toString() + history.replaceState(history.state, '', hash ? `#${hash}` : `${location.pathname}${location.search}`) +} diff --git a/web/src/library/pick.ts b/web/src/library/pick.ts new file mode 100644 index 0000000..5ee127c --- /dev/null +++ b/web/src/library/pick.ts @@ -0,0 +1,14 @@ +/** + * `` in pick mode (a job's `game` prompt): only `appIds` are + * selectable; the browser calls `onSelect` and the prompt owns `selected`. + */ +export interface PickMode { + appIds: ReadonlySet + selected: number | null + onSelect: (appId: number) => void +} + +/** The element contract GamePrompt relies on (implemented in library/). */ +export interface GameBrowserElement extends HTMLElement { + pick: PickMode | null +} diff --git a/web/src/library/rovingGrid.test.ts b/web/src/library/rovingGrid.test.ts new file mode 100644 index 0000000..c20badd --- /dev/null +++ b/web/src/library/rovingGrid.test.ts @@ -0,0 +1,136 @@ +import { fireEvent, screen, waitFor } from '@testing-library/dom' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { q } from '../api/queries' +import { makeLibGame } from '../test/fixtures' +import { card, grid, render, search, titles, withLibrary } from '../test/browserHelpers' + +describe('GameBrowser grid focus', () => { + beforeEach(() => { + withLibrary() + }) + + it('Enter on a suggestion moves focus to its card', async () => { + render() + await screen.findByText('5 of 5 games') + await userEvent.type(search(), 'hollow') + await waitFor(() => { + expect(titles()).toEqual(['Hollow Knight']) + }) + await userEvent.keyboard('{Enter}') + expect(card('Hollow Knight')).toHaveFocus() + expect(card('Hollow Knight')).toHaveAttribute('tabindex', '0') + }) + + it('a card the grid has not drawn yet is focused once it is', async () => { + render() + await screen.findByText('5 of 5 games') + // Typed and chosen in one go: the grid is a frame behind. + fireEvent.input(search(), { target: { value: 'hollow' } }) + fireEvent.keyDown(search(), { key: 'Enter' }) + await waitFor(() => { + expect(document.activeElement).toBe(card('Hollow Knight')) + }) + }) + + it('keeps focus on the same game when the library is refetched', async () => { + render() + await screen.findByText('5 of 5 games') + card('Hollow Knight').focus() + const old = card('Hollow Knight') + q.library.invalidate() + await waitFor(() => { + expect(card('Hollow Knight')).not.toBe(old) + }) + expect(card('Hollow Knight')).toHaveFocus() + }) + + describe('keyboard in the grid', () => { + beforeEach(() => { + withLibrary(Array.from({ length: 12 }, (_, i) => makeLibGame({ app_id: i + 1, name: `Game ${String(i).padStart(2, '0')}` }))) + // jsdom lays nothing out; only the grid is told it has three columns. + const real = window.getComputedStyle.bind(window) + vi.spyOn(window, 'getComputedStyle').mockImplementation((el, pseudo) => { + const style = real(el, pseudo) + if (!el.classList.contains('game-grid')) return style + return new Proxy(style, { + get: (t, k) => { + if (k === 'gridTemplateColumns') return '1fr 1fr 1fr' + const v: unknown = Reflect.get(t, k) + return typeof v === 'function' ? (v as () => unknown).bind(t) : v + }, + }) + }) + }) + afterEach(() => { + vi.restoreAllMocks() + }) + + const press = (key: string) => { + fireEvent.keyDown(document.activeElement ?? document.body, { key }) + } + const at = () => (document.activeElement as HTMLElement).querySelector('.game-title')?.textContent + + it('moves by cell, row, page and to the ends (roving tabindex)', async () => { + render() + await screen.findByText('12 of 12 games') + card('Game 00').focus() + press('ArrowRight') + expect(at()).toBe('Game 01') + press('ArrowDown') + expect(at()).toBe('Game 04') + press('ArrowLeft') + expect(at()).toBe('Game 03') + press('ArrowUp') + expect(at()).toBe('Game 00') + press('PageDown') + expect(at()).toBe('Game 09') + press('PageUp') + expect(at()).toBe('Game 00') + press('End') + expect(at()).toBe('Game 11') + expect(card('Game 11')).toHaveAttribute('tabindex', '0') + expect(card('Game 00')).toHaveAttribute('tabindex', '-1') + expect(grid().querySelectorAll('[tabindex="0"]')).toHaveLength(1) + press('Home') + expect(at()).toBe('Game 00') + press('ArrowUp') // clamps at the first card + expect(at()).toBe('Game 00') + }) + + it('leaves other keys alone', async () => { + render() + await screen.findByText('12 of 12 games') + const e = new KeyboardEvent('keydown', { key: 'a', bubbles: true, cancelable: true }) + card('Game 00').dispatchEvent(e) + expect(e.defaultPrevented).toBe(false) + }) + + it('ignores navigation keys when nothing is shown', async () => { + render() + await screen.findByText('12 of 12 games') + await userEvent.type(search(), 'qqqqqq') + await screen.findByText('No game matches.') + const e = new KeyboardEvent('keydown', { key: 'End', bubbles: true, cancelable: true }) + grid().dispatchEvent(e) + expect(e.defaultPrevented).toBe(false) + }) + + it('a click makes that card the tab stop', async () => { + render() + await screen.findByText('12 of 12 games') + await userEvent.click(card('Game 05')) + expect(card('Game 05')).toHaveAttribute('tabindex', '0') + expect(card('Game 00')).toHaveAttribute('tabindex', '-1') + }) + + it('falls back to one column when the layout is unknown', async () => { + vi.restoreAllMocks() // jsdom computes no grid columns + render() + await screen.findByText('12 of 12 games') + card('Game 00').focus() + press('ArrowDown') + expect(at()).toBe('Game 01') + }) + }) +}) diff --git a/web/src/library/rovingGrid.ts b/web/src/library/rovingGrid.ts new file mode 100644 index 0000000..38797da --- /dev/null +++ b/web/src/library/rovingGrid.ts @@ -0,0 +1,85 @@ +// The `` cover grid as one tab stop: arrows, PageUp/PageDown +// and Home/End move between cards (roving tabindex). It also holds a card +// to focus until the grid, which trails the search box by a frame, shows it. + +import { h } from '../lib/dom' +import { placeCard } from './gameCard' + +/** Grid columns as laid out right now (for ↑/↓ moving one row). */ +function columns(grid: HTMLElement): number { + return Math.max(1, getComputedStyle(grid).gridTemplateColumns.split(' ').filter(Boolean).length) +} + +export class RovingGrid { + readonly el = h('div', { + class: 'game-grid', + role: 'group', + 'aria-label': 'Games', + onkeydown: (e: Event) => { + this.onKey(e as KeyboardEvent) + }, + }) + private active = 0 + /** A card to focus once the grid shows it. */ + private focusId: number | null = null + + /** Show `cards` in this order, keeping the tab stop at the same position. */ + show(cards: HTMLButtonElement[]): void { + // A refetch replaces every card: keep focus on the same game. + const focused = document.activeElement + if (focused instanceof HTMLElement && focused.parentElement === this.el) this.focusId ??= Number(focused.dataset.appId) + const current = Math.min(this.active, Math.max(0, cards.length - 1)) + cards.forEach((card, i) => { + placeCard(card, i, i === current) + }) + this.el.replaceChildren(...cards) + this.applyFocus() + } + + /** Focus the card of `appId` now, or once the grid shows it. */ + focusGame(appId: number): void { + this.focusId = appId + this.applyFocus() + } + + /** Make `card` the grid's one tab stop. */ + setActive(card: HTMLElement): void { + for (const c of this.el.querySelectorAll('[tabindex="0"]')) c.tabIndex = -1 + card.tabIndex = 0 + this.active = Number(card.dataset.index) + } + + /** Focus the pending card if the grid shows it yet. */ + private applyFocus(): void { + if (this.focusId === null) return + const card = this.el.querySelector(`[data-app-id="${this.focusId}"]`) + if (!card) return + this.focusId = null + this.setActive(card) + card.focus() + } + + private onKey(e: KeyboardEvent): void { + const cards = [...this.el.children] as HTMLElement[] + const cols = columns(this.el) + const page = cols * 3 + const moves: Partial> = { + ArrowRight: 1, + ArrowLeft: -1, + ArrowDown: cols, + ArrowUp: -cols, + PageDown: page, + PageUp: -page, + Home: -Infinity, + End: Infinity, + } + const move = moves[e.key] + if (move === undefined || cards.length === 0) return + e.preventDefault() + const current = Math.min(this.active, cards.length - 1) + const next = Math.min(cards.length - 1, Math.max(0, current + move)) + const card = cards[next] + this.setActive(card) + card.focus() + } +} diff --git a/web/src/library/searchBox.test.ts b/web/src/library/searchBox.test.ts new file mode 100644 index 0000000..f5ac7f3 --- /dev/null +++ b/web/src/library/searchBox.test.ts @@ -0,0 +1,164 @@ +import { fireEvent, screen } from '@testing-library/dom' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import type { LibraryGame } from '../api/contract' +import { makeLibGame } from '../test/fixtures' +import { SearchBox } from './searchBox' + +const A = makeLibGame({ app_id: 1, name: 'Alpha' }) +const B = makeLibGame({ app_id: 2, name: 'Beta', ineligible_reason: 'Already finished' }) +const C = makeLibGame({ app_id: 3, name: 'Gamma' }) + +/** A box whose owner answers every edit with `suggestions`, as the browser does. */ +function setup(suggestions: LibraryGame[] = [A, B, C], parent: HTMLElement = document.body) { + const onChoose = vi.fn() + const onInput = vi.fn() + const box: SearchBox = new SearchBox({ + onInput: (v) => { + onInput(v) + box.setSuggestions(v.trim() ? suggestions : []) + }, + onChoose, + reason: (g) => g.ineligible_reason, + }) + parent.append(box.el) + return { box, onChoose, onInput } +} + +const input = () => screen.getByRole('combobox', { name: 'Search your games' }) + +describe('SearchBox', () => { + it('lists suggestions once typing starts, with reason or meta line', async () => { + setup() + expect(screen.queryByRole('option')).toBeNull() + await userEvent.type(input(), 'a') + const options = screen.getAllByRole('option') + expect(options).toHaveLength(3) + expect(options[1]).toHaveTextContent('Already finished') + expect(options[0]).toHaveTextContent('50%') + expect(input()).toHaveAttribute('aria-expanded', 'true') + expect(input()).toHaveAttribute('aria-controls', screen.getByRole('listbox').id) + }) + + it('reports every edit and keeps the same input node', async () => { + const { onInput, box } = setup() + const node = input() + await userEvent.type(node, 'ab') + expect(onInput.mock.calls).toEqual([['a'], ['ab']]) + expect(input()).toBe(node) + expect(box.value).toBe('ab') + }) + + it('stays closed when there are no suggestions', async () => { + setup([]) + await userEvent.type(input(), 'zz') + expect(input()).toHaveAttribute('aria-expanded', 'false') + }) + + it('moves through the list with the arrows, wrapping, and announces the active one', async () => { + setup() + await userEvent.type(input(), 'a') + await userEvent.keyboard('{ArrowDown}') + expect(input()).toHaveAttribute('aria-activedescendant', expect.stringMatching(/-0$/) as string) + await userEvent.keyboard('{ArrowDown}{ArrowDown}{ArrowDown}') + expect(screen.getAllByRole('option')[0]).toHaveAttribute('aria-selected', 'true') + expect(screen.getAllByRole('option')[0]).toHaveClass('active') + await userEvent.keyboard('{ArrowUp}') + expect(screen.getAllByRole('option')[2]).toHaveAttribute('aria-selected', 'true') + }) + + it('starts from the last suggestion on ArrowUp, and reopens a closed list', async () => { + setup() + await userEvent.type(input(), 'a') + await userEvent.keyboard('{Escape}') + expect(input()).toHaveAttribute('aria-expanded', 'false') + await userEvent.keyboard('{ArrowUp}') + expect(input()).toHaveAttribute('aria-expanded', 'true') + expect(screen.getAllByRole('option')[2]).toHaveAttribute('aria-selected', 'true') + }) + + it('ignores arrows with no suggestions', async () => { + setup([]) + await userEvent.type(input(), 'a') + await userEvent.keyboard('{ArrowDown}{ArrowUp}') + expect(input()).not.toHaveAttribute('aria-activedescendant') + }) + + it('Enter chooses the highlighted suggestion, else the first, and closes the list', async () => { + const { onChoose } = setup() + await userEvent.type(input(), 'a') + await userEvent.keyboard('{Enter}') + expect(onChoose).toHaveBeenLastCalledWith(A) + expect(input()).toHaveAttribute('aria-expanded', 'false') + await userEvent.type(input(), 'b') + await userEvent.keyboard('{ArrowDown}{ArrowDown}{Enter}') + expect(onChoose).toHaveBeenLastCalledWith(B) + }) + + it('falls back to the first suggestion when the highlight is out of range', () => { + const { box, onChoose } = setup() + fireEvent.input(input(), { target: { value: 'a' } }) + fireEvent.keyDown(input(), { key: 'ArrowUp' }) // highlights the last of three + box.setSuggestions([A]) + fireEvent.keyDown(input(), { key: 'Enter' }) + expect(onChoose).toHaveBeenCalledWith(A) + }) + + it('Enter does nothing while the list is hidden', async () => { + const { onChoose } = setup() + await userEvent.type(input(), '{Enter}') + await userEvent.type(input(), 'a{Escape}{Enter}') + expect(onChoose).not.toHaveBeenCalled() + }) + + it('Escape closes the list first, then clears the text, then lets the key go', async () => { + const outer = vi.fn() + const wrap = document.body.appendChild(document.createElement('div')) + wrap.addEventListener('keydown', outer) + const { onInput } = setup(undefined, wrap) + await userEvent.type(input(), 'abc') + outer.mockClear() + await userEvent.keyboard('{Escape}') + expect(input()).toHaveAttribute('aria-expanded', 'false') + expect(input()).toHaveValue('abc') + expect(outer).not.toHaveBeenCalled() + await userEvent.keyboard('{Escape}') + expect(input()).toHaveValue('') + expect(onInput).toHaveBeenLastCalledWith('') + expect(outer).not.toHaveBeenCalled() + await userEvent.keyboard('{Escape}') + expect(outer).toHaveBeenCalled() + }) + + it('leaves other keys alone', () => { + setup() + const e = new KeyboardEvent('keydown', { key: 'a', bubbles: true, cancelable: true }) + input().dispatchEvent(e) + expect(e.defaultPrevented).toBe(false) + }) + + it('opens on focus and closes on blur', async () => { + setup() + await userEvent.type(input(), 'a') + await userEvent.tab() + expect(input()).toHaveAttribute('aria-expanded', 'false') + await userEvent.click(input()) + expect(input()).toHaveAttribute('aria-expanded', 'true') + }) + + it('sets the text without reporting it or opening the list', () => { + const { box, onInput } = setup() + box.setSuggestions([A]) + box.value = 'restored' + expect(input()).toHaveValue('restored') + expect(onInput).not.toHaveBeenCalled() + expect(input()).toHaveAttribute('aria-expanded', 'false') + }) + + it('chooses a suggestion on mousedown, before blur can close the list', async () => { + const { onChoose } = setup() + await userEvent.type(input(), 'a') + fireEvent.mouseDown(screen.getByRole('option', { name: /Gamma/ })) + expect(onChoose).toHaveBeenCalledWith(C) + }) +}) diff --git a/web/src/library/searchBox.ts b/web/src/library/searchBox.ts new file mode 100644 index 0000000..4e90424 --- /dev/null +++ b/web/src/library/searchBox.ts @@ -0,0 +1,147 @@ +// Library search (was SearchBox.tsx): an ARIA combobox. Typing filters the +// grid behind it and lists the top suggestions; ↑/↓ move through them, Enter +// chooses the highlighted one (or the first), Escape closes the list, then +// clears. The input is built once; only the list and ARIA state change. + +import type { LibraryGame } from '../api/contract' +import { h } from '../lib/dom' +import { uid } from '../lib/ids' +import { metaLine } from './view' + +export interface SearchBoxOptions { + /** Every edit of the text (typing, or Escape clearing it). */ + onInput: (value: string) => void + onChoose: (game: LibraryGame) => void + /** Why a game cannot be picked here (pick mode differs from the library). */ + reason: (game: LibraryGame) => string | null +} + +export class SearchBox { + readonly el: HTMLDivElement + readonly input: HTMLInputElement + private readonly list: HTMLUListElement + private readonly listId = uid('lib-suggest') + private readonly opts: SearchBoxOptions + private suggestions: LibraryGame[] = [] + private open = false + private active = -1 + + constructor(opts: SearchBoxOptions) { + this.opts = opts + this.input = h('input', { + class: 'input', + type: 'search', + role: 'combobox', + 'aria-label': 'Search your games', + 'aria-expanded': 'false', + 'aria-controls': this.listId, + 'aria-autocomplete': 'list', + placeholder: 'Search your games (typos are fine)…', + }) + this.list = h('ul', { id: this.listId, role: 'listbox', class: 'lib-suggest', hidden: true, 'aria-label': 'Suggestions' }) + this.el = h('div', { class: 'lib-search' }, this.input, this.list) + this.input.addEventListener('input', () => { + this.open = true + this.active = -1 + opts.onInput(this.input.value) + this.render() + }) + this.input.addEventListener('focus', () => { + this.open = true + this.render() + }) + this.input.addEventListener('blur', () => { + this.open = false + this.render() + }) + this.input.addEventListener('keydown', (e) => { + this.onKeyDown(e) + }) + } + + get value(): string { + return this.input.value + } + + /** Set the text without reporting it (restoring state from the URL). */ + set value(v: string) { + this.input.value = v + this.render() + } + + setSuggestions(games: LibraryGame[]): void { + this.suggestions = games + this.render() + } + + private get shown(): boolean { + return this.open && this.input.value.trim() !== '' && this.suggestions.length > 0 + } + + private choose(game: LibraryGame): void { + this.open = false + this.active = -1 + this.render() + this.opts.onChoose(game) + } + + private onKeyDown(e: KeyboardEvent): void { + const n = this.suggestions.length + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + if (n === 0) return + e.preventDefault() + this.open = true + const step = e.key === 'ArrowDown' ? 1 : -1 + this.active = this.active < 0 && step < 0 ? n - 1 : (this.active + step + n) % n + this.render() + } else if (e.key === 'Enter') { + if (!this.shown) return + e.preventDefault() + // The highlighted suggestion, else the first. + this.choose(this.suggestions[this.active >= 0 && this.active < n ? this.active : 0]) + } else if (e.key === 'Escape') { + if (this.shown) { + e.preventDefault() + e.stopPropagation() + this.open = false + this.render() + } else if (this.input.value) { + e.preventDefault() + e.stopPropagation() + this.input.value = '' + this.opts.onInput('') + this.render() + } + } + } + + private render(): void { + const shown = this.shown + this.input.setAttribute('aria-expanded', String(shown)) + if (shown && this.active >= 0) this.input.setAttribute('aria-activedescendant', `${this.listId}-${this.active}`) + else this.input.removeAttribute('aria-activedescendant') + this.list.hidden = !shown + this.list.replaceChildren( + ...(shown + ? this.suggestions.map((g, i) => + h( + 'li', + { + id: `${this.listId}-${i}`, + role: 'option', + 'aria-selected': String(i === this.active), + class: i === this.active ? 'lib-suggest-item active' : 'lib-suggest-item', + // mousedown, not click: the input's blur would close the list first. + onmousedown: (e: Event) => { + e.preventDefault() + this.choose(g) + }, + }, + h('span', { class: 'lib-suggest-name' }, g.name), + h('span', { class: 'lib-suggest-meta' }, this.opts.reason(g) ?? metaLine(g)), + ), + ) + : []), + ) + } +} diff --git a/web/src/library/view.test.ts b/web/src/library/view.test.ts index 206984f..b9fa016 100644 --- a/web/src/library/view.test.ts +++ b/web/src/library/view.test.ts @@ -24,13 +24,10 @@ describe('sortGames', () => { const d = g({ app_id: 4, name: 'Delta', last_played: null, hltb_hours: null, achievements_total: 0 }) const names = (key: Parameters[1]) => sortGames([c, a, d, b], key).map((x) => x.name) - it('sorts by name', () => expect(names('name')).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta'])) - it('sorts recent first, never-played last', () => - expect(names('recent')).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta'])) - it('sorts by completion descending, unknown last', () => - expect(names('completion')).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta'])) - it('sorts shortest HLTB first, unknown last', () => - expect(names('hltb')).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta'])) + it('sorts by name', () => { expect(names('name')).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta']); }) + it('sorts recent first, never-played last', () => { expect(names('recent')).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta']); }) + it('sorts by completion descending, unknown last', () => { expect(names('completion')).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta']); }) + it('sorts shortest HLTB first, unknown last', () => { expect(names('hltb')).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta']); }) it('does not mutate its input', () => { const input = [b, a] diff --git a/web/src/main.tsx b/web/src/main.tsx deleted file mode 100644 index d96d9d6..0000000 --- a/web/src/main.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import './styles/tokens.css' -import './index.css' -import './styles/app.css' -import './styles/controls.css' -import './styles/screens.css' -import './styles/library.css' -import App from './App.tsx' - -createRoot(document.getElementById('root')!).render( - - - , -) diff --git a/web/src/pages/404.astro b/web/src/pages/404.astro new file mode 100644 index 0000000..0dcdfe1 --- /dev/null +++ b/web/src/pages/404.astro @@ -0,0 +1,10 @@ +--- +// Any address with no page (the server serves this with a real 404). +import PageHead from '../components/PageHead.astro' +import Shell from '../layouts/Shell.astro' +--- + + + +
+ diff --git a/web/src/pages/backlog/index.astro b/web/src/pages/backlog/index.astro new file mode 100644 index 0000000..d2facf7 --- /dev/null +++ b/web/src/pages/backlog/index.astro @@ -0,0 +1,17 @@ +--- +import PageHead from '../../components/PageHead.astro' +import Shell from '../../layouts/Shell.astro' +import '../../styles/backlog.css' +--- + + + + +
+ +
+
+ + diff --git a/web/src/pages/gaming/index.astro b/web/src/pages/gaming/index.astro new file mode 100644 index 0000000..499548c --- /dev/null +++ b/web/src/pages/gaming/index.astro @@ -0,0 +1,22 @@ +--- +import Actions from '../../components/Actions.astro' +import PageHead from '../../components/PageHead.astro' +import Shell from '../../layouts/Shell.astro' +import '../../styles/gaming.css' +--- + + + + + + + + + + + + + + diff --git a/web/src/pages/index.astro b/web/src/pages/index.astro new file mode 100644 index 0000000..312f8ff --- /dev/null +++ b/web/src/pages/index.astro @@ -0,0 +1,29 @@ +--- +import PageHead from '../components/PageHead.astro' +import Shell from '../layouts/Shell.astro' +import '../styles/dashboard.css' +import '../styles/gaming.css' +--- + + + + + + + + +
+ + +
+ + + + +
+ + diff --git a/web/src/pages/jobs/index.astro b/web/src/pages/jobs/index.astro new file mode 100644 index 0000000..2081c5a --- /dev/null +++ b/web/src/pages/jobs/index.astro @@ -0,0 +1,13 @@ +--- +// `/jobs/` lists the job history; `/jobs/?id=` shows one job live. +import Shell from '../../layouts/Shell.astro' +import '../../styles/jobs.css' +--- + + + + + + diff --git a/web/src/pages/library/index.astro b/web/src/pages/library/index.astro new file mode 100644 index 0000000..2de6c1f --- /dev/null +++ b/web/src/pages/library/index.astro @@ -0,0 +1,22 @@ +--- +import Actions from '../../components/Actions.astro' +import PageHead from '../../components/PageHead.astro' +import Shell from '../../layouts/Shell.astro' +import '../../styles/library-page.css' +--- + + + + + + + + + + + + + diff --git a/web/src/pages/picks/index.astro b/web/src/pages/picks/index.astro new file mode 100644 index 0000000..d460749 --- /dev/null +++ b/web/src/pages/picks/index.astro @@ -0,0 +1,21 @@ +--- +import Actions from '../../components/Actions.astro' +import PageHead from '../../components/PageHead.astro' +import Shell from '../../layouts/Shell.astro' +--- + + + + + + + + + + + + + + diff --git a/web/src/pages/setup/index.astro b/web/src/pages/setup/index.astro new file mode 100644 index 0000000..96deacd --- /dev/null +++ b/web/src/pages/setup/index.astro @@ -0,0 +1,14 @@ +--- +import PageHead from '../../components/PageHead.astro' +import Shell from '../../layouts/Shell.astro' +import '../../styles/setup.css' +--- + + + + + + + diff --git a/web/src/pages/store/index.astro b/web/src/pages/store/index.astro new file mode 100644 index 0000000..0c34bb1 --- /dev/null +++ b/web/src/pages/store/index.astro @@ -0,0 +1,20 @@ +--- +import Actions from '../../components/Actions.astro' +import PageHead from '../../components/PageHead.astro' +import Shell from '../../layouts/Shell.astro' +import '../../styles/store.css' +--- + + + + + + + + + + + + diff --git a/web/src/pages/system/index.astro b/web/src/pages/system/index.astro new file mode 100644 index 0000000..5db30d6 --- /dev/null +++ b/web/src/pages/system/index.astro @@ -0,0 +1,25 @@ +--- +import PageHead from '../../components/PageHead.astro' +import Shell from '../../layouts/Shell.astro' +import '../../styles/system.css' +--- + + + +
+ + +
+ + +
+ + diff --git a/web/src/router.tsx b/web/src/router.tsx deleted file mode 100644 index 5bb6ee1..0000000 --- a/web/src/router.tsx +++ /dev/null @@ -1,59 +0,0 @@ -// Code-based TanStack Router tree: one route per section, plus job detail. -// No file-based generator, so there is no generated file to keep in sync. - -import { createRootRoute, createRoute, createRouter } from '@tanstack/react-router' -import { Backlog } from './screens/Backlog' -import { Dashboard } from './screens/Dashboard' -import { Gaming } from './screens/Gaming' -import { JobDetail, Jobs } from './screens/Jobs' -import { Library } from './screens/Library' -import { NotFound } from './screens/NotFound' -import { Picks } from './screens/Picks' -import { Setup } from './screens/Setup' -import { Store } from './screens/Store' -import { System } from './screens/System' -import { AppShell } from './shell/AppShell' - -const root = createRootRoute({ component: AppShell, notFoundComponent: NotFound }) - -const page = (path: TPath, component: () => React.ReactNode) => - createRoute({ getParentRoute: () => root, path, component }) - -const gaming = createRoute({ - getParentRoute: () => root, - path: '/gaming', - validateSearch: (s: Record): { demo?: boolean } => - s.demo === true || s.demo === '1' || s.demo === 1 ? { demo: true } : {}, - component: function GamingRoute() { - return - }, -}) - -const jobDetail = createRoute({ - getParentRoute: () => root, - path: '/jobs/$jobId', - component: function JobRoute() { - return - }, -}) - -const routeTree = root.addChildren([ - page('/', Dashboard), - page('/backlog', Backlog), - page('/picks', Picks), - gaming, - page('/library', Library), - page('/store', Store), - page('/jobs', Jobs), - jobDetail, - page('/system', System), - page('/setup', Setup), -]) - -export const router = createRouter({ routeTree, defaultPreload: 'intent', scrollRestoration: true }) - -declare module '@tanstack/react-router' { - interface Register { - router: typeof router - } -} diff --git a/web/src/screens/Backlog.test.tsx b/web/src/screens/Backlog.test.tsx deleted file mode 100644 index 425fbf8..0000000 --- a/web/src/screens/Backlog.test.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { screen, within } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { makeDataset, makeGame, makePaceVsHltb } from '../test/factories' -import { apiError, renderClient, stubApi } from '../test/harness' -import { Backlog } from './Backlog' - -const summary = { qualifying: 42, rush_total: 100, leisure_total: 250, worst_total: 400 } -const stats = (pace: ReturnType | null) => ({ default_summary: summary, pace_vs_hltb: pace }) -const show = (statsReply: unknown) => { - stubApi({ 'GET /api/stats': statsReply, 'GET /api/dataset': makeDataset([makeGame({ app_id: 1, name: 'Alpha' }), makeGame({ app_id: 2, name: 'Beta' })]) }) - renderClient() -} -const fact = (label: string) => screen.getByText(label, { selector: 'dt' }).nextElementSibling! - -describe('Backlog', () => { - afterEach(() => vi.unstubAllGlobals()) - - it('shows the estimates for the default filters', async () => { - show(stats(null)) - expect(await screen.findByText('qualifying games')).toBeInTheDocument() - expect(screen.getByText('42')).toBeInTheDocument() - expect(screen.getByText('100 h')).toBeInTheDocument() - expect(screen.getByText('250 h')).toBeInTheDocument() - expect(screen.getByText('400 h')).toBeInTheDocument() - expect(screen.queryByText('Your pace')).toBeNull() - }) - - it('adds the player’s pace against HLTB once calibrated', async () => { - show(stats(makePaceVsHltb({ player_style: 'rush_to_leisure', ratio_vs_rush: 1.05, ratio_vs_leisure: 0.4, calibration_count: 12 }))) - expect(await screen.findByText('Your pace')).toBeInTheDocument() - expect(fact('Your pace')).toHaveTextContent('Between rush and leisure') - expect(fact('vs rush')).toHaveTextContent('1.05×') - expect(fact('vs leisure')).toHaveTextContent('0.40×') - expect(fact('Calibrated on')).toHaveTextContent('12 finished games') - }) - - it('dashes a ratio the server could not compute', async () => { - show(stats(makePaceVsHltb({ player_style: 'unknown', ratio_vs_rush: -1, ratio_vs_leisure: -1 }))) - expect(await screen.findByText('Not enough finished games yet')).toBeInTheDocument() - expect(fact('vs rush')).toHaveTextContent('—') - expect(fact('vs leisure')).toHaveTextContent('—') - }) - - it('shows loading while stats load', async () => { - show(() => new Promise(() => {})) - expect(await screen.findByText(/Loading stats/)).toBeInTheDocument() - }) - - it('explains a failed stats load', async () => { - show(apiError(500, 'op_failed', 'stats broke')) - expect(await screen.findByText('stats broke')).toBeInTheDocument() - }) - - it('embeds the interactive planner', async () => { - show(stats(null)) - expect(await screen.findByRole('heading', { name: 'Backlog Completion Planner' })).toBeInTheDocument() - const table = await screen.findByRole('table') - expect(within(table).getByText('Alpha')).toBeInTheDocument() - await userEvent.type(screen.getByPlaceholderText(/Search games/i), 'Beta') - expect(within(table).queryByText('Alpha')).toBeNull() - }) -}) diff --git a/web/src/screens/Backlog.tsx b/web/src/screens/Backlog.tsx deleted file mode 100644 index def2f0e..0000000 --- a/web/src/screens/Backlog.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { q } from '../api/queries' -import { PlannerView } from '../components/PlannerView' -import { fmtHours } from '../format' -import { ErrorNotice, Loading } from '../ui/Notice' -import { Card, Facts, PageHead } from '../ui/Page' - -const STYLE: Record = { - faster_than_rush: 'Faster than HLTB rush', - rush_to_leisure: 'Between rush and leisure', - slower_than_leisure: 'Slower than HLTB leisure', - unknown: 'Not enough finished games yet', -} - -/** `stats`: the CLI's completion-time estimate for the default filters. */ -function StatsCard() { - const { data, error } = useQuery(q.stats) - if (error) return - if (!data) return - const s = data.default_summary - const p = data.pace_vs_hltb - return ( - -
-
{s.qualifying}qualifying games
-
{fmtHours(s.rush_total)}rush
-
{fmtHours(s.leisure_total)}leisure
-
{fmtHours(s.worst_total)}worst case
-
- {p && ( - - )} -
- ) -} - -export function Backlog() { - return ( - <> - - -
- -
- - ) -} diff --git a/web/src/screens/Dashboard.tsx b/web/src/screens/Dashboard.tsx deleted file mode 100644 index ea68818..0000000 --- a/web/src/screens/Dashboard.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { Link } from '@tanstack/react-router' -import type { FastInstallRun, StatusPayload } from '../api/contract' -import { q } from '../api/queries' -import { CommandButton } from '../commands/CommandCard' -import { commandLabel } from '../commands/catalog' -import { fmtDuration, fmtRemaining } from '../format' -import { ProgressBar, StateBadge } from '../jobs/ProgressBar' -import { ago } from '../ui/format' -import { Empty, ErrorNotice, Loading } from '../ui/Notice' -import { Card, Facts, PageHead } from '../ui/Page' -import { useNow } from '../ui/useNow' - -/** Ticks every second from `until`; the server's `days_remaining` (rounded to - * 0.1 d) is only the fallback when no end time is known. */ -function TotalBlockLeft({ block }: { block: StatusPayload['total_block'] }) { - const now = useNow(1000) - const seconds = block.until ? (Date.parse(block.until) - now) / 1000 : block.days_remaining * 86_400 - return {fmtRemaining(seconds)} left -} - -function StatusCard({ s }: { s: StatusPayload }) { - return ( - }> -

{s.current_game_name ?? 'Nothing assigned'}

- No — install it from Library], - ['Games finished', s.finished_count], - ['Store', s.store_blocked ? 'Blocked' : Unblocked], - ['Installed games', s.installed_count], - [ - 'Total block', - s.total_block.active ? : 'Off', - ], - [ - 'Manual picks', - s.manual_picks.length ? s.manual_picks.map((p) => `${p.name} (${p.age_days} d)`).join(', ') : 'None', - ], - ]} - /> -
- ) -} - -function fastInstallLine(r: FastInstallRun): string { - const where = r.percent === null ? r.phase : `${Math.floor(r.percent)}% (${r.phase})` - return `Fast-installing ${r.name} — ${where}` -} - -/** Daemon-started fast installs; a web job shows its own in the job view. */ -function FastInstalls() { - const { data } = useQuery(q.daemon) - const runs = data?.fast_installs ?? [] - if (!runs.length) return null - return ( - - {runs.map((r) => - r.error ? ( - // The daemon has no job to report to: a failure stays here until Steam finishes. -
-

{`Fast install of ${r.name} failed: ${r.error}`}

-

Steam's own download took over.

-
- ) : ( -
-

{fastInstallLine(r)}

- -

{fmtDuration(r.elapsed_seconds)} elapsed

-
- ), - )} -
- ) -} - -function BudgetGlance() { - const { data, error } = useQuery(q.budget) - if (error) return - if (!data) return - const t = data.today - return ( - Details}> - {t ? ( - <> -

- {fmtDuration(t.seconds_remaining)} left of {fmtDuration(t.budget_seconds)} -

- 0.8 ? 'warning' : 'accent'} /> -

- {t.blocked ? 'Blocked for today.' : `Now: ${data.session.billing_label || 'nothing billing'}`} -

- - ) : ( -

{data.error ?? 'Budget state unavailable.'}

- )} -
- ) -} - -function RecentJobs() { - const { data, error } = useQuery(q.jobs) - return ( - All jobs}> - {error && } - {data?.length === 0 && No jobs yet. Run one from the actions or press Ctrl K.} -
    - {data?.slice(0, 5).map((j) => ( -
  • - - {commandLabel(j.command)} - {j.summary ?? j.progress?.label ?? ''} - {ago(j.created_at)} - - -
  • - ))} -
-
- ) -} - -export function Dashboard() { - const { data, error } = useQuery(q.status) - return ( - <> - - - - - - -
- {error ? : data ? : } - -
- - - - ) -} diff --git a/web/src/screens/Gaming.test.tsx b/web/src/screens/Gaming.test.tsx deleted file mode 100644 index 2ac283b..0000000 --- a/web/src/screens/Gaming.test.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { makeBudget } from '../test/factories' -import { makeSpec } from '../test/fixtures' -import { renderRouted, stubApi } from '../test/harness' -import { Gaming } from './Gaming' - -const SPECS = [makeSpec('gaming-reset'), makeSpec('block-gaming'), makeSpec('gaming-unblock')] - -describe('Gaming', () => { - afterEach(() => vi.unstubAllGlobals()) - - it('shows the production budget and the three gaming controls', async () => { - const mock = stubApi({ 'GET /api/commands': SPECS, 'GET /api/budget': makeBudget() }) - renderRouted() - expect(await screen.findByRole('heading', { name: 'Today' })).toBeInTheDocument() - for (const name of ['Reset today…', 'Block gaming…', 'Release mounts…']) { - expect(screen.getAllByText(name).length).toBeGreaterThan(0) - } - expect(mock.mock.calls.some(([p]) => p === '/api/budget')).toBe(true) - expect(screen.getByRole('link', { name: 'Show demo budget' })).toHaveAttribute('href', '/gaming?demo=true') - }) - - it('reads the demo run when asked, and links back', async () => { - const mock = stubApi({ 'GET /api/commands': SPECS, 'GET /api/budget?demo=1': makeBudget() }) - renderRouted() - await screen.findByRole('heading', { name: 'Today' }) - expect(mock.mock.calls.some(([p]) => p === '/api/budget?demo=1')).toBe(true) - const link = screen.getByRole('link', { name: 'Show production budget' }) - expect(link).toHaveAttribute('href', '/gaming?demo=false') - await userEvent.click(link) - }) -}) diff --git a/web/src/screens/Gaming.tsx b/web/src/screens/Gaming.tsx deleted file mode 100644 index 51b7ff7..0000000 --- a/web/src/screens/Gaming.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Link } from '@tanstack/react-router' -import { CommandCard } from '../commands/CommandCard' -import { BudgetPanel } from '../components/BudgetPanel' -import { Actions, PageHead } from '../ui/Page' - -/** - * `gaming-status` (the live budget panel) plus every gaming control. `?demo=1` - * points the panel at the short-budget demo run, the only way to watch the - * cutoff engage without spending a real day. - */ -export function Gaming({ demo }: { demo: boolean }) { - return ( - <> - - - {demo ? 'Show production budget' : 'Show demo budget'} - - - - - - - - - - ) -} diff --git a/web/src/screens/Jobs.test.tsx b/web/src/screens/Jobs.test.tsx deleted file mode 100644 index 718044e..0000000 --- a/web/src/screens/Jobs.test.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { makeJob, makeSpec } from '../test/fixtures' -import { apiError, installEventSource, renderRouted, stubApi } from '../test/harness' -import { JobDetail, Jobs } from './Jobs' - -describe('Jobs', () => { - beforeEach(installEventSource) - afterEach(() => vi.unstubAllGlobals()) - - it('lists jobs with command, params, state, duration and summary', async () => { - stubApi({ - 'GET /api/jobs': [ - makeJob({ id: 'a', command: 'pick-manual', params: { app_id: 5, note: 'x' }, state: 'succeeded', started_at: '2026-10-10T10:00:00Z', ended_at: '2026-10-10T10:01:05Z', summary: 'Picked 5' }), - makeJob({ id: 'b', command: 'scan', progress: { step: 1, total: 2, label: 'Scanning' } }), - makeJob({ id: 'c', command: 'done' }), - ], - }) - renderRouted() - expect(await screen.findByRole('table')).toBeInTheDocument() - expect(screen.getByRole('link', { name: 'Pick manual' })).toHaveAttribute('href', '/jobs/a') - expect(screen.getByText('app_id=5 note=x')).toBeInTheDocument() - expect(screen.getByText('1:05')).toBeInTheDocument() - expect(screen.getByText('Picked 5')).toBeInTheDocument() - expect(screen.getByText('Scanning')).toBeInTheDocument() - expect(screen.getAllByText('Running')).toHaveLength(2) - }) - - it('shows loading, then an invitation when there are no jobs', async () => { - let answer: (v: unknown) => void = () => {} - stubApi({ 'GET /api/jobs': () => new Promise((r) => (answer = r)) }) - renderRouted() - expect(await screen.findByText(/Loading jobs/)).toBeInTheDocument() - answer([]) - expect(await screen.findByText(/No jobs yet/)).toBeInTheDocument() - expect(screen.queryByRole('table')).toBeNull() - }) - - it('explains a failed load', async () => { - stubApi({ 'GET /api/jobs': apiError(500, 'op_failed', 'jobs broke') }) - renderRouted() - expect(await screen.findByText('jobs broke')).toBeInTheDocument() - expect(screen.queryByText(/Loading jobs/)).toBeNull() - }) -}) - -describe('JobDetail', () => { - beforeEach(installEventSource) - afterEach(() => vi.unstubAllGlobals()) - - it('shows the job with its live progress', async () => { - stubApi({ - 'GET /api/jobs/jd-1': makeJob({ id: 'jd-1', command: 'scan', created_at: '2026-10-10T10:00:00Z' }), - 'GET /api/commands': [makeSpec('scan')], - }) - renderRouted() - expect(await screen.findByRole('heading', { name: 'Scan', level: 1 })).toBeInTheDocument() - expect(screen.getByText(/created/)).toBeInTheDocument() - expect(screen.getByRole('region', { name: 'Job Scan' })).toBeInTheDocument() - await userEvent.click(screen.getByRole('link', { name: 'All jobs' })) - }) - - it('titles itself generically until the job loads, and shows an error', async () => { - stubApi({ 'GET /api/jobs/jd-2': apiError(404, 'not_found', 'no such job'), 'GET /api/commands': [] }) - renderRouted() - expect(await screen.findByRole('heading', { name: 'Job', level: 1 })).toBeInTheDocument() - expect(await screen.findByText('no such job')).toBeInTheDocument() - expect(screen.getByText('jd-2')).toBeInTheDocument() - }) -}) diff --git a/web/src/screens/Jobs.tsx b/web/src/screens/Jobs.tsx deleted file mode 100644 index 59a3a8d..0000000 --- a/web/src/screens/Jobs.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { Link } from '@tanstack/react-router' -import { q } from '../api/queries' -import { commandLabel } from '../commands/catalog' -import { JobProgress } from '../jobs/JobProgress' -import { StateBadge } from '../jobs/ProgressBar' -import { dateTime, duration } from '../ui/format' -import { Empty, ErrorNotice, Loading } from '../ui/Notice' -import { Card, PageHead } from '../ui/Page' -import { useNow } from '../ui/useNow' - -export function Jobs() { - const { data, error } = useQuery(q.jobs) - const now = useNow(1000) - return ( - <> - - - {error && } - {!data && !error && } - {data?.length === 0 && No jobs yet. Press Ctrl K to run a command.} - {data && data.length > 0 && ( -
- - - - - - - - - - - - {data.map((j) => ( - - - - - - - - ))} - -
CommandStateStartedTookSummary
- {commandLabel(j.command)} - {Object.keys(j.params).length > 0 && ( - {Object.entries(j.params).map(([k, v]) => `${k}=${v}`).join(' ')} - )} - {dateTime(j.created_at)}{duration(j.started_at, j.ended_at, now)}{j.summary ?? j.progress?.label ?? ''}
-
- )} -
- - ) -} - -export function JobDetail({ jobId }: { jobId: string }) { - const { data, error } = useQuery(q.job(jobId)) - return ( - <> - Job {jobId}{data && <> · created {dateTime(data.created_at)}}}> - All jobs - - {error && } - - - - - ) -} diff --git a/web/src/screens/Library.test.tsx b/web/src/screens/Library.test.tsx deleted file mode 100644 index eedb61a..0000000 --- a/web/src/screens/Library.test.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { makeInstalled, makeLibGame, makeSpec } from '../test/fixtures' -import { apiError, renderRouted, stubApi } from '../test/harness' -import { Library } from './Library' - -const routes = (installed: unknown = { games: [] }) => ({ - 'GET /api/library': { games: [makeLibGame({ app_id: 1, name: 'Hollow Knight' })] }, - 'GET /api/installed': installed, - 'GET /api/commands': [makeSpec('install'), makeSpec('uninstall'), makeSpec('hide'), makeSpec('unhide')], -}) - -describe('Library', () => { - afterEach(() => vi.unstubAllGlobals()) - - it('opens on the cover grid, with the install actions above', async () => { - stubApi(routes()) - renderRouted() - expect(await screen.findByText('1 of 1 games')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'All games' })).toHaveAttribute('aria-pressed', 'true') - expect(screen.getByRole('button', { name: 'Installed' })).toHaveAttribute('aria-pressed', 'false') - for (const label of ['Install assigned', 'Hide others', 'Unhide all']) { - expect(screen.getAllByText(label).length).toBeGreaterThan(0) - } - }) - - it('counts what an uninstall would remove', async () => { - stubApi(routes({ - games: [ - makeInstalled({ app_id: 1 }), - makeInstalled({ app_id: 2, assigned: true }), - makeInstalled({ app_id: 3, protected: true }), - makeInstalled({ app_id: 4 }), - ], - })) - renderRouted() - expect((await screen.findAllByText('Uninstall 2…')).length).toBeGreaterThan(0) - }) - - it('switches to the installed table and back', async () => { - stubApi(routes({ - games: [ - makeInstalled({ app_id: 1, name: 'Assigned One', assigned: true, size_bytes: 2 * 1024 ** 3 }), - makeInstalled({ app_id: 2, name: 'Proton', protected: true }), - makeInstalled({ app_id: 3, name: 'Spare', size_bytes: 1024 ** 2 }), - ], - })) - renderRouted() - await screen.findByText('1 of 1 games') - await userEvent.click(screen.getByRole('button', { name: 'Installed' })) - expect(await screen.findByRole('region', { name: 'Installed games' })).toBeInTheDocument() - expect(screen.getByText(/3 games · 3\.0 GB/)).toBeInTheDocument() // 2 GB + 1 GB + 1 MB - expect(screen.getByText('assigned')).toBeInTheDocument() - expect(screen.getByText('protected')).toBeInTheDocument() - expect(screen.getByText('uninstall candidate')).toBeInTheDocument() - expect(screen.queryByText('1 of 1 games')).toBeNull() - await userEvent.click(screen.getByRole('button', { name: 'All games' })) - expect(await screen.findByText('1 of 1 games')).toBeInTheDocument() - }) - - it('says when nothing is installed', async () => { - stubApi(routes()) - renderRouted() - await screen.findByText('1 of 1 games') - await userEvent.click(screen.getByRole('button', { name: 'Installed' })) - expect(await screen.findByText('No games installed.')).toBeInTheDocument() - expect(screen.queryByRole('table')).toBeNull() - }) - - it('shows loading, then a failure, for the installed table', async () => { - const table = routes(() => new Promise(() => {})) - stubApi(table) - renderRouted() - await screen.findByText('1 of 1 games') - await userEvent.click(screen.getByRole('button', { name: 'Installed' })) - expect(await screen.findByText(/Loading installed games/)).toBeInTheDocument() - }) - - it('explains a failed installed load', async () => { - stubApi(routes(apiError(500, 'op_failed', 'installed broke'))) - renderRouted() - await screen.findByText('1 of 1 games') - await userEvent.click(screen.getByRole('button', { name: 'Installed' })) - expect(await screen.findByText('installed broke')).toBeInTheDocument() - }) -}) diff --git a/web/src/screens/Library.tsx b/web/src/screens/Library.tsx deleted file mode 100644 index afc8677..0000000 --- a/web/src/screens/Library.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { useState } from 'react' -import { q } from '../api/queries' -import { CommandCard } from '../commands/CommandCard' -import { GameBrowser } from '../library/GameBrowser' -import { bytes } from '../ui/format' -import { Empty, ErrorNotice, Loading } from '../ui/Notice' -import { Actions, Card, PageHead } from '../ui/Page' - -const VIEWS = [ - ['all', 'All games'], - ['installed', 'Installed'], -] as const - -/** Every owned game as a cover grid, or `installed` as a table; actions above. */ -export function Library() { - const [view, setView] = useState<'all' | 'installed'>('all') - return ( - <> - - -
- {VIEWS.map(([key, label]) => ( - - ))} -
- {view === 'all' ? : } - - ) -} - -function LibraryActions() { - const { data } = useQuery(q.installed) - const removable = (data?.games ?? []).filter((g) => !g.assigned && !g.protected) - return ( - - - - - - - ) -} - -/** `installed` as a table. */ -function InstalledTable() { - const { data, error } = useQuery(q.installed) - const games = data?.games ?? [] - const total = games.reduce((a, g) => a + g.size_bytes, 0) - return ( - {games.length} games · {bytes(total)}}> - {error && } - {!data && !error && } - {data && games.length === 0 && No games installed.} - {games.length > 0 && ( -
- - - - - - - - - - - {games.map((g) => ( - - - - - - - ))} - -
GameApp idSizeRole
{g.name}{g.app_id}{bytes(g.size_bytes)} - {g.assigned && assigned} - {g.protected && protected} - {!g.assigned && !g.protected && uninstall candidate} -
-
- )} -
- ) -} diff --git a/web/src/screens/NotFound.tsx b/web/src/screens/NotFound.tsx deleted file mode 100644 index 72dce5d..0000000 --- a/web/src/screens/NotFound.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { Link } from '@tanstack/react-router' -import { PageHead } from '../ui/Page' - -export function NotFound() { - return ( - <> - - Back to the dashboard - - ) -} diff --git a/web/src/screens/Picks.tsx b/web/src/screens/Picks.tsx deleted file mode 100644 index 6c1aa3b..0000000 --- a/web/src/screens/Picks.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { q } from '../api/queries' -import { CommandButton, CommandCard } from '../commands/CommandCard' -import { Empty, ErrorNotice, Loading } from '../ui/Notice' -import { Actions, Card, PageHead } from '../ui/Page' - -/** Everything that changes which game is assigned. */ -export function Picks() { - const { data, error } = useQuery(q.status) - const none = data !== undefined && data.manual_picks.length === 0 && data.kept_picks.length === 0 - return ( - <> - Assigned: {data.current_game_name} : 'Which game you are playing, and how to move on.'} - /> - - - - - - - - - {error && } - {!data && !error && } - {none && ( - No manual picks. "Lock in a game" adds one and locks most commands for 14 days. - )} -
    - {data?.manual_picks.map((p) => ( -
  • - - {p.name} · picked {p.age_days} d ago · {p.app_id} - - -
  • - ))} - {/* Released, expired or kept by "Pick…": still installed and playable. */} - {data?.kept_picks.map((p) => ( -
  • - - {p.name} · kept · no lock · {p.app_id} - - -
  • - ))} -
- {none && ( -
- -
- )} -
- - ) -} diff --git a/web/src/screens/Setup.test.tsx b/web/src/screens/Setup.test.tsx deleted file mode 100644 index 117abcf..0000000 --- a/web/src/screens/Setup.test.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { queryClient } from '../api/queries' -import { apiError, bodiesOf, renderRouted, stubApi } from '../test/harness' -import { Setup } from './Setup' - -const KEY = '0123456789abcdef0123456789ABCDEF' -const ID = '76561198000000000' -const status = (over = {}) => ({ configured: true, has_api_key: true, steam_id: ID, ...over }) -const keyBox = () => screen.findByLabelText('Steam Web API key') -const idBox = () => screen.getByLabelText('SteamID64') - -describe('Setup', () => { - afterEach(() => vi.unstubAllGlobals()) - - it('prefills the id, never the key, and says a key is stored', async () => { - stubApi({ 'GET /api/setup': status() }) - renderRouted() - const key = await keyBox() - expect(key).toHaveValue('') - expect(key).toHaveFocus() - await waitFor(() => expect(key).toHaveAttribute('placeholder', expect.stringContaining('A key is stored'))) - await waitFor(() => expect(idBox()).toHaveValue(ID)) - expect(screen.queryByText('Not configured yet')).toBeNull() - }) - - it('warns on a first run, with a hint for the empty form', async () => { - stubApi({ 'GET /api/setup': status({ configured: false, has_api_key: false, steam_id: null }) }) - renderRouted() - expect(await screen.findByText('Not configured yet')).toBeInTheDocument() - expect(await keyBox()).toHaveAttribute('placeholder', '32 hex characters') - expect(idBox()).toHaveValue('') - }) - - it('shows loading, then a failure', async () => { - stubApi({ 'GET /api/setup': () => new Promise(() => {}) }) - renderRouted() - expect(await screen.findByText(/Loading setup/)).toBeInTheDocument() - }) - - it('explains a failed load', async () => { - stubApi({ 'GET /api/setup': apiError(500, 'op_failed', 'setup broke') }) - renderRouted() - expect(await screen.findByText('setup broke')).toBeInTheDocument() - expect(screen.queryByText(/Loading setup/)).toBeNull() - }) - - it('rejects malformed values, showing both reasons', async () => { - const mock = stubApi({ 'GET /api/setup': status({ steam_id: null }) }) - renderRouted() - await userEvent.type(await keyBox(), 'nothex') - await userEvent.type(idBox(), '123') - await userEvent.click(screen.getByRole('button', { name: 'Save and verify' })) - expect(screen.getByText('The key is 32 hexadecimal characters.')).toBeInTheDocument() - expect(screen.getByText('A SteamID64 is 17 digits and starts with 7656.')).toBeInTheDocument() - expect(screen.getByLabelText('Steam Web API key')).toHaveAttribute('aria-invalid', 'true') - expect(bodiesOf(mock, 'POST /api/setup')).toEqual([]) - }) - - it('accepts a fine key with a bad id, and a fine id with a bad key', async () => { - stubApi({ 'GET /api/setup': status() }) - renderRouted() - await userEvent.type(await keyBox(), KEY) - await userEvent.clear(idBox()) - await userEvent.click(screen.getByRole('button', { name: 'Save and verify' })) - expect(screen.queryByText('The key is 32 hexadecimal characters.')).toBeNull() - expect(screen.getByText(/17 digits/)).toBeInTheDocument() - }) - - it('saves trimmed values, clears the key and reports success', async () => { - let stored = status({ has_api_key: false, steam_id: null, configured: false }) - const mock = stubApi({ - 'GET /api/setup': () => stored, - 'POST /api/setup': () => (stored = status()), - }) - renderRouted() - await userEvent.type(await keyBox(), ` ${KEY} `) - await userEvent.type(idBox(), ` ${ID} `) - await userEvent.click(screen.getByRole('button', { name: 'Save and verify' })) - expect(await screen.findByText('Saved')).toBeInTheDocument() - expect(bodiesOf(mock, 'POST /api/setup')).toEqual([{ steam_api_key: KEY, steam_id: ID }]) - expect(screen.getByLabelText('Steam Web API key')).toHaveValue('') - expect(screen.getByRole('link', { name: 'Go to the dashboard' })).toHaveAttribute('href', '/') - expect(queryClient.getQueryData(['setup'])).toEqual(status()) - }) - - it('keeps the stored id when only the key changes', async () => { - const mock = stubApi({ 'GET /api/setup': status(), 'POST /api/setup': status() }) - renderRouted() - await userEvent.type(await keyBox(), KEY) - await waitFor(() => expect(idBox()).toHaveValue(ID)) - await userEvent.click(screen.getByRole('button', { name: 'Save and verify' })) - await screen.findByText('Saved') - expect(bodiesOf(mock, 'POST /api/setup')).toEqual([{ steam_api_key: KEY, steam_id: ID }]) - }) - - it('shows Steam’s refusal, and Checking… meanwhile', async () => { - let answer: (v: unknown) => void = () => {} - stubApi({ 'GET /api/setup': status(), 'POST /api/setup': () => new Promise((r) => (answer = r)) }) - renderRouted() - await userEvent.type(await keyBox(), KEY) - await waitFor(() => expect(idBox()).toHaveValue(ID)) - await userEvent.click(screen.getByRole('button', { name: 'Save and verify' })) - expect(await screen.findByRole('button', { name: 'Checking with Steam…' })).toBeDisabled() - answer(apiError(400, 'invalid_params', 'Steam rejected the key')) - expect(await screen.findByText('Steam rejected the key')).toBeInTheDocument() - expect(screen.queryByText('Saved')).toBeNull() - }) -}) diff --git a/web/src/screens/Setup.tsx b/web/src/screens/Setup.tsx deleted file mode 100644 index 06b729a..0000000 --- a/web/src/screens/Setup.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { useMutation, useQuery } from '@tanstack/react-query' -import { Link } from '@tanstack/react-router' -import { useState } from 'react' -import { api } from '../api/client' -import { q, queryClient } from '../api/queries' -import { ErrorNotice, Loading, Notice } from '../ui/Notice' -import { Card, PageHead } from '../ui/Page' - -const KEY_RE = /^[0-9A-Fa-f]{32}$/ -const ID_RE = /^7656\d{13}$/ - -/** - * `setup`: Steam API key + SteamID64. The key is write-only — the server - * never returns it, so the field always starts empty and only says whether - * one is stored. - */ -export function Setup() { - const { data, error } = useQuery(q.setup) - const [key, setKey] = useState('') - const [steamId, setSteamId] = useState(null) - const [touched, setTouched] = useState(false) - const id = steamId ?? data?.steam_id ?? '' - const save = useMutation({ - mutationFn: () => api.saveSetup({ steam_api_key: key.trim(), steam_id: id.trim() }), - onSuccess: (s) => { - queryClient.setQueryData(q.setup.queryKey, s) - setKey('') - void queryClient.invalidateQueries() - }, - }) - const keyErr = KEY_RE.test(key.trim()) ? null : 'The key is 32 hexadecimal characters.' - const idErr = ID_RE.test(id.trim()) ? null : 'A SteamID64 is 17 digits and starts with 7656.' - - return ( - <> - - {error && } - {!data && !error && } - {data && !data.configured && ( - Every other screen needs these two values first. - )} - {save.isSuccess && ( - - Steam accepted the key. Go to the dashboard. - - )} - -
{ - e.preventDefault() - setTouched(true) - if (!keyErr && !idErr) save.mutate() - }} - > -
- - setKey(e.target.value)} - /> -

- {touched && keyErr ? keyErr : <>Get one at steamcommunity.com/dev/apikey. It is never shown again.} -

-
-
- - setSteamId(e.target.value)} - /> -

- {touched && idErr ? idErr : 'Your profile URL or steamid.io shows it.'} -

-
- {save.error && } -
- -
- -
- - ) -} diff --git a/web/src/screens/Small.test.tsx b/web/src/screens/Small.test.tsx deleted file mode 100644 index 3e79c20..0000000 --- a/web/src/screens/Small.test.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { makeSpec, makeStatus } from '../test/fixtures' -import { apiError, renderRouted, stubApi } from '../test/harness' -import { NotFound } from './NotFound' -import { Picks } from './Picks' -import { Store } from './Store' - -const SPECS = ['check', 'done', 'pick', 'scan', 'pick-manual', 'abandon-pick', 'unblock', 'buy-dlc', 'add-exception'].map((n) => - makeSpec(n as 'check'), -) - -describe('NotFound', () => { - it('points home', async () => { - const { router } = renderRouted() - const link = await screen.findByRole('link', { name: 'Back to the dashboard' }) - await userEvent.click(link) - expect(router.state.location.pathname).toBe('/') - expect(screen.getByRole('heading', { name: 'Not found' })).toBeInTheDocument() - }) -}) - -describe('Picks', () => { - afterEach(() => vi.unstubAllGlobals()) - - it('lists manual picks, each with its abandon button', async () => { - stubApi({ - 'GET /api/commands': SPECS, - 'GET /api/status': makeStatus({ manual_picks: [{ app_id: 5, name: 'Five', age_days: 3 }, { app_id: 6, name: 'Six', age_days: 0 }] }), - }) - renderRouted() - expect(await screen.findByText('Five')).toBeInTheDocument() - expect(screen.getByText(/picked 3 d ago · 5/)).toBeInTheDocument() - expect(screen.getAllByRole('button', { name: 'Abandon…' })).toHaveLength(2) - expect(screen.queryByText(/No manual picks/)).toBeNull() - expect(screen.getByText('Hollow Knight', { selector: 'strong' })).toBeInTheDocument() - }) - - it('lists kept games under the active picks, each abandonable', async () => { - stubApi({ - 'GET /api/commands': SPECS, - 'GET /api/status': makeStatus({ - manual_picks: [{ app_id: 5, name: 'Five', age_days: 3 }], - kept_picks: [{ app_id: 7, name: 'Seven' }], - }), - }) - renderRouted() - expect(await screen.findByText('Seven')).toBeInTheDocument() - expect(screen.getByText(/kept · no lock · 7/)).toBeInTheDocument() - expect(screen.getAllByRole('button', { name: 'Abandon…' })).toHaveLength(2) - expect(screen.queryByText(/No manual picks/)).toBeNull() - }) - - it('invites locking in a game when there are none', async () => { - stubApi({ 'GET /api/commands': SPECS, 'GET /api/status': makeStatus({ current_game_name: null }) }) - renderRouted() - expect(await screen.findByText(/No manual picks/)).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Abandon a pick by app id…' })).toBeInTheDocument() - expect(screen.getByText('Which game you are playing, and how to move on.')).toBeInTheDocument() - for (const label of ['Check', 'Done', 'Pick…', 'Scan', 'Lock in a game…']) { - expect(screen.getAllByText(label).length).toBeGreaterThan(0) - } - }) - - it('shows loading, then a failure', async () => { - stubApi({ 'GET /api/commands': SPECS, 'GET /api/status': () => new Promise(() => {}) }) - renderRouted() - expect(await screen.findByText(/Loading status/)).toBeInTheDocument() - }) - - it('explains a failed load', async () => { - stubApi({ 'GET /api/commands': SPECS, 'GET /api/status': apiError(500, 'op_failed', 'status broke') }) - renderRouted() - expect(await screen.findByText('status broke')).toBeInTheDocument() - }) -}) - -describe('Store', () => { - afterEach(() => vi.unstubAllGlobals()) - - it('says the store is blocked', async () => { - stubApi({ 'GET /api/commands': SPECS, 'GET /api/status': makeStatus({ store_blocked: true }) }) - renderRouted() - expect(await screen.findByText('Store blocked')).toBeInTheDocument() - expect(screen.getByText(/capped at 30 minutes/)).toBeInTheDocument() - for (const label of ['Unblock…', 'Buy DLC…', 'Request exception…']) { - expect(screen.getAllByText(label).length).toBeGreaterThan(0) - } - }) - - it('warns when it is open', async () => { - stubApi({ 'GET /api/commands': SPECS, 'GET /api/status': makeStatus({ store_blocked: false }) }) - renderRouted() - expect(await screen.findByText('Store unblocked')).toBeInTheDocument() - expect(screen.getByText(/re-blocks automatically/)).toBeInTheDocument() - }) - - it('shows no notice until the status is known', async () => { - stubApi({ 'GET /api/commands': SPECS, 'GET /api/status': () => new Promise(() => {}) }) - renderRouted() - await screen.findByRole('heading', { name: 'Store' }) - expect(screen.queryByRole('status')).toBeNull() - }) -}) diff --git a/web/src/screens/Store.tsx b/web/src/screens/Store.tsx deleted file mode 100644 index 6fb4b92..0000000 --- a/web/src/screens/Store.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { q } from '../api/queries' -import { CommandCard } from '../commands/CommandCard' -import { Notice } from '../ui/Notice' -import { Actions, PageHead } from '../ui/Page' - -export function Store() { - const { data } = useQuery(q.status) - return ( - <> - - {data && ( - - {data.store_blocked - ? 'Unblocking takes a typed phrase and is capped at 30 minutes.' - : 'The store is open right now; it re-blocks automatically.'} - - )} - - - - - - - ) -} diff --git a/web/src/screens/System.test.tsx b/web/src/screens/System.test.tsx deleted file mode 100644 index fb18d73..0000000 --- a/web/src/screens/System.test.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { makeSpec } from '../test/fixtures' -import { renderRouted, stubApi } from '../test/harness' -import { System } from './System' - -const ROUTES = { - 'GET /api/setup': { configured: true, has_api_key: true, steam_id: '7656119800000000' }, - 'GET /api/server': { stale: false, started_at: '2026-10-10T10:00:00Z', version: '1' }, - 'GET /api/daemon': { state: 'running', started_at: null, pid: 1, journal_tail: [], restart_available_at: null }, - 'GET /api/backups': [], - 'GET /api/commands': [makeSpec('reset'), makeSpec('enforce')], -} - -describe('System', () => { - afterEach(() => vi.unstubAllGlobals()) - - it('composes the server, setup, daemon and backup cards', async () => { - stubApi(ROUTES) - renderRouted() - for (const name of ['Web server', 'Setup', 'Enforcer daemon', 'State backups']) { - expect(await screen.findByRole('region', { name })).toBeInTheDocument() - } - expect(screen.getByText(/no way to stop the enforcer/)).toBeInTheDocument() - }) - - it('summarises setup', async () => { - stubApi(ROUTES) - renderRouted() - expect(await screen.findByText('Stored (never shown)')).toBeInTheDocument() - expect(screen.getByText('Yes')).toBeInTheDocument() - expect(screen.getByText('7656119800000000')).toBeInTheDocument() - await userEvent.click(screen.getByRole('link', { name: 'Open setup' })) - }) - - it('reports an unconfigured setup', async () => { - stubApi({ ...ROUTES, 'GET /api/setup': { configured: false, has_api_key: false, steam_id: null } }) - renderRouted() - expect(await screen.findByText('Missing')).toBeInTheDocument() - expect(screen.getByText('No')).toBeInTheDocument() - }) - - it('shows placeholders until setup loads', async () => { - stubApi({ ...ROUTES, 'GET /api/setup': () => new Promise(() => {}) }) - renderRouted() - const setup = await screen.findByRole('region', { name: 'Setup' }) - expect(setup.textContent?.match(/…/g)).toHaveLength(2) - expect(setup).toHaveTextContent('—') - }) -}) diff --git a/web/src/screens/System.tsx b/web/src/screens/System.tsx deleted file mode 100644 index b47444b..0000000 --- a/web/src/screens/System.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { Link } from '@tanstack/react-router' -import { q } from '../api/queries' -import { Card, Facts, PageHead } from '../ui/Page' -import { BackupsCard } from './system/BackupsCard' -import { DaemonCard } from './system/DaemonCard' -import { ServerCard } from './system/ServerCard' - -function SetupCard() { - const { data } = useQuery(q.setup) - return ( - Open setup}> - {data?.steam_id ?? '—'}], - ]} - /> - - ) -} - -export function System() { - return ( - <> - -
- - -
- - - - ) -} diff --git a/web/src/screens/system/BackupsCard.test.tsx b/web/src/screens/system/BackupsCard.test.tsx deleted file mode 100644 index e651b6e..0000000 --- a/web/src/screens/system/BackupsCard.test.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { act, fireEvent, screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { makeJob, makeSpec } from '../../test/fixtures' -import { apiError, bodiesOf, installEventSource, renderRouted, stubApi } from '../../test/harness' -import { BackupsCard } from './BackupsCard' - -const BACKUP = { id: '20261010-100000', created_at: '2026-10-10T10:00:00Z', reason: 'before reset', size_bytes: 2048 } -const PHRASE = 'restore backup 20261010-100000' -const RESTORE = 'POST /api/backups/20261010-100000/restore' - -const show = (extra: Record = {}) => { - const mock = stubApi({ - 'GET /api/backups': [BACKUP], - 'GET /api/commands': [makeSpec('reset')], - 'GET /api/jobs': [], - 'GET /api/jobs/jr': makeJob({ id: 'jr', command: 'restore-backup' }), - ...extra, - }) - renderRouted() - return mock -} -const openDialog = async () => { - await userEvent.click(await screen.findByRole('button', { name: 'Restore…' })) - return screen.findByRole('dialog', { name: `Restore backup ${BACKUP.id}` }) -} - -describe('BackupsCard', () => { - beforeEach(installEventSource) - afterEach(() => vi.unstubAllGlobals()) - - it('lists backups with reason and size, next to the reset action', async () => { - show() - expect(await screen.findByText(BACKUP.id)).toBeInTheDocument() - expect(screen.getByText(/before reset · 2.0 KB/)).toBeInTheDocument() - expect(screen.getAllByText('Reset all state…').length).toBeGreaterThan(0) - }) - - it('says when there are none', async () => { - show({ 'GET /api/backups': [] }) - expect(await screen.findByText('No backups yet.')).toBeInTheDocument() - }) - - it('shows loading, then a failure', async () => { - show({ 'GET /api/backups': () => new Promise(() => {}) }) - expect(await screen.findByText(/Loading backups/)).toBeInTheDocument() - }) - - it('explains a failed load', async () => { - show({ 'GET /api/backups': apiError(500, 'op_failed', 'backups broke') }) - expect(await screen.findByText('backups broke')).toBeInTheDocument() - }) - - describe('restore dialog', () => { - it('asks for the exact phrase, and restores only when it matches', async () => { - const mock = show({ [RESTORE]: makeJob({ id: 'jr', command: 'restore-backup' }) }) - const dialog = await openDialog() - expect(dialog).toHaveTextContent('Replaces the current state with the backup from') - const restore = screen.getByRole('button', { name: 'Restore' }) - expect(restore).toBeDisabled() - await userEvent.type(screen.getByLabelText('Type the phrase to confirm'), `${PHRASE}{Enter}`) - await waitFor(() => expect(bodiesOf(mock, RESTORE)).toEqual([{ confirm_phrase: PHRASE }])) - expect(await screen.findByRole('region', { name: /^Job/ })).toBeInTheDocument() - }) - - it('does not restore on Enter with a wrong phrase', async () => { - const mock = show() - await openDialog() - await userEvent.type(screen.getByLabelText('Type the phrase to confirm'), 'nope') - // The button is disabled, so only a forced submit can reach the guard. - fireEvent.submit(screen.getByRole('button', { name: 'Restore' }).closest('form')!) - expect(bodiesOf(mock, RESTORE)).toEqual([]) - }) - - it('closes from the finished view, and from Cancel', async () => { - show({ [RESTORE]: makeJob({ id: 'jr', command: 'restore-backup' }) }) - await openDialog() - await userEvent.click(screen.getByRole('button', { name: 'Cancel' })) - await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) - await openDialog() - await userEvent.type(screen.getByLabelText('Type the phrase to confirm'), PHRASE) - await userEvent.click(screen.getByRole('button', { name: 'Restore' })) - await screen.findByRole('region', { name: /^Job/ }) - await userEvent.click(screen.getAllByRole('button', { name: 'Close' }).at(-1)!) // the footer one, not the header × - await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) - }) - - it('shows a refused restore and Restoring… while it runs', async () => { - let answer: (v: unknown) => void = () => {} - show({ [RESTORE]: () => new Promise((r) => (answer = r)) }) - await openDialog() - await userEvent.type(screen.getByLabelText('Type the phrase to confirm'), PHRASE) - await userEvent.click(screen.getByRole('button', { name: 'Restore' })) - expect(await screen.findByRole('button', { name: 'Restoring…' })).toBeDisabled() - await act(async () => answer(apiError(409, 'busy', 'a job is running'))) - expect(await screen.findByText('a job is running')).toBeInTheDocument() - }) - }) -}) diff --git a/web/src/screens/system/BackupsCard.tsx b/web/src/screens/system/BackupsCard.tsx deleted file mode 100644 index ff4453a..0000000 --- a/web/src/screens/system/BackupsCard.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { useMutation, useQuery } from '@tanstack/react-query' -import { useState } from 'react' -import { api } from '../../api/client' -import type { StateBackup } from '../../api/contract' -import { q, queryClient } from '../../api/queries' -import { phraseMatches } from '../../commands/catalog' -import { CommandButton } from '../../commands/CommandCard' -import { PhraseField } from '../../commands/PhraseField' -import { JobProgress } from '../../jobs/JobProgress' -import { bytes, dateTime } from '../../ui/format' -import { Dialog } from '../../ui/Dialog' -import { Empty, ErrorNotice, Loading } from '../../ui/Notice' -import { Card } from '../../ui/Page' - -// Not in any CommandSpec (restore is not a CLI command), so the UI has to -// know it. Mirrors FRICTION["restore-backup"] in _friction.py. -const RESTORE_PHRASE = (id: string) => `restore backup ${id}` - -function RestoreDialog({ backup, onClose }: { backup: StateBackup; onClose: () => void }) { - const [typed, setTyped] = useState('') - const phrase = RESTORE_PHRASE(backup.id) - const restore = useMutation({ - mutationFn: () => api.restoreBackup(backup.id, typed), - onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['jobs'] }), - }) - return ( - - {restore.data ? ( -
- -
- -
-
- ) : ( -
{ - e.preventDefault() - if (phraseMatches(phrase, typed)) restore.mutate() - }} - > -

- Replaces the current state with the backup from {dateTime(backup.created_at)} ({backup.reason}). -

- - {restore.error && } -
- - -
- - )} -
- ) -} - -export function BackupsCard() { - const { data, error } = useQuery(q.backups) - const [restoring, setRestoring] = useState(null) - return ( - }> -

Reset takes a timestamped backup first, so it can be undone here.

- {error && } - {!data && !error && } - {data?.length === 0 && No backups yet.} -
    - {data?.map((b) => ( -
  • - {b.id} - {dateTime(b.created_at)} · {b.reason} · {bytes(b.size_bytes)} - -
  • - ))} -
- {restoring && setRestoring(null)} />} -
- ) -} diff --git a/web/src/screens/system/DaemonCard.test.tsx b/web/src/screens/system/DaemonCard.test.tsx deleted file mode 100644 index 4044fff..0000000 --- a/web/src/screens/system/DaemonCard.test.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { RunnerContext } from '../../commands/runnerContext' -import { makeSpec } from '../../test/fixtures' -import { apiError, renderRouted, stubApi } from '../../test/harness' -import { DaemonCard } from './DaemonCard' - -const daemon = (over = {}) => ({ - state: 'running', - started_at: '2026-10-10T10:00:00Z', - pid: 4242, - journal_tail: ['line one', 'line two'], - restart_available_at: null, - ...over, -}) -const run = vi.fn() -const show = (reply: unknown) => { - stubApi({ 'GET /api/daemon': reply, 'GET /api/commands': [makeSpec('enforce')] }) - return renderRouted( - {} }}> - - , - ) -} - -describe('DaemonCard', () => { - afterEach(() => { - run.mockClear() - vi.unstubAllGlobals() - }) - - it('shows state, pid, uptime and the journal', async () => { - show(daemon()) - expect(await screen.findByText('4242')).toBeInTheDocument() - expect(screen.getByText('running')).toBeInTheDocument() - expect(screen.getByText(/ago/)).toBeInTheDocument() - expect(screen.getByLabelText('Daemon journal (live)')).toHaveTextContent(/line one\s+line two/) - expect(screen.queryByText('Daemon unreachable')).toBeNull() - }) - - it('restarts through the runner when allowed', async () => { - show(daemon()) - await userEvent.click(await screen.findByRole('button', { name: 'Restart daemon…' })) - expect(run).toHaveBeenCalledWith('enforce', { demo: 0 }) - }) - - it('refuses a restart while rate-limited, showing the wait', async () => { - show(daemon({ restart_available_at: new Date(Date.now() + 125_000).toISOString() })) - const button = await screen.findByRole('button', { name: /Restart available in [12]:\d\d/ }) - expect(button).toHaveAttribute('aria-disabled', 'true') - await userEvent.click(button) - expect(run).not.toHaveBeenCalled() - }) - - it('treats a lapsed rate limit as available', async () => { - show(daemon({ restart_available_at: new Date(Date.now() - 60_000).toISOString() })) - await userEvent.click(await screen.findByRole('button', { name: 'Restart daemon…' })) - expect(run).toHaveBeenCalled() - }) - - it('cannot restart a daemon that is not running, and says it is unreachable', async () => { - show(daemon({ state: 'unreachable', pid: null, started_at: null, journal_tail: [] })) - expect(await screen.findByText('Daemon unreachable')).toBeInTheDocument() - expect(screen.getByText('systemctl status steam-backlog-enforcer')).toBeInTheDocument() - const button = screen.getByRole('button', { name: 'Restart daemon…' }) - expect(button).toHaveAttribute('aria-disabled', 'true') - await userEvent.click(button) - expect(run).not.toHaveBeenCalled() - expect(screen.getByText('No journal lines.')).toBeInTheDocument() - expect(screen.getAllByText('—')).toHaveLength(2) - }) - - it('offers the demo run', async () => { - show(daemon()) - await userEvent.click(await screen.findByRole('button', { name: 'Demo run (enforce --demo)…' })) - expect(run).toHaveBeenCalledWith('enforce', { demo: 1 }) - }) - - it('shows loading, then a failure', async () => { - show(() => new Promise(() => {})) - expect(await screen.findByText(/Loading daemon status/)).toBeInTheDocument() - }) - - it('explains a failed load', async () => { - show(apiError(500, 'op_failed', 'daemon broke')) - expect(await screen.findByText('daemon broke')).toBeInTheDocument() - }) -}) diff --git a/web/src/screens/system/DaemonCard.tsx b/web/src/screens/system/DaemonCard.tsx deleted file mode 100644 index e2c02b4..0000000 --- a/web/src/screens/system/DaemonCard.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { useEffect, useRef } from 'react' -import { q } from '../../api/queries' -import { useRunner } from '../../commands/runnerContext' -import { ago, clock } from '../../ui/format' -import { CommandLine, ErrorNotice, Loading, Notice } from '../../ui/Notice' -import { Card, Facts } from '../../ui/Page' -import { useNow } from '../../ui/useNow' - -/** - * The root daemon: state, live journal tail, rate-limited restart and the - * 60 s demo run. Deliberately no stop/disable — the contract has none. - */ -export function DaemonCard() { - const { data, error } = useQuery(q.daemon) - const { run } = useRunner() - const now = useNow(1000) - const logRef = useRef(null) - const lines = data?.journal_tail.length ?? 0 - useEffect(() => { - const el = logRef.current - if (el) el.scrollTop = el.scrollHeight - }, [lines]) - - if (error) return - if (!data) return - - const waitMs = data.restart_available_at ? Date.parse(data.restart_available_at) - now : 0 - const limited = waitMs > 0 - return ( - - {data.state === 'unreachable' && ( - - Privileged actions (store unblock, gaming reset/block/unblock, restart) are unavailable. Check it from a terminal: - - - )} - -
- - {/* enforce is a "screen" command, so CommandButton would render a link - back to this page; open the runner directly, as restart does. */} - -
-

Journal

-
-        {data.journal_tail.length ? data.journal_tail.join('\n') : 'No journal lines.'}
-      
-
- ) -} diff --git a/web/src/screens/system/ServerCard.tsx b/web/src/screens/system/ServerCard.tsx deleted file mode 100644 index 2b342ef..0000000 --- a/web/src/screens/system/ServerCard.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { q } from '../../api/queries' -import { useServerRestart } from '../../shell/useServerRestart' -import { ago, dateTime } from '../../ui/format' -import { ErrorNotice, Loading, Notice } from '../../ui/Notice' -import { Card, Facts } from '../../ui/Page' - -/** `serve`: the unprivileged web server's health and restart. */ -export function ServerCard() { - const { data, error } = useQuery(q.server) - const { restart, restarting } = useServerRestart() - return ( - - {error && } - {!data && !error && } - {data && ( - <> - {data.stale && The code on disk is newer than this process.} - {data.version}], - ['Started', `${dateTime(data.started_at)} (${ago(data.started_at)})`], - ['Code', data.stale ? 'stale' : 'current'], - ]} - /> - - )} - {restart.error && } -
- -
-
- ) -} diff --git a/web/src/sections/backlog/filterPanel.test.ts b/web/src/sections/backlog/filterPanel.test.ts new file mode 100644 index 0000000..fc99e23 --- /dev/null +++ b/web/src/sections/backlog/filterPanel.test.ts @@ -0,0 +1,136 @@ +import { fireEvent, getByLabelText, getByRole, getByText, queryByRole } from '@testing-library/dom' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { makeDataset, makeFilters } from '../../test/factories' +import type { Filters } from '../../types' +import { FilterPanel } from './filterPanel' + +function setup(over: Partial = {}) { + const update = vi.fn() + const onReset = vi.fn() + const panel = new FilterPanel(makeDataset().defaults, update, onReset) + panel.sync(makeFilters(over)) + document.body.append(panel.el) + return { el: panel.el, panel, update, onReset, user: userEvent.setup() } +} +const slider = (el: HTMLElement, name: string) => getByRole(el, 'slider', { name }) + +describe('FilterPanel', () => { + it('renders the Filters heading and the CLI default hint', () => { + const { el } = setup() + expect(getByRole(el, 'heading', { name: 'Filters' })).toBeInTheDocument() + expect(getByText(el, 'Higher = more reliable HLTB times (CLI default 15).')).toBeInTheDocument() + }) + + it('switches the estimate basis when a segment is clicked', async () => { + const { el, update, user } = setup() + expect(getByRole(el, 'button', { name: 'Leisure' })).toHaveClass('seg active') + await user.click(getByRole(el, 'button', { name: 'Rush' })) + expect(update).toHaveBeenCalledWith({ basis: 'rush' }) + }) + + it('switches ProtonDB to min-tier mode; the tier select is hidden until then', async () => { + const { el, update, user } = setup() + expect(queryByRole(el, 'combobox')).toBeNull() + await user.click(getByRole(el, 'button', { name: 'Min tier' })) + expect(update).toHaveBeenCalledWith({ protonMode: 'minTier' }) + }) + + it('shows the tier dropdown when already in min-tier mode', () => { + const { el } = setup({ protonMode: 'minTier', protonMinTier: 'silver' }) + expect(getByRole(el, 'combobox')).toHaveValue('silver') + }) + + it('switches ProtonDB back to playable mode', async () => { + const { el, update, user } = setup({ protonMode: 'minTier' }) + await user.click(getByRole(el, 'button', { name: /Playable \(CLI rule\)/i })) + expect(update).toHaveBeenCalledWith({ protonMode: 'playable' }) + }) + + it('toggles the include-no-data option', async () => { + const { el, update, user } = setup() + await user.click(getByLabelText(el, /Include games with no HLTB data/i)) + expect(update).toHaveBeenCalledWith({ includeNoData: true }) + }) + + it('reveals the fallback slider when no-data games are included', () => { + const { el, panel } = setup() + expect(queryByRole(el, 'slider', { name: 'Fallback estimate' })).toBeNull() + panel.sync(makeFilters({ includeNoData: true })) + expect(getByText(el, /Fallback estimate/i)).toBeVisible() + }) + + it('calls onReset when the reset button is clicked', async () => { + const { el, onReset, user } = setup() + await user.click(getByRole(el, 'button', { name: /Reset to CLI defaults/i })) + expect(onReset).toHaveBeenCalledTimes(1) + }) + + it('updates daily hours and min completions via sliders', () => { + const { el, update } = setup() + fireEvent.input(slider(el, 'Daily play time'), { target: { value: '8' } }) + expect(update).toHaveBeenCalledWith({ dailyHours: 8 }) + fireEvent.input(slider(el, 'Min HLTB completions'), { target: { value: '40' } }) + expect(update).toHaveBeenCalledWith({ minCountComp: 40 }) + }) + + it('updates advanced confidence and the max-hours cap', () => { + const { el, update } = setup() + fireEvent.input(slider(el, 'Min polled completionist times'), { target: { value: '10' } }) + expect(update).toHaveBeenCalledWith({ minComp100: 10 }) + fireEvent.input(slider(el, 'Min confidence sum'), { target: { value: '25' } }) + expect(update).toHaveBeenCalledWith({ minConfidenceSum: 25 }) + fireEvent.input(slider(el, 'Max time per game'), { target: { value: '50' } }) + expect(update).toHaveBeenCalledWith({ maxHoursPerGame: 50 }) + }) + + it('switches the playtime mode', async () => { + const { el, update, user } = setup() + await user.click(getByRole(el, 'button', { name: 'Started' })) + expect(update).toHaveBeenCalledWith({ playtimeMode: 'started' }) + }) + + it('updates and clears the target date', async () => { + const { el, update, user } = setup({ targetDate: '2030-01-01' }) + const date = getByLabelText(el, 'Target finish date') + expect(date).toHaveValue('2030-01-01') + fireEvent.input(date, { target: { value: '2031-02-03' } }) + expect(update).toHaveBeenCalledWith({ targetDate: '2031-02-03' }) + await user.click(getByRole(el, 'button', { name: 'Clear' })) + expect(update).toHaveBeenCalledWith({ targetDate: '' }) + }) + + it('hides Clear without a target date', () => { + expect(queryByRole(setup().el, 'button', { name: 'Clear' })).toBeNull() + }) + + it('updates the min tier and treat-missing toggle in min-tier mode', async () => { + const { el, update, user } = setup({ protonMode: 'minTier' }) + fireEvent.change(getByRole(el, 'combobox'), { target: { value: 'silver' } }) + expect(update).toHaveBeenCalledWith({ protonMinTier: 'silver' }) + await user.click(getByLabelText(el, /Keep games with no ProtonDB data/i)) + expect(update).toHaveBeenCalledWith({ protonTreatMissingAsPass: false }) + }) + + it('updates the fallback estimate when no-data games are included', () => { + const { el, update } = setup({ includeNoData: true }) + fireEvent.input(slider(el, 'Fallback estimate'), { target: { value: '30' } }) + expect(update).toHaveBeenCalledWith({ fallbackHours: 30 }) + }) + + it('shows each slider value, the hours cap as off or N h', () => { + const { el, panel } = setup() + expect(getByText(el, '4 h/day')).toBeInTheDocument() + expect(getByText(el, 'off')).toBeInTheDocument() + panel.sync(makeFilters({ maxHoursPerGame: 50 })) + expect(getByText(el, '50 h')).toBeInTheDocument() + }) + + it('syncs values in place without replacing the controls', () => { + const { el, panel } = setup() + const before = slider(el, 'Daily play time') + panel.sync(makeFilters({ dailyHours: 9 })) + expect(slider(el, 'Daily play time')).toBe(before) + expect(before).toHaveValue('9') + }) +}) diff --git a/web/src/sections/backlog/filterPanel.ts b/web/src/sections/backlog/filterPanel.ts new file mode 100644 index 0000000..ce8ae4c --- /dev/null +++ b/web/src/sections/backlog/filterPanel.ts @@ -0,0 +1,194 @@ +// The planner's estimate filters (was FilterPanel.tsx). Built once; `sync()` +// writes values, active segments and conditional rows in place, so a slider +// being dragged or a date being typed never loses focus. + +import { h } from '../../lib/dom' +import { SELECTABLE_TIERS } from '../../protondb' +import type { EstimateBasis, Filters, PlaytimeMode, ProtonMode, WebDefaults } from '../../types' + +type Update = (patch: Partial) => void + +const BASES: readonly { id: EstimateBasis; label: string }[] = [ + { id: 'rush', label: 'Rush' }, + { id: 'leisure', label: 'Leisure' }, + { id: 'worst', label: 'Worst' }, + { id: 'pace', label: 'Pace' }, +] +const PROTON: readonly { id: ProtonMode; label: string }[] = [ + { id: 'playable', label: 'Playable (CLI rule)' }, + { id: 'minTier', label: 'Min tier' }, +] +const PLAYTIME: readonly { id: PlaytimeMode; label: string }[] = [ + { id: 'all', label: 'All' }, + { id: 'started', label: 'Started' }, + { id: 'untouched', label: 'Untouched' }, +] + +type NumberKey = 'dailyHours' | 'minCountComp' | 'minComp100' | 'minConfidenceSum' | 'maxHoursPerGame' | 'fallbackHours' + +interface Slider { + key: NumberKey + input: HTMLInputElement + val: HTMLSpanElement + show: (f: Filters) => string +} + +export class FilterPanel { + readonly el: HTMLElement + private readonly syncs: ((f: Filters) => void)[] = [] + + constructor(defaults: WebDefaults, update: Update, onReset: () => void) { + const slider = (key: NumberKey, label: string, min: number, max: number, step: number, show: Slider['show']) => { + const val = h('span', { class: 'val' }) + const input = h('input', { type: 'range', min, max, step, 'aria-label': label }) + input.addEventListener('input', () => { + const patch: Partial = {} + patch[key] = Number(input.value) + update(patch) + }) + this.syncs.push((f) => { + input.value = String(f[key]) + val.textContent = show(f) + }) + return { label: h('label', {}, `${label} `, val), input } + } + const segmented = (items: readonly { id: T; label: string }[], current: (f: Filters) => T, set: (id: T) => void) => { + const buttons = items.map((it) => + h('button', { type: 'button', class: 'seg', onclick: () => { set(it.id) } }, it.label), + ) + this.syncs.push((f) => { + buttons.forEach((b, i) => b.classList.toggle('active', items[i]?.id === current(f))) + }) + return h('div', { class: 'segmented' }, buttons) + } + const check = (label: string, read: (f: Filters) => boolean, set: (on: boolean) => void) => { + const input = h('input', { type: 'checkbox' }) + input.addEventListener('change', () => { + set(input.checked) + }) + this.syncs.push((f) => { + input.checked = read(f) + }) + return h('label', { class: 'check' }, input, label) + } + const shownWhen = (el: E, when: (f: Filters) => boolean): E => { + this.syncs.push((f) => { + el.hidden = !when(f) + }) + return el + } + + const daily = slider('dailyHours', 'Daily play time', 0.5, 16, 0.5, (f) => `${f.dailyHours} h/day`) + const minComp = slider('minCountComp', 'Min HLTB completions', 0, 100, 1, (f) => String(f.minCountComp)) + const min100 = slider('minComp100', 'Min polled completionist times', 0, 50, 1, (f) => String(f.minComp100)) + const minConf = slider('minConfidenceSum', 'Min confidence sum', 0, 150, 1, (f) => String(f.minConfidenceSum)) + const maxH = slider('maxHoursPerGame', 'Max time per game', 0, 200, 5, (f) => + f.maxHoursPerGame > 0 ? `${f.maxHoursPerGame} h` : 'off', + ) + const fallback = slider('fallbackHours', 'Fallback estimate', 1, 100, 1, (f) => `${f.fallbackHours} h`) + + const tier = h('select', { 'aria-label': 'Minimum ProtonDB tier' }, SELECTABLE_TIERS.map((t) => h('option', { value: t }, t))) + tier.addEventListener('change', () => { + update({ protonMinTier: tier.value }) + }) + this.syncs.push((f) => { + tier.value = f.protonMinTier + }) + + const date = h('input', { type: 'date', 'aria-label': 'Target finish date' }) + date.addEventListener('input', () => { + update({ targetDate: date.value }) + }) + this.syncs.push((f) => { + date.value = f.targetDate + }) + const clear = shownWhen( + h('button', { type: 'button', class: 'ghost', onclick: () => { update({ targetDate: '' }) } }, 'Clear'), + (f) => f.targetDate !== '', + ) + + this.el = h( + 'aside', + { class: 'panel' }, + h( + 'div', + { class: 'panel-head' }, + h('h2', {}, 'Filters'), + h('button', { type: 'button', class: 'ghost', onclick: () => { onReset() } }, 'Reset to CLI defaults'), + ), + h( + 'section', + { class: 'field' }, + h('label', {}, 'Estimate basis'), + segmented(BASES, (f) => f.basis, (basis) => { update({ basis }) }), + ), + h('section', { class: 'field' }, daily.label, daily.input), + h( + 'section', + { class: 'field' }, + minComp.label, + minComp.input, + h('p', { class: 'hint' }, `Higher = more reliable HLTB times (CLI default ${defaults.min_count_comp}).`), + ), + h( + 'details', + { class: 'field' }, + h('summary', {}, 'Advanced confidence'), + min100.label, + min100.input, + minConf.label, + minConf.input, + ), + h( + 'section', + { class: 'field' }, + h('label', {}, 'ProtonDB compatibility'), + segmented(PROTON, (f) => f.protonMode, (protonMode) => { update({ protonMode }) }), + shownWhen( + h( + 'div', + { class: 'subfield' }, + tier, + check('Keep games with no ProtonDB data', (f) => f.protonTreatMissingAsPass, (on) => { + update({ protonTreatMissingAsPass: on }) + }), + ), + (f) => f.protonMode === 'minTier', + ), + ), + h( + 'section', + { class: 'field' }, + maxH.label, + maxH.input, + h('p', { class: 'hint' }, 'Hide games longer than this (0 = no cap).'), + ), + h( + 'section', + { class: 'field' }, + h('label', {}, 'Playtime'), + segmented(PLAYTIME, (f) => f.playtimeMode, (playtimeMode) => { update({ playtimeMode }) }), + ), + h( + 'section', + { class: 'field' }, + check('Include games with no HLTB data', (f) => f.includeNoData, (on) => { + update({ includeNoData: on }) + }), + shownWhen(h('div', { class: 'subfield' }, fallback.label, fallback.input), (f) => f.includeNoData), + ), + h( + 'section', + { class: 'field' }, + h('label', {}, 'Target finish date'), + h('div', { class: 'subfield row' }, date, clear), + h('p', { class: 'hint' }, 'Pick a date to see the hours/day required.'), + ), + ) + } + + /** Show `f` without replacing any control. */ + sync(f: Filters): void { + for (const fn of this.syncs) fn(f) + } +} diff --git a/web/src/sections/backlog/gameTable.test.ts b/web/src/sections/backlog/gameTable.test.ts new file mode 100644 index 0000000..10bf3ce --- /dev/null +++ b/web/src/sections/backlog/gameTable.test.ts @@ -0,0 +1,55 @@ +import { screen } from '@testing-library/dom' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { apiError, flush, mount, stubApi } from '../../test/harness' +import { names, show } from '../../test/plannerHelpers' + +afterEach(() => { + history.replaceState(null, '', '/') +}) + +describe(' loading', () => { + it('shows loading until the dataset arrives', () => { + stubApi({ 'GET /api/dataset': () => new Promise(() => undefined) }) + mount('') + expect(screen.getByText(/Loading your backlog/i)).toBeInTheDocument() + }) + + it('renders the planner once data loads', async () => { + await show() + 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 () => { + stubApi({ 'GET /api/dataset': apiError(500, 'boom') }) + mount('') + await flush() + expect(screen.getByText('Could not load data: API returned 500 Error')).toBeInTheDocument() + expect(screen.getByText('./run.sh serve')).toBeInTheDocument() + }) + + it('handles a non-Error rejection', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue('network down')) + mount('') + await flush() + expect(screen.getByText(/network down/i)).toBeInTheDocument() + }) +}) + +describe(' hash changes', () => { + it('re-reads a hand-edited hash', async () => { + await show() + history.replaceState(null, '', '#f.name=gam') + window.dispatchEvent(new HashChangeEvent('hashchange')) + expect(screen.getByRole('searchbox', { name: 'Filter Game' })).toHaveValue('gam') + expect(names()).toEqual(['Gamma']) + }) + + it('stops listening for hash changes once removed', async () => { + const { el } = await show() + el.remove() + history.replaceState(null, '', '#f.name=gam') + window.dispatchEvent(new HashChangeEvent('hashchange')) + expect(location.hash).toBe('#f.name=gam') + }) +}) diff --git a/web/src/sections/backlog/gameTable.ts b/web/src/sections/backlog/gameTable.ts new file mode 100644 index 0000000..5cf418a --- /dev/null +++ b/web/src/sections/backlog/gameTable.ts @@ -0,0 +1,46 @@ +// : the backlog-completion planner page (was PlannerView, +// FilterPanel and GameTable). The element only loads the dataset, shows +// loading / error states and re-reads a hand-edited hash; the planner +// itself lives in planner.ts. + +import { q } from '../../api/queries' +import { h } from '../../lib/dom' +import { LiveElement } from '../../lib/element' +import { Planner } from './planner' + +const message = (e: unknown) => (e instanceof Error ? e.message : String(e)) + +export class GameTableElement extends LiveElement { + private planner: Planner | null = null + + protected override setup(): void { + this.watch(q.dataset) + const onHash = () => { this.planner?.restore() } + window.addEventListener('hashchange', onHash) + this.onCleanup(() => { window.removeEventListener('hashchange', onHash) }) + } + + protected update(): void { + const { data, error } = q.dataset.state + if (!data) { + this.planner = null + this.replaceChildren( + error + ? h( + 'div', + { class: 'status' }, + h('p', { class: 'error' }, `Could not load data: ${message(error)}`), + h('p', { class: 'hint' }, 'Is the backend running? Start it with ', h('code', {}, './run.sh serve'), '.'), + ) + : h('div', { class: 'status' }, h('p', { class: 'hint' }, 'Loading your backlog…')), + ) + return + } + if (this.planner) { + this.planner.setData(data) + return + } + this.planner = new Planner(data) + this.replaceChildren(...this.planner.nodes) + } +} diff --git a/web/src/sections/backlog/index.ts b/web/src/sections/backlog/index.ts new file mode 100644 index 0000000..fcd9639 --- /dev/null +++ b/web/src/sections/backlog/index.ts @@ -0,0 +1,9 @@ +// Backlog page elements (was screens/Backlog.tsx): the `stats` card and the +// interactive planner. + +import { define } from '../../lib/dom' +import { GameTableElement } from './gameTable' +import { BacklogStatsElement } from './stats' + +define('backlog-stats', BacklogStatsElement) +define('game-table', GameTableElement) diff --git a/web/src/sections/backlog/planner.test.ts b/web/src/sections/backlog/planner.test.ts new file mode 100644 index 0000000..e2b411e --- /dev/null +++ b/web/src/sections/backlog/planner.test.ts @@ -0,0 +1,96 @@ +import { screen, within } from '@testing-library/dom' +import { afterEach, describe, expect, it } from 'vitest' +import { q } from '../../api/queries' +import { makeDataset, makeGame, makeState } from '../../test/factories' +import { big, GAMES, hash, header, names, panel, show, table } from '../../test/plannerHelpers' + +afterEach(() => { + history.replaceState(null, '', '/') +}) + +describe(' planner', () => { + it('heads with the current game, finished count, start date and candidates', async () => { + await show(GAMES, { + state: makeState({ current_game_name: 'Hollow Knight', games_done_since_start: 3, enforcement_started_at: '2026-03-04T00:00:00+00:00' }), + }) + expect(document.querySelector('.app-head .sub')?.textContent).toBe( + 'Currently playing Hollow Knight · 3 games finished since 2026-03-04 · 3 candidates', + ) + }) + + it('dashes a missing start date', async () => { + await show() + expect(document.querySelector('.app-head .sub')?.textContent).toBe('0 games finished since — · 3 candidates') + }) + + it('recomputes scope when basis changes and a game is excluded', async () => { + const { user } = await show(GAMES.slice(0, 2), { state: makeState({ pace_games_per_day: 0.9 }) }) + expect(big()).toBe('2') + await user.click(panel().getByRole('button', { name: 'Rush' })) + expect(document.querySelector('.card.active .card-title')?.textContent).toBe('Rush') + expect(document.querySelector('.chart h2')?.textContent).toBe('Completion timeline · rush') + expect(hash().get('basis')).toBe('rush') + + const box = within(table()).getAllByRole('checkbox')[0] + await user.click(box) + expect(big()).toBe('1') + expect(box.closest('tr')).toHaveClass('excluded') + expect(box).toHaveAccessibleName('Re-include') + expect(document.activeElement).toBe(box) + expect(hash().get('x')).not.toBeNull() + await user.click(box) + expect(big()).toBe('2') + expect(box.closest('tr')).not.toHaveClass('excluded') + + await user.type(screen.getByPlaceholderText(/Search games/i), 'Alpha') + expect(names()).toEqual(['Alpha']) + + await user.click(screen.getByRole('button', { name: /Reset to CLI defaults/i })) + expect(big()).toBe('2') + expect(names()).toHaveLength(2) + expect(screen.getByPlaceholderText(/Search games/i)).toHaveValue('') + expect(location.hash).toBe('') + }) + + it('updates the slider readout and the hash as a slider moves', async () => { + await show() + const daily = screen.getByRole('slider', { name: 'Daily play time' }) + daily.focus() + ;(daily as HTMLInputElement).value = '8' + daily.dispatchEvent(new Event('input')) + expect(panel().getByText('8 h/day')).toHaveClass('val') + expect(document.activeElement).toBe(daily) + expect(hash().get('daily')).toBe('8') + }) + + it('keeps every filter when a newer dataset arrives', async () => { + const { user } = await show() + await user.type(screen.getByRole('searchbox', { name: 'Filter Game' }), 'a') + q.dataset.set(makeDataset([...GAMES, makeGame({ app_id: 4, name: 'Delta', protondb_tier: 'native', protondb_trending_tier: '' })])) + expect(screen.getByRole('searchbox', { name: 'Filter Game' })).toHaveValue('a') + expect(names()).toContain('Delta') + expect(document.querySelector('.app-head .sub')?.textContent).toMatch(/4 candidates/) + const proton = screen.getByRole('combobox', { name: 'Filter ProtonDB' }) + expect([...(proton as HTMLSelectElement).options].map((o) => o.value)).toEqual(['', 'gold', 'gold / platinum', 'native', 'platinum']) + }) + + it('drops the hash once the sort is back to the default', async () => { + const { user } = await show() + await user.click(header('Leisure')) + expect(location.hash).toBe('#sort=leisure&dir=desc') + await user.click(header('Leisure')) + expect(location.hash).toBe('') + }) + + it('restores sort, search, column and planner filters from the hash on load', async () => { + history.replaceState(null, '', '/backlog/#sort=name&dir=desc&q=a&f.rush=1..15&f.proton=gold&basis=worst') + await show() + expect(screen.getByPlaceholderText(/Search games/i)).toHaveValue('a') + expect(screen.getByRole('spinbutton', { name: 'Rush min' })).toHaveValue(1) + expect(screen.getByRole('spinbutton', { name: 'Rush max' })).toHaveValue(15) + expect(screen.getByRole('combobox', { name: 'Filter ProtonDB' })).toHaveValue('gold') + expect(header('Game').textContent).toBe('Game ▼') + expect(panel().getByRole('button', { name: 'Worst' })).toHaveClass('active') + expect(names()).toEqual(['Alpha']) + }) +}) diff --git a/web/src/sections/backlog/planner.ts b/web/src/sections/backlog/planner.ts new file mode 100644 index 0000000..2403d92 --- /dev/null +++ b/web/src/sections/backlog/planner.ts @@ -0,0 +1,154 @@ +// The backlog-completion planner (was PlannerView). One object owns the +// estimate filters and the table's sort/filter/search state, because the +// summary cards, the timeline and the table all render from the same +// filtered estimate. +// +// Every control is built once: a refresh rewrites text, classes and row +// order only, so a filter box keeps its focus and caret while typing. State +// lives in the URL hash (see tableState.ts / plannerFilters.ts) and is +// restored on load and on a hand-edited hash. + +import { applyFilters } from '../../estimate' +import { timelineChart } from '../../lib/charts/timeline' +import { h } from '../../lib/dom' +import { buildIndex, type Searchable } from '../../library/fuzzy' +import type { Filters, WebDataset } from '../../types' +import { FilterPanel } from './filterPanel' +import { defaultFilters, readFilters, writeFilters } from './plannerFilters' +import { PlannerTable } from './plannerTable' +import { summaryCards } from './summary' +import { readTable, toggleSort, visibleRows, writeTable, type TableState } from './tableState' + +/** The live parts of a built planner. */ +export class Planner { + readonly nodes: HTMLElement[] + private data: WebDataset + private index: Map + private defaults: Filters + private filters: Filters + private table: TableState + private readonly panel: FilterPanel + private readonly sub = h('p', { class: 'sub' }) + private summary: HTMLElement = h('div') + private chart: HTMLElement = h('div') + private readonly view = new PlannerTable({ + onSort: (key) => { + this.table = toggleSort(this.table, key) + this.refresh() + }, + onColumnFilter: (id, value) => { + if (value) this.table.filters.set(id, value) + else this.table.filters.delete(id) + this.refresh() + }, + onSearch: (value) => { + this.filters = { ...this.filters, search: value } + this.refresh() + }, + onToggle: (appId) => { + this.toggleExclude(appId) + }, + }) + + constructor(data: WebDataset) { + this.data = data + this.index = buildIndex(data.games) + this.defaults = defaultFilters(data.defaults) + this.filters = this.defaults + this.table = readTable(new URLSearchParams()) + this.panel = new FilterPanel(data.defaults, (patch) => { this.patch(patch) }, () => { this.reset() }) + const content = h('div', { class: 'content' }, this.summary, this.chart, this.view.el) + this.nodes = [ + h('header', { class: 'app-head' }, h('div', {}, h('h1', {}, 'Backlog Completion Planner'), this.sub)), + h('div', { class: 'layout' }, this.panel.el, content), + ] + this.restore() + } + + /** A newer dataset: keep every filter, re-derive everything else. */ + setData(data: WebDataset): void { + this.data = data + this.index = buildIndex(data.games) + this.refresh() + } + + /** Adopt the state in `location.hash` and show it in every control. */ + restore(): void { + const p = new URLSearchParams(location.hash.slice(1)) + this.table = readTable(p) + this.filters = readFilters(p, this.defaults) + this.syncControls() + this.refresh() + } + + private patch(patch: Partial): void { + this.filters = { ...this.filters, ...patch } + this.panel.sync(this.filters) + this.refresh() + } + + private reset(): void { + this.filters = { ...this.defaults, excluded: new Set() } + this.table = { ...this.table, filters: new Map() } + this.syncControls() + this.refresh() + } + + private toggleExclude(appId: number): void { + const next = new Set(this.filters.excluded) + if (next.has(appId)) next.delete(appId) + else next.add(appId) + this.filters = { ...this.filters, excluded: next } + this.refresh() + } + + private syncControls(): void { + this.panel.sync(this.filters) + this.view.syncInputs(this.filters.search, this.table.filters) + } + + private syncSub(): void { + const s = this.data.state + this.sub.replaceChildren( + ...(s.current_game_name ? ['Currently playing ', h('strong', {}, s.current_game_name), ' · '] : []), + `${s.games_done_since_start} games finished since ${s.enforcement_started_at.slice(0, 10) || '—'} · ${this.data.games.length} candidates`, + ) + } + + private writeHash(): void { + const p = new URLSearchParams() + writeTable(this.table, p) + writeFilters(this.filters, this.defaults, p) + const next = p.toString() + if (location.hash.slice(1) === next) return + history.replaceState(history.state, '', next ? `#${next}` : location.pathname + location.search) + } + + private refresh(): void { + const { data, filters } = this + const result = applyFilters(data, filters) + this.syncSub() + const summary = summaryCards({ + result, + filters, + state: data.state, + presets: data.defaults.hours_per_day_presets, + defaultQualifying: data.default_summary.qualifying, + paceVsHltb: data.pace_vs_hltb, + }) + this.summary.replaceWith(summary) + this.summary = summary + const chart = timelineChart(result, filters, data.state) + this.chart.replaceWith(chart) + this.chart = chart + + const visible = visibleRows( + result.rows.filter((r) => r.passesFilters), + this.table, + filters.search, + this.index, + ) + this.view.show(result.rows, visible, this.table) + this.writeHash() + } +} diff --git a/web/src/sections/backlog/plannerFilters.test.ts b/web/src/sections/backlog/plannerFilters.test.ts new file mode 100644 index 0000000..dc7c38a --- /dev/null +++ b/web/src/sections/backlog/plannerFilters.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { makeDataset } from '../../test/factories' +import { defaultFilters, readFilters, writeFilters } from './plannerFilters' + +const D = defaultFilters(makeDataset().defaults) +const write = (over: Partial) => { + const p = new URLSearchParams() + writeFilters({ ...D, ...over }, D, p) + return p.toString() +} + +describe('defaultFilters', () => { + it('starts from the CLI thresholds', () => { + expect(D).toMatchObject({ minCountComp: 15, minComp100: 3, minConfidenceSum: 18, protonMinTier: 'gold', basis: 'leisure', dailyHours: 4 }) + }) +}) + +describe('planner filters in the hash', () => { + it('writes nothing at the defaults', () => { + expect(write({})).toBe('') + }) + + it('round-trips every non-default filter', () => { + const f = { + ...D, + dailyHours: 6.5, + minCountComp: 40, + minComp100: 10, + minConfidenceSum: 25, + maxHoursPerGame: 50, + fallbackHours: 30, + protonTreatMissingAsPass: false, + includeNoData: true, + basis: 'rush' as const, + protonMode: 'minTier' as const, + protonMinTier: 'silver', + playtimeMode: 'started' as const, + targetDate: '2030-01-02', + search: 'hollow knight', + excluded: new Set([9, 3]), + } + const p = new URLSearchParams() + writeFilters(f, D, p) + expect(p.get('x')).toBe('3,9') + expect(p.get('keepmissing')).toBe('0') + expect(p.get('nodata')).toBe('1') + expect(readFilters(new URLSearchParams(p.toString()), D)).toEqual(f) + }) + + it('falls back to the defaults for junk', () => { + const p = new URLSearchParams('daily=&mincomp=abc&nodata=yes&basis=fast&proton=x&tier=mythic&playtime=y&target=soon&x=a,-1,0,2.5') + expect(readFilters(p, D)).toEqual({ ...D, excluded: new Set() }) + }) +}) diff --git a/web/src/sections/backlog/plannerFilters.ts b/web/src/sections/backlog/plannerFilters.ts new file mode 100644 index 0000000..b300d28 --- /dev/null +++ b/web/src/sections/backlog/plannerFilters.ts @@ -0,0 +1,86 @@ +// The planner's estimate filters (was PlannerView's defaultFilters) and their +// URL-hash form. Only values that differ from the CLI defaults are written, +// so a fresh page has an empty hash and "Reset to CLI defaults" clears it. + +import { SELECTABLE_TIERS } from '../../protondb' +import type { EstimateBasis, Filters, PlaytimeMode, ProtonMode, WebDefaults } from '../../types' + +export 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: '', + } +} + +type NumberKey = 'dailyHours' | 'minCountComp' | 'minComp100' | 'minConfidenceSum' | 'maxHoursPerGame' | 'fallbackHours' +type FlagKey = 'protonTreatMissingAsPass' | 'includeNoData' + +const NUMBERS: readonly (readonly [string, NumberKey])[] = [ + ['daily', 'dailyHours'], + ['mincomp', 'minCountComp'], + ['min100', 'minComp100'], + ['minconf', 'minConfidenceSum'], + ['maxh', 'maxHoursPerGame'], + ['fallback', 'fallbackHours'], +] +const FLAGS: readonly (readonly [string, FlagKey])[] = [ + ['keepmissing', 'protonTreatMissingAsPass'], + ['nodata', 'includeNoData'], +] +const BASES: readonly EstimateBasis[] = ['rush', 'leisure', 'worst', 'pace'] +const PROTON: readonly ProtonMode[] = ['playable', 'minTier'] +const PLAYTIME: readonly PlaytimeMode[] = ['all', 'started', 'untouched'] +const TIERS: readonly string[] = SELECTABLE_TIERS + +const pickOne = (allowed: readonly T[], raw: string | null, fallback: T): T => + allowed.find((v) => v === raw) ?? fallback + +/** Write the non-default planner filters into `p`. */ +export function writeFilters(f: Filters, d: Filters, p: URLSearchParams): void { + for (const [key, field] of NUMBERS) if (f[field] !== d[field]) p.set(key, String(f[field])) + for (const [key, field] of FLAGS) if (f[field] !== d[field]) p.set(key, f[field] ? '1' : '0') + if (f.basis !== d.basis) p.set('basis', f.basis) + if (f.protonMode !== d.protonMode) p.set('proton', f.protonMode) + if (f.protonMinTier !== d.protonMinTier) p.set('tier', f.protonMinTier) + if (f.playtimeMode !== d.playtimeMode) p.set('playtime', f.playtimeMode) + if (f.targetDate) p.set('target', f.targetDate) + if (f.search) p.set('q', f.search) + if (f.excluded.size > 0) p.set('x', [...f.excluded].sort((a, b) => a - b).join(',')) +} + +/** The planner filters `p` describes, anything absent or invalid from `d`. */ +export function readFilters(p: URLSearchParams, d: Filters): Filters { + const f: Filters = { ...d, excluded: new Set() } + for (const [key, field] of NUMBERS) { + const raw = p.get(key) + const n = raw ? Number(raw) : Number.NaN + if (Number.isFinite(n)) f[field] = n + } + for (const [key, field] of FLAGS) { + const raw = p.get(key) + if (raw === '1' || raw === '0') f[field] = raw === '1' + } + f.basis = pickOne(BASES, p.get('basis'), d.basis) + f.protonMode = pickOne(PROTON, p.get('proton'), d.protonMode) + f.protonMinTier = pickOne(TIERS, p.get('tier'), d.protonMinTier) + f.playtimeMode = pickOne(PLAYTIME, p.get('playtime'), d.playtimeMode) + const target = p.get('target') ?? '' + f.targetDate = /^\d{4}-\d{2}-\d{2}$/.test(target) ? target : '' + f.search = p.get('q') ?? '' + const ids = (p.get('x') ?? '').split(',').map(Number) + f.excluded = new Set(ids.filter((n) => Number.isInteger(n) && n > 0)) + return f +} diff --git a/web/src/sections/backlog/plannerTable.test.ts b/web/src/sections/backlog/plannerTable.test.ts new file mode 100644 index 0000000..f6f1f42 --- /dev/null +++ b/web/src/sections/backlog/plannerTable.test.ts @@ -0,0 +1,127 @@ +import { screen, within } from '@testing-library/dom' +import { afterEach, describe, expect, it } from 'vitest' +import { makeGame } from '../../test/factories' +import { GAMES, hash, header, names, show, table } from '../../test/plannerHelpers' +import { hltbUrl } from './plannerTable' + +afterEach(() => { + history.replaceState(null, '', '/') +}) + +describe(' table', () => { + it('renders the row count', async () => { + await show() + expect(screen.getByText('Games (3)')).toBeInTheDocument() + }) + + it('sorts by leisure ascending by default', async () => { + await show() + expect(names()).toEqual(['Beta', 'Gamma', 'Alpha']) + expect(header('Leisure')).toHaveAttribute('aria-sort', 'ascending') + expect(header('Leisure').textContent).toBe('Leisure ▲') + }) + + it('toggles sort direction when a header is clicked', async () => { + const { user } = await show() + const game = header('Game') + await user.click(game) + expect(game.textContent).toContain('▲') + expect(names()).toEqual(['Alpha', 'Beta', 'Gamma']) + expect(header('Leisure')).not.toHaveAttribute('aria-sort') + await user.click(game) + expect(game.textContent).toContain('▼') + expect(game).toHaveAttribute('aria-sort', 'descending') + expect(names()).toEqual(['Gamma', 'Beta', 'Alpha']) + expect(hash().get('sort')).toBe('name') + expect(hash().get('dir')).toBe('desc') + }) + + it('every header sorts, and flipping it changes the first row', async () => { + const { user } = await show() + // One excluded game so the Keep column has two values to sort by. + await user.click(within(table()).getAllByRole('checkbox')[1]) + for (const label of ['Game', '%', 'Played', 'Rush', 'Leisure', 'Worst', 'HLTB n', 'ProtonDB', 'Keep']) { + await user.click(header(label)) + const first = names()[0] + await user.click(header(label)) + expect(names()[0], label).not.toBe(first) + } + }) + + it('filters by a typo-tolerant search and keeps focus while typing', async () => { + const { user } = await show() + const box = screen.getByPlaceholderText(/Search games/i) + await user.type(box, 'gama') + expect(names()).toEqual(['Gamma']) + expect(document.activeElement).toBe(box) + expect(screen.getByPlaceholderText(/Search games/i)).toBe(box) + expect(hash().get('q')).toBe('gama') + expect(screen.getByText('Games (1)')).toBeInTheDocument() + }) + + it('narrows rows by a text column filter without losing focus', async () => { + const { user } = await show() + const input = screen.getByRole('searchbox', { name: 'Filter Game' }) + await user.type(input, 'al') + expect(names()).toEqual(['Alpha']) + expect(document.activeElement).toBe(input) + expect(hash().get('f.name')).toBe('al') + await user.clear(input) + expect(names()).toHaveLength(3) + expect(hash().has('f.name')).toBe(false) + }) + + it('narrows rows by a numeric min/max range', async () => { + const { user } = await show() + const min = screen.getByRole('spinbutton', { name: 'Rush min' }) + await user.type(min, '10') + expect(names()).toEqual(['Beta', 'Gamma']) + expect(document.activeElement).toBe(min) + await user.type(screen.getByRole('spinbutton', { name: 'Rush max' }), '15') + expect(names()).toEqual(['Gamma']) + expect(hash().get('f.rush')).toBe('10..15') + }) + + it('narrows rows by a select column', async () => { + const { user } = await show() + const proton = screen.getByRole('combobox', { name: 'Filter ProtonDB' }) + await user.selectOptions(proton, 'platinum') + expect(names()).toEqual(['Beta']) + await user.click(within(table()).getAllByRole('checkbox')[0]) + await user.selectOptions(proton, '') + await user.selectOptions(screen.getByRole('combobox', { name: 'Filter Keep' }), 'Excluded') + expect(names()).toEqual(['Beta']) + expect(hash().get('f.keep')).toBe('excluded') + }) + + it('links to a direct HLTB page when the id is known, else search', async () => { + await show([makeGame({ app_id: 1, name: 'Alpha', hltb_game_id: 555 }), makeGame({ app_id: 2, name: 'Beta Two', hltb_game_id: 0 })]) + expect(screen.getByRole('link', { name: 'Alpha' })).toHaveAttribute('href', 'https://howlongtobeat.com/game/555') + expect(screen.getByRole('link', { name: 'Beta Two' })).toHaveAttribute('href', 'https://howlongtobeat.com/?q=Beta%20Two') + expect(hltbUrl(makeGame({ hltb_game_id: 7 }))).toBe('https://howlongtobeat.com/game/7') + }) + + it('formats each cell like the CLI list', async () => { + await show(GAMES.slice(0, 1)) + const cells = [...table().querySelectorAll('tbody td')].map((td) => td.textContent) + expect(cells.slice(0, 8)).toEqual(['Alpha', '10', '1.0 h', '5.0', '30.0', '40.0', '20', 'gold']) + }) + + it('shows a no-data badge for games without HLTB times when they are included', async () => { + history.replaceState(null, '', '/backlog/#nodata=1') + await show([makeGame({ app_id: 1, name: 'Alpha', rush_hours: -1, leisure_hours: -1, worst_hours: -1 })]) + expect(screen.getByText('no data')).toBeInTheDocument() + expect(screen.getByText('Fallback estimate', { exact: false })).toBeVisible() + }) + + it('caps the table and notes the overflow', async () => { + await show(Array.from({ length: 301 }, (_, i) => makeGame({ app_id: i + 1, name: `Game ${i}` }))) + expect(screen.getByText(/Showing first 300 of 301/i)).toBeVisible() + expect(table().querySelectorAll('tbody tr')).toHaveLength(300) + }) + + it('hides the overflow note under the cap', async () => { + await show() + expect(screen.getByText(/Showing first 300/i)).not.toBeVisible() + }) +}) diff --git a/web/src/sections/backlog/plannerTable.ts b/web/src/sections/backlog/plannerTable.ts new file mode 100644 index 0000000..456b45c --- /dev/null +++ b/web/src/sections/backlog/plannerTable.ts @@ -0,0 +1,185 @@ +// The planner's game table (was GameTable.tsx): sortable headers, a +// per-column filter row, the search box and one row per game. Rows are +// built once per game and repainted in place; the planner owns the sort and +// filter state (tableState.ts) and passes it into every `show()`. + +import type { GameRow } from '../../estimate' +import { fmtHoursPrecise, fmtPlaytime } from '../../format' +import { h } from '../../lib/dom' +import { tierLabel } from '../../protondb' +import type { WebGame } from '../../types' +import { COLUMNS, optionsFor, type Column, type ColumnKey, type TableState } from './tableState' + +export const DISPLAY_CAP = 300 + +export interface PlannerTableOptions { + onSort: (key: ColumnKey) => void + /** A column filter edited; `id` is `` or `.min|max`, `value` '' when cleared. */ + onColumnFilter: (id: string, value: string) => void + onSearch: (value: string) => void + onToggle: (appId: number) => void +} + +interface RowEls { + tr: HTMLTableRowElement + name: HTMLTableCellElement + link: HTMLAnchorElement + badge: HTMLSpanElement + cells: HTMLTableCellElement[] + keep: HTMLInputElement +} + +export function hltbUrl(game: WebGame): string { + if (game.hltb_game_id > 0) return `https://howlongtobeat.com/game/${game.hltb_game_id}` + return `https://howlongtobeat.com/?q=${encodeURIComponent(game.name)}` +} + +const optionLabel = (c: Column, v: string) => (c.key === 'keep' ? (v === 'kept' ? 'Kept' : 'Excluded') : v) + +export class PlannerTable { + readonly el: HTMLElement + private readonly opts: PlannerTableOptions + private readonly count = h('h2') + private readonly search = h('input', { type: 'search', placeholder: 'Search games…', 'aria-label': 'Search games' }) + private readonly heads: [Column, HTMLTableCellElement, HTMLButtonElement][] = [] + private readonly inputs = new Map() + private readonly selects: [Column, HTMLSelectElement][] = [] + private readonly tbody = h('tbody') + private readonly overflow = h('p', { class: 'hint' }) + private readonly rows = new Map() + + constructor(opts: PlannerTableOptions) { + this.opts = opts + this.search.addEventListener('input', () => { + opts.onSearch(this.search.value) + }) + this.el = this.build() + } + + /** Write the search text and every column filter into their inputs. */ + syncInputs(search: string, filters: ReadonlyMap): void { + this.search.value = search + for (const [id, input] of this.inputs) input.value = filters.get(id) ?? '' + } + + /** Show `visible` (capped) under headers and filters matching `table`; `all` feeds the select options. */ + show(all: readonly GameRow[], visible: readonly GameRow[], table: TableState): void { + this.syncOptions(all, table) + this.syncHeads(table) + this.count.textContent = `Games (${visible.length})` + const trs = visible.slice(0, DISPLAY_CAP).map((r) => this.rowFor(r)) + const current = this.tbody.children + // Re-order only when the order changed: moving a row would blur its checkbox. + if (trs.length !== current.length || trs.some((tr, i) => current[i] !== tr)) this.tbody.replaceChildren(...trs) + this.overflow.hidden = visible.length <= DISPLAY_CAP + this.overflow.textContent = `Showing first ${DISPLAY_CAP} of ${visible.length}. Use search to narrow.` + } + + private build(): HTMLElement { + const filterCell = (c: Column): HTMLTableCellElement => { + const field = (id: string, el: HTMLInputElement | HTMLSelectElement) => { + this.inputs.set(id, el) + el.addEventListener(el instanceof HTMLSelectElement ? 'change' : 'input', () => { + this.opts.onColumnFilter(id, el.value) + }) + return el + } + if (c.filter === 'range') { + const bound = (side: 'min' | 'max') => + field(`${c.key}.${side}`, h('input', { type: 'number', inputmode: 'decimal', placeholder: side, 'aria-label': `${c.label} ${side}` })) + return h('td', { class: 'range-filter' }, h('div', {}, bound('min'), bound('max'))) + } + if (c.filter === 'select') { + const select = h('select', { 'aria-label': `Filter ${c.label}` }, h('option', { value: '' }, 'All')) + this.selects.push([c, select]) + return h('td', {}, field(c.key, select)) + } + return h('td', {}, field(c.key, h('input', { type: 'search', placeholder: 'Filter…', 'aria-label': `Filter ${c.label}` }))) + } + const headCell = (c: Column): HTMLTableCellElement => { + const button = h('button', { type: 'button', class: 'sort-btn' }, c.label) + const th = h('th', { class: c.numeric ? 'num clickable' : 'clickable', scope: 'col' }, button) + th.addEventListener('click', () => { + this.opts.onSort(c.key) + }) + this.heads.push([c, th, button]) + return th + } + return h( + 'div', + { class: 'table-wrap' }, + h('div', { class: 'table-head' }, this.count, this.search), + h( + 'div', + { class: 'table-scroll' }, + h( + 'table', + {}, + h( + 'thead', + {}, + h('tr', {}, COLUMNS.map(headCell)), + h('tr', { class: 'filter-row' }, COLUMNS.map(filterCell)), + ), + this.tbody, + ), + ), + this.overflow, + ) + } + + private rowFor(r: GameRow): HTMLTableRowElement { + const id = r.game.app_id + let row = this.rows.get(id) + if (!row) { + const cells = Array.from({ length: 7 }, (_, i) => h('td', { class: i === 6 ? 'num proton' : 'num' })) + const keep = h('input', { type: 'checkbox' }) + keep.addEventListener('change', () => { this.opts.onToggle(id) }) + const link = h('a', { target: '_blank', rel: 'noreferrer' }) + const name = h('td', { class: 'name' }, link) + row = { tr: h('tr', {}, name, cells, h('td', { class: 'num' }, keep)), name, link, badge: h('span', { class: 'badge' }, 'no data'), cells, keep } + this.rows.set(id, row) + } + const g = r.game + row.tr.className = r.excluded ? 'excluded' : '' + row.link.href = hltbUrl(g) + row.link.textContent = g.name + if (r.noData) row.name.append(row.badge) + else row.badge.remove() + const texts = [ + g.completion_pct.toFixed(0), + fmtPlaytime(g.playtime_minutes), + fmtHoursPrecise(r.rush), + fmtHoursPrecise(r.leisure), + fmtHoursPrecise(r.worst), + String(g.count_comp), + tierLabel(g.protondb_tier, g.protondb_trending_tier), + ] + texts.forEach((text, i) => { row.cells[i].textContent = text }) + row.keep.checked = !r.excluded + row.keep.setAttribute('aria-label', r.excluded ? 'Re-include' : 'Exclude') + return row.tr + } + + private syncOptions(all: readonly GameRow[], table: TableState): void { + for (const [c, select] of this.selects) { + const current = table.filters.get(c.key) ?? '' + const want = ['', ...optionsFor(c, all, current)] + const have = [...select.options].map((o) => o.value) + if (want.join('\n') !== have.join('\n')) { + select.replaceChildren(...want.map((v) => h('option', { value: v }, v ? optionLabel(c, v) : 'All'))) + } + select.value = current + } + } + + private syncHeads(table: TableState): void { + for (const [c, th, button] of this.heads) { + const active = c.key === table.sort + const arrow = active ? (table.asc ? ' ▲' : ' ▼') : '' + button.textContent = `${c.label}${arrow}` + if (active) th.setAttribute('aria-sort', table.asc ? 'ascending' : 'descending') + else th.removeAttribute('aria-sort') + } + } +} diff --git a/web/src/sections/backlog/stats.test.ts b/web/src/sections/backlog/stats.test.ts new file mode 100644 index 0000000..fdf50a0 --- /dev/null +++ b/web/src/sections/backlog/stats.test.ts @@ -0,0 +1,58 @@ +import { screen } from '@testing-library/dom' +import { describe, expect, it } from 'vitest' +import { apiError, flush, mount, stubApi } from '../../test/harness' +import { makePaceVsHltb } from '../../test/factories' +import './index' + +const summary = { qualifying: 42, rush_total: 100, leisure_total: 250, worst_total: 400 } +const stats = (pace: ReturnType | null) => ({ default_summary: summary, pace_vs_hltb: pace }) +async function show(reply: unknown) { + stubApi({ 'GET /api/stats': reply as object }) + mount('') + await flush() +} +const fact = (label: string) => screen.getByText(label, { selector: 'dt' }).nextElementSibling + +describe('', () => { + it('shows the estimates for the default filters', async () => { + await show(stats(null)) + expect(screen.getByRole('heading', { name: 'Estimates (default filters)' })).toBeInTheDocument() + expect(screen.getByText('qualifying games')).toBeInTheDocument() + expect(screen.getByText('42')).toBeInTheDocument() + expect(screen.getByText('100 h')).toBeInTheDocument() + expect(screen.getByText('250 h')).toBeInTheDocument() + expect(screen.getByText('400 h')).toBeInTheDocument() + expect(screen.queryByText('Your pace')).toBeNull() + }) + + it('adds the player’s pace against HLTB once calibrated', async () => { + await show(stats(makePaceVsHltb({ player_style: 'rush_to_leisure', ratio_vs_rush: 1.05, ratio_vs_leisure: 0.4, calibration_count: 12 }))) + expect(fact('Your pace')).toHaveTextContent('Between rush and leisure') + expect(fact('vs rush')).toHaveTextContent('1.05×') + expect(fact('vs leisure')).toHaveTextContent('0.40×') + expect(fact('Calibrated on')).toHaveTextContent('12 finished games') + }) + + it('dashes a ratio the server could not compute', async () => { + await show(stats(makePaceVsHltb({ player_style: 'unknown', ratio_vs_rush: -1, ratio_vs_leisure: -1 }))) + expect(screen.getByText('Not enough finished games yet')).toBeInTheDocument() + expect(fact('vs rush')).toHaveTextContent('—') + expect(fact('vs leisure')).toHaveTextContent('—') + }) + + it('shows an unlabelled style as its key', async () => { + await show(stats(makePaceVsHltb({ player_style: 'odd' as 'unknown' }))) + expect(fact('Your pace')).toHaveTextContent('odd') + }) + + it('shows loading while stats load', async () => { + await show(() => new Promise(() => undefined)) + expect(screen.getByText(/Loading stats/)).toBeInTheDocument() + }) + + it('explains a failed stats load', async () => { + await show(apiError(500, 'op_failed', 'stats broke')) + expect(screen.getByText('stats broke')).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Estimates' })).toBeInTheDocument() + }) +}) diff --git a/web/src/sections/backlog/stats.ts b/web/src/sections/backlog/stats.ts new file mode 100644 index 0000000..e7244f7 --- /dev/null +++ b/web/src/sections/backlog/stats.ts @@ -0,0 +1,59 @@ +// : the CLI's `stats` estimate for the default filters (was +// screens/Backlog.tsx StatsCard). + +import { q } from '../../api/queries' +import { fmtHours } from '../../format' +import { h } from '../../lib/dom' +import { LiveElement } from '../../lib/element' +import { card, errorNotice, facts, loading } from '../../ui/build' + +const STYLE: Record = { + faster_than_rush: 'Faster than HLTB rush', + rush_to_leisure: 'Between rush and leisure', + slower_than_leisure: 'Slower than HLTB leisure', + unknown: 'Not enough finished games yet', +} + +const ratio = (r: number) => (r < 0 ? '—' : `${r.toFixed(2)}×`) +const stat = (num: string | number, label: string) => + h('div', { class: 'stat' }, h('span', { class: 'stat-num' }, num), h('span', { class: 'stat-label' }, label)) + +export class BacklogStatsElement extends LiveElement { + protected override setup(): void { + this.watch(q.stats) + } + + protected update(): void { + const { data, error } = q.stats.state + if (error) { + this.replaceChildren(card('Estimates', errorNotice(error))) + return + } + if (!data) { + this.replaceChildren(card('Estimates', loading('stats'))) + return + } + const s = data.default_summary + const p = data.pace_vs_hltb + this.replaceChildren( + card('Estimates (default filters)', [ + h( + 'div', + { class: 'stat-row' }, + stat(s.qualifying, 'qualifying games'), + stat(fmtHours(s.rush_total), 'rush'), + stat(fmtHours(s.leisure_total), 'leisure'), + stat(fmtHours(s.worst_total), 'worst case'), + ), + p + ? facts([ + ['Your pace', STYLE[p.player_style] ?? p.player_style], + ['vs rush', ratio(p.ratio_vs_rush)], + ['vs leisure', ratio(p.ratio_vs_leisure)], + ['Calibrated on', `${p.calibration_count} finished games`], + ]) + : null, + ]), + ) + } +} diff --git a/web/src/sections/backlog/summary.test.ts b/web/src/sections/backlog/summary.test.ts new file mode 100644 index 0000000..ea2ef71 --- /dev/null +++ b/web/src/sections/backlog/summary.test.ts @@ -0,0 +1,135 @@ +import { getAllByText, getByText, queryByText } from '@testing-library/dom' +import { describe, expect, it } from 'vitest' +import { applyFilters } from '../../estimate' +import { makeDataset, makeFilters, makeGame, makePaceVsHltb, makeState } from '../../test/factories' +import type { Filters, PaceVsHLTB } from '../../types' +import { summaryCards } from './summary' + +const NOW = new Date('2026-01-01T00:00:00Z') + +function cards(over: Partial = {}, statePace = 0, paceVsHltb: PaceVsHLTB | null = null, qualifying?: number) { + const filters = makeFilters(over) + const result = applyFilters(makeDataset([makeGame({ app_id: 1 })]), filters) + const el = summaryCards( + { + result, + filters, + state: makeState({ pace_games_per_day: statePace }), + presets: [2, 4, 6, 8], + defaultQualifying: qualifying ?? result.remainingGames, + paceVsHltb, + }, + NOW, + ) + document.body.append(el) + return el +} + +describe('summaryCards', () => { + it('shows the in-scope count and a CLI-parity match badge', () => { + const el = cards() + expect(getByText(el, /games in scope/i)).toBeInTheDocument() + expect(el.querySelector('.big')?.textContent).toBe('1') + expect(getByText(el, /match/i)).toBeInTheDocument() + }) + + it('drops the match badge when the count differs from the CLI', () => { + const el = cards({}, 0, null, 7) + expect(el.querySelector('.parity')?.textContent).toBe('CLI default qualifies 7') + }) + + it('marks the active basis card and lists presets for hour bases only', () => { + const el = cards({ basis: 'rush' }) + expect(el.querySelector('.card.active .card-title')?.textContent).toBe('Rush') + expect(el.querySelectorAll('.cards .card')).toHaveLength(4) + expect(el.querySelectorAll('.cards .presets')).toHaveLength(3) + expect(el.querySelector('.card .card-total')?.textContent).toBe('10 h') + }) + + it('notes a missing start date in the pace card', () => { + expect(getByText(cards({}, 0), /No start date set/i)).toBeInTheDocument() + }) + + it('shows the pace card ETA when a pace is known', () => { + const el = cards({}, 0.5) + const pace = [...el.querySelectorAll('.card')].at(-1) + expect(pace?.querySelector('.card-total')?.textContent).toBe('0.5 games/day') + expect(pace?.querySelector('.card-eta')?.textContent).toMatch(/^2 days · /) + }) + + it('renders the target-date banner with required hours/day', () => { + const banner = cards({ targetDate: '2026-01-11', basis: 'leisure' }).querySelector('.target-banner') + expect(banner?.textContent).toBe('To finish 1 games by 2026-01-11 (10 days) on the leisure model, you need 2.0 h/day.') + }) + + it('renders the target banner in games/day for the pace basis', () => { + const banner = cards({ targetDate: '2099-01-01', basis: 'pace' }, 0.9).querySelector('.target-banner') + expect(banner?.textContent).toMatch(/games\/day/i) + }) + + it('counts a past target as one day', () => { + expect(cards({ targetDate: '2020-01-01' }).querySelector('.target-banner')?.textContent).toMatch(/\(1 days\)/) + }) + + it('defaults the clock to now', () => { + const result = applyFilters(makeDataset(), makeFilters()) + const el = summaryCards({ result, filters: makeFilters(), state: makeState(), presets: [], defaultQualifying: 1, paceVsHltb: null }) + expect(el.querySelector('.target-banner')).toBeNull() + }) +}) + +describe('player insight', () => { + it('shows the empty state when paceVsHltb is null', () => { + const el = cards({}, 0, null) + expect(getByText(el, /Your Play Style vs HLTB/i)).toBeInTheDocument() + expect(getByText(el, /No calibration data yet/i)).toBeInTheDocument() + }) + + it('shows the empty state with zero calibration games', () => { + expect(getByText(cards({}, 0, makePaceVsHltb({ calibration_count: 0 })), /No calibration data yet/i)).toBeInTheDocument() + }) + + it('shows calibration stats when paceVsHltb is provided', () => { + const el = cards({}, 0, makePaceVsHltb()) + expect(getByText(el, /Calibration games/i)).toBeInTheDocument() + expect(getByText(el, /vs Rush speed/i)).toBeInTheDocument() + expect(getByText(el, 'Play style')).toBeInTheDocument() + expect(getByText(el, /Between rush and leisure/i)).toHaveClass('player-insight-style--rush_to_leisure') + expect(getByText(el, '1.05×')).toHaveClass('player-insight-slow') + expect(getByText(el, '0.050')).toHaveAttribute('title', '0 = rush speed · 1 = leisure speed') + }) + + it('shows the estimated total at the player’s pace', () => { + // rush 10, leisure 20, t = 0.05 → 10.5 h + const el = cards({}, 0, makePaceVsHltb({ interpolation_t: 0.05 })) + expect(el.querySelector('.player-insight-estimate-hours')?.textContent).toBe('11 h') + expect(el.querySelectorAll('.player-insight-estimate .preset')).toHaveLength(4) + }) + + it('hides the leisure ratio and t when they are -1', () => { + const el = cards({}, 0, makePaceVsHltb({ ratio_vs_leisure: -1, interpolation_t: -1 })) + expect(queryByText(el, /vs Leisure speed/i)).toBeNull() + expect(queryByText(el, /Interpolation t/i)).toBeNull() + }) + + it('hides the rush ratio and the estimate when nothing is known', () => { + const el = cards({}, 0, makePaceVsHltb({ ratio_vs_rush: -1, interpolation_t: -1 })) + expect(queryByText(el, /vs Rush speed/i)).toBeNull() + expect(queryByText(el, /Estimated total/i)).toBeNull() + }) + + it('marks the rush ratio as fast when at or below 1', () => { + expect(getByText(cards({}, 0, makePaceVsHltb({ ratio_vs_rush: 0.9 })), '0.90×')).toHaveClass('player-insight-fast') + }) + + it('falls back to the raw player_style key when it has no display label', () => { + const pace = makePaceVsHltb({ player_style: 'totally_unknown' as PaceVsHLTB['player_style'] }) + expect(getByText(cards({}, 0, pace), 'totally_unknown')).toBeInTheDocument() + }) + + it('shows N/A presets when the estimated pace total is not positive', () => { + const el = cards({ excluded: new Set([1]) }, 0, makePaceVsHltb()) + expect(getByText(el, /Estimated total at your pace/i)).toBeInTheDocument() + expect(getAllByText(el, 'N/A').length).toBeGreaterThan(0) + }) +}) diff --git a/web/src/sections/backlog/summary.ts b/web/src/sections/backlog/summary.ts new file mode 100644 index 0000000..a00321d --- /dev/null +++ b/web/src/sections/backlog/summary.ts @@ -0,0 +1,164 @@ +// The planner's headline: games in scope, the target-date banner, one card per +// estimate basis and the player's pace vs HLTB (was SummaryCards.tsx). + +import { basisTotal, etaDays, paceDays, playerEstimatedTotal, type EstimateResult } from '../../estimate' +import { fmtEta, fmtHours } from '../../format' +import { h } from '../../lib/dom' +import type { EstimateBasis, Filters, PaceVsHLTB, WebStateInfo } from '../../types' + +export interface SummaryProps { + result: EstimateResult + filters: Filters + state: WebStateInfo + presets: readonly number[] + defaultQualifying: number + paceVsHltb: PaceVsHLTB | null +} + +const CARDS: readonly { basis: EstimateBasis; title: string; blurb: string }[] = [ + { 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' }, +] + +const STYLE_LABELS: Record = { + faster_than_rush: 'Faster than rush', + rush_to_leisure: 'Between rush and leisure', + slower_than_leisure: 'Slower than leisure', + unknown: 'Unknown', +} + +function targetBanner({ result, filters }: SummaryProps, now: Date): HTMLDivElement | null { + if (!filters.targetDate) return null + const target = new Date(filters.targetDate) + const days = Math.max(1, Math.ceil((target.getTime() - now.getTime()) / 86400000)) + // Pace is the only basis without an hours total: it needs games per day. + const total = basisTotal(result, filters.basis) + const need = + total === null ? `${(result.remainingGames / days).toFixed(2)} games/day` : `${(total / days).toFixed(1)} h/day` + return h( + 'div', + { class: 'target-banner' }, + 'To finish ', + h('strong', {}, result.remainingGames), + ' games by ', + h('strong', {}, filters.targetDate), + ` (${days} days) on the `, + h('strong', {}, filters.basis), + ' model, you need ', + h('strong', {}, need), + '.', + ) +} + +const presetRows = (presets: readonly number[], eta: (perDay: number) => number | null) => + h( + 'div', + { class: 'presets' }, + presets.map((p) => h('div', { class: 'preset' }, h('span', {}, `${p} h/day`), h('span', {}, fmtEta(eta(p))))), + ) + +const pair = (label: string, value: string, attrs: Record = {}) => [ + h('span', {}, label), + h('span', attrs, value), +] + +function playerInsight({ result, paceVsHltb: pace, presets }: SummaryProps): HTMLDivElement { + const title = h('div', { class: 'player-insight-title' }, 'Your Play Style vs HLTB') + if (!pace || pace.calibration_count === 0) { + return h( + 'div', + { class: 'player-insight player-insight--empty' }, + title, + h( + 'p', + { class: 'player-insight-empty' }, + 'No calibration data yet. Finish games (100% achievements) and re-run ', + h('code', {}, 'stats'), + ' to see your pace estimate.', + ), + ) + } + const estimated = playerEstimatedTotal(result.rushTotal, result.leisureTotal, pace) + return h( + 'div', + { class: 'player-insight' }, + title, + h( + 'div', + { class: 'player-insight-grid' }, + pair('Calibration games', String(pace.calibration_count)), + pace.ratio_vs_rush !== -1 + ? pair('vs Rush speed', `${pace.ratio_vs_rush.toFixed(2)}×`, { + class: pace.ratio_vs_rush <= 1 ? 'player-insight-fast' : 'player-insight-slow', + }) + : null, + pace.ratio_vs_leisure !== -1 ? pair('vs Leisure speed', `${pace.ratio_vs_leisure.toFixed(2)}×`) : null, + pace.interpolation_t !== -1 + ? pair('Interpolation t', pace.interpolation_t.toFixed(3), { title: '0 = rush speed · 1 = leisure speed' }) + : null, + pair('Play style', STYLE_LABELS[pace.player_style] ?? pace.player_style, { + class: `player-insight-style player-insight-style--${pace.player_style}`, + }), + ), + estimated !== null + ? h( + 'div', + { class: 'player-insight-estimate' }, + h( + 'div', + { class: 'player-insight-estimate-total' }, + 'Estimated total at your pace: ', + h('strong', { class: 'player-insight-estimate-hours' }, fmtHours(estimated)), + ), + presetRows(presets, (p) => (estimated > 0 ? Math.floor(estimated / p) : null)), + ) + : null, + ) +} + +function basisCard(props: SummaryProps, c: (typeof CARDS)[number]): HTMLDivElement { + const { result, filters, state, presets } = props + const isPace = c.basis === 'pace' + const total = basisTotal(result, c.basis) ?? 0 + const pace = state.pace_games_per_day + const eta = isPace ? paceDays(result.remainingGames, pace) : etaDays(total, filters.dailyHours) + return h( + 'div', + { class: filters.basis === c.basis ? 'card active' : 'card' }, + h('div', { class: 'card-title' }, c.title), + h('div', { class: 'card-blurb' }, c.blurb), + h('div', { class: 'card-total' }, isPace ? `${pace} games/day` : fmtHours(total)), + h('div', { class: 'card-eta' }, isPace && !pace ? 'No start date set' : fmtEta(eta)), + isPace ? null : presetRows(presets, (p) => etaDays(total, p)), + ) +} + +export function summaryCards(props: SummaryProps, now: Date = new Date()): HTMLDivElement { + const { result, defaultQualifying } = props + return h( + 'div', + { class: 'summary' }, + h( + 'div', + { class: 'summary-head' }, + h( + 'div', + {}, + h('span', { class: 'big' }, result.remainingGames.toLocaleString()), + h('span', { class: 'big-label' }, 'games in scope'), + ), + h( + 'div', + { class: 'parity' }, + 'CLI default qualifies ', + h('strong', {}, defaultQualifying), + result.remainingGames === defaultQualifying ? h('span', { class: 'ok' }, ' ✓ match') : null, + ), + ), + targetBanner(props, now), + h('div', { class: 'cards' }, CARDS.map((c) => basisCard(props, c))), + playerInsight(props), + ) +} diff --git a/web/src/sections/backlog/tableState.test.ts b/web/src/sections/backlog/tableState.test.ts new file mode 100644 index 0000000..70fb757 --- /dev/null +++ b/web/src/sections/backlog/tableState.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'vitest' +import { applyFilters, type GameRow } from '../../estimate' +import { buildIndex } from '../../library/fuzzy' +import { makeDataset, makeFilters, makeGame } from '../../test/factories' +import type { WebGame } from '../../types' +import { COLUMNS, compareValues, defaultTable, optionsFor, readTable, toggleSort, visibleRows, writeTable, type ColumnKey, type TableState } from './tableState' + +const GAMES: WebGame[] = [ + makeGame({ app_id: 1, name: 'Hollow Knight', completion_pct: 40, playtime_minutes: 600, rush_hours: 30, leisure_hours: 50, worst_hours: 70, count_comp: 90, protondb_tier: 'platinum', protondb_trending_tier: 'platinum', protondb_score: 0.9 }), + makeGame({ app_id: 2, name: 'Celeste', completion_pct: 10, playtime_minutes: 0, rush_hours: 10, leisure_hours: 20, worst_hours: 25, count_comp: 40, protondb_tier: 'gold', protondb_trending_tier: 'silver', protondb_score: 0.5 }), + makeGame({ app_id: 3, name: 'Hades', completion_pct: 70, playtime_minutes: 120, rush_hours: -1, leisure_hours: -1, worst_hours: -1, count_comp: 20, protondb_tier: 'gold', protondb_trending_tier: 'gold', protondb_score: 0.7 }), +] + +function rows(excluded: number[] = []): GameRow[] { + return applyFilters(makeDataset(GAMES), makeFilters({ includeNoData: true, fallbackHours: 15, excluded: new Set(excluded) })).rows +} +const index = buildIndex(GAMES) +const names = (s: TableState, query = '', r = rows()) => visibleRows(r, s, query, index).map((x) => x.game.name) +const withFilters = (f: Record, over: Partial = {}): TableState => ({ + ...defaultTable(), + ...over, + filters: new Map(Object.entries(f)), +}) + +describe('toggleSort', () => { + it('flips the direction on the same column, names start A→Z and numbers high→low', () => { + const s = defaultTable() + expect(toggleSort(s, 'leisure')).toMatchObject({ sort: 'leisure', asc: false }) + expect(toggleSort(s, 'name')).toMatchObject({ sort: 'name', asc: true }) + expect(toggleSort(s, 'rush')).toMatchObject({ sort: 'rush', asc: false }) + }) +}) + +describe('visibleRows', () => { + it('sorts by leisure ascending by default, fallback hours counting as data', () => { + expect(names(defaultTable())).toEqual(['Hades', 'Celeste', 'Hollow Knight']) + }) + + it('sorts every column both ways and changes the order', () => { + const r = applyFilters(makeDataset(GAMES), makeFilters({ excluded: new Set([2]) })).rows + for (const c of COLUMNS) { + const asc = names({ ...defaultTable(), sort: c.key, asc: true }, '', r) + const desc = names({ ...defaultTable(), sort: c.key, asc: false }, '', r) + expect(asc, c.key).toHaveLength(3) + expect(desc, c.key).not.toEqual(asc) + } + }) + + it('keeps games with no data last whichever way the column runs', () => { + const more = [...GAMES, makeGame({ app_id: 4, name: 'Abzu', rush_hours: -1, leisure_hours: -1, worst_hours: -1 })] + const r = applyFilters(makeDataset(more), makeFilters()).rows + expect(names({ ...defaultTable(), sort: 'rush', asc: true }, '', r)).toEqual(['Celeste', 'Hollow Knight', 'Abzu', 'Hades']) + expect(names({ ...defaultTable(), sort: 'rush', asc: false }, '', r)).toEqual(['Hollow Knight', 'Celeste', 'Abzu', 'Hades']) + }) + + it('breaks ties by name', () => { + const tied = rows().map((x) => ({ ...x, game: { ...x.game, completion_pct: 5 } })) + expect(names({ ...defaultTable(), sort: 'completion' }, '', tied)).toEqual(['Celeste', 'Hades', 'Hollow Knight']) + }) + + it('filters by the fuzzy search, typos and app ids included', () => { + expect(names(defaultTable(), 'holow knigt')).toEqual(['Hollow Knight']) + expect(names(defaultTable(), '2')).toEqual(['Celeste']) + expect(names(defaultTable(), ' ')).toHaveLength(3) + }) + + it('filters a text column by substring', () => { + expect(names(withFilters({ name: ' HA ' }))).toEqual(['Hades']) + }) + + it('filters a range by min, max or both; a blank or junk bound is ignored', () => { + expect(names(withFilters({ 'completion.min': '20' }))).toEqual(['Hades', 'Hollow Knight']) + expect(names(withFilters({ 'playtime.max': '2' }))).toEqual(['Hades', 'Celeste']) + expect(names(withFilters({ 'count_comp.min': '30', 'count_comp.max': '50' }))).toEqual(['Celeste']) + expect(names(withFilters({ 'rush.min': ' ', 'rush.max': 'abc' }))).toHaveLength(3) + }) + + it('drops rows with no data once a range is set on that column', () => { + const r = applyFilters(makeDataset(GAMES), makeFilters()).rows + expect(names(withFilters({ 'worst.max': '1000' }), '', r)).toEqual(['Celeste', 'Hollow Knight']) + }) + + it('filters select columns by the exact option, case-insensitively', () => { + expect(names(withFilters({ proton: 'GOLD' }))).toEqual(['Hades']) + expect(names(withFilters({ proton: 'gold / silver' }))).toEqual(['Celeste']) + expect(names(withFilters({ keep: 'excluded' }), '', rows([3]))).toEqual(['Hades']) + expect(names(withFilters({ keep: 'kept' }), '', rows([3]))).toEqual(['Celeste', 'Hollow Knight']) + }) +}) + +describe('compareValues', () => { + it('puts missing values last both ways and compares strings and numbers', () => { + expect(compareValues(null, 1, true)).toBe(1) + expect(compareValues(null, 1, false)).toBe(1) + expect(compareValues(1, null, false)).toBe(-1) + expect(compareValues(null, null, true)).toBe(0) + expect(compareValues('a', 'b', true)).toBeLessThan(0) + expect(compareValues(1, 2, false)).toBe(1) + }) +}) + +describe('optionsFor', () => { + const col = (key: ColumnKey) => COLUMNS.find((c) => c.key === key) ?? COLUMNS[0] + it('lists the distinct ProtonDB labels, plus a restored value', () => { + expect(optionsFor(col('proton'), rows())).toEqual(['gold', 'gold / silver', 'platinum']) + expect(optionsFor(col('proton'), rows(), 'borked')).toEqual(['borked', 'gold', 'gold / silver', 'platinum']) + }) + it('offers kept / excluded for the keep column', () => { + expect(optionsFor(col('keep'), [])).toEqual(['kept', 'excluded']) + }) +}) + +describe('hash form', () => { + it('writes nothing for the default state', () => { + const p = new URLSearchParams() + writeTable(defaultTable(), p) + expect(p.toString()).toBe('') + }) + + it('round-trips sort, text, select and range filters', () => { + const s = withFilters( + { name: 'hol', proton: 'gold', 'rush.min': '5', 'rush.max': ' ', 'worst.max': '40', 'completion.min': ' ' }, + { sort: 'rush', asc: false }, + ) + const p = new URLSearchParams() + writeTable(s, p) + expect(p.toString()).toBe('sort=rush&dir=desc&f.name=hol&f.rush=5..&f.worst=..40&f.proton=gold') + const back = readTable(new URLSearchParams(p.toString())) + expect(back.sort).toBe('rush') + expect(back.asc).toBe(false) + expect([...back.filters]).toEqual([ + ['name', 'hol'], + ['rush.min', '5'], + ['worst.max', '40'], + ['proton', 'gold'], + ]) + }) + + it('writes a non-default direction on the default column, reads asc unless told desc', () => { + const p = new URLSearchParams() + writeTable({ ...defaultTable(), asc: false }, p) + expect(p.toString()).toBe('sort=leisure&dir=desc') + expect(readTable(new URLSearchParams('sort=name')).asc).toBe(true) + }) + + it('ignores an unknown sort column and a bare range', () => { + const s = readTable(new URLSearchParams('sort=bogus&dir=desc&f.rush=..')) + expect(s).toEqual(defaultTable()) + }) +}) diff --git a/web/src/sections/backlog/tableState.ts b/web/src/sections/backlog/tableState.ts new file mode 100644 index 0000000..855ecd9 --- /dev/null +++ b/web/src/sections/backlog/tableState.ts @@ -0,0 +1,154 @@ +// The game table's columns, sort and per-column filters (was GameTable's +// sort state), as pure functions so the element only wires inputs to them. +// Hash grammar: `sort=&dir=asc|desc`, `f.=text` for text/select +// columns and `f.=min..max` (either side optional) for numeric ones. + +import type { GameRow } from '../../estimate' +import { search, type Searchable } from '../../library/fuzzy' +import { tierLabel } from '../../protondb' + +export type ColumnKey = 'name' | 'completion' | 'playtime' | 'rush' | 'leisure' | 'worst' | 'count_comp' | 'proton' | 'keep' +export type FilterKind = 'text' | 'range' | 'select' + +export interface Column { + key: ColumnKey + label: string + numeric: boolean + filter: FilterKind + /** Sort key and range-filter value; null = no data (sorts last, fails any bound). */ + value: (r: GameRow) => number | string | null + /** Select columns: the option a row belongs to. */ + option?: (r: GameRow) => string +} + +const hours = (h: number) => (h > 0 ? h : null) +const keepOption = (r: GameRow) => (r.excluded ? 'excluded' : 'kept') + +export const COLUMNS: readonly Column[] = [ + { key: 'name', label: 'Game', numeric: false, filter: 'text', value: (r) => r.game.name.toLowerCase() }, + { key: 'completion', label: '%', numeric: true, filter: 'range', value: (r) => r.game.completion_pct }, + { key: 'playtime', label: 'Played', numeric: true, filter: 'range', value: (r) => r.game.playtime_minutes / 60 }, + { key: 'rush', label: 'Rush', numeric: true, filter: 'range', value: (r) => hours(r.rush) }, + { key: 'leisure', label: 'Leisure', numeric: true, filter: 'range', value: (r) => hours(r.leisure) }, + { key: 'worst', label: 'Worst', numeric: true, filter: 'range', value: (r) => hours(r.worst) }, + { key: 'count_comp', label: 'HLTB n', numeric: true, filter: 'range', value: (r) => r.game.count_comp }, + { + key: 'proton', + label: 'ProtonDB', + numeric: true, + filter: 'select', + value: (r) => r.game.protondb_score, + option: (r) => tierLabel(r.game.protondb_tier, r.game.protondb_trending_tier), + }, + { key: 'keep', label: 'Keep', numeric: true, filter: 'select', value: (r) => (r.excluded ? 1 : 0), option: keepOption }, +] + +const BY_KEY = Object.fromEntries(COLUMNS.map((c) => [c.key, c])) as Record + +export interface TableState { + sort: ColumnKey + asc: boolean + /** Raw filter input values: `` for text/select, `.min` / `.max` for ranges. */ + filters: Map +} + +export const defaultTable = (): TableState => ({ sort: 'leisure', asc: true, filters: new Map() }) + +/** Header click: flip the direction, or switch column (names A→Z, numbers high→low). */ +export function toggleSort(s: TableState, key: ColumnKey): TableState { + if (key === s.sort) return { ...s, asc: !s.asc } + return { ...s, sort: key, asc: key === 'name' } +} + +const bound = (raw: string | undefined): number | null => { + const t = raw?.trim() ?? '' + const n = Number(t) + return t === '' || !Number.isFinite(n) ? null : n +} + +function passes(r: GameRow, c: Column, filters: ReadonlyMap): boolean { + if (c.filter === 'range') { + const min = bound(filters.get(`${c.key}.min`)) + const max = bound(filters.get(`${c.key}.max`)) + if (min === null && max === null) return true + const v = c.value(r) as number | null + return v !== null && (min === null || v >= min) && (max === null || v <= max) + } + const want = (filters.get(c.key) ?? '').trim().toLowerCase() + if (!want) return true + if (c.option) return c.option(r).toLowerCase() === want + return r.game.name.toLowerCase().includes(want) +} + +/** Column order for two values, missing ones last in either direction. */ +export function compareValues(a: number | string | null, b: number | string | null, asc: boolean): number { + if (a === null || b === null) return Number(a === null) - Number(b === null) + const cmp = typeof a === 'string' ? a.localeCompare(String(b)) : a - (b as number) + return asc ? cmp : -cmp +} + +/** + * Rows matching the fuzzy `query` (src/library/fuzzy: typos, roman numerals, + * app ids) and every column filter, in the state's sort order. + */ +export function visibleRows( + rows: readonly GameRow[], + s: TableState, + query: string, + index: ReadonlyMap, +): GameRow[] { + const hits = query.trim() + ? new Set(search(rows.map((r) => r.game), index, query).map((g) => g.app_id)) + : null + const col = BY_KEY[s.sort] + return rows + .filter((r) => (hits === null || hits.has(r.game.app_id)) && COLUMNS.every((c) => passes(r, c, s.filters))) + .sort((a, b) => compareValues(col.value(a), col.value(b), s.asc) || a.game.name.localeCompare(b.game.name)) +} + +/** Distinct options of a select column across `rows`, plus `keep` (a restored value). */ +export function optionsFor(c: Column, rows: readonly GameRow[], keep = ''): string[] { + if (c.key === 'keep') return ['kept', 'excluded'] + const opts = new Set(rows.map((r) => (c.option as (r: GameRow) => string)(r))) + if (keep) opts.add(keep) + return [...opts].sort() +} + +export function writeTable(s: TableState, p: URLSearchParams): void { + const d = defaultTable() + if (s.sort !== d.sort || s.asc !== d.asc) { + p.set('sort', s.sort) + p.set('dir', s.asc ? 'asc' : 'desc') + } + for (const c of COLUMNS) { + if (c.filter === 'range') { + const min = s.filters.get(`${c.key}.min`)?.trim() ?? '' + const max = s.filters.get(`${c.key}.max`)?.trim() ?? '' + if (min || max) p.set(`f.${c.key}`, `${min}..${max}`) + } else { + const v = s.filters.get(c.key) ?? '' + if (v) p.set(`f.${c.key}`, v) + } + } +} + +export function readTable(p: URLSearchParams): TableState { + const s = defaultTable() + const sort = COLUMNS.find((c) => c.key === p.get('sort')) + if (sort) { + s.sort = sort.key + s.asc = p.get('dir') !== 'desc' + } + for (const c of COLUMNS) { + const raw = p.get(`f.${c.key}`) + if (raw === null) continue + if (c.filter === 'range') { + const [min = '', max = ''] = raw.split('..') + if (min) s.filters.set(`${c.key}.min`, min) + if (max) s.filters.set(`${c.key}.max`, max) + } else { + s.filters.set(c.key, raw) + } + } + return s +} diff --git a/web/src/screens/Dashboard.test.tsx b/web/src/sections/dashboard.test.ts similarity index 61% rename from web/src/screens/Dashboard.test.tsx rename to web/src/sections/dashboard.test.ts index cd7d74e..2a87e41 100644 --- a/web/src/screens/Dashboard.test.tsx +++ b/web/src/sections/dashboard.test.ts @@ -1,9 +1,9 @@ -import { act, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { screen, waitFor, within } from '@testing-library/dom' +import { describe, expect, it, vi } from 'vitest' import { makeBudget, makeBudgetToday } from '../test/factories' import { makeJob, makeSpec, makeStatus } from '../test/fixtures' -import { apiError, renderRouted, stubApi } from '../test/harness' -import { Dashboard } from './Dashboard' +import { apiError, mount, stubApi, type Handler } from '../test/harness' +import { blockSecondsLeft } from './dashboard' const daemon = (fast_installs: unknown[] = []) => ({ state: 'running', @@ -14,7 +14,7 @@ const daemon = (fast_installs: unknown[] = []) => ({ fast_installs, }) const bg3 = { app_id: 1086940, name: "Baldur's Gate 3", phase: 'downloading depot 3/8', percent: 46.9, elapsed_seconds: 312 } -const base = (over: Record = {}) => ({ +const base = (over: Record = {}): Record => ({ 'GET /api/status': makeStatus(), 'GET /api/daemon': daemon(), 'GET /api/budget': makeBudget(), @@ -22,31 +22,52 @@ const base = (over: Record = {}) => ({ 'GET /api/commands': [makeSpec('done'), makeSpec('install'), makeSpec('pick'), makeSpec('scan'), makeSpec('check')], ...over, }) -const show = (over: Record = {}) => { - stubApi(base(over)) - return renderRouted() + +// The body of src/pages/index.astro. +const PAGE = `
+
+ + + + +
+
+ + +
` + +const show = (over: Record = {}) => { + const mock = stubApi(base(over)) + mount(PAGE) + return mock } -const fact = (label: string) => screen.getByText(label, { selector: 'dt' }).nextElementSibling! +const fact = (label: string) => screen.getByText(label, { selector: 'dt' }).nextElementSibling as HTMLElement +const box = (name: string) => screen.getByRole('region', { name }) describe('Dashboard', () => { - afterEach(() => vi.unstubAllGlobals()) - - it('offers the quick actions', async () => { + it('offers the quick actions, enabled once the catalog loads', async () => { show() - expect(await screen.findByRole('button', { name: 'I got an achievement' })).toBeInTheDocument() - for (const name of ['Install assigned', 'Pick next', 'Scan']) expect(screen.getByRole('button', { name })).toBeInTheDocument() + await waitFor(() => { + expect(screen.getByRole('button', { name: 'I got an achievement' })).toHaveAttribute('aria-disabled', 'false') + }) + expect(screen.getByRole('button', { name: 'I got an achievement' })).toHaveClass('btn-primary') + for (const name of ['Install assigned', 'Pick next', 'Scan']) { + expect(screen.getByRole('button', { name })).toHaveAttribute('aria-disabled', 'false') + } }) describe('assignment', () => { it('summarises a healthy state', async () => { show() - expect(await screen.findByText('Hollow Knight')).toBeInTheDocument() + expect(await screen.findByText('Hollow Knight')).toHaveClass('hero-game') expect(fact('Installed')).toHaveTextContent('Yes') expect(fact('Games finished')).toHaveTextContent('3') expect(fact('Store')).toHaveTextContent('Blocked') expect(fact('Installed games')).toHaveTextContent('4') expect(fact('Total block')).toHaveTextContent('Off') expect(fact('Manual picks')).toHaveTextContent('None') + // The aside is its own , so it follows the catalog. + expect(await within(box('Assignment')).findByRole('button', { name: 'Check now' })).toHaveAttribute('aria-disabled', 'false') }) it('warns about an uninstalled assignment, open store, total block and picks', async () => { @@ -56,42 +77,42 @@ describe('Dashboard', () => { assigned_game_installed: false, store_blocked: false, total_block: { active: true, days_remaining: 5, until: null }, - manual_picks: [{ app_id: 1, name: 'Alpha', age_days: 2 }, { app_id: 2, name: 'Beta', age_days: 9 }], + manual_picks: [ + { app_id: 1, name: 'Alpha', age_days: 2 }, + { app_id: 2, name: 'Beta', age_days: 9 }, + ], }), }) expect(await screen.findByText('Nothing assigned')).toBeInTheDocument() expect(fact('Installed')).toHaveTextContent('No — install it from Library') expect(fact('Store')).toHaveTextContent('Unblocked') expect(fact('Total block')).toHaveTextContent('5d 0h left') + expect(fact('Total block').querySelector('.text-danger')).not.toBeNull() expect(fact('Manual picks')).toHaveTextContent('Alpha (2 d), Beta (9 d)') }) it('counts the total block down from until, every second', async () => { vi.useFakeTimers({ shouldAdvanceTime: true, now: Date.parse('2026-10-23T10:00:00Z') }) - try { - show({ - 'GET /api/status': makeStatus({ - total_block: { active: true, days_remaining: 0, until: '2026-10-23T11:08:02Z' }, - }), - }) - expect(await screen.findByText('1h 8m left')).toBeInTheDocument() - // The jump alone re-renders nothing; the 1 s tick picks it up, then - // every further tick takes exactly one second off. - act(() => vi.setSystemTime(Date.parse('2026-10-23T11:07:58Z'))) - expect(fact('Total block')).toHaveTextContent('1h 8m left') - act(() => vi.advanceTimersByTime(1000)) - const first = Number(/^(\d)s left$/.exec(fact('Total block').textContent ?? '')?.[1]) - expect([3, 4]).toContain(first) - act(() => vi.advanceTimersByTime(1000)) - expect(fact('Total block')).toHaveTextContent(`${first - 1}s left`) - } finally { - vi.useRealTimers() - } + show({ 'GET /api/status': makeStatus({ total_block: { active: true, days_remaining: 0, until: '2026-10-23T11:08:02Z' } }) }) + expect(await screen.findByText('1h 8m left')).toBeInTheDocument() + const check = screen.getByRole('button', { name: 'Check now' }) + // The jump alone re-renders nothing; the 1 s tick picks it up, then + // every further tick takes exactly one second off. + vi.setSystemTime(Date.parse('2026-10-23T11:07:58Z')) + expect(fact('Total block')).toHaveTextContent('1h 8m left') + vi.advanceTimersByTime(1000) + const first = Number(/^(\d)s left$/.exec(fact('Total block').textContent)?.[1]) + expect([3, 4]).toContain(first) + vi.advanceTimersByTime(1000) + expect(fact('Total block')).toHaveTextContent(`${first - 1}s left`) + // Only the countdown ticks: the card and its button are not rebuilt. + expect(screen.getByRole('button', { name: 'Check now' })).toBe(check) }) - it('shows loading, then an error', async () => { - show({ 'GET /api/status': () => new Promise(() => {}) }) + it('shows loading while status is in flight', async () => { + show({ 'GET /api/status': () => new Promise(() => undefined) }) expect(await screen.findByText(/Loading status/)).toBeInTheDocument() + expect(box('Assignment')).toBeInTheDocument() }) it('explains a failed load', async () => { @@ -100,6 +121,20 @@ describe('Dashboard', () => { }) }) + describe('', () => { + it('is empty until status arrives', () => { + stubApi({ 'GET /api/status': () => new Promise(() => undefined) }) + expect(mount('')).toBeEmptyDOMElement() + }) + }) + + describe('blockSecondsLeft', () => { + it('prefers until, else days_remaining', () => { + expect(blockSecondsLeft({ active: true, days_remaining: 9, until: '2026-10-23T11:00:00Z' }, Date.parse('2026-10-23T10:00:00Z'))).toBe(3600) + expect(blockSecondsLeft({ active: true, days_remaining: 0.5, until: null })).toBe(43_200) + }) + }) + describe('fast install', () => { it('shows a daemon-started run with its depot percent', async () => { show({ 'GET /api/daemon': daemon([bg3]) }) @@ -123,19 +158,20 @@ describe('Dashboard', () => { }) it('stays out of the way when nothing is installing', async () => { - show() + show({ 'GET /api/daemon': { ...daemon(), fast_installs: undefined } }) expect(await screen.findByText('Hollow Knight')).toBeInTheDocument() expect(screen.queryByText(/Fast-installing/)).not.toBeInTheDocument() + expect(screen.queryByRole('region', { name: 'Fast install' })).not.toBeInTheDocument() }) }) describe('gaming today', () => { it('shows time left and what is billing', async () => { show() - expect(await screen.findByText(/left of/)).toBeInTheDocument() + expect(await screen.findByText(/left of 8h 0m/)).toBeInTheDocument() expect(screen.getByText('Now: Hollow Knight')).toBeInTheDocument() - expect(screen.getByRole('progressbar', { name: 'Budget used' }).className).toContain('bar-accent') - expect(screen.getByRole('link', { name: 'Details' })).toHaveAttribute('href', '/gaming') + expect(screen.getByRole('progressbar', { name: 'Budget used' })).toHaveClass('bar-accent') + expect(screen.getByRole('link', { name: 'Details' })).toHaveAttribute('href', '/gaming/') }) it('says nothing is billing', async () => { @@ -143,7 +179,7 @@ describe('Dashboard', () => { expect(await screen.findByText('Now: nothing billing')).toBeInTheDocument() }) - it('warns when the day is almost used, and reports a block', async () => { + it('warns when the day is almost used', async () => { show({ 'GET /api/budget': makeBudget({ today: makeBudgetToday({ fraction_used: 0.9 }) }) }) expect(await screen.findByRole('progressbar', { name: 'Budget used' })).toHaveClass('bar-warning') }) @@ -156,7 +192,7 @@ describe('Dashboard', () => { it('shows the budget error, or a default, when there is no today', async () => { show({ 'GET /api/budget': makeBudget({ today: null, error: 'state file unreadable' }) }) - expect(await screen.findByText('state file unreadable')).toBeInTheDocument() + expect(await screen.findByText('state file unreadable')).toHaveClass('text-danger') }) it('has a default message too', async () => { @@ -164,8 +200,8 @@ describe('Dashboard', () => { expect(await screen.findByText('Budget state unavailable.')).toBeInTheDocument() }) - it('shows loading, then a request error', async () => { - show({ 'GET /api/budget': () => new Promise(() => {}) }) + it('shows loading while the budget is in flight', async () => { + show({ 'GET /api/budget': () => new Promise(() => undefined) }) expect(await screen.findByText(/Loading budget/)).toBeInTheDocument() }) @@ -179,7 +215,7 @@ describe('Dashboard', () => { it('invites running one when there are none', async () => { show() expect(await screen.findByText(/No jobs yet/)).toBeInTheDocument() - expect(screen.getByRole('link', { name: 'All jobs' })).toHaveAttribute('href', '/jobs') + expect(screen.getByRole('link', { name: 'All jobs' })).toHaveAttribute('href', '/jobs/') }) it('lists five with summary, progress or nothing', async () => { @@ -195,7 +231,10 @@ describe('Dashboard', () => { expect(await screen.findByText('Scanned 12')).toBeInTheDocument() expect(screen.getByText('Checking games')).toBeInTheDocument() expect(document.querySelectorAll('.rows .row-link')).toHaveLength(5) - expect(document.querySelector('a[href="/jobs/a"]')).toHaveTextContent('Scan') + const first = document.querySelector('a[href="/jobs/?id=a"]') + expect(first).toHaveTextContent('Scan') + expect(first).toHaveTextContent('Succeeded') + expect(screen.queryByText(/No jobs yet/)).not.toBeInTheDocument() }) it('explains a failed load', async () => { diff --git a/web/src/sections/dashboard.ts b/web/src/sections/dashboard.ts new file mode 100644 index 0000000..44596b1 --- /dev/null +++ b/web/src/sections/dashboard.ts @@ -0,0 +1,184 @@ +// Dashboard sections (was screens/Dashboard.tsx): what the enforcer is doing +// right now, at a glance. + +import type { FastInstallRun, StatusPayload } from '../api/contract' +import { q } from '../api/queries' +import { commandLabel } from '../commands/catalog' +import '../commands/commandButton' +import { fmtDuration, fmtRemaining } from '../format' +import { progressBar, stateBadge, type BarTone } from '../jobs/progress' +import { define, h, hx } from '../lib/dom' +import { LiveElement } from '../lib/element' +import { jobHref } from '../lib/runner' +import { card, empty, errorNotice, facts, link, loading } from '../ui/build' +import { ago } from '../ui/format' + +/** Seconds left on the total block: from `until` when known, else the + * server's `days_remaining` (rounded to 0.1 d). */ +export function blockSecondsLeft(block: StatusPayload['total_block'], now: number = Date.now()): number { + return block.until ? (Date.parse(block.until) - now) / 1000 : block.days_remaining * 86_400 +} + +/** The total-block countdown. Ticks on its own every second so the status + * card (and its "Check now" button) is rebuilt only when status changes. */ +export class BlockCountdownElement extends LiveElement { + protected override setup(): void { + this.watch(q.status) + this.tick(1000) + } + + protected update(): void { + const block = q.status.state.data?.total_block + this.replaceChildren(...(block ? [h('span', { class: 'text-danger' }, `${fmtRemaining(blockSecondsLeft(block))} left`)] : [])) + } +} + +function statusFacts(s: StatusPayload): HTMLDListElement { + return facts([ + ['Installed', s.assigned_game_installed ? 'Yes' : h('span', { class: 'text-warning' }, 'No — install it from Library')], + ['Games finished', s.finished_count], + ['Store', s.store_blocked ? 'Blocked' : h('span', { class: 'text-warning' }, 'Unblocked')], + ['Installed games', s.installed_count], + ['Total block', s.total_block.active ? hx('block-countdown') : 'Off'], + ['Manual picks', s.manual_picks.length ? s.manual_picks.map((p) => `${p.name} (${p.age_days} d)`).join(', ') : 'None'], + ]) +} + +export class DashboardStatusElement extends LiveElement { + protected override setup(): void { + this.watch(q.status) + } + + protected update(): void { + const { data, error } = q.status.state + if (error) { + this.replaceChildren(card('Assignment', errorNotice(error))) + } else if (!data) { + this.replaceChildren(card('Assignment', loading('status'))) + } else { + this.replaceChildren( + card('Assignment', [h('p', { class: 'hero-game' }, data.current_game_name ?? 'Nothing assigned'), statusFacts(data)], { + // Its own element, so it follows the command catalog by itself. + aside: hx('command-button', { name: 'check', label: 'Check now' }), + }), + ) + } + } +} + +function budgetTone(blocked: boolean, used: number): BarTone { + if (blocked) return 'danger' + return used > 0.8 ? 'warning' : 'accent' +} + +export class BudgetGlanceElement extends LiveElement { + protected override setup(): void { + this.watch(q.budget) + } + + protected update(): void { + const { data, error } = q.budget.state + if (error) { + this.replaceChildren(card('Gaming today', errorNotice(error))) + return + } + if (!data) { + this.replaceChildren(card('Gaming today', loading('budget'))) + return + } + const t = data.today + const body = t + ? [ + h( + 'p', + { class: 'hero-num' }, + `${fmtDuration(t.seconds_remaining)} `, + h('span', { class: 'muted' }, `left of ${fmtDuration(t.budget_seconds)}`), + ), + progressBar(t.fraction_used, 'Budget used', budgetTone(t.blocked, t.fraction_used)), + h('p', { class: 'field-help' }, t.blocked ? 'Blocked for today.' : `Now: ${data.session.billing_label || 'nothing billing'}`), + ] + : h('p', { class: 'text-danger' }, data.error ?? 'Budget state unavailable.') + this.replaceChildren(card('Gaming today', body, { aside: link('/gaming/', 'Details') })) + } +} + +function fastInstallLine(r: FastInstallRun): string { + const where = r.percent === null ? r.phase : `${Math.floor(r.percent)}% (${r.phase})` + return `Fast-installing ${r.name} — ${where}` +} + +function fastInstallRow(r: FastInstallRun): HTMLDivElement { + if (r.error) { + // The daemon has no job to report to: a failure stays here until Steam finishes. + return h( + 'div', + {}, + h('p', { class: 'text-danger' }, `Fast install of ${r.name} failed: ${r.error}`), + h('p', { class: 'field-help' }, "Steam's own download took over."), + ) + } + return h( + 'div', + {}, + h('p', {}, fastInstallLine(r)), + progressBar(r.percent === null ? null : Math.floor(r.percent) / 100, `${r.name} download`), + h('p', { class: 'field-help' }, `${fmtDuration(r.elapsed_seconds)} elapsed`), + ) +} + +/** Daemon-started fast installs; a web job shows its own in the job view. */ +export class FastInstallsElement extends LiveElement { + protected override setup(): void { + this.watch(q.daemon) + } + + protected update(): void { + const runs = q.daemon.state.data?.fast_installs ?? [] + this.replaceChildren(...(runs.length ? [card('Fast install', runs.map(fastInstallRow))] : [])) + } +} + +export class RecentJobsElement extends LiveElement { + protected override setup(): void { + this.watch(q.jobs) + } + + protected update(): void { + const { data, error } = q.jobs.state + this.replaceChildren( + card( + 'Recent jobs', + [ + error ? errorNotice(error) : null, + data?.length === 0 ? empty('No jobs yet. Run one from the actions or press Ctrl K.') : null, + h( + 'ul', + { class: 'rows' }, + (data ?? []).slice(0, 5).map((j) => + h( + 'li', + {}, + h( + 'a', + { href: jobHref(j.id), class: 'row-link' }, + h('span', {}, commandLabel(j.command)), + h('span', { class: 'muted grow ellipsis' }, j.summary ?? j.progress?.label ?? ''), + h('span', { class: 'muted' }, ago(j.created_at)), + stateBadge(j.state), + ), + ), + ), + ), + ], + { aside: link('/jobs/', 'All jobs') }, + ), + ) + } +} + +define('block-countdown', BlockCountdownElement) +define('dashboard-status', DashboardStatusElement) +define('budget-glance', BudgetGlanceElement) +define('fast-installs', FastInstallsElement) +define('recent-jobs', RecentJobsElement) diff --git a/web/src/sections/gaming/cards.test.ts b/web/src/sections/gaming/cards.test.ts new file mode 100644 index 0000000..5c75edb --- /dev/null +++ b/web/src/sections/gaming/cards.test.ts @@ -0,0 +1,154 @@ +import { screen } from '@testing-library/dom' +import { describe, expect, it } from 'vitest' +import { makeBudgetRules, makeBudgetSession, makeBudgetToday } from '../../test/factories' +import { historyCard, liveCard, rulesCard, todayCard, todayUnavailable } from './cards' + +const show = (el: HTMLElement) => { + document.body.replaceChildren(el) +} + +describe('todayCard', () => { + it('shows remaining time, usage and the next warning', () => { + show(todayCard(makeBudgetToday(), [], 0)) + expect(screen.getByText('7h 0m')).toBeInTheDocument() + expect(screen.getByText(/1h 0m of 8h 0m used · 13% · gaming day 2026-08-28 \(starts 06:00 local\)/)).toBeInTheDocument() + expect(screen.getByText(/Next warning at 1h 0m remaining/)).toBeInTheDocument() + expect(document.querySelector('.budget-meter--ok')).not.toBeNull() + expect(document.querySelector('.budget-meter-fill')?.style.width).toBe('12.5%') + }) + + it('turns the meter amber once a warning has fired', () => { + show(todayCard(makeBudgetToday({ seconds_remaining: 900, warned_seconds: [3600, 1800] }), [], 0)) + expect(document.querySelector('.budget-meter--warn')).not.toBeNull() + }) + + it('says so when no warnings remain', () => { + show(todayCard(makeBudgetToday({ next_warning_seconds: null, warned_seconds: [3600, 1800, 600, 300] }), [], 0)) + expect(screen.getByText(/No further warnings today/)).toBeInTheDocument() + expect(document.querySelector('.budget-meter--warn')).not.toBeNull() + }) + + const blockedToday = () => + makeBudgetToday({ blocked: true, blocked_at: 1, seconds_used: 28800, seconds_remaining: 0, fraction_used: 1, next_warning_seconds: null }) + + it('shows the cutoff badge when blocked', () => { + show(todayCard(blockedToday(), [], 2)) + expect(screen.getByText(/Cutoff engaged — launchers are masked/)).toBeInTheDocument() + expect(document.querySelector('.budget-meter--blocked')).not.toBeNull() + }) + + it('does not claim a mask that is not in place', () => { + show(todayCard(blockedToday(), [], 0)) + expect(screen.getByText(/Cutoff engaged — launchers are not currently masked/)).toBeInTheDocument() + }) + + it('colours games by legend position, else by their own row', () => { + const games = [ + { key: 'app:1', label: 'One', seconds: 1800, fraction: 0.5 }, + { key: 'app:2', label: 'Two', seconds: 1800, fraction: 0.5 }, + ] + show(todayCard(makeBudgetToday({ games }), [{ key: 'app:9', label: 'Nine' }, { key: 'app:2', label: 'Two' }], 0)) + const swatches = [...document.querySelectorAll('.budget-games .swatch')].map((s) => s.className) + // app:1 is not in the legend → its row index 0 → cat-1; app:2 is legend entry 1 → cat-2. + expect(swatches).toEqual(['swatch seg cat-1', 'swatch seg cat-2']) + expect(screen.getByText('Today by game')).toBeInTheDocument() + expect(screen.getAllByText('50%')).toHaveLength(2) + }) + + it('omits the per-game list when nothing billed', () => { + show(todayCard(makeBudgetToday({ games: [] }), [], 0)) + expect(screen.queryByText('Today by game')).toBeNull() + }) +}) + +describe('todayUnavailable', () => { + it('shows the backend reason, or a default', () => { + show(todayUnavailable('Cannot read the budget state file.')) + expect(screen.getByRole('heading', { name: 'Today' })).toBeInTheDocument() + expect(screen.getByText('Cannot read the budget state file.')).toHaveClass('error') + show(todayUnavailable(null)) + expect(screen.getByText('Budget state unavailable.')).toBeInTheDocument() + }) +}) + +describe('liveCard', () => { + it('reports an engaged session with its game', () => { + show(liveCard(makeBudgetSession())) + expect(screen.getByText(/Billing — you are playing/)).toHaveClass('budget-verdict--engaged') + expect(screen.getByText(/Assigned game/)).toBeInTheDocument() + // Engaged, so the credited game is described in the present tense. + expect(screen.getByText(/Billing to/)).toBeInTheDocument() + expect(screen.getAllByText('Hollow Knight')).toHaveLength(2) + expect(screen.getByText(/3 qualifying processes/)).toBeInTheDocument() + expect(screen.getByText('as of just now')).toBeInTheDocument() + }) + + it('falls back to the raw verdict when unrecognised', () => { + show(liveCard(makeBudgetSession({ state: 'weird' }))) + expect(screen.getByText('weird')).toBeInTheDocument() + expect(screen.getByText(/Last billed to/)).toBeInTheDocument() + }) + + it('omits the game line when nothing qualifies', () => { + show(liveCard(makeBudgetSession({ state: 'not_applicable', qualifying_count: 0, processes: [] }))) + expect(screen.getByText(/no game running/)).toBeInTheDocument() + expect(screen.queryByText(/qualifying processes/)).toBeNull() + }) + + it('handles a qualifying session with no assigned game name', () => { + show(liveCard(makeBudgetSession({ game_name: '', billing_label: '' }))) + expect(screen.getByText(/3 qualifying processes/)).toBeInTheDocument() + expect(screen.queryByText('Hollow Knight')).toBeNull() + expect(screen.queryByText(/Assigned game/)).toBeNull() + }) + + it('says when no verdict has been logged', () => { + show(liveCard(makeBudgetSession({ available: false }))) + expect(screen.getByText(/No verdict has been logged yet/)).toBeInTheDocument() + }) +}) + +describe('historyCard', () => { + const legend = [ + { key: 'app:475150', label: 'Titan Quest' }, + { key: 'proc:osu-lazer', label: 'osu!lazer' }, + { key: 'unattributed', label: 'Unattributed' }, + ] + + it('explains itself when nothing has been recorded', () => { + show(historyCard([], [], 28800)) + expect(screen.getByText(/No budget history recorded yet/)).toBeInTheDocument() + expect(document.querySelector('svg')).toBeNull() + }) + + it('draws the chart as real SVG with a legend', () => { + show(historyCard([{ day: '2026-08-28', seconds: 14400, segments: [{ key: 'app:475150', seconds: 14400 }] }], legend, 28800)) + expect(screen.getByRole('heading', { name: 'Last 14 days' })).toBeInTheDocument() + const svg = document.querySelector('.chart > svg.chart-svg') + expect(svg?.namespaceURI).toBe('http://www.w3.org/2000/svg') + expect(document.querySelector('rect.seg title')?.textContent).toContain('Titan Quest') + expect(document.querySelectorAll('.chart-legend li')).toHaveLength(legend.length) + expect(document.querySelector('.chart-legend li:last-child .swatch')).toHaveClass('unattributed') + expect(screen.getByText(/The dashed line is the daily budget/)).toBeInTheDocument() + }) +}) + +describe('rulesCard', () => { + it('lists the production policy', () => { + show(rulesCard(makeBudgetRules())) + expect(screen.getByText('8h 0m')).toBeInTheDocument() + expect(screen.getByText('on')).toBeInTheDocument() + expect(screen.getByText('yes')).toBeInTheDocument() + expect(screen.getByText(/1h 0m, 30m, 10m, 5m remaining/)).toBeInTheDocument() + expect(screen.queryByText(/demo/)).toBeNull() + expect(screen.queryByText('Masked now')).toBeNull() + }) + + it('shows the demo mode and masked launchers when they apply', () => { + show(rulesCard(makeBudgetRules({ enforcement: false, counts_launchers: false, demo: true, masked_launchers: ['/usr/bin/steam', 'lutris'] }))) + expect(screen.getByText(/demo \(short budget/)).toBeInTheDocument() + expect(screen.getByText('/usr/bin/steam, lutris')).toBeInTheDocument() + expect(screen.getByText('no')).toBeInTheDocument() + expect(screen.getByText('off')).toBeInTheDocument() + }) +}) diff --git a/web/src/sections/gaming/cards.ts b/web/src/sections/gaming/cards.ts new file mode 100644 index 0000000..b241c26 --- /dev/null +++ b/web/src/sections/gaming/cards.ts @@ -0,0 +1,181 @@ +// The four budget panel sections (were BudgetTodayCard, BudgetLiveCard, +// BudgetHistoryChart and BudgetRulesCard.tsx) as pure DOM builders. + +import { seriesClass } from '../../budgetSeries' +import { fmtAgo, fmtDuration } from '../../format' +import { budgetHistorySvg } from '../../lib/charts/budgetHistory' +import { h } from '../../lib/dom' +import type { BudgetHistoryDay, BudgetLegendEntry, BudgetRules, BudgetSession, BudgetToday } from '../../types' + +/** Which colour the meter takes: normal, into the warning band, or spent. + * + * Keyed on warnings already fired rather than on `next_warning_seconds`: that + * field is by construction always *below* the remaining time, so comparing the + * two could never turn the meter amber. "The enforcer has warned you" is also + * the thing the colour is actually trying to say. + */ +function meterState(today: BudgetToday): string { + if (today.blocked) return 'blocked' + return today.warned_seconds.length > 0 ? 'warn' : 'ok' +} + +function warningLine(today: BudgetToday, maskedCount: number): HTMLElement | string { + if (today.blocked) { + // Read the live mount count rather than inferring it from `blocked`: the + // total-block branch releases every mount while leaving `blocked_at` set, + // so the two can genuinely disagree, and a badge claiming a mask that is + // not there is the one thing this panel must never do. + return h( + 'span', + { class: 'budget-badge budget-badge--blocked' }, + maskedCount > 0 ? 'Cutoff engaged — launchers are masked' : 'Cutoff engaged — launchers are not currently masked', + ) + } + return today.next_warning_seconds === null + ? 'No further warnings today.' + : `Next warning at ${fmtDuration(today.next_warning_seconds)} remaining.` +} + +export function todayCard(today: BudgetToday, legend: readonly BudgetLegendEntry[], maskedCount: number): HTMLElement { + // Colour by the chart's legend position so a game reads the same in both + // places; today can hold games the capped 14-day legend left out, and those + // fall back to their own row order. + const indexFor = new Map(legend.map((e, i) => [e.key, i])) + const fill = h('div', { class: 'budget-meter-fill' }) + fill.style.width = `${(today.fraction_used * 100).toFixed(1)}%` + return h( + 'section', + { class: 'budget-today' }, + h('h2', {}, 'Today'), + h('p', { class: 'budget-hero' }, h('strong', {}, fmtDuration(today.seconds_remaining)), ' left'), + h('div', { class: `budget-meter budget-meter--${meterState(today)}` }, fill), + h( + 'p', + { class: 'hint' }, + `${fmtDuration(today.seconds_used)} of ${fmtDuration(today.budget_seconds)} used · ` + + `${Math.round(today.fraction_used * 100)}% · gaming day ${today.gaming_day} (starts ${today.day_starts_at})`, + ), + h('p', { class: 'hint' }, warningLine(today, maskedCount)), + today.games.length > 0 + ? [ + h('h3', { class: 'budget-subhead' }, 'Today by game'), + h( + 'ul', + { class: 'budget-games' }, + today.games.map((game, i) => + h( + 'li', + {}, + h('span', { class: `swatch ${seriesClass(game.key, indexFor.get(game.key) ?? i)}`, 'aria-hidden': 'true' }), + h('span', { class: 'budget-game-name' }, game.label), + h('span', { class: 'budget-game-time' }, fmtDuration(game.seconds)), + h('span', { class: 'budget-game-share' }, `${Math.round(game.fraction * 100)}%`), + ), + ), + ), + ] + : null, + ) +} + +/** Shown in place of `todayCard` when the state file cannot be read. */ +export function todayUnavailable(error: string | null): HTMLElement { + return h('section', { class: 'budget-today' }, h('h2', {}, 'Today'), h('p', { class: 'error' }, error ?? 'Budget state unavailable.')) +} + +const VERDICT_LABELS = new Map([ + ['engaged', 'Billing — you are playing'], + ['not_applicable', 'Not billing — no game running'], +]) + +export function liveCard(session: BudgetSession): HTMLElement { + const title = h('h2', {}, 'Right now') + if (!session.available) { + return h('section', { class: 'budget-live' }, title, h('p', { class: 'hint' }, 'No verdict has been logged yet.')) + } + return h( + 'section', + { class: 'budget-live' }, + title, + h( + 'p', + {}, + h('span', { class: `budget-verdict budget-verdict--${session.state}` }, VERDICT_LABELS.get(session.state) ?? session.state), + ), + session.qualifying_count > 0 + ? h( + 'p', + { class: 'hint' }, + session.game_name ? ['Assigned game ', h('strong', {}, session.game_name), ' · '] : null, + `${session.qualifying_count} qualifying processes`, + ) + : null, + // The assignment above is what the enforcer told you to play; this is + // what the budget charged. A counted non-Steam game is never the + // assignment, so the two legitimately differ. The key is the *last* one + // credited, so it must not claim to be live once nothing qualifies. + session.billing_label + ? h( + 'p', + { class: 'hint' }, + session.state === 'engaged' ? 'Billing to ' : 'Last billed to ', + h('strong', {}, session.billing_label), + ) + : null, + // The daemon logs on change or a 5-minute heartbeat, so this reading can + // legitimately be minutes old. Saying so beats implying "now". + h('p', { class: 'hint' }, `as of ${fmtAgo(session.observed_at)}`), + ) +} + +export function historyCard(days: readonly BudgetHistoryDay[], legend: readonly BudgetLegendEntry[], budgetSeconds: number): HTMLElement { + const title = h('h2', {}, 'Last 14 days') + if (days.length === 0) { + return h( + 'div', + { class: 'chart' }, + title, + h('p', { class: 'hint' }, 'No budget history recorded yet — a bar appears for each gaming day from now on.'), + ) + } + // A