diff --git a/.gitignore b/.gitignore index e50688c..f529101 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ build/ builddir/ meson-logs/ meson-private/ +/web/node_modules/ +/web/dist/ +/web/coverage/ # Android / Gradle .gradle/ diff --git a/README.md b/README.md index f9290e3..2ea8dde 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,27 @@ Desktop builds require: headers; - Python 3 for generated sources, validators, import/export helpers, and tests. +The embedded Web frontend additionally uses Node and npm at build time only. +Prepare its exact locked dependencies explicitly, then enable the release +frontend build: + +```bash +cd web +npm ci +npm run typecheck +npm test +cd .. +meson setup build -Dweb=enabled +meson compile -C build +``` + +Meson never runs `npm install` or downloads packages. The default `web=auto` +embeds the frontend when Node, npm, and the prepared `web/node_modules` are +available; otherwise it preserves a desktop-only build and `trainlog -w` +reports that frontend support is absent. Release builds must use +`-Dweb=enabled`. `-Dweb=disabled` is the explicit desktop-only choice. Node, +npm, `node_modules`, and `web/dist` are not runtime dependencies. + Android builds require JDK 17, an Android SDK supporting the configured API levels, and the Gradle wrapper committed in this repository. People installing the APK do not need Java, Gradle, or the Android SDK. @@ -231,7 +252,8 @@ As of 2026-09-15: - `APP_SHELL_V1=IMPLEMENTED_AWAITING_VISUAL_REVIEW_2`; - `TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING` pending a real Drive plus Android-triggered bidirectional smoke test. -- `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`. +- `WEB_FRONTEND_SHELL_V1=PASS/FROZEN` and + `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`. The latest executable result belongs in [current state](docs/current_state.md), not in multiple README narratives. diff --git a/docs/architecture.md b/docs/architecture.md index 290374e..e2237cf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -207,8 +207,8 @@ the process event-loop thread. A concrete IPv4 `sockaddr_in` binds only handlers only set a `sig_atomic_t` flag. The loop then stops libmicrohttpd, returns to the process owner and lets it close the database. -The only implemented route is read-only `GET /api/v1/health`; `/` and every -unknown route return 404, and other methods on health return 405. The adapter +The read-only technical route remains `GET /api/v1/health`; unknown API routes +return JSON 404 and never fall through to the SPA. The adapter bounds concurrent/per-IP connections at 32, listen backlog at 32, per-connection memory at 16 KiB, decoded request headers at 8 KiB, request bodies at 4 KiB and idle time at 10 seconds. It validates `Host` against `127.0.0.1` and the active @@ -217,6 +217,29 @@ and no permissive CORS header. Assets, telemetry and remote resources are absent. Before the first mutation route, origin validation and explicit CSRF protection remain mandatory in addition to these transport bounds. +`WEB_FRONTEND_SHELL_V1=PASS/FROZEN` adds the sibling `web/` React 19, +TypeScript and Vite application. A History API router owned by Trainlog handles +the five fixed shell destinations without another runtime dependency. `/`, +`/analyse`, `/programmes`, `/seances`, and `/exercices` receive the embedded +`index.html`; exact generated asset URLs receive only their manifest bytes. +Unknown assets and traversal attempts are 404. `HEAD` is supported for UI +assets; other asset methods are 405. API dispatch remains separate. + +Vite writes only into the Meson build directory. A bounded deterministic +Python generator sorts at most 128 known files, rejects files over 4 MiB or a +manifest over 16 MiB, assigns MIME and SHA-256 ETag metadata, then emits static +C arrays linked into `trainlog`. Runtime serving performs exact URL lookup and +never reads a filesystem path. HTML uses `no-cache`; hash-named Vite assets use +one-year immutable caching. The CSP permits only same-origin scripts, styles, +connections and images, with no `unsafe-inline` or `unsafe-eval`. + +The Meson feature option `web=auto|enabled|disabled` separates provisioning +from compilation. Meson never installs npm packages. `auto` embeds assets only +when Node, npm and prepared `web/node_modules` exist; `enabled` makes those +build prerequisites mandatory and is required for releases; `disabled` +produces a desktop-only binary whose Web launch fails explicitly. An embedded +binary requires neither Node/npm nor `web/dist` at runtime. + Every Web page retains one application shell: - Header navigation: Dashboard, Analyse, Programmes, Séances, Exercices; @@ -235,7 +258,11 @@ the architecture. React, TypeScript, Vite, and Apache ECharts are the intended frontend stack. Node and npm are build-only dependencies; installed Trainlog must not require either. All production assets are local, built ahead of time, and incorporated into the `trainlog` binary from the sibling root `web/` -directory. The grid library remains open pending a dedicated spike. +directory. The shell implements semantic Header/Body/Footer landmarks, +keyboard-visible focus, responsive tile scaffolding and reduced-motion rules. +Absent user/session/zone facts remain visibly unavailable. The grid library +remains open pending a dedicated spike; ECharts remains deferred until a real +visualization exists. The Dashboard is a canonical 12-column grid containing exactly seven V1 tiles: diff --git a/docs/current_state.md b/docs/current_state.md index 9638057..ce65585 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -11,9 +11,10 @@ closed incidents belong in [reviews](reviews/) and the Android is the field companion: it captures training and body data, preserves the active draft, receives AI proposals, triggers synchronization, and shows quick summaries. The C17/Notcurses TUI is the administration, inspection, -maintenance, import/export, and technical-tooling surface. The documented but -not yet implemented local Web sibling will own analysis, visualization, and -program/session preparation through Trainlog Core. +maintenance, import/export, and technical-tooling surface. The local Web +sibling now provides its embedded application shell; its future business +surfaces will own analysis, visualization, and program/session preparation +through Trainlog Core. Trainlog Core owns business truth. The desktop SQLite database remains the canonical local source of truth and long-term history; no interface owns a @@ -40,6 +41,7 @@ parallel implementation of its rules. | Dashboard Core read model | `WEB_DASHBOARD_CORE_READ_MODEL_V1=PASS/FROZEN` | | TUI Dashboard adoption | `WEB_TUI_READ_MODEL_ADOPTION_V1=PASS/FROZEN` | | Web CLI/HTTP infrastructure | `WEB_CLI_HTTP_INFRASTRUCTURE_V1=PASS/FROZEN` | +| Web frontend shell | `WEB_FRONTEND_SHELL_V1=PASS/FROZEN` | | Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` | Desktop and Android schema numbers are independent. Neither changes the frozen @@ -137,10 +139,12 @@ the result. No Dashboard HTTP endpoint exists yet. `TRAINLOG_WEB_V1` architecture, API independence, local-network boundary, browser shell, Dashboard, layout ownership, build/runtime separation, and -security invariants are canonical. The CLI and local HTTP infrastructure are -implemented: `-w`/`--web`, optional `--port`, exact IPv4 loopback binding and -the technical `/api/v1/health` route. No frontend, embedded asset, Dashboard -route, business mutation, browser launch or layout persistence exists yet. +security invariants are canonical. The CLI/local HTTP infrastructure and +embedded frontend shell are implemented: `-w`/`--web`, optional `--port`, exact +IPv4 loopback binding, `/api/v1/health`, React navigation and the persistent +Header/Body/Footer shell. Production assets are generated from the npm lockfile +and linked into the binary. No Dashboard business route, real metric, grid +engine, business mutation, browser launch or layout persistence exists yet. ## Data semantics @@ -176,6 +180,11 @@ The CLI/HTTP infrastructure tranche passes **67/67 normal Meson tests**, adding pure CLI parsing, process-level help/version checks and an isolated loopback HTTP lifecycle/security suite. The same suite is required under ASan/UBSan. +The frontend shell tranche passes **70/70 normal and ASan/UBSan Meson tests**, +including TypeScript typecheck, 10 Vitest component tests, deterministic asset +generation bounds, real embedded-asset HTTP coverage, SPA/API separation and +runtime execution without Node/npm or `web/dist`. + `TRAINLOG_I18N_V0_1_1=PASS` is covered by desktop presentation, persistence, formatting, layout-invariance and source-derived text-boundary tests, plus Android resource-parity, language-owner, typed sync-presentation, and @@ -188,8 +197,9 @@ smoke test are not automated. The latter is why ## Active limitations -- `TRAINLOG_WEB_V1` has only its Core prerequisite and CLI/HTTP infrastructure; - the next cursor is the frontend shell. No Dashboard HTTP route exists. +- `TRAINLOG_WEB_V1` has its Core prerequisite, CLI/HTTP infrastructure and + embedded frontend shell. The next cursor begins the explicitly partitioned + Dashboard V1 work; no Dashboard HTTP route or real Web metric exists yet. - `APP_SHELL_V1` still awaits the recorded human visual/accessibility review. - AI proposal exchange still awaits one real Drive plus Android-triggered bidirectional smoke test. diff --git a/docs/roadmap.md b/docs/roadmap.md index a4d3360..e9d0dc5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -25,19 +25,20 @@ Zones V1, Training Feedback V1/V2, STATS V1, and Session Generator V1 are implemented. Session Generator V1 is hidden pending V2. AI session-draft exchange and APP_SHELL_V1 retain their explicit manual validation/review gates. `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`; its local-only -CLI/HTTP infrastructure exists, but no frontend or business endpoint exists. +CLI/HTTP infrastructure and embedded frontend shell exist, but no Dashboard +business endpoint or real Web metric exists. ## Current cursor ```text -CURRENT_OPERATIONAL_CURSOR=WEB_FRONTEND_SHELL_V1 +CURRENT_OPERATIONAL_CURSOR=WEB_DASHBOARD_V1 ``` -`WEB_CLI_HTTP_INFRASTRUCTURE_V1=PASS/FROZEN`. `trainlog -w` and `--web` now -dispatch through the common process owner to a bounded, single-threaded -loopback HTTP adapter after one database open. Only `/api/v1/health` exists. -The next slice is the frontend shell and embedded production assets; it does -not authorize Dashboard business routes or new metrics. +`WEB_FRONTEND_SHELL_V1=PASS/FROZEN`. `trainlog -w` serves the embedded React, +TypeScript and Vite shell with its five client routes, Catppuccin Mocha design +system, permanent Header/Footer, empty-data states and health status. The next +Dashboard tranche is partitioned below; this cursor does not authorize an +undifferentiated implementation of every Dashboard concern. ## TRAINLOG_WEB_V1 implementation gate @@ -48,7 +49,7 @@ WEB_DASHBOARD_CHARACTERIZATION_V1 -> WEB_DASHBOARD_CORE_READ_MODEL_V1 [PASS/FROZEN] -> WEB_TUI_READ_MODEL_ADOPTION_V1 [PASS/FROZEN] -> WEB_CLI_HTTP_INFRASTRUCTURE_V1 [PASS/FROZEN] - -> WEB_FRONTEND_SHELL_V1 + -> WEB_FRONTEND_SHELL_V1 [PASS/FROZEN] -> WEB_DASHBOARD_V1 ``` @@ -64,6 +65,20 @@ single serialized Core/SQLite owner, build-only Node/npm, and no change to business SQLite or frozen exchange formats. Dashboard Activité and Progression mathematics remain specification gates before their read models. +`WEB_DASHBOARD_V1` must advance through independently reviewable slices: + +```text +missing Dashboard API/read models + -> grid-library spike and bounded grid engine + -> separately versioned layout persistence + -> real-data tile delivery + -> real-data visualization delivery +``` + +The slice order may be refined by the next contract, but it must preserve the +existing Core ownership, keep visual layout outside business SQLite and frozen +formats, and specify Activité/Progression before implementing those metrics. + ## Next ### Gym catalog V1 diff --git a/docs/tests.md b/docs/tests.md index d3b9f08..ee7c8da 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -27,6 +27,34 @@ SIGTERM shutdown and explicit failure on an already occupied port. It never uses the Internet. A separate real-launch smoke uses an isolated XDG directory, performs the health request and stops the production binary with SIGINT. +`WEB_FRONTEND_SHELL_V1` adds TypeScript typechecking and 10 Vitest/jsdom +component tests for the persistent shell, five client destinations, active +navigation, browser history, unavailable factual fields, successful/failed/ +invalid health responses and accessible landmarks. Production Vite output is +then generated and embedded for the C HTTP test, which additionally verifies +HTML root and SPA routes, JS/CSS MIME, HTML and immutable cache policies, +SHA-256 ETag, strict CSP, HEAD, unknown assets, traversal rejection and the +invariant that unknown `/api/` paths never receive `index.html`. + +The generator contract test proves byte-for-byte deterministic output, stable +path ordering and the 128-asset bound. Runtime smoke coverage starts the linked +binary with Node/npm absent from `PATH`, removes any dependency on `web/dist`, +and checks root, health and SIGINT. A separate `-Dweb=disabled` build verifies +the explicit diagnostic instead of pretending that a frontend exists. + +Frontend preparation and direct validation are: + +```bash +cd web +npm ci +npm run typecheck +npm test +npm run build +``` + +Release-oriented Meson builds must use `-Dweb=enabled`; Meson never provisions +or downloads npm dependencies. + ## 2. Frozen Trainlog JSON v1 Run: diff --git a/meson_options.txt b/meson_options.txt new file mode 100644 index 0000000..49d99ea --- /dev/null +++ b/meson_options.txt @@ -0,0 +1,6 @@ +option( + 'web', + type: 'feature', + value: 'auto', + description: 'Build and embed the Trainlog Web frontend', +) diff --git a/tools/build_web_frontend.py b/tools/build_web_frontend.py new file mode 100644 index 0000000..b2e0dac --- /dev/null +++ b/tools/build_web_frontend.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Build the locked Trainlog frontend into a Meson-owned directory.""" + +from __future__ import annotations + +import pathlib +import shutil +import subprocess +import sys + + +def main() -> int: + if len(sys.argv) != 5: + raise SystemExit("usage: build_web_frontend.py NPM WEB_ROOT DIST STAMP") + npm = pathlib.Path(sys.argv[1]) + web_root = pathlib.Path(sys.argv[2]).resolve() + dist = pathlib.Path(sys.argv[3]).resolve() + stamp = pathlib.Path(sys.argv[4]).resolve() + if not (web_root / "package-lock.json").is_file(): + raise SystemExit("package-lock.json absent") + if not (web_root / "node_modules").is_dir(): + raise SystemExit("web/node_modules absent; exécutez 'npm ci' dans web/") + if dist.exists(): + shutil.rmtree(dist) + subprocess.run( + [str(npm), "run", "build", "--", "--outDir", str(dist), "--emptyOutDir"], + cwd=web_root, + check=True, + ) + if not (dist / "index.html").is_file(): + raise SystemExit("Vite n'a pas produit index.html") + stamp.write_text("trainlog-web-build-v1\n", encoding="ascii") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/generate_web_assets.py b/tools/generate_web_assets.py new file mode 100644 index 0000000..daf18d2 --- /dev/null +++ b/tools/generate_web_assets.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Generate a deterministic, bounded C manifest from a Vite dist tree.""" + +from __future__ import annotations + +import hashlib +import pathlib +import sys + +MAX_ASSETS = 128 +MAX_ASSET_BYTES = 4 * 1024 * 1024 +MAX_TOTAL_BYTES = 16 * 1024 * 1024 +MIME = { + ".css": "text/css; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".ico": "image/x-icon", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".png": "image/png", + ".svg": "image/svg+xml", + ".webp": "image/webp", +} + + +def c_string(value: str) -> str: + return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def byte_array(name: str, data: bytes) -> str: + lines = [] + for offset in range(0, len(data), 12): + chunk = data[offset : offset + 12] + lines.append(" " + ", ".join(f"0x{value:02x}" for value in chunk) + ",") + return f"static const unsigned char {name}[] = {{\n" + "\n".join(lines) + "\n};\n" + + +def main() -> int: + if len(sys.argv) != 3: + raise SystemExit("usage: generate_web_assets.py DIST OUTPUT_C") + root = pathlib.Path(sys.argv[1]).resolve() + output = pathlib.Path(sys.argv[2]) + files = sorted(path for path in root.rglob("*") if path.is_file()) + if not files or len(files) > MAX_ASSETS: + raise SystemExit(f"nombre d'assets invalide: {len(files)}") + assets: list[tuple[str, str, bytes, str, bool]] = [] + total = 0 + for path in files: + relative = path.relative_to(root) + if any(part in ("", ".", "..") for part in relative.parts): + raise SystemExit(f"chemin d'asset invalide: {relative}") + mime = MIME.get(path.suffix.lower()) + if mime is None: + raise SystemExit(f"type d'asset non autorisé: {relative}") + data = path.read_bytes() + if len(data) > MAX_ASSET_BYTES: + raise SystemExit(f"asset trop grand: {relative}") + total += len(data) + if total > MAX_TOTAL_BYTES: + raise SystemExit("taille totale des assets dépassée") + url = "/" + relative.as_posix() + etag = '"sha256-' + hashlib.sha256(data).hexdigest() + '"' + immutable = relative.parts[0] == "assets" + assets.append((url, mime, data, etag, immutable)) + if not any(url == "/index.html" for url, *_ in assets): + raise SystemExit("index.html absent du manifeste") + + parts = [ + "/* Generated deterministically by tools/generate_web_assets.py. */\n", + '#include "web_assets.h"\n\n', + "#include \n\n", + ] + for index, (_, _, data, _, _) in enumerate(assets): + parts.append(byte_array(f"trainlog_web_data_{index}", data)) + parts.append("\n") + parts.append("static const TrainlogWebAsset trainlog_web_assets[] = {\n") + for index, (url, mime, data, etag, immutable) in enumerate(assets): + parts.append( + f" {{{c_string(url)}, {c_string(mime)}, trainlog_web_data_{index}, " + f"{len(data)}U, {c_string(etag)}, {'true' if immutable else 'false'}}},\n" + ) + parts.append("};\n\n") + parts.append( + "bool trainlog_web_assets_available(void) { return true; }\n\n" + "const TrainlogWebAsset *trainlog_web_asset_find(const char *path)\n" + "{\n" + " size_t index;\n" + " if (path == NULL) return NULL;\n" + " for (index = 0U; index < sizeof(trainlog_web_assets) /\n" + " sizeof(trainlog_web_assets[0]); ++index)\n" + " if (strcmp(path, trainlog_web_assets[index].path) == 0)\n" + " return &trainlog_web_assets[index];\n" + " return NULL;\n" + "}\n\n" + "const TrainlogWebAsset *trainlog_web_index_asset(void)\n" + "{\n" + " return trainlog_web_asset_find(\"/index.html\");\n" + "}\n" + ) + output.write_text("".join(parts), encoding="utf-8", newline="\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/test_generate_web_assets.py b/tools/test_generate_web_assets.py new file mode 100644 index 0000000..720186e --- /dev/null +++ b/tools/test_generate_web_assets.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Contract tests for the bounded deterministic Web asset generator.""" + +from __future__ import annotations + +import pathlib +import subprocess +import sys +import tempfile + + +def run(generator: pathlib.Path, root: pathlib.Path, output: pathlib.Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(generator), str(root), str(output)], + check=False, + text=True, + capture_output=True, + ) + + +def main() -> int: + if len(sys.argv) != 2: + raise SystemExit("usage: test_generate_web_assets.py GENERATOR") + generator = pathlib.Path(sys.argv[1]).resolve() + with tempfile.TemporaryDirectory(prefix="trainlog-web-assets-") as temporary: + base = pathlib.Path(temporary) + dist = base / "dist" + (dist / "assets").mkdir(parents=True) + (dist / "index.html").write_text("", encoding="utf-8") + (dist / "assets" / "z.js").write_text("export const z=1;", encoding="utf-8") + (dist / "assets" / "a.css").write_text(":root{color:#cdd6f4}", encoding="utf-8") + first = base / "first.c" + second = base / "second.c" + if run(generator, dist, first).returncode != 0 or run(generator, dist, second).returncode != 0: + return 1 + first_bytes = first.read_bytes() + if first_bytes != second.read_bytes(): + return 1 + text = first_bytes.decode("utf-8") + if text.index('"/assets/a.css"') > text.index('"/assets/z.js"'): + return 1 + + excessive = base / "excessive" + excessive.mkdir() + (excessive / "index.html").write_text("ok", encoding="utf-8") + for index in range(128): + (excessive / f"asset-{index:03d}.js").write_text("0", encoding="ascii") + if run(generator, excessive, base / "excessive.c").returncode == 0: + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tui/meson.build b/tui/meson.build index 2b7101f..d9b36f8 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -1,4 +1,5 @@ cc = meson.get_compiler('c') +fs = import('fs') sqlite3_dep = dependency('sqlite3', required: true) uuid_dep = dependency('uuid', required: true) @@ -16,6 +17,86 @@ endif m_dep = cc.find_library('m', required: true) trainlog_include = include_directories('include') +trainlog_private_include = include_directories('src') + +web_option = get_option('web') +node = find_program('node', required: web_option.enabled()) +npm = find_program('npm', required: web_option.enabled()) +web_dependencies_ready = fs.is_dir(meson.project_source_root() / 'web/node_modules') +if web_option.enabled() and not web_dependencies_ready + error('Web enabled but web/node_modules is absent; run npm ci in web/') +endif +web_assets_enabled = not web_option.disabled() and node.found() and npm.found() and web_dependencies_ready +if web_option.auto() and not web_assets_enabled + warning('Web frontend disabled in auto mode; install Node/npm and run npm ci in web/') +endif + +if web_assets_enabled + frontend_sources = files( + '../web/package.json', + '../web/package-lock.json', + '../web/tsconfig.json', + '../web/tsconfig.app.json', + '../web/tsconfig.node.json', + '../web/vite.config.ts', + '../web/index.html', + '../web/src/main.tsx', + '../web/src/vite-env.d.ts', + '../web/src/api/health.ts', + '../web/src/app/App.tsx', + '../web/src/app/routes.ts', + '../web/src/app/useRoute.ts', + '../web/src/components/EmptyState.tsx', + '../web/src/components/Footer.tsx', + '../web/src/components/Header.tsx', + '../web/src/components/Tile.tsx', + '../web/src/routes/DashboardPage.tsx', + '../web/src/routes/PlaceholderPage.tsx', + '../web/src/theme/app.css', + '../web/src/theme/tokens.css', + ) + web_dist = meson.current_build_dir() / 'web-dist' + web_frontend_build = custom_target( + 'web_frontend_build', + input: meson.project_source_root() / 'web/package-lock.json', + output: 'web-frontend.stamp', + depend_files: frontend_sources, + command: [ + find_program('python3'), + meson.project_source_root() / 'tools/build_web_frontend.py', + npm, + meson.project_source_root() / 'web', + web_dist, + '@OUTPUT@', + ], + ) + web_assets_source = custom_target( + 'web_assets_generated', + input: web_frontend_build, + output: 'web_assets_generated.c', + depend_files: meson.project_source_root() / 'tools/generate_web_assets.py', + command: [ + find_program('python3'), + meson.project_source_root() / 'tools/generate_web_assets.py', + web_dist, + '@OUTPUT@', + ], + ) + test( + 'web_frontend_typecheck', + npm, + args: ['run', 'typecheck'], + workdir: meson.project_source_root() / 'web', + ) + test( + 'web_frontend', + npm, + args: ['test'], + workdir: meson.project_source_root() / 'web', + ) +else + web_assets_source = files('src/web_assets_disabled.c') +endif equipment_catalog_generated = custom_target( 'equipment_catalog_generated', @@ -154,6 +235,7 @@ trainlog_tui_sources = files( 'src/tui.c', 'src/web_server.c', ) +trainlog_tui_sources += web_assets_source trainlog_exe = executable( 'trainlog', @@ -163,6 +245,7 @@ trainlog_exe = executable( notcurses_dep, microhttpd_dep, ], + include_directories: trainlog_private_include, c_args: strict_c_args, install: true, ) @@ -233,8 +316,9 @@ test('cli', test_cli) test_web_server = executable( 'test_web_server', - ['tests/test_web_server.c', 'src/web_server.c'], + ['tests/test_web_server.c', 'src/web_server.c', web_assets_source], dependencies: [trainlog_core_dep, microhttpd_dep], + include_directories: trainlog_private_include, c_args: strict_c_args, ) test('web_server', test_web_server, timeout: 30) @@ -248,6 +332,14 @@ test_web_headers = executable( c_args: strict_c_args, ) test('web_headers', test_web_headers) +test( + 'web_asset_generator', + find_program('python3'), + args: [ + meson.project_source_root() / 'tools/test_generate_web_assets.py', + meson.project_source_root() / 'tools/generate_web_assets.py', + ], +) test_training_knowledge = executable( 'test_training_knowledge', diff --git a/tui/src/web_assets.h b/tui/src/web_assets.h new file mode 100644 index 0000000..7033b3f --- /dev/null +++ b/tui/src/web_assets.h @@ -0,0 +1,22 @@ +#ifndef TRAINLOG_WEB_ASSETS_H +#define TRAINLOG_WEB_ASSETS_H + +#include +#include + +typedef struct TrainlogWebAsset { + const char *path; + const char *mime; + const unsigned char *bytes; + size_t size; + const char *etag; + bool immutable; +} TrainlogWebAsset; + +/* CONTRACT: generated assets have static process lifetime. Lookups are exact + * canonical URL matches; callers never derive filesystem paths at runtime. */ +bool trainlog_web_assets_available(void); +const TrainlogWebAsset *trainlog_web_asset_find(const char *path); +const TrainlogWebAsset *trainlog_web_index_asset(void); + +#endif diff --git a/tui/src/web_assets_disabled.c b/tui/src/web_assets_disabled.c new file mode 100644 index 0000000..0fe84ae --- /dev/null +++ b/tui/src/web_assets_disabled.c @@ -0,0 +1,17 @@ +#include "web_assets.h" + +bool trainlog_web_assets_available(void) +{ + return false; +} + +const TrainlogWebAsset *trainlog_web_asset_find(const char *path) +{ + (void)path; + return NULL; +} + +const TrainlogWebAsset *trainlog_web_index_asset(void) +{ + return NULL; +} diff --git a/tui/src/web_server.c b/tui/src/web_server.c index 55dbeaf..0f6325d 100644 --- a/tui/src/web_server.c +++ b/tui/src/web_server.c @@ -10,6 +10,8 @@ #include #include +#include "web_assets.h" + #define TRAINLOG_HTTP_CONNECTION_LIMIT 32U #define TRAINLOG_HTTP_PER_IP_LIMIT 32U #define TRAINLOG_HTTP_CONNECTION_MEMORY_LIMIT (16U * 1024U) @@ -35,7 +37,7 @@ static void web_diagnostic(char *output, size_t capacity, const char *message) } static enum MHD_Result queue_json(struct MHD_Connection *connection, - unsigned int status, const char *body) + unsigned int status, const char *body, const char *allow) { struct MHD_Response *response; enum MHD_Result result; @@ -56,9 +58,8 @@ static enum MHD_Result queue_json(struct MHD_Connection *connection, MHD_destroy_response(response); return MHD_NO; } - if (status == MHD_HTTP_METHOD_NOT_ALLOWED && - MHD_add_response_header(response, MHD_HTTP_HEADER_ALLOW, "GET") != - MHD_YES) { + if (allow != NULL && MHD_add_response_header(response, + MHD_HTTP_HEADER_ALLOW, allow) != MHD_YES) { MHD_destroy_response(response); return MHD_NO; } @@ -67,6 +68,49 @@ static enum MHD_Result queue_json(struct MHD_Connection *connection, return result; } +static enum MHD_Result queue_asset(struct MHD_Connection *connection, + const TrainlogWebAsset *asset) +{ + static const char CSP[] = "default-src 'self'; script-src 'self'; " + "style-src 'self'; connect-src 'self'; img-src 'self'; " + "object-src 'none'; base-uri 'none'; frame-ancestors 'none'"; + const char *cache = asset->immutable + ? "public, max-age=31536000, immutable" : "no-cache"; + struct MHD_Response *response = MHD_create_response_from_buffer(asset->size, + (void *)asset->bytes, MHD_RESPMEM_PERSISTENT); + enum MHD_Result result; + if (response == NULL) return MHD_NO; + if (MHD_add_response_header(response, MHD_HTTP_HEADER_CONTENT_TYPE, + asset->mime) != MHD_YES || + MHD_add_response_header(response, + MHD_HTTP_HEADER_X_CONTENT_TYPE_OPTIONS, "nosniff") != MHD_YES || + MHD_add_response_header(response, MHD_HTTP_HEADER_CACHE_CONTROL, + cache) != MHD_YES || + MHD_add_response_header(response, MHD_HTTP_HEADER_ETAG, + asset->etag) != MHD_YES || + MHD_add_response_header(response, "Content-Security-Policy", CSP) != + MHD_YES || + MHD_add_response_header(response, MHD_HTTP_HEADER_CONNECTION, + "close") != MHD_YES) { + MHD_destroy_response(response); + return MHD_NO; + } + result = MHD_queue_response(connection, MHD_HTTP_OK, response); + MHD_destroy_response(response); + return result; +} + +static bool is_ui_route(const char *url) +{ + static const char *const routes[] = { + "/", "/analyse", "/programmes", "/seances", "/exercices" + }; + size_t index; + for (index = 0U; index < sizeof(routes) / sizeof(routes[0]); ++index) + if (strcmp(url, routes[index]) == 0) return true; + return false; +} + static bool valid_host(const TrainlogWebContext *context, const char *host) { char expected[32]; @@ -103,7 +147,10 @@ static enum MHD_Result handle_request(void *closure, TrainlogWebContext *context = closure; TrainlogHttpRequestState *state = *request_closure; const union MHD_ConnectionInfo *header_info; + const TrainlogWebAsset *asset; const char *host; + bool is_get; + bool is_head; (void)version; if (state == NULL) { state = calloc(1U, sizeof(*state)); @@ -127,22 +174,39 @@ static enum MHD_Result handle_request(void *closure, if (header_info == NULL || header_info->header_size > TRAINLOG_HTTP_HEADER_LIMIT) return queue_json(connection, MHD_HTTP_REQUEST_HEADER_FIELDS_TOO_LARGE, - "{\"error\":\"headers_too_large\"}\n"); + "{\"error\":\"headers_too_large\"}\n", NULL); if (state->body_too_large) return queue_json(connection, MHD_HTTP_CONTENT_TOO_LARGE, - "{\"error\":\"body_too_large\"}\n"); + "{\"error\":\"body_too_large\"}\n", NULL); host = MHD_lookup_connection_value(connection, MHD_HEADER_KIND, MHD_HTTP_HEADER_HOST); if (!valid_host(context, host)) return queue_json(connection, MHD_HTTP_BAD_REQUEST, - "{\"error\":\"invalid_host\"}\n"); - if (strcmp(url, "/api/v1/health") != 0) + "{\"error\":\"invalid_host\"}\n", NULL); + is_get = strcmp(method, MHD_HTTP_METHOD_GET) == 0; + is_head = strcmp(method, MHD_HTTP_METHOD_HEAD) == 0; + if (strcmp(url, "/api/v1/health") == 0) { + if (!is_get) + return queue_json(connection, MHD_HTTP_METHOD_NOT_ALLOWED, + "{\"error\":\"method_not_allowed\"}\n", "GET"); + return queue_json(connection, MHD_HTTP_OK, HEALTH, NULL); + } + /* INVARIANT: API paths never fall through to the SPA index. */ + if (strncmp(url, "/api/", 5U) == 0) return queue_json(connection, MHD_HTTP_NOT_FOUND, - "{\"error\":\"not_found\"}\n"); - if (strcmp(method, MHD_HTTP_METHOD_GET) != 0) + "{\"error\":\"not_found\"}\n", NULL); + if (strstr(url, "..") != NULL) + return queue_json(connection, MHD_HTTP_NOT_FOUND, + "{\"error\":\"not_found\"}\n", NULL); + asset = is_ui_route(url) ? trainlog_web_index_asset() + : trainlog_web_asset_find(url); + if (asset == NULL) + return queue_json(connection, MHD_HTTP_NOT_FOUND, + "{\"error\":\"not_found\"}\n", NULL); + if (!is_get && !is_head) return queue_json(connection, MHD_HTTP_METHOD_NOT_ALLOWED, - "{\"error\":\"method_not_allowed\"}\n"); - return queue_json(connection, MHD_HTTP_OK, HEALTH); + "{\"error\":\"method_not_allowed\"}\n", "GET, HEAD"); + return queue_asset(connection, asset); } static void request_completed(void *closure, struct MHD_Connection *connection, @@ -176,6 +240,11 @@ int trainlog_web_server_run(TrainlogDatabase *database, uint16_t port, int result = 0; if (database == NULL || stop_requested == NULL) return -1; web_diagnostic(diagnostic, diagnostic_capacity, ""); + if (!trainlog_web_assets_available()) { + web_diagnostic(diagnostic, diagnostic_capacity, + "support frontend absent de ce binaire"); + return -1; + } (void)memset(&bind_address, 0, sizeof(bind_address)); bind_address.sin_family = AF_INET; bind_address.sin_port = htons(port); diff --git a/tui/tests/test_web_server.c b/tui/tests/test_web_server.c index 8e71c54..6b3d3e7 100644 --- a/tui/tests/test_web_server.c +++ b/tui/tests/test_web_server.c @@ -15,6 +15,7 @@ #include "trainlog/database.h" #include "trainlog/web_server.h" +#include "web_assets.h" #define CHECK(value) do { if (!(value)) { \ (void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", \ @@ -97,10 +98,36 @@ static bool exchange(uint16_t port, const char *request, char *response, return true; } +static bool asset_path(const char *html, const char *suffix, char *output, + size_t capacity) +{ + const char *start = html; + size_t suffix_length = strlen(suffix); + while ((start = strstr(start, "/assets/")) != NULL) { + const char *end = start; + size_t length; + while (*end != '\0' && *end != '"' && *end != '\'' && *end != '<' && + *end != '>') ++end; + length = (size_t)(end - start); + if (length >= suffix_length && + memcmp(end - suffix_length, suffix, suffix_length) == 0 && + length < capacity) { + (void)memcpy(output, start, length); + output[length] = '\0'; + return true; + } + start = end; + } + return false; +} + static bool test_http_contract(TrainlogDatabase *database) { char response[32768]; char large_request[12000]; + char request[512]; + char javascript_path[256]; + char stylesheet_path[256]; uint16_t port; pid_t child; int status; @@ -130,7 +157,45 @@ static bool test_http_contract(TrainlogDatabase *database) "{\"api_version\":1,\"status\":\"ok\",\"product\":\"trainlog\"," "\"version\":\"0.1.2\"}") != NULL); CHECK(exchange(port, "GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", + response, sizeof(response)) && strstr(response, "HTTP/1.1 200") != NULL); + CHECK(strstr(response, "Content-Type: text/html; charset=utf-8") != NULL); + CHECK(strstr(response, "Cache-Control: no-cache") != NULL); + CHECK(strstr(response, "Content-Security-Policy: default-src 'self'") != NULL); + CHECK(strstr(response, "unsafe-inline") == NULL); + CHECK(strstr(response, "unsafe-eval") == NULL); + CHECK(strstr(response, "Trainlog") != NULL); + CHECK(asset_path(response, ".js", javascript_path, sizeof(javascript_path))); + CHECK(asset_path(response, ".css", stylesheet_path, sizeof(stylesheet_path))); + (void)snprintf(request, sizeof(request), + "GET %s HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", javascript_path); + CHECK(exchange(port, request, response, sizeof(response)) && + strstr(response, "HTTP/1.1 200") != NULL); + CHECK(strstr(response, "Content-Type: text/javascript; charset=utf-8") != NULL); + CHECK(strstr(response, + "Cache-Control: public, max-age=31536000, immutable") != NULL); + CHECK(strstr(response, "ETag: \"sha256-") != NULL); + (void)snprintf(request, sizeof(request), + "GET %s HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", stylesheet_path); + CHECK(exchange(port, request, response, sizeof(response)) && + strstr(response, "Content-Type: text/css; charset=utf-8") != NULL); + CHECK(exchange(port, + "GET /analyse HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", + response, sizeof(response)) && strstr(response, "Trainlog") != NULL); + CHECK(exchange(port, + "GET /api/v1/unknown HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", + response, sizeof(response)) && strstr(response, "HTTP/1.1 404") != NULL && + strstr(response, "Trainlog") == NULL); + CHECK(exchange(port, + "GET /assets/unknown.js HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", response, sizeof(response)) && strstr(response, "HTTP/1.1 404") != NULL); + CHECK(exchange(port, + "GET /assets/../index.html HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", + response, sizeof(response)) && strstr(response, "HTTP/1.1 404") != NULL); + (void)snprintf(request, sizeof(request), + "HEAD %s HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", javascript_path); + CHECK(exchange(port, request, response, sizeof(response)) && + strstr(response, "HTTP/1.1 200") != NULL && + strstr(response, "Content-Type: text/javascript; charset=utf-8") != NULL); CHECK(exchange(port, "POST /api/v1/health HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 0\r\n\r\n", response, sizeof(response)) && strstr(response, "HTTP/1.1 405") != NULL); @@ -180,7 +245,14 @@ int main(void) bool passed; if (trainlog_database_open(":memory:", &database) != TRAINLOG_STATUS_OK) return 1; - passed = test_http_contract(database) && test_port_in_use(database); + if (!trainlog_web_assets_available()) { + volatile sig_atomic_t stop = 0; + char diagnostic[256]; + passed = trainlog_web_server_run(database, 8080U, &stop, diagnostic, + sizeof(diagnostic)) != 0 && strstr(diagnostic, "frontend") != NULL; + } else { + passed = test_http_contract(database) && test_port_in_use(database); + } trainlog_database_close(database); return passed ? 0 : 1; } diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..bc336f1 --- /dev/null +++ b/web/index.html @@ -0,0 +1,14 @@ + + + + + + + + Trainlog + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..a568e66 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2418 @@ +{ + "name": "trainlog-web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "trainlog-web", + "version": "0.0.0", + "dependencies": { + "react": "19.3.0", + "react-dom": "19.3.0" + }, + "devDependencies": { + "@testing-library/jest-dom": "7.0.1", + "@testing-library/react": "16.3.3", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "6.1.1", + "jsdom": "30.0.1", + "typescript": "7.0.2", + "vite": "8.3.0", + "vitest": "5.0.1" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz", + "integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz", + "integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.4.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz", + "integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.149.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", + "integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", + "integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==", + "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.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz", + "integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==", + "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.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz", + "integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==", + "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.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz", + "integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==", + "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.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz", + "integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==", + "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.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz", + "integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==", + "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.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz", + "integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==", + "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.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz", + "integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==", + "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.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz", + "integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==", + "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.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz", + "integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==", + "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.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz", + "integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==", + "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.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz", + "integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==", + "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.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz", + "integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==", + "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.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz", + "integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==", + "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.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz", + "integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@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", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "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/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz", + "integrity": "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.1", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.1.tgz", + "integrity": "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/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/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", + "peer": true + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "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/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "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/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/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz", + "integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "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/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/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.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "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.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", + "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.149.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.8", + "@rolldown/binding-android-arm64": "1.2.8", + "@rolldown/binding-darwin-arm64": "1.2.8", + "@rolldown/binding-darwin-x64": "1.2.8", + "@rolldown/binding-freebsd-x64": "1.2.8", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", + "@rolldown/binding-linux-arm64-gnu": "1.2.8", + "@rolldown/binding-linux-arm64-musl": "1.2.8", + "@rolldown/binding-linux-ppc64-gnu": "1.2.8", + "@rolldown/binding-linux-s390x-gnu": "1.2.8", + "@rolldown/binding-linux-x64-gnu": "1.2.8", + "@rolldown/binding-linux-x64-musl": "1.2.8", + "@rolldown/binding-openharmony-arm64": "1.2.8", + "@rolldown/binding-win32-arm64-msvc": "1.2.8", + "@rolldown/binding-win32-x64-msvc": "1.2.8" + } + }, + "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/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tldts": { + "version": "7.4.13", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.13.tgz", + "integrity": "sha512-iHtaIWWIbMDkCeJdTBzZFGgbluE5J+oHlb2g7+oAz1S1gpuVpabRZdQyd471Vl8UUkcz2vXSL8xZH2kyCe8tfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.13" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.13", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.13.tgz", + "integrity": "sha512-mbYsrih5FRtGxs3Usvl/PqwJsNpp+jsmrdFviiK02teHDG0/HebBG/pqCylje3kzgXYzuLoHJF/0mz9W53t8Xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", + "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/vite": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "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", + "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.0.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.1.tgz", + "integrity": "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.1", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^2.3.0" + }, + "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.0.1", + "@vitest/browser-preview": "5.0.1", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.1", + "@vitest/coverage-v8": "5.0.1", + "@vitest/ui": "5.0.1", + "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": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.1.tgz", + "integrity": "sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..2f32dc0 --- /dev/null +++ b/web/package.json @@ -0,0 +1,26 @@ +{ + "name": "trainlog-web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "build": "tsc -b && vite build", + "test": "vitest run", + "typecheck": "tsc -b --pretty false" + }, + "dependencies": { + "react": "19.3.0", + "react-dom": "19.3.0" + }, + "devDependencies": { + "@testing-library/jest-dom": "7.0.1", + "@testing-library/react": "16.3.3", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "6.1.1", + "jsdom": "30.0.1", + "typescript": "7.0.2", + "vite": "8.3.0", + "vitest": "5.0.1" + } +} diff --git a/web/src/api/health.ts b/web/src/api/health.ts new file mode 100644 index 0000000..c91e119 --- /dev/null +++ b/web/src/api/health.ts @@ -0,0 +1,25 @@ +export interface Health { + api_version: 1 + status: 'ok' + product: 'trainlog' + version: string +} + +function isHealth(value: unknown): value is Health { + if (typeof value !== 'object' || value === null) return false + const candidate = value as Record + return candidate.api_version === 1 && candidate.status === 'ok' && + candidate.product === 'trainlog' && typeof candidate.version === 'string' && + candidate.version.length > 0 +} + +export async function fetchHealth(signal?: AbortSignal): Promise { + const response = await fetch('/api/v1/health', { + headers: { Accept: 'application/json' }, + signal, + }) + if (!response.ok) throw new Error(`health HTTP ${response.status}`) + const value: unknown = await response.json() + if (!isHealth(value)) throw new Error('health JSON invalide') + return value +} diff --git a/web/src/app/App.test.tsx b/web/src/app/App.test.tsx new file mode 100644 index 0000000..ea35e00 --- /dev/null +++ b/web/src/app/App.test.tsx @@ -0,0 +1,82 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { App } from './App' + +const health = { + api_version: 1, + status: 'ok', + product: 'trainlog', + version: '0.1.2', +} + +function mockHealth(value: unknown = health) { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(value), + })) +} + +describe('shell Trainlog', () => { + it('rend le shell, les sept états vides et le health sans données fictives', async () => { + mockHealth() + render() + expect(screen.getByRole('banner')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Aller au contenu' })).toHaveAttribute('href', '#main-content') + expect(screen.getByRole('navigation', { name: 'Navigation principale' })).toBeInTheDocument() + expect(screen.getByRole('contentinfo')).toBeInTheDocument() + expect(screen.getAllByText('INDISPONIBLE')).toHaveLength(7) + expect(screen.getByLabelText('Utilisateur')).toHaveTextContent('—') + expect(screen.getByLabelText('Dernière séance')).toHaveTextContent('Indisponible') + expect(screen.getByLabelText('Dernière zone')).toHaveTextContent('Indisponible') + expect(document.body).not.toHaveTextContent('fy59') + expect(document.body).not.toHaveTextContent('DOS · BICEPS') + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Backend connecté · v0.1.2')) + }) + + it.each([ + ['Analyse', '/analyse'], + ['Programmes', '/programmes'], + ['Séances', '/seances'], + ['Exercices', '/exercices'], + ['Dashboard', '/'], + ])('navigue vers %s sans remonter le shell', async (label, path) => { + mockHealth() + render() + const header = screen.getByRole('banner') + const footer = screen.getByRole('contentinfo') + fireEvent.click(screen.getByRole('link', { name: label })) + expect(window.location.pathname).toBe(path) + expect(screen.getByRole('heading', { level: 1, name: label })).toBeInTheDocument() + expect(screen.getByRole('link', { name: label })).toHaveAttribute('aria-current', 'page') + expect(screen.getByRole('banner')).toBe(header) + expect(screen.getByRole('contentinfo')).toBe(footer) + }) + + it('suit la navigation arrière du navigateur', () => { + mockHealth() + render() + fireEvent.click(screen.getByRole('link', { name: 'Analyse' })) + window.history.pushState(null, '', '/programmes') + fireEvent.popState(window) + expect(screen.getByRole('heading', { level: 1, name: 'Programmes' })).toBeInTheDocument() + }) + + it('présente un échec health discret et accessible', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('network'))) + render() + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Backend indisponible')) + }) + + it('rejette un JSON health invalide', async () => { + mockHealth({ status: 'ok' }) + render() + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Backend indisponible')) + }) + + it('expose les cinq destinations dans une navigation clavier sémantique', () => { + mockHealth() + render() + expect(screen.getAllByRole('link').filter((link) => link.closest('nav'))).toHaveLength(5) + expect(screen.getByRole('link', { name: 'Dashboard' })).toHaveAttribute('aria-current', 'page') + }) +}) diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx new file mode 100644 index 0000000..ff6cdf3 --- /dev/null +++ b/web/src/app/App.tsx @@ -0,0 +1,45 @@ +import { useEffect, useState } from 'react' +import { fetchHealth, type Health } from '../api/health' +import { Footer } from '../components/Footer' +import { Header } from '../components/Header' +import { DashboardPage } from '../routes/DashboardPage' +import { PlaceholderPage } from '../routes/PlaceholderPage' +import type { RouteId } from './routes' +import { useRoute } from './useRoute' + +const placeholderContent: Record, [string, string, string]> = { + analysis: ['Analyse', 'COMPRENDRE', 'Les analyses détaillées apparaîtront ici lorsque leurs read models Core seront disponibles.'], + programs: ['Programmes', 'PRÉPARER', 'La préparation des programmes utilisera des command services Core explicites.'], + sessions: ['Séances', 'ORGANISER', 'Les séances préparées et terminées seront présentées sans confondre plan et réalisé.'], + exercises: ['Exercices', 'EXPLORER', 'Le catalogue canonique des exercices sera consultable depuis cette page.'], +} + +export function App() { + const [route, navigate] = useRoute() + const [health, setHealth] = useState(null) + const [healthPending, setHealthPending] = useState(true) + const [healthFailed, setHealthFailed] = useState(false) + + useEffect(() => { + const controller = new AbortController() + fetchHealth(controller.signal).then((value) => { + setHealth(value) + setHealthFailed(false) + }).catch(() => setHealthFailed(true)).finally(() => setHealthPending(false)) + return () => controller.abort() + }, []) + + const content = route.id === 'dashboard' ? : + + + return ( +
+ Aller au contenu +
+
{content}
+
+
+ ) +} diff --git a/web/src/app/routes.ts b/web/src/app/routes.ts new file mode 100644 index 0000000..741ed9d --- /dev/null +++ b/web/src/app/routes.ts @@ -0,0 +1,19 @@ +export type RouteId = 'dashboard' | 'analysis' | 'programs' | 'sessions' | 'exercises' + +export interface AppRoute { + id: RouteId + path: string + label: string +} + +export const routes: readonly AppRoute[] = [ + { id: 'dashboard', path: '/', label: 'Dashboard' }, + { id: 'analysis', path: '/analyse', label: 'Analyse' }, + { id: 'programs', path: '/programmes', label: 'Programmes' }, + { id: 'sessions', path: '/seances', label: 'Séances' }, + { id: 'exercises', path: '/exercices', label: 'Exercices' }, +] + +export function routeFromPath(pathname: string): AppRoute { + return routes.find((route) => route.path === pathname) ?? routes[0] +} diff --git a/web/src/app/useRoute.ts b/web/src/app/useRoute.ts new file mode 100644 index 0000000..2c33f57 --- /dev/null +++ b/web/src/app/useRoute.ts @@ -0,0 +1,20 @@ +import { useEffect, useState } from 'react' +import { routeFromPath, type AppRoute } from './routes' + +export function useRoute(): [AppRoute, (path: string) => void] { + const [route, setRoute] = useState(() => routeFromPath(window.location.pathname)) + + useEffect(() => { + const onPopState = () => setRoute(routeFromPath(window.location.pathname)) + window.addEventListener('popstate', onPopState) + return () => window.removeEventListener('popstate', onPopState) + }, []) + + const navigate = (path: string) => { + if (window.location.pathname !== path) window.history.pushState(null, '', path) + setRoute(routeFromPath(path)) + window.scrollTo({ top: 0, behavior: 'instant' }) + } + + return [route, navigate] +} diff --git a/web/src/components/EmptyState.tsx b/web/src/components/EmptyState.tsx new file mode 100644 index 0000000..2446b80 --- /dev/null +++ b/web/src/components/EmptyState.tsx @@ -0,0 +1,12 @@ +interface EmptyStateProps { + message: string +} + +export function EmptyState({ message }: EmptyStateProps) { + return ( +
+ +

{message}

+
+ ) +} diff --git a/web/src/components/Footer.tsx b/web/src/components/Footer.tsx new file mode 100644 index 0000000..982df39 --- /dev/null +++ b/web/src/components/Footer.tsx @@ -0,0 +1,32 @@ +import type { Health } from '../api/health' + +interface FooterProps { + health: Health | null + healthPending: boolean + healthFailed: boolean +} + +export function Footer({ health, healthPending, healthFailed }: FooterProps) { + const backendLabel = healthPending ? 'Connexion en cours' : + healthFailed ? 'Backend indisponible' : `Backend connecté · v${health?.version ?? '—'}` + + return ( +
+
+ UTILISATEUR + — +
+
+ DERNIÈRE SÉANCE + Indisponible +
+
+ DERNIÈRE ZONE + Indisponible +
+ + +
+ ) +} diff --git a/web/src/components/Header.tsx b/web/src/components/Header.tsx new file mode 100644 index 0000000..dbdb64f --- /dev/null +++ b/web/src/components/Header.tsx @@ -0,0 +1,35 @@ +import type { MouseEvent } from 'react' +import { routes, type AppRoute } from '../app/routes' + +interface HeaderProps { + activeRoute: AppRoute + onNavigate: (path: string) => void +} + +export function Header({ activeRoute, onNavigate }: HeaderProps) { + const follow = (event: MouseEvent, path: string) => { + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return + event.preventDefault() + onNavigate(path) + } + + return ( +
+ follow(event, '/')} + aria-label="Trainlog — Dashboard"> + + TRAINLOG + + +
+ ) +} diff --git a/web/src/components/Tile.tsx b/web/src/components/Tile.tsx new file mode 100644 index 0000000..0881dcb --- /dev/null +++ b/web/src/components/Tile.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from 'react' + +interface TileProps { + title: string + eyebrow?: string + children: ReactNode + className?: string +} + +export function Tile({ title, eyebrow, children, className = '' }: TileProps) { + return ( +
+
+
+ {eyebrow &&

{eyebrow}

} +

{title}

+
+ INDISPONIBLE +
+
{children}
+
+ ) +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..db0946a --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { App } from './app/App' +import './theme/tokens.css' +import './theme/app.css' + +const root = document.getElementById('root') +if (root === null) throw new Error('Racine Trainlog absente') + +createRoot(root).render() diff --git a/web/src/routes/DashboardPage.tsx b/web/src/routes/DashboardPage.tsx new file mode 100644 index 0000000..399ad0e --- /dev/null +++ b/web/src/routes/DashboardPage.tsx @@ -0,0 +1,31 @@ +import { EmptyState } from '../components/EmptyState' +import { Tile } from '../components/Tile' + +const dashboardTiles = [ + ['Prochaine séance', 'Planification', 'Aucune séance préparée disponible.'], + ['Activité', 'Régularité', 'Les données d’activité ne sont pas encore exposées.'], + ['Progression', 'Évolution', 'La métrique de progression reste à définir.'], + ['Dernière séance', 'Historique', 'Aucune séance terminée disponible.'], + ['Records / MAX', 'Performances', 'Aucun MAX mesuré disponible.'], + ['Répartition musculaire', 'Zones', 'La répartition par zone n’est pas encore disponible.'], + ['Cardio / récupération', 'Physiologie', 'Aucune donnée cardio ou récupération disponible.'], +] as const + +export function DashboardPage() { + return ( +
+
+

VUE D’ENSEMBLE

Dashboard

+

Vos repères d’entraînement, sans extrapolation ni donnée inventée.

+
+
+ {dashboardTiles.map(([title, eyebrow, message], index) => ( + + + + ))} +
+
+ ) +} diff --git a/web/src/routes/PlaceholderPage.tsx b/web/src/routes/PlaceholderPage.tsx new file mode 100644 index 0000000..35e0669 --- /dev/null +++ b/web/src/routes/PlaceholderPage.tsx @@ -0,0 +1,29 @@ +import { EmptyState } from '../components/EmptyState' +import { Tile } from '../components/Tile' + +interface PlaceholderPageProps { + title: string + eyebrow: string + description: string +} + +export function PlaceholderPage({ title, eyebrow, description }: PlaceholderPageProps) { + return ( +
+
+

{eyebrow}

{title}

+

{description}

+
+
+ + + + +
+
+ ) +} diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts new file mode 100644 index 0000000..a3f7252 --- /dev/null +++ b/web/src/test/setup.ts @@ -0,0 +1,11 @@ +import '@testing-library/jest-dom/vitest' +import { afterEach, vi } from 'vitest' +import { cleanup } from '@testing-library/react' + +Object.defineProperty(window, 'scrollTo', { value: vi.fn(), writable: true }) + +afterEach(() => { + cleanup() + vi.restoreAllMocks() + window.history.replaceState(null, '', '/') +}) diff --git a/web/src/theme/app.css b/web/src/theme/app.css new file mode 100644 index 0000000..18aa754 --- /dev/null +++ b/web/src/theme/app.css @@ -0,0 +1,100 @@ +.app-shell { + min-height: 100vh; + overflow-x: hidden; + display: grid; + grid-template-rows: auto 1fr auto; + background: + radial-gradient(circle at 92% 12%, rgb(180 190 254 / 7%), transparent 25rem), + var(--crust); +} + +.skip-link { position: fixed; z-index: 20; top: .75rem; left: .75rem; padding: .65rem .85rem; color: var(--crust); background: var(--lavender); border-radius: .45rem; font-weight: 700; text-decoration: none; transform: translateY(-200%); } +.skip-link:focus { transform: translateY(0); } + +.app-header { + position: sticky; + top: 0; + z-index: 10; + min-height: 4.5rem; + padding: .75rem var(--shell-gutter); + display: flex; + align-items: center; + gap: clamp(1.5rem, 4vw, 5rem); + background: rgb(17 17 27 / 92%); + border-bottom: 1px solid rgb(69 71 90 / 55%); + backdrop-filter: blur(16px); +} + +.brand { display: flex; align-items: center; gap: .7rem; text-decoration: none; font-weight: 800; letter-spacing: .13em; } +.brand-mark { width: 2rem; height: 2rem; display: grid; place-items: center; color: var(--crust); background: var(--lavender); border-radius: .55rem; font: 700 .7rem var(--font-mono); letter-spacing: -.04em; } +.main-nav { min-width: 0; display: flex; align-items: center; gap: .25rem; overflow-x: auto; scrollbar-width: none; } +.main-nav::-webkit-scrollbar { display: none; } +.nav-link { position: relative; padding: .7rem .85rem; color: var(--subtext-0); text-decoration: none; font-size: .91rem; font-weight: 570; white-space: nowrap; transition: color 140ms ease, background 140ms ease; border-radius: .55rem; } +.nav-link:hover { color: var(--text); background: rgb(49 50 68 / 48%); } +.nav-link.is-active { color: var(--lavender); } +.nav-link.is-active::after { content: ""; position: absolute; left: .85rem; right: .85rem; bottom: .22rem; height: 2px; background: var(--lavender); border-radius: 2px; } + +.app-main { width: 100%; padding: clamp(2.25rem, 5vw, 5rem) var(--shell-gutter) clamp(3rem, 7vw, 7rem); } +.page { width: min(100%, 92rem); min-width: 0; margin: 0 auto; } +.page-heading { margin-bottom: clamp(2rem, 4vw, 3.5rem); display: flex; justify-content: space-between; align-items: end; gap: 2rem; } +.eyebrow { margin: 0 0 .55rem; color: var(--lavender); font: 700 .68rem/1.2 var(--font-mono); letter-spacing: .16em; text-transform: uppercase; } +h1, h2, p { margin-top: 0; } +h1 { margin-bottom: 0; font-size: clamp(2.25rem, 5vw, 4.5rem); line-height: .96; letter-spacing: -.055em; font-weight: 720; } +h2 { color: var(--text); font-size: 1.08rem; letter-spacing: -.02em; } +.page-intro { max-width: 34rem; margin-bottom: .15rem; color: var(--subtext-0); font-size: clamp(.95rem, 1.4vw, 1.08rem); line-height: 1.65; } + +.tile-grid { min-width: 0; display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 1rem; } +.tile { min-width: 0; grid-column: span 4; min-height: 17rem; padding: clamp(1.15rem, 2vw, 1.6rem); display: flex; flex-direction: column; background: var(--base); border: 1px solid rgb(69 71 90 / 38%); border-radius: var(--radius); box-shadow: 0 1.2rem 3rem rgb(0 0 0 / 12%); } +.tile-wide { grid-column: span 6; } +.tile-feature { grid-column: span 8; min-height: 25rem; } +.tile-header { min-width: 0; display: flex; flex-wrap: wrap; align-items: start; justify-content: space-between; gap: 1rem; } +.tile-header h2 { margin-bottom: 0; } +.tile-state { flex: none; color: var(--subtext-0); font: 600 .6rem var(--font-mono); letter-spacing: .1em; padding: .42rem .58rem; background: var(--surface-0); border-radius: 99rem; } +.tile-content { flex: 1; display: grid; place-items: center; } +.empty-state { max-width: 22rem; color: var(--subtext-0); text-align: center; line-height: 1.5; } +.empty-state p { margin: .75rem 0 0; overflow-wrap: anywhere; } +.empty-glyph { display: inline-grid; width: 2.4rem; height: 2.4rem; place-items: center; color: var(--lavender); background: rgb(180 190 254 / 9%); border-radius: 50%; font: 600 1rem var(--font-mono); } +.principle-panel { grid-column: span 4; min-height: 25rem; padding: 1.6rem; display: flex; flex-direction: column; justify-content: space-between; color: var(--subtext-0); background: var(--mantle); border-radius: var(--radius); } +.principle-panel h2 { font-size: clamp(1.3rem, 2vw, 1.8rem); } +.principle-panel p:last-child { line-height: 1.65; } +.principle-index { color: var(--surface-1); font: 700 4rem/1 var(--font-mono); letter-spacing: -.1em; } + +.status-footer { position: sticky; bottom: 0; z-index: 10; min-height: 3.75rem; padding: .65rem var(--shell-gutter); display: grid; grid-template-columns: 1fr 1.25fr 1.25fr auto; align-items: center; gap: 1.5rem; background: rgb(24 24 37 / 96%); border-top: 1px solid rgb(69 71 90 / 62%); backdrop-filter: blur(16px); } +.status-block { min-width: 0; display: flex; flex-direction: column; gap: .16rem; } +.status-label { color: var(--surface-1); font: 700 .58rem var(--font-mono); letter-spacing: .12em; } +.status-value { overflow: hidden; color: var(--subtext-0); font-size: .78rem; text-overflow: ellipsis; white-space: nowrap; } +.backend-state { display: flex; align-items: center; gap: .42rem; color: var(--green); font: 600 .65rem var(--font-mono); white-space: nowrap; } +.backend-state.is-error { color: var(--red); } +.backend-dot { width: .45rem; height: .45rem; background: currentColor; border-radius: 50%; } + +@media (max-width: 70rem) { + .tile { grid-column: span 6; } + .tile-feature { grid-column: span 8; } + .principle-panel { grid-column: span 4; } + .status-footer { grid-template-columns: 1fr 1fr 1fr; } + .backend-state { grid-column: 1 / -1; justify-self: end; } +} + +@media (max-width: 48rem) { + .app-header { align-items: stretch; flex-direction: column; gap: .55rem; padding-block: .7rem .45rem; } + .brand { align-self: start; } + .main-nav { margin-inline: calc(var(--shell-gutter) * -1); padding-inline: var(--shell-gutter); } + .page-heading { align-items: start; flex-direction: column; gap: 1rem; } + .tile-grid { display: flex; flex-direction: column; } + .tile, .tile-wide, .tile-feature, .principle-panel { width: 100%; grid-column: auto; } + .tile { min-height: 14rem; } + .principle-panel { min-height: 17rem; } + .status-footer { position: static; grid-template-columns: 1fr 1fr; gap: .8rem 1.25rem; } + .status-zone { grid-column: 1 / -1; } + .backend-state { grid-column: 1 / -1; justify-self: start; } +} + +@media (max-width: 28rem) { + .status-footer { grid-template-columns: 1fr; } + .status-zone, .backend-state { grid-column: auto; } + h1 { font-size: 2.3rem; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; } +} diff --git a/web/src/theme/tokens.css b/web/src/theme/tokens.css new file mode 100644 index 0000000..f65bae9 --- /dev/null +++ b/web/src/theme/tokens.css @@ -0,0 +1,36 @@ +:root { + color-scheme: dark; + --crust: #11111b; + --mantle: #181825; + --base: #1e1e2e; + --surface-0: #313244; + --surface-1: #45475a; + --text: #cdd6f4; + --subtext-0: #a6adc8; + --lavender: #b4befe; + --mauve: #cba6f7; + --blue: #89b4fa; + --sapphire: #74c7ec; + --green: #a6e3a1; + --yellow: #f9e2af; + --peach: #fab387; + --red: #f38ba8; + --font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + "Segoe UI", sans-serif; + --font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + --shell-gutter: clamp(1rem, 3vw, 3rem); + --radius: 1rem; + font-family: var(--font-sans); + color: var(--text); + background: var(--crust); + font-synthesis: none; + text-rendering: optimizeLegibility; +} + +* { box-sizing: border-box; } +html, body, #root { min-width: 20rem; min-height: 100%; margin: 0; } +body { min-height: 100vh; background: var(--crust); } +button, a { font: inherit; } +a { color: inherit; } +::selection { color: var(--crust); background: var(--lavender); } +:focus-visible { outline: 2px solid var(--lavender); outline-offset: 4px; border-radius: .25rem; } diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json new file mode 100644 index 0000000..8fcfef4 --- /dev/null +++ b/web/tsconfig.app.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..d2cd07b --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["vite.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..ce8771d --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,16 @@ +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + plugins: [react()], + build: { + assetsDir: 'assets', + sourcemap: false, + reportCompressedSize: false, + }, + test: { + environment: 'jsdom', + setupFiles: './src/test/setup.ts', + css: true, + }, +})