diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index b4548f65..92d23a6b 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -8,7 +8,7 @@ - [ ] Breaking HTTP / pins / NVS / servo defaults / hook CLI called out as `type!:` plus a `BREAKING CHANGE:` footer - [ ] Firmware: `pio run` and/or `pio test -e native` when that code changed - [ ] Packages: `npm test --prefix packages/tiny-engineer-cursor` / `…/tiny-engineer-antigravity` / `…/tiny-engineer-claude-code` when that package changed -- [ ] HTTP: HTML index (`src/http/index_page.cpp`) + `docs/api.md` (and README / `docs/hardware/testing.md` if the route list changed) +- [ ] HTTP: HTML index (`ui/index.html`) + `docs/api.md` (and README / `docs/hardware/testing.md` if the route list changed) - [ ] Settings: layers in `docs/settings.md`; no raw `access_token` in logs - [ ] CAD: `.f3d` + exported `.3mf`; CERN-OHL-S; `AiEmblem.3mf` not used as a branding swap - [ ] Pins: `include/pins.h` + `docs/hardware/` together diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad4f3da3..7d59e090 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,11 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.11" + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: ui/package-lock.json - uses: actions/cache@v4 with: path: | @@ -61,11 +66,18 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 + cache: npm + cache-dependency-path: ui/package-lock.json - name: Check OLED expression assets run: | node scripts/expressions/generate.js --check node scripts/expressions/test-assets.js + - name: Lint and test web UI + run: | + npm ci --prefix ui + npm run lint --prefix ui + npm test --prefix ui - name: Cursor package tests run: npm test --prefix packages/tiny-engineer-cursor - name: Antigravity package tests diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 82bd14c5..2abad845 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,6 +18,11 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.11" + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: ui/package-lock.json - uses: actions/cache@v4 with: path: | diff --git a/.gitignore b/.gitignore index 53b2e49d..04e1d6a6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .DS_Store __pycache__/ data/*.* +data/ui/ artifacts/ # Cursor hook event log diff --git a/AGENTS.md b/AGENTS.md index 73fb5895..7e8852e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ Tiny Engineer is an open-source ESP32-C3 Wi-Fi desk robot: 3D-printed mechanics, | Path | Role | | --- | --- | | `src/`, `include/`, `lib/`, `data/` | Firmware (PlatformIO) | +| `ui/` | Web control panel (Vite + ES modules); `pio run` builds, minifies and gzips it onto LittleFS | | `packages/` | HTTP / hook CLIs (Cursor, Antigravity, Claude Code, …) — scope `integrations` | | `skills/` | Agent skills shared by Claude Code, Cursor and Antigravity — scope `integrations` | | `3d_models/` | CAD and printables (CERN-OHL-S) | @@ -22,7 +23,7 @@ Physical module: **Waveshare ESP32-C3-Zero**. PlatformIO `board = esp32-c3-devki ## Commands -From the repo root (Node 18+ for packages). Host only — CI never flashes. +From the repo root (Node 18+ for packages, Node 20.19+ on 20.x or 22.12+ for `ui/` and therefore `pio run`). Host only — CI never flashes. ```bash pio run @@ -33,6 +34,8 @@ node scripts/expressions/test-assets.js npm test --prefix packages/tiny-engineer-cursor npm test --prefix packages/tiny-engineer-antigravity npm test --prefix packages/tiny-engineer-claude-code +npm run lint --prefix ui +npm test --prefix ui python3 scripts/check_pcb.py ``` @@ -51,7 +54,7 @@ Significant: logic, types, APIs, headers, firmware under `src/` / `include/` / ` When adding, removing, or changing method, path, or query params of any HTTP route, update in the **same** change: -1. **HTML index** — [`src/http/index_page.cpp`](src/http/index_page.cpp): endpoint table and param lists for `/anim`, `/settings`, `/test/servo` +1. **HTML index** — [`ui/index.html`](ui/index.html): endpoint table and param lists for `/anim`, `/settings`, `/test/servo` 2. **API reference** — [`docs/api.md`](docs/api.md) 3. **Summaries** — if the endpoint list, params, or boot URLs changed: [`README.md`](README.md) and [`docs/hardware/testing.md`](docs/hardware/testing.md) 4. **405 paths** — if needed: `isHttpTestPath()` / `handleNotFound` in `src/http/` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ff09b874..2d802148 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -59,7 +59,7 @@ python3 scripts/check_pcb.py **Firmware.** `pio run` plus native tests when you touch settings validation. Pin changes update [`include/pins.h`](include/pins.h) **and** [`docs/hardware/`](docs/hardware/) in the same PR. -**HTTP / API.** Same PR must update the HTML index ([`src/http/index_page.cpp`](src/http/index_page.cpp)) and [`docs/api.md`](docs/api.md). If the route list, params, or boot URLs changed, also update [`README.md`](README.md) and [`docs/hardware/testing.md`](docs/hardware/testing.md). See [.cursor/rules/sync-api-endpoints.mdc](.cursor/rules/sync-api-endpoints.mdc). +**HTTP / API.** Same PR must update the HTML index ([`ui/index.html`](ui/index.html)) and [`docs/api.md`](docs/api.md). If the route list, params, or boot URLs changed, also update [`README.md`](README.md) and [`docs/hardware/testing.md`](docs/hardware/testing.md). See [.cursor/rules/sync-api-endpoints.mdc](.cursor/rules/sync-api-endpoints.mdc). **Settings.** Follow the layer checklist in [docs/settings.md](docs/settings.md). Never log the raw `access_token` (set/unset only). diff --git a/docs/api.md b/docs/api.md index 75158e76..1fe892ba 100644 --- a/docs/api.md +++ b/docs/api.md @@ -6,7 +6,7 @@ Listens on **port 80** after STA Wi-Fi connects, or during setup AP mode at `htt Optional auth: when an `access_token` is configured in settings, all JSON API routes require `Authorization: Bearer `. Empty token (default) means no auth. `GET /auth` is always public and reports whether auth is required. Missing/wrong token → **401** `{"ok":false,"error":"unauthorized"}`. HTML panel routes stay public (the UI prompts for the token). `Content-Type: application/json`. CORS: `Access-Control-Allow-Origin: *`, `Access-Control-Allow-Headers: Authorization`. -When WiFi credentials are not saved yet, control APIs (`/anim`, `/test/*`) return **503** `{"ok":false,"error":"wifi not configured"}`. Setup routes (`/`, `/config`, `/auth`, `/health`, `/settings`, `/setup/servo`, `/setup/led`, `/setup/audio`, `/setup/oled`) stay available on the setup AP. +When WiFi credentials are not saved yet, control APIs (`/anim`, `/test/*`) return **503** `{"ok":false,"error":"wifi not configured"}`. Setup routes (`/`, `/config`, `/ui/*`, `/auth`, `/health`, `/settings`, `/setup/servo`, `/setup/led`, `/setup/audio`, `/setup/oled`) stay available on the setup AP. If boot WiFi credentials are missing, the device opens setup AP mode (`TinyEngineer-XXXX`) and serves a five-step setup wizard at `/config` (servo calibration, OLED orientation, RGB LED, speaker test, then Wi-Fi). If saved credentials fail, it reopens setup AP mode. Hardware tests: [`docs/hardware/testing.md`](hardware/testing.md). How to wire this API into AI tools: [`integration.md`](integration.md). @@ -24,7 +24,11 @@ Open in a browser: curl http://tiny-engineer.local/ ``` -Returns `Content-Type: text/html; charset=utf-8`. +Returns `Content-Type: text/html; charset=utf-8`, gzip-encoded. The page is served from LittleFS (`/ui/index.html.gz`); if the filesystem image is missing, or its `/ui/version.txt` does not match the firmware version, it returns **503** with flashing instructions. + +### `GET /ui/*` + +Web UI assets from LittleFS, gzip-encoded. Bundled files under `/ui/assets/` have content-hashed names and are sent with `Cache-Control: max-age=31536000, immutable`; anything else under `/ui/` is `no-cache`. Always public. Source lives in [`ui/`](../ui/); `pio run` builds it with Vite and gzips the output into `data/ui/`. ### `GET /auth` diff --git a/docs/flash.md b/docs/flash.md index ebf40bc0..50cb6076 100644 --- a/docs/flash.md +++ b/docs/flash.md @@ -41,6 +41,7 @@ Firmware is Arduino on [PlatformIO](https://platformio.org/) ([pioarduino](https ### Install and connect 1. Install [PlatformIO Core](https://docs.platformio.org/en/latest/core/installation.html) (or the PlatformIO IDE extension). + Also install [Node.js](https://nodejs.org/) 20.19+ on 20.x, or 22.12+: `pio run` builds the web UI in [`ui/`](../ui/) with npm. 2. Use a USB-C **data** cable. Charge-only cables fail upload and serial. 3. Plug the data cable into the **main control board USB-C** once the ESP32 is seated on its headers. Advanced breakout build: use the Adafruit 5993, or the C3-Zero onboard USB-C before that breakout is wired — [hardware/wiring.md](hardware/wiring.md). @@ -54,7 +55,7 @@ pio run -t upload # flash firmware + LittleFS pio device monitor # serial (115200) ``` -After firmware upload, a post-script also uploads **LittleFS** ([`scripts/upload_fs_after_upload.py`](../scripts/upload_fs_after_upload.py)) so WAV assets (`welcome`, `bell`, and friends) land on the board. If animations move but stay silent, run `pio run -t uploadfs` once. +After firmware upload, a post-script also uploads **LittleFS** ([`scripts/upload_fs_after_upload.py`](../scripts/upload_fs_after_upload.py)) so the web UI and WAV assets (`welcome`, `bell`, and friends) land on the board. If the web page says the UI is missing, or animations move but stay silent, run `pio run -t uploadfs` once. Several serial ports: @@ -118,15 +119,16 @@ After that, with the robot on your home Wi-Fi: ```bash export TINY_ENGINEER_URL=http://192.168.x.x # default: tiny-engineer.local export TINY_ENGINEER_TOKEN=... # only if access_token is set -pio run -e ota -t ota # firmware -pio run -e ota -t otafs # LittleFS (WAV assets) +pio run -e ota -t ota # LittleFS (web UI, WAV assets), then firmware ``` +Firmware and filesystem are one release: the web UI is stamped with the firmware version it was built with, and the firmware refuses to serve a UI from another build (`/` returns **503** naming both versions). `-t ota` therefore uploads the filesystem first, waits for the robot to reboot, then uploads the firmware. `pio run -e ota -t otafs` uploads the filesystem alone, for recovery. + These are the same variables the agent integrations read ([integration.md](integration.md)). OTA listens on UDP/TCP port 3232 and only runs while connected to home Wi-Fi, not in setup AP mode. When `access_token` is set it is also the OTA password; changing it applies to OTA without a reboot. An update is written to the inactive slot and booted once; it becomes permanent only after it connects to Wi-Fi and starts its OTA listener. Firmware that never gets that far is rolled back to the previous slot on the next reset or power cycle. A firmware that hangs needs that power cycle to recover. -A filesystem update that fails midway reboots the robot; re-run `pio run -e ota -t otafs` or fall back to `pio run -e ota -t uploadfs` over USB. +A filesystem update that fails midway reboots the robot; re-run `pio run -e ota -t ota` or fall back to `pio run -e ota -t upload` over USB. Partition changes, including a switch back to the default build, cannot be applied over the air. Flash those with `pio run -t upload` over USB. diff --git a/docs/hardware/testing.md b/docs/hardware/testing.md index 28969a18..bb615e0d 100644 --- a/docs/hardware/testing.md +++ b/docs/hardware/testing.md @@ -106,6 +106,7 @@ curl -X POST "http://tiny-engineer.local/test/servo?index=0&angle=90" | Method | Path | Body | | --- | --- | --- | | `GET` | `/` | HTML endpoint index | +| `GET` | `/ui/*` | Web UI assets from LittleFS (gzip) | | `GET` | `/auth` | Auth status (`ok`, `required`, `wifi_configured`, `provisioning`) — always public | | `GET` | `/health` | Health JSON — full field list in [api.md](../api.md#get-health) | | `GET` / `POST` | `/anim` | Current animation / start one (`name`, optional `interrupt`) — full params in [api.md](../api.md#post-anim) | diff --git a/docs/settings.md b/docs/settings.md index b68aba7d..d45bd031 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -52,7 +52,7 @@ Call the getter wherever the value affects behavior (e.g. `settingsVolume()` in - Pass pointer into `saveSettings` 4. Update the “missing …” **400** message to list the new param. -### 4. Web UI — [`src/http/index_page.cpp`](../src/http/index_page.cpp) +### 4. Web UI — [`ui/`](../ui/) 1. Config form control (`#config-foo`). Use `.range-row` + range/number for 0–N scales (see volume). 2. `loadSettings()` reads `j.foo`; save POST includes `&foo=…`. @@ -91,7 +91,7 @@ Flash only when you want to try it on hardware (`pio run -t upload`). - **JSON buffer:** `sendSettingsJson` uses a fixed `char` buffer — bump size when adding fields. - **Factory reset:** `factoryResetSettings()` in [`reset.cpp`](../src/settings/reset.cpp) clears NVS namespace `te` and writes defaults, including WiFi credentials. Servo min/max (`sranges`), RGB LED mapping (`rgb_ord`), and OLED rotation (`oled_rot`) are written back unchanged. Exposed as `POST /settings/reset`. After reset, power-cycle into setup AP mode to configure WiFi again (WiFi is not editable on the normal Config page). Servo ranges, LED mapping, and screen rotation can be retuned in that wizard. - **Hostname-style settings:** freeze the boot value separately if live change cannot apply (see `settingsBootHostname()` / `reboot_required`). -- **HTML string size:** the panel is a big string literal in `index_page.cpp`; keep controls compact. +- **UI size:** the panel ships gzipped on LittleFS next to the WAVs; keep controls compact. - **Doc drift:** HTML param tables must match `api.md` exactly. ## Related diff --git a/docs/testing.md b/docs/testing.md index 1e3bf360..e717c0e4 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -26,6 +26,19 @@ pio run -e expression-demo The `expression-demo` environment builds a separate OLED-only application without changing the default robot build. For a later bench test, follow its [wiring and upload notes](../lib/TinyEngineerExpressions/README.md#standalone-oled-demo), leave the separate servo supply off, and observe all 16 faces through a complete 48-second cycle. Report physical display results separately from host tests and compilation. +## Web UI + +The control panel in [`ui/`](../ui/) is linted with [html-validate](https://html-validate.org/), ESLint, [Stylelint](https://stylelint.io/) (`stylelint-config-standard`) and Prettier. Node 20.19+ on 20.x, or 22.12+. + +```bash +npm ci --prefix ui +npm run lint --prefix ui +npm test --prefix ui +npm run format --prefix ui # apply Prettier +``` + +`npm test` runs [Vitest](https://vitest.dev/) in jsdom: each test loads the real `index.html`, imports the UI modules, and drives them against a fake robot that stubs `fetch` and records requests ([`ui/test/robot.js`](../ui/test/robot.js)). The tests cover the auth gate, config saving and access-token handling, the servo page range hint, and the setup wizard (step order, what each step saves, step-only calibration moves, LED order validation). + ## Packages Node 18+. No robot. diff --git a/partitions.csv b/partitions.csv index fc4dd2aa..283fb68b 100644 --- a/partitions.csv +++ b/partitions.csv @@ -1,6 +1,6 @@ # Name, Type, SubType, Offset, Size, Flags # 4MB: one app slot (USB flash, no OTA). Dual 0x150000 OTA apps overflowed -# the firmware (~1.38MB HTML UI). LittleFS is ~1.81MB so a mod can replace +# the firmware (~1.3MB). LittleFS is ~1.81MB so a mod can replace # some WAVs with longer clips and keep the stock files it does not ship. # NVS stays at 0x9000. spiffs moved; reflash app + filesystem together. nvs, data, nvs, 0x9000, 0x5000, diff --git a/scripts/audio_pack.py b/scripts/audio_pack.py index adb92416..be7de3a0 100644 --- a/scripts/audio_pack.py +++ b/scripts/audio_pack.py @@ -275,16 +275,16 @@ def pack_audio(assets_dir, dest_dir, partition_bytes, mod_dir=None): total = sum( path.stat().st_size - for path in dest_dir.iterdir() + for path in dest_dir.rglob("*") if path.is_file() ) limit = partition_bytes - HEADROOM_BYTES if total > limit: raise AudioPackError( - f"Audio image is {total} bytes; " + f"Filesystem image is {total} bytes; " f"spiffs allows {limit} after {HEADROOM_BYTES} bytes headroom" ) - print(f"Audio image {total} bytes (limit {limit})") + print(f"Filesystem image {total} bytes (limit {limit})") def _overlay_mod(mod_dir, dest_dir): diff --git a/scripts/copy_assets.py b/scripts/copy_assets.py index 2c32e283..81ab58ba 100644 --- a/scripts/copy_assets.py +++ b/scripts/copy_assets.py @@ -9,6 +9,7 @@ sys.path.insert(0, str(scripts_dir)) from audio_pack import pack_audio, spiffs_size_bytes, validate_mod_name +from ui_pack import build_ui, pack_ui mod_name = env.GetProjectOption("custom_audio_mod", "") mod_name = "" if mod_name is None else str(mod_name).strip() @@ -18,6 +19,9 @@ mod_dir = project_dir / "mods" / mod_name / "assets" print(f"Audio mod overlay: {mod_name}") +# Before pack_audio so its size check covers the UI files too. +pack_ui(build_ui(project_dir / "ui"), project_dir / "data" / "ui", env["TE_FW_VERSION"]) + pack_audio( project_dir / "assets", project_dir / "data", diff --git a/scripts/firmware_version.py b/scripts/firmware_version.py index 79347d5c..815d9a5c 100644 --- a/scripts/firmware_version.py +++ b/scripts/firmware_version.py @@ -35,3 +35,5 @@ def _sanitize(raw: str) -> str: version = _sanitize(_git_describe(project_dir)) print(f"Firmware version: {version}") env.Append(CPPDEFINES=[("FW_VERSION", '\\"%s\\"' % version)]) +# copy_assets.py stamps the web UI with the same string so the firmware can detect a mismatched filesystem. +env.Replace(TE_FW_VERSION=version) diff --git a/scripts/ota.py b/scripts/ota.py index 3d1dd87e..fd712839 100644 --- a/scripts/ota.py +++ b/scripts/ota.py @@ -2,10 +2,15 @@ import os import subprocess +import time +import urllib.error +import urllib.request from pathlib import Path from urllib.parse import urlparse DEFAULT_HOST = "tiny-engineer.local" +REBOOT_TIMEOUT_S = 90 +FIRMWARE_ATTEMPTS = 3 def ota_host(): @@ -15,7 +20,7 @@ def ota_host(): return host or DEFAULT_HOST -def espota(image, filesystem): +def espota(image, filesystem, check=True): command = [ env.subst("$PYTHONEXE"), str(Path(env.PioPlatform().get_package_dir("framework-arduinoespressif32")) / "tools" / "espota.py"), @@ -33,29 +38,59 @@ def espota(image, filesystem): command.append("--spiffs") print(f"OTA {'filesystem' if filesystem else 'firmware'} -> {ota_host()}") - subprocess.run(command, check=True) + return subprocess.run(command, check=check).returncode == 0 -def upload_firmware(source, target, env): - espota(env.subst("$BUILD_DIR/${PROGNAME}.bin"), filesystem=False) +def wait_for_reboot(): + # The robot restarts after an OTA write; any HTTP answer (even 401) means it is back. + time.sleep(5) + deadline = time.monotonic() + REBOOT_TIMEOUT_S + while time.monotonic() < deadline: + try: + urllib.request.urlopen(f"http://{ota_host()}/health", timeout=3) + return + except urllib.error.HTTPError: + return + except OSError: + time.sleep(2) -def upload_filesystem(source, target, env): + raise RuntimeError(f"{ota_host()} did not come back within {REBOOT_TIMEOUT_S} s") + + +def build_filesystem(): project_dir = env.subst("$PROJECT_DIR") # The platform only wires up the LittleFS image builder when buildfs is a command-line target. subprocess.run( [env.subst("$PYTHONEXE"), "-m", "platformio", "run", "-t", "buildfs", "-e", env.subst("$PIOENV"), "-d", project_dir], check=True, ) - espota(env.subst("$BUILD_DIR/${ESP32_FS_IMAGE_NAME}.bin"), filesystem=True) + return env.subst("$BUILD_DIR/${ESP32_FS_IMAGE_NAME}.bin") + + +def upload_release(source, target, env): + # Filesystem first: until the firmware follows, the robot keeps its old firmware and the + # web UI refuses to load on the version mismatch instead of calling an older API. + espota(build_filesystem(), filesystem=True) + wait_for_reboot() + + # The OTA listener starts just after the HTTP server, so the first attempt can be early. + for attempt in range(1, FIRMWARE_ATTEMPTS + 1): + if espota(env.subst("$BUILD_DIR/${PROGNAME}.bin"), filesystem=False, check=attempt == FIRMWARE_ATTEMPTS): + return + time.sleep(3) + + +def upload_filesystem(source, target, env): + espota(build_filesystem(), filesystem=True) env.AddCustomTarget( name="ota", dependencies="$BUILD_DIR/${PROGNAME}.bin", - actions=upload_firmware, + actions=upload_release, title="Upload OTA", - description="Upload firmware over Wi-Fi", + description="Upload filesystem, then firmware, over Wi-Fi", ) env.AddCustomTarget( diff --git a/scripts/ui_pack.py b/scripts/ui_pack.py new file mode 100644 index 00000000..3da8d4e0 --- /dev/null +++ b/scripts/ui_pack.py @@ -0,0 +1,45 @@ +import gzip +import shutil +import subprocess +from pathlib import Path + + +class UiPackError(Exception): + pass + + +def _npm(ui_dir, *args): + npm = shutil.which("npm") + if npm is None: + raise UiPackError("npm not found; building the web UI needs Node 20.19+ on 20.x, or 22.12+") + subprocess.run([npm, *args], cwd=ui_dir, check=True) + + +def build_ui(ui_dir): + ui_dir = Path(ui_dir) + # npm writes this marker on install; an older one means the lockfile changed since. + installed = ui_dir / "node_modules" / ".package-lock.json" + lockfile = ui_dir / "package-lock.json" + if not installed.is_file() or installed.stat().st_mtime < lockfile.stat().st_mtime: + _npm(ui_dir, "ci") + _npm(ui_dir, "run", "build") + return ui_dir / "dist" + + +def pack_ui(dist_dir, dest_dir, version): + dist_dir = Path(dist_dir) + dest_dir = Path(dest_dir) + shutil.rmtree(dest_dir, ignore_errors=True) + + for source in sorted(path for path in dist_dir.rglob("*") if path.is_file()): + name = source.relative_to(dist_dir) + dest = dest_dir / f"{name}.gz" + dest.parent.mkdir(parents=True, exist_ok=True) + # mtime=0 keeps the image byte-identical when sources are unchanged. + packed = gzip.compress(source.read_bytes(), compresslevel=9, mtime=0) + dest.write_bytes(packed) + print(f"Packed {name}: {source.stat().st_size} -> {len(packed)} bytes") + + # Plain text: the firmware reads it to refuse a UI built for another firmware version. + (dest_dir / "version.txt").write_text(version) + print(f"Stamped web UI with {version}") diff --git a/src/http/index_page.cpp b/src/http/index_page.cpp index e8754d3f..41c3daa9 100644 --- a/src/http/index_page.cpp +++ b/src/http/index_page.cpp @@ -1,1630 +1,69 @@ #include "http/index_page.h" +#include + +#include "firmware_version.h" #include "http/json.h" namespace { -static const char INDEX_HTML[] = R"html( - - - - -Tiny Engineer - - - -
-
-

Access token required

-

Enter the device access token to use the control panel.

-
-
- - -
-
Invalid access token.
- -
-
-
-
-
-

Factory reset done

-

Settings and WiFi credentials are cleared. Servo ranges, RGB LED mapping, and screen rotation stay. Power-cycle or press the device reset button.

-

Then join the robot WiFi shown on the OLED and open the setup page to configure home WiFi. You can retune servo ranges, screen rotation, and LED mapping there.

-
-
- +const char kIndexPath[] = "/ui/index.html.gz"; +const char kVersionPath[] = "/ui/version.txt"; -
+String uiVersion() { + File file = LittleFS.open(kVersionPath, "r"); -
-
-

Tiny Engineer

-

A desk robot that acts out your AI coding assistant while it works.

-
-
Loading status…
- -
+ if (!file) { + return "unknown"; + } -
-

API reference

-

JSON HTTP API on port 80. Optional Bearer auth when an access token is configured. Test routes are POST only. GET /auth is always public.

- - - - - - - - - - - - - - - - - - - - - - - - - - -
MethodPathDescription
GET/Web control panel
GET/animationsAnimations page
GET/testsHardware tests page
GET/servoServo control page
GET/configConfig page
GET/apiThis API reference
GET/authWhether API auth is required (always public)
GET/healthHealth JSON, no side effects
GET/settingsPersistent settings
POST/settingsUpdate settings (see parameters below)
POST/settings/resetFactory reset all settings to defaults
GET/animCurrent animation name
POST/animSet animation (see parameters below)
POST/playStream a WAV while an animation runs (see parameters below)
POST/test/audioPlay tone test
POST/test/audio/bellPlay bell WAV from LittleFS
POST/test/screenOLED demo
POST/test/movementAll servos exercise
POST/test/ledRGB LED cycle
POST/test/servoMove one servo (see parameters below)
POST/setup/servoSetup AP only: slow 0–180° move (see parameters below)
POST/setup/ledSetup AP only: light logical RGB or one WS2812 wire byte (see parameters below)
POST/setup/audioSetup AP only: play welcome.wav from LittleFS
POST/setup/oledSetup AP only: preview OLED 180° rotation (see parameters below)
-

POST /settings — query params (at least one required):

- - - - - - - - - - - - - - - - - -
ParamTypeRange
sleep_timeoutinteger1–1440 minutes (positive)
hostnamestring1–31 chars, letters/digits/hyphen
volumeinteger0–100 percent (speaker gain)
welcomeinteger0 or 1 (boot welcome animation)
serial_loginteger0 or 1 (USB serial debug logging)
continuous_timeoutinteger1–1440 minutes
loadingstringprogress or sleep_inertia (boot screen; next reboot)
eyes_stylestringclassic, kaomoji, cover, or dots (OLED eyes look; immediate)
access_tokenstring0–64 printable ASCII; empty clears (disables auth)
wifi_ssidstring1–32 chars; setup AP only; requires wifi_password
wifi_passwordstring0–63 chars; setup AP only; tested before save
servo_minsstring5 comma-separated ints 0–180; setup AP only; requires servo_maxs
servo_maxsstring5 comma-separated ints 0–180; setup AP only; each max > min
rgb_orderstringRGB, RBG, GRB, GBR, BRG, or BGR; setup AP only; default GRB
oled_rotate_180integer0 or 1; setup AP only; default 0
-

WiFi credentials, servo min/max, RGB LED mapping, and OLED rotation can only be set in setup AP mode. During setup, hostname may be sent with wifi_ssid and wifi_password (same hostname rules as Config). During setup (credentials not saved), control APIs return 503 wifi not configured; POST /setup/servo, POST /setup/led, POST /setup/audio, and POST /setup/oled stay available. Change WiFi later via factory reset. Servo ranges, RGB mapping, and screen rotation survive factory reset and can be retuned in the setup wizard.

-

POST /anim — query param name:

- - - - - - - - - - - - - - - -
ValueDescription
noneIdle pose
typingTyping gesture
readingReading gesture
thinkingThinking gesture
talkingSpeaking gesture
ringOne-shot bell gesture
welcomeOne-shot hello gesture
attentionInput-request gesture + audio
errorObstacle gesture + audio
abortAbort gesture + audio
deadOut-of-power: dead.wav, then X X
wakeupOne-shot sleep-inertia wake
sleepClose eyes and sleep
-

POST /play or /play/{name} — body: 16-bit mono PCM WAV at 22050 Hz. name runs while the clip plays:

- - - - - - - -
ValueDescription
talkingDefault
typingTyping gesture
readingReading gesture
thinkingThinking gesture
noneIdle pose
-

POST /test/servo — query params:

- - - - -
ParamTypeRange
indexinteger0–4
anglenumber0–180
-

POST /setup/servo — setup AP only, ~25°/s, no safe-range clamp. Query params:

- - - - - -
ParamTypeRange
allinteger90 (move every joint to 90°)
indexinteger0–4 (required with angle when all is omitted)
anglenumber0–180
-

POST /setup/led — setup AP only, no Wi-Fi gate. Lights a logical color or one WS2812 wire byte. Query params:

- - - - - -
ParamTypeRange
colorstringR, G, or B (logical; uses saved or rgb_order)
rgb_orderstringRGB, RBG, GRB, GBR, BRG, or BGR (optional with color)
bytestring0, 1, 2, or off (omit or off releases the hold)
-

POST /setup/audio — setup AP only, no Wi-Fi gate. Plays welcome.wav from LittleFS. No query params.

-

POST /setup/oled — setup AP only, no Wi-Fi gate. Preview OLED rotation and draw THIS WAY UP. Omit rotate_180 to restore the provisioning screen. Query params:

- - - -
ParamTypeRange
rotate_180integer0 or 1 (omit to restore provisioning text)
-
+ String version = file.readString(); + file.close(); + version.trim(); + return version; +} -
-

Animations

-

Set the robot’s current gesture. Continuous animations keep going until changed or timed out.

-
Current: …
-
- - - - - - - - - - - - -
-
+void sendUiUnavailable(WebServer& server, const String& heading, const String& detail) { + const String html = + String("") + + "Tiny Engineer

" + heading + "

" + detail + + "

Flash firmware and filesystem from the same build: " + "pio run -t upload over USB, or pio run -e ota -t ota over Wi-Fi.

" + ""; -
-

Servo control

-

Move a single servo to a target angle.

-
-
- - -

Safe range: 60–130°

-
-
- -
-
-
-
- -
-
0°90°180°
-
- -
-
-
- - -
-
-
+ httpSendHtml(server, 503, html.c_str()); +} -
-

Hardware tests

-

Run built-in diagnostics. Each test blocks until complete.

-

Play ascending tones through the speaker.

-

Play the bell WAV file from storage.

-

Draw a demo pattern on the OLED screen.

-

Exercise all five servos through a sweep.

-

Cycle the onboard RGB LED through colors.

-
+} // namespace -
-

Config

-

Saved to flash. Most changes apply right away.

-
-

Tiny Engineer setup

-

Step 1 of 5 · Servos

-
-
-
-
-

Attach the printed parts

-

All joints use the full 0–180° electrical scale. 90° is shaft center, not the middle of the safe band.

-

Press each printed part onto the servo shaft at rest: head toward the laptop, neck straight, hands down, body centered in the chair. Snug the shaft screws.

- - -
-
- -
- - - - - -
-
-
-

Network

After reboot
-
- - -

Letters, digits, hyphen — no .local

-
-
-
-

Timeouts

Immediate
-
- - -

Minutes in animation none before OLED blanks

-
-
- - -

Max time for typing / reading / thinking

-
-
-
-

Audio

Immediate
-
- -
- - -
-
-
-
-

Display

-
-
Immediate
- -

Classic is the default procedural eyes. Kaomoji maps each animation to a face from the expression library. Cover fills each half of the screen for a mask with eye holes; animations change height with black margins. Dots draws a 12x12 circle per eye; size and position follow the same poses as classic. Add new styles in firmware under display/eyes/styles/.

-
-
-
-

Boot behavior

-

Loading screen changes take effect on next boot.

-
-
After reboot
- -

Head motion during boot needs Welcome enabled.

-
-
-
Welcome on bootImmediate
-
- - -
-
-
-
Serial loggingImmediate
-
- - -
-
-
-
-

Security

-
- -
- - -
-

Auth disabled

-
-
- -
-
-

Factory reset

-

Erases saved settings and restores defaults. WiFi credentials are cleared. Servo ranges, RGB LED mapping, and screen rotation stay. Power-cycle the device to reopen setup AP mode and configure WiFi again.

- -
-
+void registerUiAssetRoutes(WebServer& server) { + // Bundler output under /ui/assets/ is content-hashed, so it never changes in place. + server.serveStatic("/ui/assets/", LittleFS, "/ui/assets/", "max-age=31536000, immutable"); + server.serveStatic("/ui/", LittleFS, "/ui/", "no-cache"); +} - +void sendIndexPage(WebServer& server) { + File page = LittleFS.open(kIndexPath, "r"); - - - -)html"; -} // namespace - -void sendIndexPage(WebServer& server) { - httpSendHtml(server, 200, INDEX_HTML); + server.sendHeader("Cache-Control", "no-cache"); + server.streamFile(page, "text/html; charset=utf-8"); + page.close(); } diff --git a/src/http/index_page.h b/src/http/index_page.h index 2a0bdf56..634bff89 100644 --- a/src/http/index_page.h +++ b/src/http/index_page.h @@ -2,4 +2,5 @@ #include +void registerUiAssetRoutes(WebServer& server); void sendIndexPage(WebServer& server); diff --git a/src/http/json.cpp b/src/http/json.cpp index 910225e8..dcb021e8 100644 --- a/src/http/json.cpp +++ b/src/http/json.cpp @@ -46,7 +46,6 @@ void httpSendJson(WebServer& server, int code, const char* body) { void httpSendHtml(WebServer& server, int code, const char* body) { sendCorsHeaders(server); - // send() copies into Arduino String and fails on the large panel. server.send_P(code, "text/html; charset=utf-8", body); } diff --git a/src/http/routes.cpp b/src/http/routes.cpp index cbf0c409..b24ae563 100644 --- a/src/http/routes.cpp +++ b/src/http/routes.cpp @@ -109,5 +109,6 @@ void registerHttpRoutes() { }); registerHttpTestRoutes(server); registerHttpSetupRoutes(server); + registerUiAssetRoutes(server); server.onNotFound(handleNotFound); } diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 00000000..b9470778 --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/ui/.htmlvalidate.json b/ui/.htmlvalidate.json new file mode 100644 index 00000000..b94358ab --- /dev/null +++ b/ui/.htmlvalidate.json @@ -0,0 +1,3 @@ +{ + "extends": ["html-validate:recommended", "html-validate:prettier"] +} diff --git a/ui/.prettierignore b/ui/.prettierignore new file mode 100644 index 00000000..3ad4c840 --- /dev/null +++ b/ui/.prettierignore @@ -0,0 +1,2 @@ +dist/ +package-lock.json diff --git a/ui/.prettierrc.json b/ui/.prettierrc.json new file mode 100644 index 00000000..de753c53 --- /dev/null +++ b/ui/.prettierrc.json @@ -0,0 +1,3 @@ +{ + "printWidth": 100 +} diff --git a/ui/.stylelintrc.json b/ui/.stylelintrc.json new file mode 100644 index 00000000..0a08a157 --- /dev/null +++ b/ui/.stylelintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["stylelint-config-standard"] +} diff --git a/ui/eslint.config.js b/ui/eslint.config.js new file mode 100644 index 00000000..138cf723 --- /dev/null +++ b/ui/eslint.config.js @@ -0,0 +1,45 @@ +import js from "@eslint/js"; +import stylistic from "@stylistic/eslint-plugin"; +import globals from "globals"; + +const controlFlow = [ + "if", + "for", + "while", + "do", + "switch", + "try", + "return", + "throw", + "break", + "continue", +]; + +export default [ + { ignores: ["dist/"] }, + js.configs.recommended, + { + plugins: { "@stylistic": stylistic }, + languageOptions: { globals: globals.browser }, + rules: { + curly: ["error", "all"], + eqeqeq: ["error", "always", { null: "ignore" }], + "no-var": "error", + "object-shorthand": "error", + "prefer-arrow-callback": "error", + "prefer-const": "error", + "prefer-template": "error", + "@stylistic/padding-line-between-statements": [ + "error", + { blankLine: "always", prev: "*", next: controlFlow }, + { blankLine: "always", prev: "block-like", next: "*" }, + { blankLine: "always", prev: ["const", "let"], next: "*" }, + { blankLine: "any", prev: ["const", "let"], next: ["const", "let"] }, + ], + }, + }, + { + files: ["*.config.js"], + languageOptions: { globals: globals.node }, + }, +]; diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 00000000..66656dfb --- /dev/null +++ b/ui/index.html @@ -0,0 +1,1050 @@ + + + + + + Tiny Engineer + + + +
+
+

Access token required

+

Enter the device access token to use the control panel.

+
+
+ + +
+
Invalid access token.
+ +
+
+
+
+
+

Factory reset done

+

+ Settings and WiFi credentials are cleared. Servo ranges, RGB LED mapping, and screen + rotation stay. Power-cycle or press the device reset button. +

+

+ Then join the robot WiFi shown on the OLED and open the setup page to configure home WiFi. + You can retune servo ranges, screen rotation, and LED mapping there. +

+
+
+ + +
+ +
+
+

Tiny Engineer

+

A desk robot that acts out your AI coding assistant while it works.

+
+
Loading status…
+ +
+ +
+

API reference

+

+ JSON HTTP API on port 80. Optional Bearer auth when an access token is configured. Test + routes are POST only. GET /auth is always public. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodPathDescription
GET/Web control panel
GET/animationsAnimations page
GET/testsHardware tests page
GET/servoServo control page
GET/configConfig page
GET/apiThis API reference
GET/ui/*Web UI assets from LittleFS
GET/authWhether API auth is required (always public)
GET/healthHealth JSON, no side effects
GET/settingsPersistent settings
POST/settingsUpdate settings (see parameters below)
POST/settings/resetFactory reset all settings to defaults
GET/animCurrent animation name
POST/animSet animation (see parameters below)
POST/playStream a WAV while an animation runs (see parameters below)
POST/test/audioPlay tone test
POST/test/audio/bellPlay bell WAV from LittleFS
POST/test/screenOLED demo
POST/test/movementAll servos exercise
POST/test/ledRGB LED cycle
POST/test/servoMove one servo (see parameters below)
POST/setup/servoSetup AP only: slow 0–180° move (see parameters below)
POST/setup/ledSetup AP only: light logical RGB or one WS2812 wire byte (see parameters below)
POST/setup/audioSetup AP only: play welcome.wav from LittleFS
POST/setup/oledSetup AP only: preview OLED 180° rotation (see parameters below)
+

POST /settings — query params (at least one required):

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParamTypeRange
sleep_timeoutinteger1–1440 minutes (positive)
hostnamestring1–31 chars, letters/digits/hyphen
volumeinteger0–100 percent (speaker gain)
welcomeinteger0 or 1 (boot welcome animation)
serial_loginteger0 or 1 (USB serial debug logging)
continuous_timeoutinteger1–1440 minutes
loadingstringprogress or sleep_inertia (boot screen; next reboot)
eyes_stylestring + classic, kaomoji, cover, or + dots (OLED eyes look; immediate) +
access_tokenstring0–64 printable ASCII; empty clears (disables auth)
wifi_ssidstring + 1–32 chars; setup AP only; requires + wifi_password +
wifi_passwordstring0–63 chars; setup AP only; tested before save
servo_minsstring + 5 comma-separated ints 0–180; setup AP only; requires + servo_maxs +
servo_maxsstring5 comma-separated ints 0–180; setup AP only; each max > min
rgb_orderstring + RGB, RBG, GRB, GBR, + BRG, or BGR; setup AP only; default GRB +
oled_rotate_180integer + 0 or 1; setup AP only; default + 0 +
+

+ WiFi credentials, servo min/max, RGB LED mapping, and OLED rotation can only be set in setup + AP mode. During setup, hostname may be sent with wifi_ssid and + wifi_password (same hostname rules as Config). During setup (credentials not + saved), control APIs return 503 wifi not configured; + POST /setup/servo, POST /setup/led, + POST /setup/audio, and POST /setup/oled stay available. Change + WiFi later via factory reset. Servo ranges, RGB mapping, and screen rotation survive factory + reset and can be retuned in the setup wizard. +

+

POST /anim — query param name:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ValueDescription
noneIdle pose
typingTyping gesture
readingReading gesture
thinkingThinking gesture
talkingSpeaking gesture
ringOne-shot bell gesture
welcomeOne-shot hello gesture
attentionInput-request gesture + audio
errorObstacle gesture + audio
abortAbort gesture + audio
deadOut-of-power: dead.wav, then X X
wakeupOne-shot sleep-inertia wake
sleepClose eyes and sleep
+

+ POST /play or /play/{name} — body: 16-bit mono PCM WAV at + 22050 Hz. name runs while the clip plays: +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ValueDescription
talkingDefault
typingTyping gesture
readingReading gesture
thinkingThinking gesture
noneIdle pose
+

POST /test/servo — query params:

+ + + + + + + + + + + + + + + + + + + + +
ParamTypeRange
indexinteger0–4
anglenumber0–180
+

+ POST /setup/servo — setup AP only, ~25°/s, no safe-range clamp. Query + params: +

+ + + + + + + + + + + + + + + + + + + + + + + + + +
ParamTypeRange
allinteger90 (move every joint to 90°)
indexinteger0–4 (required with angle when all is omitted)
anglenumber0–180
+

+ POST /setup/led — setup AP only, no Wi-Fi gate. Lights a logical color or + one WS2812 wire byte. Query params: +

+ + + + + + + + + + + + + + + + + + + + + + + + + +
ParamTypeRange
colorstring + R, G, or B (logical; uses saved or + rgb_order) +
rgb_orderstring + RGB, RBG, GRB, GBR, + BRG, or BGR (optional with color) +
bytestring + 0, 1, 2, or off (omit or + off releases the hold) +
+

+ POST /setup/audio — setup AP only, no Wi-Fi gate. Plays + welcome.wav from LittleFS. No query params. +

+

+ POST /setup/oled — setup AP only, no Wi-Fi gate. Preview OLED rotation + and draw THIS WAY UP. Omit rotate_180 to restore the provisioning + screen. Query params: +

+ + + + + + + + + + + + + + + +
ParamTypeRange
rotate_180integer0 or 1 (omit to restore provisioning text)
+
+ +
+

Animations

+

+ Set the robot’s current gesture. Continuous animations keep going until changed or + timed out. +

+
Current: …
+
+ + + + + + + + + + + + +
+
+ +
+

Servo control

+

Move a single servo to a target angle.

+
+
+ + +

Safe range: 60–130°

+
+
+ +
+
+
+
+
+
+ +
+
+ 0°90°180° +
+
+ +
+
+
+ + +
+
+
+ +
+

Hardware tests

+

Run built-in diagnostics. Each test blocks until complete.

+
+

Play ascending tones through the speaker.

+ +
+
+

Play the bell WAV file from storage.

+ +
+
+

Draw a demo pattern on the OLED screen.

+ +
+
+

Exercise all five servos through a sweep.

+ +
+
+

Cycle the onboard RGB LED through colors.

+ +
+
+ +
+

Config

+

Saved to flash. Most changes apply right away.

+
+

Tiny Engineer setup

+

Step 1 of 5 · Servos

+
+
+
+
+
+
+

Attach the printed parts

+

+ All joints use the full 0–180° electrical scale. + 90° is shaft center, not the middle of the safe band. +

+

+ Press each printed part onto the servo shaft at rest: head toward the laptop, neck + straight, hands down, body centered in the chair. Snug the shaft screws. +

+ + +
+
+ +
+ + + + + +
+
+
+
+

Network

+ After reboot +
+
+ + +

Letters, digits, hyphen — no .local

+
+
+
+
+

Timeouts

+ Immediate +
+
+ + +

Minutes in animation none before OLED blanks

+
+
+ + +

Max time for typing / reading / thinking

+
+
+
+
+

Audio

+ Immediate +
+
+ +
+ + +
+
+
+
+

Display

+
+
+ Immediate +
+ +

+ Classic is the default procedural eyes. Kaomoji maps each animation to a face from the + expression library. Cover fills each half of the screen for a mask with eye holes; + animations change height with black margins. Dots draws a 12x12 circle per eye; size + and position follow the same poses as classic. Add new styles in firmware under + display/eyes/styles/. +

+
+
+
+

Boot behavior

+

Loading screen changes take effect on next boot.

+
+
+ After reboot +
+ + +
+
+
+ Welcome on bootImmediate +
+
+ + +
+
+
+
+ Serial loggingImmediate +
+
+ + +
+
+
+
+

Security

+
+ +
+ + +
+

Auth disabled

+
+
+ +
+
+

Factory reset

+

+ Erases saved settings and restores defaults. WiFi credentials are cleared. Servo ranges, + RGB LED mapping, and screen rotation stay. Power-cycle the device to reopen setup AP mode + and configure WiFi again. +

+ +
+
+ + + + + + diff --git a/ui/package-lock.json b/ui/package-lock.json new file mode 100644 index 00000000..7a4d62bb --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,4282 @@ +{ + "name": "tiny-engineer-ui", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tiny-engineer-ui", + "devDependencies": { + "@eslint/js": "^10.0.1", + "@stylistic/eslint-plugin": "^5.10.0", + "eslint": "^10.12.0", + "globals": "^17.13.0", + "html-minifier-terser": "^7.2.0", + "html-validate": "^11.16.2", + "jsdom": "^30.1.2", + "prettier": "^3.9.9", + "stylelint": "^17.16.0", + "stylelint-config-standard": "^40.0.0", + "vite": "^8.3.3", + "vitest": "^5.0.3" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.3.tgz", + "integrity": "sha512-1t1U8Cm3RBl9vh3RAZhjkPaduzLSvQJRToRrxpGR/eDRW99mOREleuu+mWfiFa795bUzjwht7NgphCKTs2ianQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.4.2", + "@csstools/css-color-parser": "^4.2.5", + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.4.tgz", + "integrity": "sha512-YnVzxDxLaqL0t7Q4wfcgHZjg55Wm6y5EaO7Bhu3q9J7wq/8iw3PhsnWiOQhYekx4VexnbkgWpTT6n+QIV7kczw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.1.0", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.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", + "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", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz", + "integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.2.1" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "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.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", + "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.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz", + "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==", + "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.2", + "@csstools/css-calc": "^3.4.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", + "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.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", + "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", + "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.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "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/@csstools/media-query-list-parser": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-5.0.2.tgz", + "integrity": "sha512-qx19O8AbbLLbDSAcI6YOby/Vq8rV51psgIvlF5OZT/L8fQJDCyQifL6k+kYU4a8ySpzJ4RBzZkIs530bAn8y4Q==", + "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.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/selector-resolve-nested": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-4.0.2.tgz", + "integrity": "sha512-JpjNBJFA+4OnAHKRK4AMp8xrYyLhI9XapOeBlP2PhRU/IylSw+1ep3BFNTksBJnrTS3KT6kqKNVfpzLLwZUW5Q==", + "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" + }, + "peerDependencies": { + "postcss-selector-parser": "^7.1.1" + } + }, + "node_modules/@csstools/selector-specificity": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-6.0.1.tgz", + "integrity": "sha512-MBuqFIIvqk4S593MGAskTkPpEUUzZNvGzh8mMxEwl3oHwjQlXmqHEDxNbN3Y0Tsh+xjmem4GZjeg7I0qkwBivg==", + "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" + }, + "peerDependencies": { + "postcss-selector-parser": "^7.1.1" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@html-validate/stylish": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@html-validate/stylish/-/stylish-6.1.0.tgz", + "integrity": "sha512-ceNvF6/5hgvw/OrxXtdl2KurRVLN/Q/OkD/qminRRY/IIERmVaqJOpDF1nhRZMrOG/vyKKW0+Nxw6lSoHUP4Ow==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.16.0 || >= 24.0.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/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/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", + "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz", + "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==", + "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.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz", + "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==", + "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.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz", + "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==", + "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.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz", + "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==", + "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.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz", + "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==", + "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.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz", + "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==", + "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.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz", + "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==", + "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.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz", + "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==", + "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.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz", + "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==", + "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.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz", + "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==", + "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.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz", + "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==", + "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.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz", + "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==", + "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.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz", + "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==", + "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.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz", + "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==", + "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.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz", + "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==", + "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/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@stylistic/eslint-plugin": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.10.0.tgz", + "integrity": "sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/types": "^8.56.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "estraverse": "^5.3.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": "^9.0.0 || ^10.0.0" + } + }, + "node_modules/@stylistic/eslint-plugin/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@stylistic/eslint-plugin/node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/types": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.1.tgz", + "integrity": "sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", + "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.3", + "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.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", + "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "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/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/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/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camel-case": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", + "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pascal-case": "^3.1.2", + "tslib": "^2.0.3" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/clean-css": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", + "integrity": "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "source-map": "~0.6.0" + }, + "engines": { + "node": ">= 10.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colord": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.10.0.tgz", + "integrity": "sha512-AidJptpBJmjTclAp9BkLwJi0T93fo5epJnbaZslpg6QVzpHjAiveF55mE9AcUJiGMqRHgMDY8soMsQtuNYMHfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/cosmiconfig": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-functions-list": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.3.3.tgz", + "integrity": "sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "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/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/data-urls": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz", + "integrity": "sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.2" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/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/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.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/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.12.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.12.0.tgz", + "integrity": "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/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/get-east-asian-width": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/global-modules": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", + "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "global-prefix": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/global-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz", + "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.5", + "kind-of": "^6.0.2", + "which": "^1.3.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/global-prefix/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/globals": { + "version": "17.13.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz", + "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/globby/-/globby-16.2.4.tgz", + "integrity": "sha512-c8B/VNLmxRcmqqenRA9t+9IyOjf9+V6lTxPaUJLqOCONdQkWZ0ETYgX0qbtJqPsgCNusT9MZ5Jeidw8Eb9tn2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.5", + "is-path-inside": "^4.0.0", + "micromatch": "^4.0.8", + "slash": "^5.1.0", + "unicorn-magic": "^0.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby/node_modules/ignore": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/globjoin": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/globjoin/-/globjoin-0.1.4.tgz", + "integrity": "sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-5.0.1.tgz", + "integrity": "sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-encoding-sniffer": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/html-minifier-terser": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-7.2.0.tgz", + "integrity": "sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "camel-case": "^4.1.2", + "clean-css": "~5.3.2", + "commander": "^10.0.0", + "entities": "^4.4.0", + "param-case": "^3.0.4", + "relateurl": "^0.2.7", + "terser": "^5.15.1" + }, + "bin": { + "html-minifier-terser": "cli.js" + }, + "engines": { + "node": "^14.13.1 || >=16.0.0" + } + }, + "node_modules/html-tags": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-5.1.0.tgz", + "integrity": "sha512-n6l5uca7/y5joxZ3LUePhzmBFUJ+U2YWzhMa8XUTecSeSlQiZdF5XAd/Q3/WUl0VsXgUwWi8I7CNIwdI5WN1SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/html-validate": { + "version": "11.16.2", + "resolved": "https://registry.npmjs.org/html-validate/-/html-validate-11.16.2.tgz", + "integrity": "sha512-oci8F4t1AQdAwkOK895hOOrMTSY0zZ6fk4p2LCvGKQDjL/CYDTGyIoVTzZWII0DfwA7gpq7+qJxAKubUofgo1Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/html-validate" + } + ], + "license": "MIT", + "dependencies": { + "@html-validate/stylish": "^6.0.0", + "@sidvind/better-ajv-errors": "7.0.0", + "ajv": "^8.0.0", + "kleur": "^4.1.0", + "prompts": "^2.0.0", + "semver": "^7.0.0" + }, + "bin": { + "html-validate": "bin/html-validate.mjs" + }, + "engines": { + "node": "^22.22.0 || >= 24.8.0" + }, + "peerDependencies": { + "@jest/globals": "^29.0.3 || ^30.0.0", + "@vitest/expect": "^3.2.0 || ^4.0.1 || ^5.0.0", + "jest": "^29.0.3 || ^30.0.0", + "jest-snapshot": "^29.0.3 || ^30.0.0", + "vitest": "^3.2.0 || ^4.0.1 || ^5.0.0" + }, + "peerDependenciesMeta": { + "@jest/globals": { + "optional": true + }, + "@vitest/expect": { + "optional": true + }, + "jest": { + "optional": true + }, + "jest-snapshot": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/html-validate/node_modules/@sidvind/better-ajv-errors": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@sidvind/better-ajv-errors/-/better-ajv-errors-7.0.0.tgz", + "integrity": "sha512-imVsp5D3KxJ8+uUmu2dsLKnFg3qdX952v/L1gZoCa0NO2ivhQWHMpYM2KmpFrRXHWFjlqkNZ/935nxiTqXEi1A==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^22.12 || >= 24.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + } + }, + "node_modules/html-validate/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/html-validate/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.2.tgz", + "integrity": "sha512-0FFE/jE1rppmVfUrJUgxqXjcwolZYIGtAgj1pTussMhNZxIxi7W/PvfWaMNroGi2B36X1IKHbexpZ9DhkoOPiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^7.1.3", + "@asamuzakjp/dom-selector": "^9.2.4", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.15", + "@exodus/bytes": "^1.16.0", + "css-tree": "^3.2.1", + "data-urls": "^8.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^7.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "tough-cookie": "^6.0.2", + "undici": "^8.11.2", + "w3c-xmlserializer": "^6.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.2.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/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/mathml-tag-names": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-4.0.0.tgz", + "integrity": "sha512-aa6AU2Pcx0VP/XWnh8IGL0SYSgQHDT6Ucror2j2mXeFAlN3ahaNs8EZtG1YiticMkSLj3Gt6VPFfZogt7G5iFQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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/meow": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-14.1.0.tgz", + "integrity": "sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/param-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", + "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/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/pascal-case": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", + "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", + "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.19", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-safe-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.1.0.tgz", + "integrity": "sha512-1WzZxRLaAFwEh6Do+zyGpjWV3nGJNxxhuh7Ubu/q1ICImMgZJnLwhoaEpRbG4pJppp2Y1ncL9ffA2f+LhrefQg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", + "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prompts/node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/relateurl": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", + "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "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/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", + "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.152.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.12", + "@rolldown/binding-android-arm64": "1.2.12", + "@rolldown/binding-darwin-arm64": "1.2.12", + "@rolldown/binding-darwin-x64": "1.2.12", + "@rolldown/binding-freebsd-x64": "1.2.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", + "@rolldown/binding-linux-arm64-gnu": "1.2.12", + "@rolldown/binding-linux-arm64-musl": "1.2.12", + "@rolldown/binding-linux-ppc64-gnu": "1.2.12", + "@rolldown/binding-linux-s390x-gnu": "1.2.12", + "@rolldown/binding-linux-x64-gnu": "1.2.12", + "@rolldown/binding-linux-x64-musl": "1.2.12", + "@rolldown/binding-openharmony-arm64": "1.2.12", + "@rolldown/binding-win32-arm64-msvc": "1.2.12", + "@rolldown/binding-win32-x64-msvc": "1.2.12" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz", + "integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/stylelint": { + "version": "17.16.0", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.16.0.tgz", + "integrity": "sha512-ejlWnbJ13x1GU32+zZXa6sYQJsKKkwSdv2B4rAdO3OWdEmvhSJKcXi5pOphQYSzyaj1kXpuGJr7pcMBNfDIuPw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/stylelint" + }, + { + "type": "github", + "url": "https://github.com/sponsors/stylelint" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-syntax-patches-for-csstree": "^1.1.9", + "@csstools/css-tokenizer": "^4.0.0", + "@csstools/media-query-list-parser": "^5.0.0", + "@csstools/selector-resolve-nested": "^4.0.1", + "@csstools/selector-specificity": "^6.0.0", + "colord": "^2.10.0", + "cosmiconfig": "^9.0.2", + "css-functions-list": "^3.3.3", + "css-tree": "^3.2.1", + "debug": "^4.4.3", + "fast-glob": "^3.3.3", + "fastest-levenshtein": "^1.0.16", + "file-entry-cache": "^11.1.5", + "global-modules": "^2.0.0", + "globby": "^16.2.4", + "globjoin": "^0.1.4", + "html-tags": "^5.1.0", + "ignore": "^7.0.6", + "import-meta-resolve": "^4.2.0", + "mathml-tag-names": "^4.0.0", + "meow": "^14.1.0", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.5.26", + "postcss-safe-parser": "^7.0.1", + "postcss-selector-parser": "^7.1.5", + "postcss-value-parser": "^4.2.0", + "string-width": "^8.2.2", + "supports-hyperlinks": "^4.5.0", + "svg-tags": "^1.0.0", + "table": "^6.9.0", + "write-file-atomic": "^7.0.1" + }, + "bin": { + "stylelint": "bin/stylelint.mjs" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/stylelint-config-recommended": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-18.0.0.tgz", + "integrity": "sha512-mxgT2XY6YZ3HWWe3Di8umG6aBmWmHTblTgu/f10rqFXnyWxjKWwNdjSWkgkwCtxIKnqjSJzvFmPT5yabVIRxZg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/stylelint" + }, + { + "type": "github", + "url": "https://github.com/sponsors/stylelint" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "stylelint": "^17.0.0" + } + }, + "node_modules/stylelint-config-standard": { + "version": "40.0.0", + "resolved": "https://registry.npmjs.org/stylelint-config-standard/-/stylelint-config-standard-40.0.0.tgz", + "integrity": "sha512-EznGJxOUhtWck2r6dJpbgAdPATIzvpLdK9+i5qPd4Lx70es66TkBPljSg4wN3Qnc6c4h2n+WbUrUynQ3fanjHw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/stylelint" + }, + { + "type": "github", + "url": "https://github.com/sponsors/stylelint" + } + ], + "license": "MIT", + "dependencies": { + "stylelint-config-recommended": "^18.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "stylelint": "^17.0.0" + } + }, + "node_modules/stylelint/node_modules/ignore": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-hyperlinks": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-4.6.0.tgz", + "integrity": "sha512-FL/q/YePS4tFFTNiBOR+zPk2ZrKheqNtmTJ/9OUZ4e1wKHJofDbGQSmSsxS0XNnabSkqexryCaXGvEKz1vf9UQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^5.0.1", + "supports-color": "^10.2.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" + } + }, + "node_modules/svg-tags": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/svg-tags/-/svg-tags-1.0.0.tgz", + "integrity": "sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==", + "dev": true + }, + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/table/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/table/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/table/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/table/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/table/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/terser": { + "version": "5.51.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.51.2.tgz", + "integrity": "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.1.tgz", + "integrity": "sha512-IAbQaPJIIdhVxi0mqy9lez1wEwje0UQf9F1vwtbelEG5Nbgn0nPOxCvU+mNeNS9gyJTAhrHp74CbUaSynQpZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tldts": { + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.16.tgz", + "integrity": "sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.16" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.16.tgz", + "integrity": "sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-7.0.0.tgz", + "integrity": "sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/undici": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/unicorn-magic": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.1.tgz", + "integrity": "sha512-lzlXPoVB0Uy/FvTaUgX39RbixYiSKoc3JSoSf01+0c2B6FR3qdYIPN7Qjo/AV7n6sqLCk0509cT3LRj1oSZ1+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.3.tgz", + "integrity": "sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.11", + "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.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", + "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.3", + "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": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.3", + "@vitest/browser-preview": "5.0.3", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.3", + "@vitest/coverage-v8": "5.0.3", + "@vitest/ui": "5.0.3", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.2.0.tgz", + "integrity": "sha512-IhLIHNcBrzIixC5XXsbr4DxdjajyA5vQ5RwGZMtrUp+GbtE5Nrsge9QKau7jXrC0aTXLYGV0xiW7Tb4YS2aomw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.16.0", + "tr46": "^7.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/write-file-atomic": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.1.tgz", + "integrity": "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==", + "dev": true, + "license": "ISC", + "dependencies": { + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 00000000..bdc1b3b8 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,29 @@ +{ + "name": "tiny-engineer-ui", + "private": true, + "type": "module", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "scripts": { + "dev": "vite", + "build": "vite build", + "test": "vitest run", + "lint": "html-validate index.html && eslint . && stylelint \"src/**/*.css\" && prettier --check .", + "format": "prettier --write ." + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@stylistic/eslint-plugin": "^5.10.0", + "eslint": "^10.12.0", + "globals": "^17.13.0", + "html-minifier-terser": "^7.2.0", + "html-validate": "^11.16.2", + "jsdom": "^30.1.2", + "prettier": "^3.9.9", + "stylelint": "^17.16.0", + "stylelint-config-standard": "^40.0.0", + "vite": "^8.3.3", + "vitest": "^5.0.3" + } +} diff --git a/ui/src/animations.js b/ui/src/animations.js new file mode 100644 index 00000000..bb0eb26a --- /dev/null +++ b/ui/src/animations.js @@ -0,0 +1,35 @@ +import { apiGetJson } from "./api.js"; +import { $, $$ } from "./dom.js"; +import { perform, setStatus } from "./status.js"; + +const currentAnimation = $("#anim-current strong"); + +export async function refreshAnimation() { + try { + const anim = await apiGetJson("/anim"); + + if (anim.ok) { + currentAnimation.textContent = anim.animation; + } + } catch { + // The badge keeps its last value. + } +} + +for (const button of $$("[data-anim]")) { + button.addEventListener("click", async () => { + const result = await perform({ + path: "/anim", + params: { name: button.dataset.anim }, + pending: "Setting animation…", + failure: "Failed", + }); + + if (!result?.ok) { + return; + } + + setStatus(`Animation: ${result.data.animation}`, "ok"); + currentAnimation.textContent = result.data.animation; + }); +} diff --git a/ui/src/api.js b/ui/src/api.js new file mode 100644 index 00000000..6389cf7d --- /dev/null +++ b/ui/src/api.js @@ -0,0 +1,57 @@ +const TOKEN_KEY = "te_access_token"; + +// sessionStorage throws in some private modes; a missing token just means "not logged in". +export const token = { + get() { + try { + return sessionStorage.getItem(TOKEN_KEY) || ""; + } catch { + return ""; + } + }, + set(value) { + try { + if (value) { + sessionStorage.setItem(TOKEN_KEY, value); + } else { + sessionStorage.removeItem(TOKEN_KEY); + } + } catch { + // Ignored, see above. + } + }, + clear() { + this.set(""); + }, +}; + +const withQuery = (path, params) => (params ? `${path}?${new URLSearchParams(params)}` : path); + +export function apiFetch(path, options = {}) { + const headers = { ...options.headers }; + const stored = token.get(); + + if (stored) { + headers.Authorization = `Bearer ${stored}`; + } + + return fetch(path, { ...options, headers }); +} + +export async function apiGetJson(path) { + const response = await apiFetch(path); + + return response.json(); +} + +// `ok` also covers firmware errors reported as {"ok":false} in a 200 body. +export async function apiPost(path, params) { + const response = await apiFetch(withQuery(path, params), { method: "POST" }); + const data = await response.json(); + + return { ok: response.ok && data.ok !== false, data }; +} + +export function postQuietly(path, params) { + apiFetch(withQuery(path, params), { method: "POST" }).catch(() => {}); +} diff --git a/ui/src/config.js b/ui/src/config.js new file mode 100644 index 00000000..6827b7a6 --- /dev/null +++ b/ui/src/config.js @@ -0,0 +1,237 @@ +import { token } from "./api.js"; +import { $ } from "./dom.js"; +import { showRebootGate } from "./shell.js"; +import { clearStatus, perform, setStatus } from "./status.js"; + +const EYES_STYLES = ["classic", "kaomoji", "cover", "dots"]; +const DEFAULT_VOLUME = 70; + +const hostnameField = $("#config-hostname"); +const sleepField = $("#config-sleep"); +const continuousField = $("#config-continuous"); +const volumeField = $("#config-volume"); +const volumeSlider = $("#config-volume-slider"); +const welcomeToggle = $("#config-welcome"); +const serialLogToggle = $("#config-serial-log"); +const loadingSelect = $("#config-loading"); +const eyesStyleSelect = $("#config-eyes-style"); + +// The field shows a mask while a token is saved; typing replaces it, the toggle stages removal. +const accessToken = { + MASK: "********", + field: $("#config-access-token"), + toggle: $("#config-access-token-toggle"), + status: $("#config-access-token-status"), + configured: false, + masked: false, + clearPending: false, + + loadFromServer(isSet) { + this.configured = Boolean(isSet); + this.clearPending = false; + this.masked = this.configured; + this.render(); + }, + + // Value for the access_token param: "" clears, undefined leaves the saved token alone. + get pendingValue() { + if (this.clearPending) { + return ""; + } + + if (!this.masked && this.field.value) { + return this.field.value; + } + + return undefined; + }, + + render() { + const { field, toggle, status } = this; + + field.disabled = this.clearPending; + toggle.hidden = !this.configured && !this.clearPending; + + if (this.clearPending) { + field.value = ""; + field.placeholder = "Token will be removed on Save"; + toggle.textContent = "Undo"; + status.textContent = "Will remove on save"; + } else if (this.configured) { + if (this.masked) { + field.value = this.MASK; + } + + field.placeholder = "Enter a new token to replace"; + toggle.textContent = "Remove token"; + status.textContent = "Auth enabled — click field to replace"; + } else { + if (!this.masked) { + field.value = ""; + } + + field.placeholder = "Enter access token"; + status.textContent = "Auth disabled"; + } + }, +}; + +function setVolume(value) { + const parsed = parseInt(value, 10); + const volume = Number.isNaN(parsed) ? DEFAULT_VOLUME : Math.min(100, Math.max(0, parsed)); + + volumeField.value = volume; + volumeSlider.value = volume; + $("#config-volume-label").textContent = `${volume}%`; +} + +function updateWelcomeMotionHint() { + $("#config-welcome-motion-hint").hidden = + loadingSelect.value !== "sleep_inertia" || welcomeToggle.checked; +} + +const loadingMode = (settings) => + settings.loading === "sleep_inertia" ? "sleep_inertia" : "progress"; + +export function applyConfigSettings( + settings, + fallback = { hostname: "", continuous_timeout: 5, volume: DEFAULT_VOLUME }, +) { + hostnameField.value = settings.hostname || fallback.hostname; + sleepField.value = settings.sleep_timeout; + continuousField.value = settings.continuous_timeout ?? fallback.continuous_timeout; + setVolume(settings.volume ?? fallback.volume); + welcomeToggle.checked = settings.welcome !== false; + serialLogToggle.checked = Boolean(settings.serial_log); + loadingSelect.value = loadingMode(settings); + eyesStyleSelect.value = EYES_STYLES.includes(settings.eyes_style) + ? settings.eyes_style + : "classic"; + accessToken.loadFromServer(settings.access_token_set); + updateWelcomeMotionHint(); +} + +function saveMessage(previousHost, previousLoading, saved) { + const hostChanged = + (saved.hostname || previousHost) !== previousHost || Boolean(saved.reboot_required); + const loadingChanged = loadingMode(saved) !== previousLoading; + + if (hostChanged && loadingChanged) { + return "Saved. Hostname and loading screen apply after reboot."; + } + + if (hostChanged) { + return "Saved. Hostname applies after reboot."; + } + + if (loadingChanged) { + return "Saved. Loading screen applies after reboot."; + } + + return "Settings saved."; +} + +async function saveConfig() { + const host = hostnameField.value.trim(); + const previousLoading = loadingSelect.value; + const newToken = accessToken.pendingValue; + const params = { + sleep_timeout: sleepField.value, + hostname: host, + volume: volumeField.value, + welcome: welcomeToggle.checked ? 1 : 0, + serial_log: serialLogToggle.checked ? 1 : 0, + continuous_timeout: continuousField.value, + loading: loadingSelect.value, + eyes_style: eyesStyleSelect.value, + }; + + if (newToken !== undefined) { + params.access_token = newToken; + } + + const result = await perform({ + path: "/settings", + params, + pending: "Saving…", + failure: "Save failed", + }); + + if (!result?.ok) { + return; + } + + applyConfigSettings(result.data, { + hostname: host, + continuous_timeout: params.continuous_timeout, + volume: params.volume, + }); + + if (newToken !== undefined) { + token.set(newToken); + } + + setStatus(saveMessage(host, previousLoading, result.data), "ok"); +} + +async function factoryReset() { + const confirmed = confirm( + "Reset settings to factory defaults? WiFi credentials will be cleared. Servo ranges, RGB LED mapping, and screen rotation stay. Power-cycle the device to reopen setup AP mode and configure WiFi again.", + ); + + if (!confirmed) { + return; + } + + const result = await perform({ + path: "/settings/reset", + pending: "Resetting…", + failure: "Factory reset failed", + }); + + if (!result?.ok) { + return; + } + + token.clear(); + accessToken.loadFromServer(false); + clearStatus(); + showRebootGate(true); +} + +volumeSlider.addEventListener("input", () => setVolume(volumeSlider.value)); +volumeField.addEventListener("input", () => setVolume(volumeField.value)); +loadingSelect.addEventListener("change", updateWelcomeMotionHint); +welcomeToggle.addEventListener("change", updateWelcomeMotionHint); + +accessToken.field.addEventListener("focus", () => { + if (!accessToken.masked) { + return; + } + + accessToken.field.value = ""; + accessToken.masked = false; +}); +accessToken.field.addEventListener("blur", () => { + if (accessToken.clearPending || !accessToken.configured) { + return; + } + + if (accessToken.field.value.trim() !== "") { + return; + } + + accessToken.masked = true; + accessToken.render(); +}); +accessToken.toggle.addEventListener("click", () => { + accessToken.clearPending = !accessToken.clearPending; + accessToken.masked = !accessToken.clearPending; + accessToken.render(); +}); + +$("#config-form").addEventListener("submit", (event) => { + event.preventDefault(); + saveConfig(); +}); +$("#config-factory-reset").addEventListener("click", factoryReset); diff --git a/ui/src/device.js b/ui/src/device.js new file mode 100644 index 00000000..de2206ed --- /dev/null +++ b/ui/src/device.js @@ -0,0 +1,7 @@ +export const device = { + provisioning: false, + wifiConfigured: false, + get inSetup() { + return this.provisioning || !this.wifiConfigured; + }, +}; diff --git a/ui/src/dom.js b/ui/src/dom.js new file mode 100644 index 00000000..2d6241a7 --- /dev/null +++ b/ui/src/dom.js @@ -0,0 +1,14 @@ +export const $ = (selector, root = document) => root.querySelector(selector); +export const $$ = (selector, root = document) => root.querySelectorAll(selector); + +export function element(tag, className, text) { + const node = document.createElement(tag); + + node.className = className; + + if (text != null) { + node.textContent = text; + } + + return node; +} diff --git a/ui/src/health.js b/ui/src/health.js new file mode 100644 index 00000000..034574e3 --- /dev/null +++ b/ui/src/health.js @@ -0,0 +1,93 @@ +import { apiGetJson } from "./api.js"; +import { $ } from "./dom.js"; + +const healthInfo = $("#health-info"); +let pollTimer = null; + +export function setFirmwareVersion(health) { + if (health.ok && health.version) { + $("#fw-version").textContent = `Firmware ${health.version}`; + } +} + +function formatUptime(ms) { + const totalSeconds = Math.floor(ms / 1000); + const seconds = totalSeconds % 60; + const minutes = Math.floor(totalSeconds / 60) % 60; + const hours = Math.floor(totalSeconds / 3600) % 24; + const days = Math.floor(totalSeconds / 86400); + + if (totalSeconds < 60) { + return `${seconds} s`; + } + + if (totalSeconds < 3600) { + return `${minutes} min ${seconds} s`; + } + + if (totalSeconds < 86400) { + return `${hours} h ${minutes} min`; + } + + return `${days} d ${hours} h`; +} + +function formatBytes(bytes) { + if (bytes >= 1048576) { + return `${(bytes / 1048576).toFixed(1)} MB`; + } + + if (bytes >= 1024) { + return `${Math.round(bytes / 1024)} KB`; + } + + return `${bytes} B`; +} + +function describeHealth(health) { + const ip = health.wifi?.connected ? health.wifi.ip : "offline"; + const heapUsed = health.heap_size + ? Math.round((1 - health.free_heap / health.heap_size) * 100) + : 0; + const parts = [ + `IP: ${ip}`, + `Uptime: ${formatUptime(health.uptime_ms)}`, + `Heap: ${heapUsed}% used (${formatBytes(health.free_heap)} free)`, + ]; + + if (typeof health.cpu_temp_c === "number") { + parts.push(`Temp: ${health.cpu_temp_c.toFixed(1)} °C`); + } + + if (health.version) { + parts.push(health.version); + } + + return parts.join(" · "); +} + +async function loadHealth() { + try { + const health = await apiGetJson("/health"); + + if (!health.ok) { + throw new Error("health not ok"); + } + + setFirmwareVersion(health); + healthInfo.textContent = describeHealth(health); + } catch { + healthInfo.textContent = "Could not load status."; + } +} + +export function startHealthPolling() { + stopHealthPolling(); + loadHealth(); + pollTimer = setInterval(loadHealth, 1000); +} + +export function stopHealthPolling() { + clearInterval(pollTimer); + pollTimer = null; +} diff --git a/ui/src/main.js b/ui/src/main.js new file mode 100644 index 00000000..c199fd01 --- /dev/null +++ b/ui/src/main.js @@ -0,0 +1,7 @@ +import "./animations.js"; +import "./config.js"; +import "./servo.js"; +import "./tests.js"; +import { boot } from "./shell.js"; + +boot(); diff --git a/ui/src/servo-ranges.js b/ui/src/servo-ranges.js new file mode 100644 index 00000000..c206aae4 --- /dev/null +++ b/ui/src/servo-ranges.js @@ -0,0 +1,23 @@ +export const DEFAULT_SERVO_RANGES = [ + [60, 130], + [40, 130], + [45, 135], + [35, 125], + [40, 130], +]; + +export const cloneRanges = (ranges) => ranges.map(([min, max]) => [min, max]); + +export const servoRanges = cloneRanges(DEFAULT_SERVO_RANGES); + +export function applyServoRanges(settings) { + const { servo_mins: mins, servo_maxs: maxs } = settings; + + if (mins?.length !== servoRanges.length || maxs?.length !== servoRanges.length) { + return; + } + + mins.forEach((min, index) => { + servoRanges[index] = [min, maxs[index]]; + }); +} diff --git a/ui/src/servo.js b/ui/src/servo.js new file mode 100644 index 00000000..c02a2820 --- /dev/null +++ b/ui/src/servo.js @@ -0,0 +1,82 @@ +import { $ } from "./dom.js"; +import { servoRanges } from "./servo-ranges.js"; +import { perform, setStatus } from "./status.js"; + +const servoSelect = $("#servo-index"); +const slider = $("#servo-slider"); +const angleInput = $("#servo-angle"); +const rangeHint = $("#servo-range-hint"); + +const currentRange = () => servoRanges[Number(servoSelect.value)] ?? servoRanges[0]; + +function currentMid() { + const [min, max] = currentRange(); + + return (min + max) / 2; +} + +function updateRangeHint() { + const [min, max] = currentRange(); + const angle = parseFloat(angleInput.value); + const inRange = angle >= min && angle <= max; + + rangeHint.textContent = inRange + ? `Safe range: ${min}–${max}°` + : `Outside safe range — firmware clamps to ${min}–${max}°`; + rangeHint.classList.toggle("warn", !inRange); +} + +function setAngle(angle) { + slider.value = angle; + angleInput.value = angle; + updateRangeHint(); +} + +export function refreshServoPage() { + const [min, max] = currentRange(); + + for (const input of [slider, angleInput]) { + input.min = min; + input.max = max; + } + + const angle = parseFloat(angleInput.value); + + setAngle(angle >= min && angle <= max ? angle : currentMid()); + $("#servo-scale-min").textContent = `${min}°`; + $("#servo-scale-mid").textContent = `${Math.round(currentMid())}°`; + $("#servo-scale-max").textContent = `${max}°`; +} + +async function moveServo() { + const index = servoSelect.value; + const angle = angleInput.value; + const result = await perform({ + path: "/test/servo", + params: { index, angle }, + pending: "Moving servo…", + failure: "Move failed", + }); + + if (result?.ok) { + setStatus(`Servo ${index} moved to ${angle}°.`, "ok"); + } +} + +slider.addEventListener("input", () => { + angleInput.value = slider.value; + updateRangeHint(); +}); +angleInput.addEventListener("input", () => { + slider.value = angleInput.value; + updateRangeHint(); +}); +servoSelect.addEventListener("change", refreshServoPage); +$("#servo-center").addEventListener("click", () => { + setAngle(currentMid()); + moveServo(); +}); +$("#servo-form").addEventListener("submit", (event) => { + event.preventDefault(); + moveServo(); +}); diff --git a/ui/src/settings.js b/ui/src/settings.js new file mode 100644 index 00000000..80244173 --- /dev/null +++ b/ui/src/settings.js @@ -0,0 +1,25 @@ +import { apiGetJson } from "./api.js"; +import { applyConfigSettings } from "./config.js"; +import { device } from "./device.js"; +import { refreshServoPage } from "./servo.js"; +import { applyServoRanges } from "./servo-ranges.js"; +import { applyWizardSettings } from "./setup/wizard.js"; +import { setStatus } from "./status.js"; + +export async function loadSettings() { + try { + const settings = await apiGetJson("/settings"); + + if (!settings.ok) { + return; + } + + device.wifiConfigured = Boolean(settings.wifi_configured); + applyConfigSettings(settings); + applyServoRanges(settings); + refreshServoPage(); + applyWizardSettings(settings); + } catch { + setStatus("Could not load settings", "err"); + } +} diff --git a/ui/src/setup/calibration.js b/ui/src/setup/calibration.js new file mode 100644 index 00000000..81867761 --- /dev/null +++ b/ui/src/setup/calibration.js @@ -0,0 +1,151 @@ +import { $, $$ } from "../dom.js"; +import { cloneRanges, DEFAULT_SERVO_RANGES, servoRanges } from "../servo-ranges.js"; +import { clearStatus, isBusy, perform, setStatus } from "../status.js"; + +const JOINT_COPY = [ + "Pitch only. Watch cable slack to the head. Stop before the head hits the neck piece. Down is toward the laptop; up is away.", + "Yaw left and right. Stop when the cables pull taut. Do not twist until the loom binds.", + "Lowest is forearm horizontal. Highest is upper arm horizontal. On this servo, higher angle is up.", + "Same physical stops, inverted scale: higher angle is down. Physical lowest (forearm horizontal) is toward the high end of the bar; physical highest (upper arm horizontal) is toward the low end.", + "Rotate until the right hand sits over the bell at the extreme. Prefer a band symmetric about 90° (hint only — stock 40–130 is fine).", +]; +const BODY_JOINT = 4; +const CENTER = 90; + +const marker = $("#setup-calib-marker"); +const readout = $("#setup-calib-angle"); + +async function postSetupServo(params) { + const result = await perform({ + path: "/setup/servo", + params, + pending: "Moving servos…", + failure: "Move failed", + }); + + if (result?.ok) { + clearStatus(); + } + + return result; +} + +// Servo step state: "horns" while the printed parts go on, then "ranges" to find each joint's limits. +export const calibration = { + phase: "horns", + joint: 0, + angles: Array(DEFAULT_SERVO_RANGES.length).fill(CENTER), + ranges: cloneRanges(servoRanges), + + get angle() { + return this.angles[this.joint]; + }, + + get valid() { + return this.ranges.every(([min, max]) => min < max); + }, + + reset() { + this.phase = "horns"; + this.joint = 0; + this.angles.fill(CENTER); + this.ranges = cloneRanges(servoRanges); + }, + + showAngle() { + readout.textContent = String(this.angle); + marker.style.left = `${(this.angle / 180) * 100}%`; + }, + + async nudge(delta) { + const { joint } = this; + const previous = this.angle; + const next = Math.max(0, Math.min(180, previous + delta)); + + if (isBusy() || next === previous) { + return; + } + + this.angles[joint] = next; + this.showAngle(); + const result = await postSetupServo({ index: joint, angle: next }); + + if (result?.ok) { + return; + } + + this.angles[joint] = previous; + this.showAngle(); + }, + + setLimit(bound) { + const range = this.ranges[this.joint]; + + if (bound === "min" && this.angle >= range[1]) { + setStatus("Min must be less than max.", "err"); + + return; + } + + if (bound === "max" && this.angle <= range[0]) { + setStatus("Max must be greater than min.", "err"); + + return; + } + + range[bound === "min" ? 0 : 1] = this.angle; + this.render(); + }, + + render() { + const [min, max] = this.ranges[this.joint]; + const band = $("#setup-calib-band"); + + band.style.left = `${(min / 180) * 100}%`; + band.style.width = `${((max - min) / 180) * 100}%`; + $("#setup-min-label").textContent = min; + $("#setup-max-label").textContent = max; + $("#setup-joint-copy").textContent = JOINT_COPY[this.joint]; + + for (const tab of $$("#setup-joint-tabs [data-joint]")) { + tab.classList.toggle("active", Number(tab.dataset.joint) === this.joint); + } + + const symmetry = $("#setup-body-sym"); + + symmetry.hidden = this.joint !== BODY_JOINT; + symmetry.textContent = `Distance below 90°: ${CENTER - min}° · above 90°: ${max - CENTER}°`; + $("#setup-next").disabled = !this.valid; + this.showAngle(); + }, +}; + +$("#setup-move-90").addEventListener("click", async () => { + const result = await postSetupServo({ all: CENTER }); + + if (result?.ok) { + calibration.angles.fill(CENTER); + } +}); + +for (const tab of $$("#setup-joint-tabs [data-joint]")) { + tab.addEventListener("click", () => { + if (isBusy()) { + return; + } + + calibration.joint = Number(tab.dataset.joint); + calibration.render(); + }); +} + +for (const button of $$(".calib-nudge [data-nudge]")) { + button.addEventListener("click", () => calibration.nudge(Number(button.dataset.nudge))); +} + +$("#setup-set-min").addEventListener("click", () => calibration.setLimit("min")); +$("#setup-set-max").addEventListener("click", () => calibration.setLimit("max")); +$("#setup-reset-joint").addEventListener("click", () => { + calibration.ranges[calibration.joint] = [...DEFAULT_SERVO_RANGES[calibration.joint]]; + calibration.render(); +}); diff --git a/ui/src/setup/led.js b/ui/src/setup/led.js new file mode 100644 index 00000000..15088796 --- /dev/null +++ b/ui/src/setup/led.js @@ -0,0 +1,119 @@ +import { apiPost, postQuietly } from "../api.js"; +import { $, $$, element } from "../dom.js"; +import { clearStatus, isBusy, setStatus } from "../status.js"; + +const RGB_ORDERS = ["RGB", "RBG", "GRB", "GBR", "BRG", "BGR"]; +const DEFAULT_ORDER = "GRB"; + +async function preview(params) { + try { + const result = await apiPost("/setup/led", params); + + if (result.ok) { + clearStatus(); + } else { + setStatus(result.data.error || "LED preview failed", "err"); + } + } catch { + setStatus("Network error", "err"); + } +} + +// `looks` holds the color seen for each WS2812 wire byte; together they spell the RGB order. +export const ledMapping = { + savedOrder: DEFAULT_ORDER, + looks: [...DEFAULT_ORDER], + remapOpen: false, + + get order() { + return this.looks.join(""); + }, + + get valid() { + return RGB_ORDERS.includes(this.order); + }, + + applySavedOrder(order) { + if (RGB_ORDERS.includes(order)) { + this.savedOrder = order; + } + + this.looks = [...this.savedOrder]; + }, + + reset() { + this.applySavedOrder(this.savedOrder); + this.remapOpen = false; + this.release(); + }, + + release() { + postQuietly("/setup/led", { byte: "off" }); + }, + + render() { + $("#setup-led-remap").hidden = !this.remapOpen; + + if (!this.remapOpen) { + return; + } + + for (const row of $$(".led-looks")) { + const picked = this.looks[Number(row.dataset.ledByte)]; + + for (const button of $$("[data-look]", row)) { + button.classList.toggle("active", button.dataset.look === picked); + } + } + + $("#setup-led-map").replaceChildren( + ...this.looks.map((channel) => { + const chip = element("div", "led-chip-wrap"); + + chip.append( + element("div", `led-chip led-${channel.toLowerCase()}`), + element("span", "led-chip-letter", channel), + ); + + return chip; + }), + ); + }, +}; + +for (const button of $$("[data-led-color]")) { + button.addEventListener("click", () => { + if (isBusy()) { + return; + } + + const order = ledMapping.valid ? ledMapping.order : ledMapping.savedOrder; + + preview({ color: button.dataset.ledColor, rgb_order: order }); + }); +} + +$("#setup-led-remap-toggle").addEventListener("click", () => { + ledMapping.remapOpen = true; + ledMapping.render(); +}); + +for (const button of $$(".led-light[data-led-byte]")) { + button.addEventListener("click", () => { + if (isBusy()) { + return; + } + + preview({ byte: button.dataset.ledByte }); + }); +} + +for (const button of $$(".led-looks [data-look]")) { + button.addEventListener("click", () => { + const byte = Number(button.closest(".led-looks").dataset.ledByte); + + ledMapping.looks[byte] = button.dataset.look; + ledMapping.render(); + $("#setup-next").disabled = !ledMapping.valid; + }); +} diff --git a/ui/src/setup/wizard.js b/ui/src/setup/wizard.js new file mode 100644 index 00000000..0effb196 --- /dev/null +++ b/ui/src/setup/wizard.js @@ -0,0 +1,300 @@ +import { postQuietly } from "../api.js"; +import { device } from "../device.js"; +import { $ } from "../dom.js"; +import { refreshServoPage } from "../servo.js"; +import { applyServoRanges, cloneRanges, servoRanges } from "../servo-ranges.js"; +import { syncSetupUi } from "../shell.js"; +import { clearStatus, isBusy, perform, setStatus } from "../status.js"; +import { calibration } from "./calibration.js"; +import { ledMapping } from "./led.js"; + +const STEPS = [ + { id: "servos", title: "Servos", next: "Next: Screen" }, + { id: "oled", title: "Screen", next: "Next: RGB mapping" }, + { id: "led", title: "RGB mapping", next: "Next: Speaker" }, + { id: "speaker", title: "Speaker", next: "Next: Network" }, + { id: "network", title: "Network" }, +]; + +const nextButton = $("#setup-next"); +let stepIndex = 0; +let oledRotate180 = false; + +const currentStep = () => STEPS[stepIndex]; + +function goTo(id) { + stepIndex = STEPS.findIndex((step) => step.id === id); +} + +function previewOled() { + postQuietly("/setup/oled", { rotate_180: oledRotate180 ? 1 : 0 }); +} + +function saveSettings(params, pending) { + return perform({ + path: "/settings", + params, + pending, + failure: "Save failed", + }); +} + +export function renderWizard() { + const step = currentStep(); + const placingHorns = step.id === "servos" && calibration.phase === "horns"; + + $("#setup-progress-label").textContent = + `Step ${stepIndex + 1} of ${STEPS.length} · ${step.title}`; + $("#setup-progress-bar").style.width = `${((stepIndex + 1) / STEPS.length) * 100}%`; + + for (const { id } of STEPS) { + $(`#setup-step-${id}`).hidden = id !== step.id; + } + + $("#setup-phase-horns").hidden = !placingHorns; + $("#setup-phase-ranges").hidden = step.id !== "servos" || placingHorns; + $("#setup-footer").hidden = placingHorns; + $("#setup-back").hidden = placingHorns; + nextButton.hidden = !step.next || placingHorns; + nextButton.textContent = step.next ?? "Next"; + nextButton.disabled = false; + + if (step.id === "servos") { + nextButton.disabled = !calibration.valid; + + if (!placingHorns) { + calibration.render(); + } + } + + if (step.id === "led") { + nextButton.disabled = !ledMapping.valid; + ledMapping.render(); + } + + if (step.id === "network") { + $("#config-wifi-ssid").focus(); + } +} + +export function resetWizard() { + stepIndex = 0; + calibration.reset(); + ledMapping.reset(); + renderWizard(); +} + +export function applyWizardSettings(settings) { + $("#config-wifi-hostname").value = settings.hostname || ""; + ledMapping.applySavedOrder(settings.rgb_order); + + if (typeof settings.oled_rotate_180 === "boolean") { + oledRotate180 = settings.oled_rotate_180; + } + + calibration.ranges = cloneRanges(servoRanges); + + if (device.inSetup) { + renderWizard(); + } +} + +const advance = { + async servos() { + if (!calibration.valid) { + return; + } + + const result = await saveSettings( + { + servo_mins: calibration.ranges.map(([min]) => min).join(","), + servo_maxs: calibration.ranges.map(([, max]) => max).join(","), + }, + "Saving servo ranges…", + ); + + if (!result?.ok) { + return; + } + + applyServoRanges(result.data); + calibration.ranges = cloneRanges(servoRanges); + refreshServoPage(); + goTo("oled"); + previewOled(); + clearStatus(); + }, + + async oled() { + const result = await saveSettings( + { oled_rotate_180: oledRotate180 ? 1 : 0 }, + "Saving screen rotation…", + ); + + if (!result?.ok) { + return; + } + + if (typeof result.data.oled_rotate_180 === "boolean") { + oledRotate180 = result.data.oled_rotate_180; + } + + goTo("led"); + clearStatus(); + }, + + async led() { + if (!ledMapping.valid) { + return; + } + + const result = await saveSettings({ rgb_order: ledMapping.order }, "Saving LED mapping…"); + + if (!result?.ok) { + return; + } + + ledMapping.applySavedOrder(result.data.rgb_order); + ledMapping.release(); + goTo("speaker"); + clearStatus(); + }, + + speaker() { + goTo("network"); + }, +}; + +const retreat = { + servos() { + calibration.phase = "horns"; + }, + oled() { + postQuietly("/setup/oled"); + goTo("servos"); + calibration.phase = "ranges"; + }, + led() { + ledMapping.release(); + goTo("oled"); + previewOled(); + }, + speaker() { + goTo("led"); + }, + network() { + goTo("speaker"); + }, +}; + +nextButton.addEventListener("click", async () => { + if (isBusy()) { + return; + } + + await advance[currentStep().id]?.(); + // perform() re-enables every button, so restore this step's Next state. + renderWizard(); +}); + +$("#setup-back").addEventListener("click", () => { + retreat[currentStep().id](); + renderWizard(); +}); + +$("#setup-horns-done").addEventListener("click", () => { + calibration.phase = "ranges"; + calibration.joint = 0; + calibration.angles[0] = 90; + renderWizard(); +}); + +$("#setup-oled-rotate").addEventListener("click", () => { + if (isBusy()) { + return; + } + + oledRotate180 = !oledRotate180; + previewOled(); +}); + +$("#setup-audio-play").addEventListener("click", async () => { + const result = await perform({ + path: "/setup/audio", + pending: "Playing…", + failure: "Playback failed", + }); + + if (result?.ok) { + setStatus("Done.", "ok"); + } + + renderWizard(); +}); + +$("#config-wifi-password-toggle").addEventListener("click", (event) => { + const field = $("#config-wifi-password"); + const reveal = field.type === "password"; + + field.type = reveal ? "text" : "password"; + event.currentTarget.textContent = reveal ? "Hide" : "Show"; +}); + +$("#config-wifi-connect").addEventListener("click", async () => { + if (isBusy()) { + return; + } + + const ssid = $("#config-wifi-ssid").value.trim(); + const passwordField = $("#config-wifi-password"); + const hostnameField = $("#config-wifi-hostname"); + + hostnameField.value = hostnameField.value.trim(); + + if (!ssid) { + setStatus("Enter a WiFi network name.", "err"); + + return; + } + + if (!hostnameField.reportValidity()) { + return; + } + + const result = await perform({ + path: "/settings", + params: { + wifi_ssid: ssid, + wifi_password: passwordField.value, + hostname: hostnameField.value, + }, + pending: "Testing WiFi credentials…", + failure: "WiFi connection failed", + }); + + if (!result?.ok) { + return; + } + + const { wifi_connect_success: connected, wifi_ip: ip, wifi_hostname: hostname } = result.data; + + if (!connected) { + setStatus(result.data.error || "WiFi connection failed", "err"); + + return; + } + + passwordField.value = ""; + device.wifiConfigured = true; + device.provisioning = false; + document.body.classList.remove("setup-mode"); + syncSetupUi(); + const links = [ip, hostname].filter(Boolean).map((host) => `http://${host}`); + + setStatus( + links.length + ? `WiFi connected. Rejoin your home network and open ${links.join(" or ")}.` + : "WiFi connected. Rejoin your home network.", + "ok", + ); +}); diff --git a/ui/src/shell.js b/ui/src/shell.js new file mode 100644 index 00000000..114919ad --- /dev/null +++ b/ui/src/shell.js @@ -0,0 +1,190 @@ +import { refreshAnimation } from "./animations.js"; +import { apiFetch, apiGetJson, token } from "./api.js"; +import { device } from "./device.js"; +import { $, $$ } from "./dom.js"; +import { setFirmwareVersion, startHealthPolling, stopHealthPolling } from "./health.js"; +import { loadSettings } from "./settings.js"; +import { renderWizard, resetWizard } from "./setup/wizard.js"; + +const VIEWS = { + "/": "view-home", + "/animations": "view-animations", + "/servo": "view-servo", + "/tests": "view-tests", + "/config": "view-config", + "/api": "view-api", +}; + +const authGate = $("#auth-gate"); +const authError = $("#auth-error"); +const authTokenField = $("#auth-token"); +const rebootGate = $("#reboot-gate"); +let unlocked = false; + +function showPage(path) { + if (!unlocked) { + return; + } + + const viewId = VIEWS[path] ?? "view-home"; + + for (const view of $$(".view")) { + view.classList.toggle("active", view.id === viewId); + } + + for (const link of $$("nav a")) { + link.classList.toggle("active", link.dataset.nav === path); + } + + if (viewId === "view-animations") { + refreshAnimation(); + } + + if (viewId === "view-home") { + startHealthPolling(); + } else { + stopHealthPolling(); + } +} + +export function showAuthGate(show) { + authGate.classList.toggle("show", show); + rebootGate.classList.remove("show"); + document.body.classList.toggle("locked", show); + + if (!show) { + return; + } + + authError.classList.remove("show"); + authTokenField.value = ""; + authTokenField.focus(); +} + +export function showRebootGate(show) { + rebootGate.classList.toggle("show", show); + authGate.classList.remove("show"); + document.body.classList.toggle("locked", show); + + if (!show) { + return; + } + + unlocked = false; + stopHealthPolling(); +} + +export async function syncSetupUi() { + try { + const health = await apiGetJson("/health"); + + if (!health.ok) { + return; + } + + setFirmwareVersion(health); + device.provisioning = Boolean(health.provisioning); + device.wifiConfigured = Boolean(health.wifi_configured); + const { inSetup } = device; + + document.body.classList.toggle("setup-mode", inSetup); + $("#config-page-title").textContent = inSetup ? "WiFi setup" : "Config"; + $("#config-page-desc").textContent = inSetup + ? "Calibrate servos, set screen orientation, check the LED and speaker, then enter a device name and your home WiFi network. The robot tests the connection before saving." + : "Saved to flash. Most changes apply right away."; + + if (inSetup) { + renderWizard(); + } + } catch { + // Keep the normal UI; the next health poll or reload retries. + } +} + +async function enterApp() { + unlocked = true; + showAuthGate(false); + showRebootGate(false); + await syncSetupUi(); + + if (device.inSetup) { + resetWizard(); + showPage("/config"); + } else { + showPage(location.pathname); + } + + loadSettings(); +} + +async function tokenAccepted() { + const response = await apiFetch("/settings"); + + return response.status !== 401; +} + +async function unlockWithToken(event) { + event.preventDefault(); + const candidate = authTokenField.value; + + if (!candidate) { + return; + } + + token.set(candidate); + authError.classList.remove("show"); + + try { + if (await tokenAccepted()) { + enterApp(); + + return; + } + } catch { + // Treated as a rejected token below. + } + + token.clear(); + authError.classList.add("show"); +} + +export async function boot() { + document.body.classList.add("locked"); + $("#auth-form").addEventListener("submit", unlockWithToken); + + let auth; + + try { + auth = await (await fetch("/auth")).json(); + } catch { + enterApp(); + + return; + } + + if (!auth.ok || !auth.required) { + enterApp(); + + return; + } + + if (!token.get()) { + showAuthGate(true); + + return; + } + + try { + if (await tokenAccepted()) { + enterApp(); + + return; + } + + token.clear(); + } catch { + // Network trouble: ask for the token again without discarding it. + } + + showAuthGate(true); +} diff --git a/ui/src/status.js b/ui/src/status.js new file mode 100644 index 00000000..80de14cd --- /dev/null +++ b/ui/src/status.js @@ -0,0 +1,52 @@ +import { apiPost } from "./api.js"; +import { $, $$ } from "./dom.js"; + +const statusBar = $("#status"); +let busy = false; + +export const isBusy = () => busy; + +export function setStatus(message, type) { + statusBar.textContent = message; + statusBar.className = type ? `show ${type}` : "show"; +} + +export function clearStatus() { + statusBar.className = ""; + statusBar.textContent = ""; +} + +function setBusy(on) { + busy = on; + + for (const control of $$(".btn, [type=submit]")) { + control.disabled = on; + } +} + +// POSTs with the controls locked, reporting progress and failures in the status bar. +// Resolves to the apiPost result, or null when already busy or the request failed to send. +export async function perform({ path, params, pending, failure }) { + if (busy) { + return null; + } + + setBusy(true); + setStatus(pending, "loading"); + + try { + const result = await apiPost(path, params); + + if (!result.ok) { + setStatus(result.data.error || failure, "err"); + } + + return result; + } catch { + setStatus("Network error", "err"); + + return null; + } finally { + setBusy(false); + } +} diff --git a/ui/src/style.css b/ui/src/style.css new file mode 100644 index 00000000..67a3a4ac --- /dev/null +++ b/ui/src/style.css @@ -0,0 +1,996 @@ +:root { + --bg: #faf9f7; + --text: #1a1a1a; + --muted: #666; + --accent: #e85d04; + --accent-hover: #d45304; + --card: #fff; + --border: #e0ddd8; + --success: #2d6a4f; + --error: #c1121f; + --loading: #555; +} + +* { + box-sizing: border-box; +} + +[hidden] { + display: none !important; +} + +body { + font-family: + system-ui, + -apple-system, + sans-serif; + background: var(--bg); + color: var(--text); + max-width: 42rem; + margin: 0 auto; + padding: 0 1rem 3rem; + line-height: 1.5; +} + +a { + color: var(--accent); + text-decoration: none; +} + +nav { + display: flex; + gap: 0.25rem; + flex-wrap: wrap; + padding: 1rem 0; + border-bottom: 1px solid var(--border); + margin-bottom: 1.5rem; + position: sticky; + top: 0; + background: var(--bg); + z-index: 10; +} + +nav a { + padding: 0.4rem 0.75rem; + border-radius: 0.35rem; + color: var(--muted); + font-size: 0.9rem; + font-weight: 500; + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +nav a:hover { + background: #eee; + color: var(--text); + text-decoration: none; +} + +nav a.active { + background: var(--accent); + color: #fff; +} + +.view { + display: none; +} + +.view.active { + display: block; +} + +.hero { + margin-bottom: 2rem; +} + +.hero h1 { + font-size: 1.75rem; + margin: 0 0 0.5rem; + font-weight: 700; +} + +.hero p { + color: var(--muted); + margin: 0; + font-size: 1.05rem; +} + +.info-strip { + background: var(--card); + border: 1px solid var(--border); + border-radius: 0.5rem; + padding: 0.6rem 1rem; + margin: 1rem 0; + font-size: 0.85rem; + color: var(--muted); +} + +#fw-version { + margin: 0.4rem 0 0; + font-size: 0.8rem; + font-variant-numeric: tabular-nums; +} + +.cards { + display: grid; + gap: 0.85rem; + margin: 1.5rem 0; + grid-template-columns: 1fr; +} + +@media (width >= 480px) { + .cards { + grid-template-columns: 1fr 1fr; + } +} + +.card { + display: block; + background: var(--card); + border: 1px solid var(--border); + border-radius: 0.6rem; + padding: 1.1rem 1.25rem; + text-decoration: none; + color: inherit; + transition: + box-shadow 0.15s, + border-color 0.15s, + transform 0.15s; +} + +.card:hover { + border-color: var(--accent); + box-shadow: 0 2px 8px rgb(0 0 0 / 6%); + text-decoration: none; +} + +.card h3 { + margin: 0 0 0.35rem; + font-size: 1rem; + color: var(--accent); +} + +.card p { + margin: 0; + font-size: 0.9rem; + color: var(--muted); +} + +.card-github { + background: var(--accent); + border-color: var(--accent); + color: #fff; + box-shadow: 0 2px 10px rgb(232 93 4 / 28%); +} + +.card-github h3 { + color: #fff; +} + +.card-github p { + color: rgb(255 255 255 / 85%); +} + +.card-github:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); + box-shadow: 0 4px 14px rgb(232 93 4 / 35%); + transform: translateY(-1px); + text-decoration: none; +} + +.page-title { + font-size: 1.35rem; + margin: 0 0 0.5rem; +} + +.page-desc { + color: var(--muted); + margin: 0 0 1.25rem; + font-size: 0.95rem; +} + +#status { + padding: 0.65rem 1rem; + border-radius: 0.4rem; + margin-bottom: 1rem; + font-size: 0.9rem; + display: none; +} + +#status.show { + display: block; +} + +#status.loading { + background: #eee; + color: var(--loading); +} + +#status.ok { + background: #d8f3dc; + color: var(--success); +} + +#status.err { + background: #fde8e8; + color: var(--error); +} + +.badge { + display: inline-block; + background: #eee; + padding: 0.25rem 0.6rem; + border-radius: 0.3rem; + font-size: 0.85rem; + margin-bottom: 1rem; +} + +.badge strong { + color: var(--accent); +} + +.btn-grid { + display: grid; + gap: 0.75rem; +} + +@media (width >= 400px) { + .btn-grid { + grid-template-columns: 1fr 1fr; + } +} + +.btn { + display: block; + width: 100%; + padding: 0.75rem 1rem; + background: var(--card); + border: 1px solid var(--border); + border-radius: 0.5rem; + cursor: pointer; + text-align: left; + font: inherit; + color: inherit; + transition: + border-color 0.15s, + background 0.15s; +} + +.btn-title { + font-weight: 600; + display: block; + margin-bottom: 0.15rem; +} + +.btn-hint { + font-size: 0.8rem; + color: var(--muted); +} + +.btn-primary { + background: var(--accent); + color: #fff; + border-color: var(--accent); + text-align: center; + font-weight: 600; + margin-top: 1rem; +} + +.test-card { + background: var(--card); + border: 1px solid var(--border); + border-radius: 0.5rem; + padding: 1rem; + margin-bottom: 0.75rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.test-card p { + margin: 0; + font-size: 0.9rem; + color: var(--muted); + flex: 1; +} + +.test-card .btn { + width: auto; + min-width: 7rem; + text-align: center; + padding: 0.55rem 1rem; +} + +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + font-weight: 600; + margin-bottom: 0.35rem; + font-size: 0.9rem; +} + +.form-group select, +.form-group input[type="number"], +.form-group input[type="text"] { + width: 100%; + padding: 0.5rem 0.65rem; + border: 1px solid var(--border); + border-radius: 0.35rem; + font: inherit; + background: var(--card); +} + +.form-group input[type="range"] { + width: 100%; + margin: 0.5rem 0; + accent-color: var(--accent); +} + +.range-row { + display: flex; + align-items: center; + gap: 1rem; +} + +.range-row input[type="number"] { + width: 5rem; +} + +.toggle-row { + display: flex; + align-items: center; + gap: 0.65rem; +} + +.toggle-row input[type="checkbox"] { + width: 1.15rem; + height: 1.15rem; + accent-color: var(--accent); +} + +.toggle-row label { + margin: 0; + font-weight: 600; + font-size: 0.9rem; +} + +.hint { + font-size: 0.8rem; + color: var(--muted); + margin-top: 0.25rem; +} + +.hint.warn { + color: var(--error); +} + +.servo-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; + margin-top: 1rem; +} + +.servo-actions .btn { + text-align: center; + font-weight: 600; +} + +.servo-actions .btn-primary { + margin-top: 0; +} + +.servo-slider-wrap { + flex: 1; + min-width: 0; +} + +.servo-slider-track { + position: relative; + height: 2rem; +} + +.servo-slider-bg { + position: absolute; + left: 0.625rem; + right: 0.625rem; + top: 50%; + height: 0.4rem; + margin-top: -0.2rem; + background: #e8e4df; + border-radius: 99px; + pointer-events: none; +} + +.servo-safe-band { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 100%; + background: #f4c9a8; + border-radius: 99px; +} + +input[type="range"].servo-slider { + appearance: none; + background: transparent; + position: relative; + z-index: 1; + width: 100%; + height: 2rem; + margin: 0; +} + +.servo-slider::-webkit-slider-runnable-track { + height: 2rem; + background: transparent; + border: none; +} + +.servo-slider::-moz-range-track { + height: 0.4rem; + background: transparent; + border: none; +} + +.servo-slider::-webkit-slider-thumb { + appearance: none; + width: 1.25rem; + height: 1.25rem; + border-radius: 50%; + background: var(--accent); + border: 2px solid #fff; + box-shadow: 0 1px 3px rgb(0 0 0 / 25%); + margin-top: 0.375rem; + cursor: pointer; +} + +.servo-slider::-moz-range-thumb { + width: 1.25rem; + height: 1.25rem; + border-radius: 50%; + background: var(--accent); + border: 2px solid #fff; + box-shadow: 0 1px 3px rgb(0 0 0 / 25%); + cursor: pointer; +} + +.servo-scale { + display: flex; + justify-content: space-between; + font-size: 0.75rem; + color: var(--muted); + padding: 0 0.625rem; + margin-top: 0.1rem; +} + +.servo-range { + align-items: flex-start; +} + +.servo-range input[type="number"] { + margin-top: 0.2rem; + height: 2rem; + padding: 0.25rem 0.4rem; +} + +table { + border-collapse: collapse; + width: 100%; + margin-bottom: 1rem; + font-size: 0.85rem; +} + +th, +td { + border: 1px solid var(--border); + padding: 0.35rem 0.5rem; + text-align: left; + vertical-align: top; +} + +th { + background: #f4f4f4; +} + +code { + background: #f4f4f4; + padding: 0.1rem 0.3rem; + border-radius: 0.2rem; + font-size: 0.8rem; +} + +footer { + margin-top: 2.5rem; + padding-top: 1rem; + border-top: 1px solid var(--border); + font-size: 0.85rem; + color: var(--muted); + text-align: center; +} + +.gate { + display: none; + position: fixed; + inset: 0; + background: var(--bg); + z-index: 100; + padding: 2rem 1rem; + align-items: center; + justify-content: center; +} + +.gate.show { + display: flex; +} + +.gate-box { + width: 100%; + max-width: 22rem; + background: var(--card); + border: 1px solid var(--border); + border-radius: 0.6rem; + padding: 1.5rem; +} + +.gate h1 { + font-size: 1.35rem; + margin: 0 0 0.5rem; +} + +.gate p { + color: var(--muted); + margin: 0 0 1.25rem; + font-size: 0.95rem; +} + +.gate p:last-child { + margin-bottom: 0; +} + +#auth-error { + display: none; + color: var(--error); + font-size: 0.85rem; + margin-bottom: 0.75rem; +} + +#auth-error.show { + display: block; +} + +body.locked nav, +body.locked #status, +body.locked .view, +body.locked footer { + display: none !important; +} + +.form-group input[type="password"] { + width: 100%; + padding: 0.5rem 0.65rem; + border: 1px solid var(--border); + border-radius: 0.35rem; + font: inherit; + background: var(--card); +} + +.token-row { + display: flex; + gap: 0.5rem; + align-items: stretch; +} + +.token-row input[type="password"], +.token-row input[type="text"] { + flex: 1; +} + +.btn-token-toggle { + width: auto; + min-width: 7rem; + text-align: center; + padding: 0.5rem 0.75rem; + font-size: 0.85rem; + flex-shrink: 0; + align-self: stretch; +} + +.config-section { + background: var(--card); + border: 1px solid var(--border); + border-radius: 0.6rem; + padding: 1rem 1.15rem; + margin-bottom: 0.85rem; +} + +.config-section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.85rem; +} + +.config-section h3 { + margin: 0; + font-size: 1rem; + color: var(--accent); +} + +.config-section-desc { + font-size: 0.85rem; + color: var(--muted); + margin: -0.35rem 0 0.85rem; +} + +.config-section .form-group:last-child { + margin-bottom: 0; +} + +.apply-badge { + font-size: 0.7rem; + font-weight: 600; + padding: 0.2rem 0.5rem; + border-radius: 0.25rem; + white-space: nowrap; + flex-shrink: 0; +} + +.apply-now { + background: #d8f3dc; + color: var(--success); +} + +.apply-reboot { + background: #fff3cd; + color: #856404; +} + +.field-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.35rem; +} + +.field-head label { + margin: 0; + font-weight: 600; + font-size: 0.9rem; +} + +.btn-danger { + border-color: var(--error); + color: var(--error); + text-align: center; + font-weight: 600; +} + +.btn-danger:hover:not(:disabled) { + border-color: var(--error); + background: #fde8e8; +} + +.config-danger { + border-color: #f5c2c7; + margin-top: 1rem; +} + +body.setup-mode nav, +body.setup-mode footer { + display: none !important; +} + +body.setup-mode #config-form, +body.setup-mode .config-danger { + display: none !important; +} + +body.setup-mode #config-page-title, +body.setup-mode #config-page-desc { + display: none !important; +} + +body:not(.setup-mode) #setup-wizard { + display: none !important; +} + +.setup-progress-track { + height: 0.35rem; + background: #e8e4df; + border-radius: 99px; + margin: 0 0 1.25rem; + overflow: hidden; +} + +.setup-progress-bar { + height: 100%; + width: 25%; + background: var(--accent); + border-radius: 99px; +} + +.setup-footer { + display: flex; + gap: 0.75rem; + margin-top: 1.25rem; + align-items: stretch; +} + +.setup-footer[hidden] { + display: none !important; +} + +.setup-footer .btn { + width: auto; + min-width: 7rem; + text-align: center; + font-weight: 600; +} + +.setup-footer .btn-primary { + margin-top: 0; + flex: 1; +} + +.setup-copy { + font-size: 0.9rem; + color: var(--muted); + margin: 0 0 1rem; +} + +.joint-tabs { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; + margin: 0 0 1rem; +} + +.joint-tabs .btn { + width: 100%; + min-height: 2.85rem; + padding: 0.65rem 0.5rem; + font-size: 0.95rem; + text-align: center; + font-weight: 600; +} + +.calib-guide { + pointer-events: none; + user-select: none; + margin: 0.25rem 0 0.5rem; +} + +.calib-marker { + position: absolute; + top: 50%; + width: 1.25rem; + height: 1.25rem; + margin-top: -0.625rem; + border-radius: 50%; + background: var(--accent); + border: 2px solid #fff; + box-shadow: 0 1px 3px rgb(0 0 0 / 25%); + transform: translateX(-50%); + z-index: 1; +} + +.calib-angle-readout { + text-align: center; + font-size: 2rem; + font-weight: 700; + margin: 0.35rem 0 0.85rem; + letter-spacing: -0.02em; +} + +.calib-nudge { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 0.5rem; + margin: 0 0 1rem; +} + +.calib-nudge .btn { + width: 100%; + min-height: 3.1rem; + text-align: center; + font-weight: 700; + font-size: 1.05rem; + padding: 0.7rem 0.35rem; +} + +.calib-minmax { + display: flex; + gap: 0.5rem; + margin: 0.75rem 0; +} + +.calib-minmax .btn { + flex: 1; + text-align: center; + font-weight: 600; + min-height: 2.85rem; +} + +#setup-move-90 { + margin-top: 0; +} + +#setup-horns-done { + margin-top: 0.75rem; + text-align: center; + font-weight: 600; +} + +.led-map { + display: flex; + gap: 0.65rem; + align-items: flex-end; + margin: 0 0 1.15rem; +} + +.led-chip-wrap { + text-align: center; +} + +.led-chip { + width: 2.5rem; + height: 2.5rem; + border-radius: 0.45rem; + border: 1px solid var(--border); +} + +.led-chip.led-r { + background: #e53935; +} + +.led-chip.led-g { + background: #43a047; +} + +.led-chip.led-b { + background: #1e88e5; +} + +.led-chip-letter { + display: block; + font-size: 0.8rem; + font-weight: 700; + margin-top: 0.25rem; +} + +.led-test { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 0.5rem; + margin: 0 0 1rem; +} + +.led-test .btn { + width: 100%; + min-height: 3.1rem; + text-align: center; + font-weight: 700; + padding: 0.7rem 0.35rem; + color: #fff; +} + +#setup-led-remap-toggle { + margin-top: 0; + text-align: center; + font-weight: 600; +} + +#setup-audio-play { + margin-top: 0; +} + +.led-byte { + margin: 0 0 1rem; + padding-bottom: 0.85rem; + border-bottom: 1px solid var(--border); +} + +.led-byte:last-child { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: 0; +} + +.led-byte-label { + font-weight: 600; + font-size: 0.9rem; + margin: 0 0 0.45rem; +} + +.led-looks { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.led-looks .btn { + width: 100%; + min-height: 2.85rem; + text-align: center; + font-weight: 600; + padding: 0.65rem 0.35rem; +} + +.led-looks .btn.active.led-r { + background: #e53935; + color: #fff; + border-color: #e53935; +} + +.led-looks .btn.active.led-g { + background: #43a047; + color: #fff; + border-color: #43a047; +} + +.led-looks .btn.active.led-b { + background: #1e88e5; + color: #fff; + border-color: #1e88e5; +} + +.led-light { + margin-top: 0; + text-align: center; + font-weight: 600; +} + +/* Button states last, so they win over the layout rules for buttons in each section. */ +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn:hover:not(:disabled) { + border-color: var(--accent); + background: #fff8f4; +} + +.btn-primary:hover:not(:disabled) { + background: var(--accent-hover); + border-color: var(--accent-hover); +} + +.joint-tabs .btn.active { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} + +.led-test .btn.led-r { + background: #e53935; + border-color: #e53935; +} + +.led-test .btn.led-g { + background: #43a047; + border-color: #43a047; +} + +.led-test .btn.led-b { + background: #1e88e5; + border-color: #1e88e5; +} + +#auth-form .btn-primary, +#config-wifi-connect { + margin-top: 0; +} diff --git a/ui/src/tests.js b/ui/src/tests.js new file mode 100644 index 00000000..ac411fff --- /dev/null +++ b/ui/src/tests.js @@ -0,0 +1,16 @@ +import { $$ } from "./dom.js"; +import { perform, setStatus } from "./status.js"; + +for (const button of $$("[data-test]")) { + button.addEventListener("click", async () => { + const result = await perform({ + path: button.dataset.test, + pending: "Running…", + failure: "Request failed", + }); + + if (result?.ok) { + setStatus("Done.", "ok"); + } + }); +} diff --git a/ui/test/auth.test.js b/ui/test/auth.test.js new file mode 100644 index 00000000..0160c8f2 --- /dev/null +++ b/ui/test/auth.test.js @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { $, fakeRobot, loadApp, submit, type } from "./robot.js"; + +describe("access token gate", () => { + beforeEach(() => sessionStorage.clear()); + + it("asks for a token when the robot requires one and none is stored", async () => { + fakeRobot({ authRequired: true, token: "secret" }); + await loadApp("/config"); + + expect($("#auth-gate").classList.contains("show")).toBe(true); + expect(document.body.classList.contains("locked")).toBe(true); + }); + + it("rejects a wrong token without remembering it", async () => { + fakeRobot({ authRequired: true, token: "secret" }); + await loadApp("/config"); + + type("#auth-token", "wrong"); + await submit("#auth-form"); + + expect($("#auth-error").classList.contains("show")).toBe(true); + expect($("#auth-gate").classList.contains("show")).toBe(true); + expect(sessionStorage.length).toBe(0); + }); + + it("unlocks with the right token and sends it on later requests", async () => { + const robot = fakeRobot({ authRequired: true, token: "secret" }); + + await loadApp("/config"); + + type("#auth-token", "secret"); + await submit("#auth-form"); + + expect($("#auth-gate").classList.contains("show")).toBe(false); + expect(document.body.classList.contains("locked")).toBe(false); + expect(robot.requests.at(-1).authorization).toBe("Bearer secret"); + }); + + it("skips the gate when a stored token is still accepted", async () => { + fakeRobot({ authRequired: true, token: "secret" }); + sessionStorage.setItem("te_access_token", "secret"); + await loadApp("/config"); + + expect($("#auth-gate").classList.contains("show")).toBe(false); + expect($("#view-config").classList.contains("active")).toBe(true); + }); +}); diff --git a/ui/test/config.test.js b/ui/test/config.test.js new file mode 100644 index 00000000..1ca1884e --- /dev/null +++ b/ui/test/config.test.js @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { $, click, fakeRobot, loadApp, submit, type } from "./robot.js"; + +describe("config page", () => { + beforeEach(() => sessionStorage.clear()); + + it("fills the form from the robot's settings", async () => { + fakeRobot({ settings: { hostname: "desk-bot", volume: 35, eyes_style: "dots" } }); + await loadApp("/config"); + + expect($("#config-hostname").value).toBe("desk-bot"); + expect($("#config-volume-label").textContent).toBe("35%"); + expect($("#config-eyes-style").value).toBe("dots"); + }); + + it("saves the form without touching a saved token", async () => { + const robot = fakeRobot({ settings: { access_token_set: true } }); + + await loadApp("/config"); + + $("#config-sleep").value = "20"; + await submit("#config-form"); + + const { params } = robot.lastPost("/settings"); + + expect(params).toMatchObject({ + sleep_timeout: "20", + hostname: "tiny-engineer", + volume: "70", + welcome: "1", + serial_log: "0", + loading: "progress", + eyes_style: "classic", + }); + expect(params).not.toHaveProperty("access_token"); + expect($("#status").textContent).toBe("Settings saved."); + }); + + it("says when a hostname change needs a reboot", async () => { + fakeRobot(); + await loadApp("/config"); + + $("#config-hostname").value = "desk-bot"; + await submit("#config-form"); + + expect($("#status").textContent).toBe("Saved. Hostname applies after reboot."); + }); + + it("sends an empty token to remove it and forgets the stored copy", async () => { + const robot = fakeRobot({ settings: { access_token_set: true } }); + + sessionStorage.setItem("te_access_token", "old"); + await loadApp("/config"); + + await click("#config-access-token-toggle"); + await submit("#config-form"); + + expect(robot.lastPost("/settings").params.access_token).toBe(""); + expect(sessionStorage.getItem("te_access_token")).toBeNull(); + expect($("#config-access-token-status").textContent).toBe("Auth disabled"); + }); + + it("replaces the token with a typed one and keeps using it", async () => { + const robot = fakeRobot({ settings: { access_token_set: true } }); + + await loadApp("/config"); + + type("#config-access-token", "new-token"); + await submit("#config-form"); + + expect(robot.lastPost("/settings").params.access_token).toBe("new-token"); + expect(sessionStorage.getItem("te_access_token")).toBe("new-token"); + }); +}); diff --git a/ui/test/robot.js b/ui/test/robot.js new file mode 100644 index 00000000..ab947c20 --- /dev/null +++ b/ui/test/robot.js @@ -0,0 +1,149 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { vi } from "vitest"; + +const page = new DOMParser().parseFromString( + readFileSync(fileURLToPath(import.meta.resolve("../index.html")), "utf8"), + "text/html", +); + +const DEFAULT_SETTINGS = { + ok: true, + hostname: "tiny-engineer", + sleep_timeout: 10, + continuous_timeout: 5, + volume: 70, + welcome: true, + serial_log: false, + loading: "progress", + eyes_style: "classic", + access_token_set: false, + wifi_configured: true, + servo_mins: [60, 40, 45, 35, 40], + servo_maxs: [130, 130, 135, 125, 130], + rgb_order: "GRB", + oled_rotate_180: false, +}; + +function applySettingsParams(settings, params) { + const next = { ...settings }; + + for (const [key, value] of Object.entries(params)) { + if (key === "servo_mins" || key === "servo_maxs") { + next[key] = value.split(",").map(Number); + } else if (key === "access_token") { + next.access_token_set = value !== ""; + } else if (key === "welcome" || key === "serial_log" || key === "oled_rotate_180") { + next[key] = value === "1"; + } else if (/^\d+$/.test(value)) { + next[key] = Number(value); + } else { + next[key] = value; + } + } + + next.reboot_required = next.hostname !== settings.hostname; + + return next; +} + +// Stands in for the firmware HTTP API and records every request the UI makes. +export function fakeRobot({ + authRequired = false, + token = "", + provisioning = false, + settings = {}, +} = {}) { + const robot = { + requests: [], + failing: new Set(), + settings: { ...DEFAULT_SETTINGS, wifi_configured: !provisioning, ...settings }, + posts(path) { + return this.requests.filter((request) => request.method === "POST" && request.path === path); + }, + lastPost(path) { + return this.posts(path).at(-1); + }, + }; + + globalThis.fetch = vi.fn(async (input, options = {}) => { + const url = new URL(input, "http://robot.test"); + const request = { + method: options.method ?? "GET", + path: url.pathname, + params: Object.fromEntries(url.searchParams), + authorization: options.headers?.Authorization, + }; + const json = (body, status = 200) => new Response(JSON.stringify(body), { status }); + + robot.requests.push(request); + + if (request.path === "/auth") { + return json({ ok: true, required: authRequired }); + } + + if (authRequired && request.authorization !== `Bearer ${token}`) { + return json({ ok: false, error: "unauthorized" }, 401); + } + + if (robot.failing.has(request.path)) { + return json({ ok: false, error: "robot said no" }, 400); + } + + if (request.path === "/health") { + return json({ ok: true, version: "test", provisioning, wifi_configured: !provisioning }); + } + + if (request.path === "/settings" && request.method === "POST") { + robot.settings = applySettingsParams(robot.settings, request.params); + } + + if (request.path === "/settings") { + return json(robot.settings); + } + + if (request.path === "/anim") { + return json({ ok: true, animation: request.params.name ?? "none" }); + } + + return json({ ok: true }); + }); + + return robot; +} + +// Lets the chain of awaited fetches and DOM updates behind one user action finish. +export async function settle() { + for (let i = 0; i < 20; i++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +export async function loadApp(path = "/") { + document.body.innerHTML = page.body.innerHTML; + document.body.className = ""; + window.history.replaceState({}, "", path); + vi.resetModules(); + await import("../src/main.js"); + await settle(); +} + +export const $ = (selector) => document.querySelector(selector); + +export async function click(selector) { + $(selector).click(); + await settle(); +} + +export async function submit(selector) { + $(selector).dispatchEvent(new Event("submit", { cancelable: true })); + await settle(); +} + +export function type(selector, value) { + const field = $(selector); + + field.dispatchEvent(new Event("focus")); + field.value = value; + field.dispatchEvent(new Event("input")); +} diff --git a/ui/test/servo.test.js b/ui/test/servo.test.js new file mode 100644 index 00000000..dfbf41e3 --- /dev/null +++ b/ui/test/servo.test.js @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { $, fakeRobot, loadApp, submit, type } from "./robot.js"; + +describe("servo page", () => { + it("bounds the slider by the selected joint's saved range", async () => { + fakeRobot({ + settings: { servo_mins: [70, 40, 45, 35, 40], servo_maxs: [110, 130, 135, 125, 130] }, + }); + await loadApp("/servo"); + + expect($("#servo-slider").min).toBe("70"); + expect($("#servo-slider").max).toBe("110"); + expect($("#servo-scale-mid").textContent).toBe("90°"); + }); + + it("warns when a typed angle is outside the safe range", async () => { + fakeRobot(); + await loadApp("/servo"); + + type("#servo-angle", "150"); + + expect($("#servo-range-hint").classList.contains("warn")).toBe(true); + expect($("#servo-range-hint").textContent).toContain("firmware clamps to 60–130°"); + }); + + it("moves only when the form is submitted", async () => { + const robot = fakeRobot(); + + await loadApp("/servo"); + + type("#servo-angle", "100"); + + expect(robot.posts("/test/servo")).toHaveLength(0); + + await submit("#servo-form"); + + expect(robot.lastPost("/test/servo").params).toEqual({ index: "0", angle: "100" }); + }); +}); diff --git a/ui/test/wizard.test.js b/ui/test/wizard.test.js new file mode 100644 index 00000000..f2b25a96 --- /dev/null +++ b/ui/test/wizard.test.js @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { $, click, fakeRobot, loadApp } from "./robot.js"; + +const angle = () => $("#setup-calib-angle").textContent; +const stepLabel = () => $("#setup-progress-label").textContent; + +async function startCalibration() { + const robot = fakeRobot({ provisioning: true }); + + await loadApp("/"); + await click("#setup-horns-done"); + + return robot; +} + +describe("setup wizard", () => { + beforeEach(() => sessionStorage.clear()); + + it("takes over the config page while the robot is provisioning", async () => { + fakeRobot({ provisioning: true }); + await loadApp("/"); + + expect(document.body.classList.contains("setup-mode")).toBe(true); + expect($("#view-config").classList.contains("active")).toBe(true); + expect(stepLabel()).toBe("Step 1 of 5 · Servos"); + }); + + it("offers no control that moves a servo from the calibration bar", async () => { + await startCalibration(); + + expect($(".calib-guide").getAttribute("aria-hidden")).toBe("true"); + expect($(".calib-guide").querySelector("input, button")).toBeNull(); + }); + + it("moves the selected joint in steps and stops at 180°", async () => { + const robot = await startCalibration(); + + for (let i = 0; i < 10; i++) { + await click('[data-nudge="10"]'); + } + + expect(angle()).toBe("180"); + expect(robot.posts("/setup/servo").map((request) => request.params.angle)).toEqual([ + "100", + "110", + "120", + "130", + "140", + "150", + "160", + "170", + "180", + ]); + }); + + it("restores the shown angle when a move fails", async () => { + const robot = await startCalibration(); + + robot.failing.add("/setup/servo"); + await click('[data-nudge="5"]'); + + expect(angle()).toBe("90"); + expect($("#status").textContent).toBe("robot said no"); + }); + + it("keeps each joint's angle when switching tabs", async () => { + const robot = await startCalibration(); + + await click('[data-nudge="-10"]'); + await click('#setup-joint-tabs [data-joint="1"]'); + + expect(angle()).toBe("90"); + + await click('[data-nudge="1"]'); + + expect(robot.lastPost("/setup/servo").params).toEqual({ index: "1", angle: "91" }); + + await click('#setup-joint-tabs [data-joint="0"]'); + + expect(angle()).toBe("80"); + }); + + it("refuses a min at or above the max", async () => { + await startCalibration(); + + await click("#setup-set-max"); + await click('[data-nudge="5"]'); + await click("#setup-set-min"); + + expect($("#status").textContent).toBe("Min must be less than max."); + expect($("#setup-min-label").textContent).toBe("60"); + expect($("#setup-max-label").textContent).toBe("90"); + }); + + it("saves the calibrated ranges and previews the screen on Next", async () => { + const robot = await startCalibration(); + + await click('[data-nudge="-10"]'); + await click("#setup-set-min"); + await click("#setup-next"); + + expect(robot.lastPost("/settings").params).toEqual({ + servo_mins: "80,40,45,35,40", + servo_maxs: "130,130,135,125,130", + }); + expect(robot.lastPost("/setup/oled").params).toEqual({ rotate_180: "0" }); + expect(stepLabel()).toBe("Step 2 of 5 · Screen"); + }); + + it("blocks a color mapping that uses a color twice", async () => { + const robot = await startCalibration(); + + await click("#setup-next"); + await click("#setup-next"); + + expect(stepLabel()).toBe("Step 3 of 5 · RGB mapping"); + + await click("#setup-led-remap-toggle"); + await click('.led-looks[data-led-byte="0"] [data-look="R"]'); + + expect($("#setup-next").disabled).toBe(true); + + await click('.led-looks[data-led-byte="1"] [data-look="G"]'); + + expect($("#setup-next").disabled).toBe(false); + + await click("#setup-next"); + + expect(robot.lastPost("/settings").params).toEqual({ rgb_order: "RGB" }); + expect(stepLabel()).toBe("Step 4 of 5 · Speaker"); + }); +}); diff --git a/ui/vite.config.js b/ui/vite.config.js new file mode 100644 index 00000000..ffa10060 --- /dev/null +++ b/ui/vite.config.js @@ -0,0 +1,39 @@ +import { minify } from "html-minifier-terser"; +import { defineConfig } from "vite"; + +const robot = process.env.TINY_ENGINEER_URL || "http://tiny-engineer.local"; +// Anchored so page routes such as /animations and /tests stay on the dev server. +const apiRoutes = [ + "^/auth([?]|$)", + "^/health([?]|$)", + "^/settings([/?]|$)", + "^/anim([?]|$)", + "^/play([/?]|$)", + "^/test/", + "^/setup/", +]; + +const minifyHtml = { + name: "minify-html", + apply: "build", + enforce: "post", + transformIndexHtml: (html) => + minify(html, { collapseWhitespace: true, removeComments: true, minifyCSS: true }), +}; + +export default defineConfig(({ command }) => ({ + // The firmware serves the built files from LittleFS under /ui/. + base: command === "build" ? "/ui/" : "/", + plugins: [minifyHtml], + build: { + outDir: "dist", + emptyOutDir: true, + }, + test: { + environment: "jsdom", + }, + server: { + host: "127.0.0.1", + proxy: Object.fromEntries(apiRoutes.map((route) => [route, robot])), + }, +}));