diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..fe5802d --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,37 @@ +# Lint, type-check, and build the application. +name: Check + +on: + pull_request: + types: [opened, synchronize, reopened] + push: + branches: [main] + workflow_dispatch: + +jobs: + check: + name: Check + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + + - name: Install + run: npm ci + + - name: Generate + run: npm run gen + + - name: Lint + run: npm run lint + + - name: Typecheck + run: npm run typecheck + + - name: Build + run: npm run build -- --verbose diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1af2a90 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,237 @@ +# Build production artifacts on macOS and Windows, then draft a GitHub Release. +# +# Requires a committed package-lock.json (npm ci). +name: Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +permissions: + contents: read + +jobs: + build-macos: + environment: production + runs-on: macos-latest + + env: + # Base64-encoded Developer ID Application certificate (.p12). + MAC_CERTIFICATE: ${{ secrets.MAC_CERTIFICATE }} + # Password used to export the .p12 certificate from Keychain Access. + MAC_CERTIFICATE_PWD: ${{ secrets.MAC_CERTIFICATE_PWD }} + # Password for the temporary CI keychain created during the build. + MAC_KEYCHAIN_PWD: ${{ secrets.MAC_KEYCHAIN_PWD }} + # Signing identity string, e.g. "Developer ID Application: Your Name (TEAMID)". + MAC_CODESIGN_IDENTITY: ${{ secrets.MAC_CODESIGN_IDENTITY }} + # Apple Developer Team ID (10-character string found in developer.apple.com). + MAC_TEAM_ID: ${{ secrets.MAC_TEAM_ID }} + # Apple ID email used for notarization. + MAC_APPLE_ID: ${{ secrets.MAC_APPLE_ID }} + # App-specific password generated at appleid.apple.com for notarization. + MAC_APPLE_PASSWORD: ${{ secrets.MAC_APPLE_PASSWORD }} + # Shared private key used to sign auto-update packages on every platform. + CI_MOBROWSER_AUTOUPDATE_SIGNING_KEY: ${{ secrets.CI_MOBROWSER_AUTOUPDATE_SIGNING_KEY }} + + steps: + - uses: actions/checkout@v4 + + - name: Verify macOS release secrets + run: | + set -euo pipefail + missing=() + [ -z "${MAC_CERTIFICATE:-}" ] && missing+=(MAC_CERTIFICATE) + [ -z "${MAC_CERTIFICATE_PWD:-}" ] && missing+=(MAC_CERTIFICATE_PWD) + [ -z "${MAC_KEYCHAIN_PWD:-}" ] && missing+=(MAC_KEYCHAIN_PWD) + [ -z "${MAC_CODESIGN_IDENTITY:-}" ] && missing+=(MAC_CODESIGN_IDENTITY) + [ -z "${MAC_TEAM_ID:-}" ] && missing+=(MAC_TEAM_ID) + [ -z "${MAC_APPLE_ID:-}" ] && missing+=(MAC_APPLE_ID) + [ -z "${MAC_APPLE_PASSWORD:-}" ] && missing+=(MAC_APPLE_PASSWORD) + [ -z "${CI_MOBROWSER_AUTOUPDATE_SIGNING_KEY:-}" ] && missing+=(CI_MOBROWSER_AUTOUPDATE_SIGNING_KEY) + if [ ${#missing[@]} -gt 0 ]; then + echo "Missing required macOS release secrets: ${missing[*]}" + exit 1 + fi + + - uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Install dependencies + run: npm ci + + - name: Set up macOS code signing + run: | + set -euo pipefail + KEYCHAIN_PATH=$RUNNER_TEMP/build.keychain + security create-keychain -p "$MAC_KEYCHAIN_PWD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$MAC_KEYCHAIN_PWD" "$KEYCHAIN_PATH" + + curl -fsSL https://www.apple.com/certificateauthority/AppleRootCA-G3.cer -o AppleRootCA-G3.cer + curl -fsSL https://www.apple.com/certificateauthority/AppleWWDRCAG6.cer -o AppleWWDRCAG6.cer + curl -fsSL https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer -o DeveloperIDG2CA.cer + + security import ./AppleRootCA-G3.cer -k "$KEYCHAIN_PATH" + security import ./AppleWWDRCAG6.cer -k "$KEYCHAIN_PATH" + security import ./DeveloperIDG2CA.cer -k "$KEYCHAIN_PATH" + + echo "$MAC_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/certificate.p12" + security import "$RUNNER_TEMP/certificate.p12" -k "$KEYCHAIN_PATH" -P "$MAC_CERTIFICATE_PWD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple: -s -k "$MAC_KEYCHAIN_PWD" "$KEYCHAIN_PATH" + security list-keychain -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"') + + - name: Build release package + run: npm run build -- --verbose + + - name: Build DMG and auto-update packages + run: npm run pack -- --verbose + + - name: Upload macOS artifact + uses: actions/upload-artifact@v4 + with: + name: artifacts-mac-arm64 + path: | + build/dist/mac-arm64/pack/*.dmg + build/dist/mac-arm64/pack/*.nupkg + build/dist/mac-arm64/pack/releases.osx.json + if-no-files-found: error + + build-windows: + # To securely authenticate GitHub Actions with Azure using federated credentials we need + # an environment-based approach that can be triggered by any event (including version tags). + environment: production + runs-on: windows-latest + + permissions: + contents: read + id-token: write + + env: + # Azure Artifact Signing account name (Artifact Signing resource in the Azure portal). + AZURE_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_SIGNING_ACCOUNT_NAME }} + # Certificate profile name within the Artifact Signing account. + AZURE_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_CERTIFICATE_PROFILE_NAME }} + # Azure Artifact Signing endpoint URL (e.g. "https://weu.codesigning.azure.net/"). + AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + # Service principal client ID (app registration) used by DefaultAzureCredential. + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + # Azure Active Directory tenant ID of the service principal. + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + # Azure subscription ID of the service principal. + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + # Shared private key used to sign auto-update packages on every platform. + CI_MOBROWSER_AUTOUPDATE_SIGNING_KEY: ${{ secrets.CI_MOBROWSER_AUTOUPDATE_SIGNING_KEY }} + + steps: + - uses: actions/checkout@v4 + + - name: Verify Windows release secrets + shell: pwsh + run: | + $missing = @() + if ([string]::IsNullOrWhiteSpace($env:AZURE_SIGNING_ACCOUNT_NAME)) { $missing += "AZURE_SIGNING_ACCOUNT_NAME" } + if ([string]::IsNullOrWhiteSpace($env:AZURE_CERTIFICATE_PROFILE_NAME)) { $missing += "AZURE_CERTIFICATE_PROFILE_NAME" } + if ([string]::IsNullOrWhiteSpace($env:AZURE_SIGNING_ENDPOINT)) { $missing += "AZURE_SIGNING_ENDPOINT" } + if ([string]::IsNullOrWhiteSpace($env:AZURE_CLIENT_ID)) { $missing += "AZURE_CLIENT_ID" } + if ([string]::IsNullOrWhiteSpace($env:AZURE_TENANT_ID)) { $missing += "AZURE_TENANT_ID" } + if ([string]::IsNullOrWhiteSpace($env:AZURE_SUBSCRIPTION_ID)) { $missing += "AZURE_SUBSCRIPTION_ID" } + if ([string]::IsNullOrWhiteSpace($env:CI_MOBROWSER_AUTOUPDATE_SIGNING_KEY)) { $missing += "CI_MOBROWSER_AUTOUPDATE_SIGNING_KEY" } + if ($missing.Count -gt 0) { + Write-Error "Missing required Windows release secrets: $($missing -join ', ')" + exit 1 + } + + - uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Install dependencies + run: npm ci + + - name: Azure login (for code signing) + uses: azure/login@v3 + with: + client-id: ${{ env.AZURE_CLIENT_ID }} + tenant-id: ${{ env.AZURE_TENANT_ID }} + subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }} + + - name: Build release package + run: npm run build -- --verbose + + - name: Sign Windows binaries + uses: azure/artifact-signing-action@v2 + with: + endpoint: ${{ env.AZURE_SIGNING_ENDPOINT }} + signing-account-name: ${{ env.AZURE_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ env.AZURE_CERTIFICATE_PROFILE_NAME }} + files-folder: ${{ github.workspace }}\build\dist\win-x64\bin + files-folder-filter: exe,dll + files-folder-recurse: true + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + + - name: Build installer and auto-update packages + id: pack + shell: pwsh + run: npm run pack -- --verbose + + - name: Sign installer + uses: azure/artifact-signing-action@v2 + with: + endpoint: ${{ env.AZURE_SIGNING_ENDPOINT }} + signing-account-name: ${{ env.AZURE_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ env.AZURE_CERTIFICATE_PROFILE_NAME }} + files-folder: ${{ github.workspace }}\build\dist\win-x64\pack + files-folder-filter: exe + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + + - name: Upload Windows artifact + uses: actions/upload-artifact@v4 + with: + name: artifacts-win-x64 + path: | + build/dist/win-x64/pack/*.exe + build/dist/win-x64/pack/*.nupkg + build/dist/win-x64/pack/releases.win.json + if-no-files-found: error + + release: + name: Create GitHub Release draft + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + needs: + - build-macos + - build-windows + + permissions: + contents: write + + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: dist/ + + - name: Create GitHub Release draft + uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda # v2.2.1 + with: + files: | + dist/artifacts-mac-arm64/**/*.dmg + dist/artifacts-mac-arm64/**/*.nupkg + dist/artifacts-mac-arm64/**/releases.osx.json + dist/artifacts-win-x64/**/*.exe + dist/artifacts-win-x64/**/*.nupkg + dist/artifacts-win-x64/**/releases.win.json + tag_name: ${{ github.ref_name }} + name: ${{ github.ref_name }} + draft: true + generate_release_notes: true + + - name: List release artifacts + run: ls -R dist diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..05525e0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Node dependencies. +node_modules/ + +# Build output. +build/ +dist/ +out/ + +# IDEs and editors. +.idea/ +.vscode/ + +# Local environment and secrets. +.env +.env.* +!.env.example + +# Agent session state. +.mobrowser/ + +# Generated code. +gen/ +*.tsbuildinfo + +# macOS metadata. +.DS_Store + +# Windows metadata. +Thumbs.db +Desktop.ini diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..299a624 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,87 @@ + +# Do not rely on training data for MōBrowser + +Your MōBrowser training data is outdated — APIs have been renamed and reorganized, so code written +from prior knowledge will not compile. **Before writing or modifying any MōBrowser code**, read the +docs in `node_modules/@mobrowser/api/docs/`: +- `guides/` — architecture, project structure, process model, IPC, native C++ module, features, examples. +- `api/` — API reference with code examples. + +Don't guess API names, signatures, or import paths — look them up. If `docs/` is missing, ask the +user to run `npm run gen` (it downloads the docs when the project contains this `AGENTS.md`). + + + +# Building & code generation + +The `mobrowser` CLI is a **local** dependency, not on your `PATH`. Always run it via npm: +`npm run mobrowser -- ` (or `npx --no-install mobrowser `). A bare `mobrowser …` +fails with "command not found". + +- `npm run gen` — regenerate IPC/protobuf bindings after editing a `.proto` file (output in + `src/*/gen/`). No launch. +- `npm run dev:build` — build without launching: code generation, the **main** process (plus the C++ + module for native projects — slow on first run), and the **renderer** bundle. Run it before + launching for automation, and re-run after main-process, native, or renderer changes. Build errors + fail here — fix them first. +- `npm run mobrowser -- agent launch` — launches the `dev:build` output (does **not** build; errors + if no build exists). +- `npm run dev` — human build-and-run (build + launch). Under `dev` (and `dev -- --automation`) the + renderer is served live by Vite (hot reload, no rebuild); under `agent launch` it comes from the + `dev:build` bundle. The renderer is **not type-checked in dev** — run `npm run build` to catch + renderer/type errors. +- Automation mode lives in `mobrowser.conf.json` (`"automation": { "mode": ... }`): `"interactive"` + (default — window shown, left running) or `"autonomous"` (window hidden, agent stops it when done). + + + +# Verifying changes by driving the app + +`npm run mobrowser -- agent ` drives a running app so you can verify changes against the +real thing. Each command prints JSON on stdout and exits non-zero on failure; see `agent --help` for +the full list. (Commands are shortened to `agent …` below — always prefix with `npm run mobrowser --`.) + +## Modes: interactive (default) and autonomous + +Default to **interactive**; use **autonomous** only when asked ("headless", "run autonomously") or +when `mobrowser.conf.json` sets it. Launch the app **once** and reuse it for every command — never +relaunch per command. +- **Interactive:** window shown; **leave it running** when done (the user closes it) and re-attach on + later turns. Don't `agent stop` unless asked. +- **Autonomous:** window hidden; finish the job, then **always** stop it (even on failure) via + `agent stop` or by terminating the `npm run dev -- --automation` process. + +Pick the launch command by task (mode sets visibility automatically): +- **`npm run dev -- --automation`** for **renderer/UI** work: renderer live via Vite (hot reload). + Runs in the foreground — start it in a background terminal, drive from another, stop by terminating it. +- **`npm run mobrowser -- agent launch`** otherwise: the prebuilt app (`dev:build` first), detached; + re-run `dev:build` and relaunch after main-process, native, or renderer changes. + +`--show-window[=false]` overrides the mode's default visibility. + +Typical loop: +1. `dev:build`, then start the app once (see modes above). +2. `agent snapshot` — accessibility tree with stable `[ref=eN]` markers; prefer it over screenshots. +3. Act by `ref`: `agent click --ref e12`, `fill --ref e5 --value "hi"`, `type --ref e5 --text "hi"`, + `press-key --key Enter`. Confirm with `agent snapshot` or `agent screenshot --out shot.png`. +4. Inspect: `agent eval "location.href"`, `agent console`, `agent network`; `agent list-windows` / + `select-window` for multi-window apps. +5. Finish per mode: interactive — leave running; autonomous — stop. + +Native dialogs (alerts, confirms, prompts, file pickers) are app-modal and **intercepted** under +automation. Pre-arm an answer **before** the triggering action (consumed in order): +`agent answer-dialog --button OK | --text "Robot" | --path /tmp/file.txt | --cancel`; inspect with +`agent dialogs`. + +Scope: the agent drives the **renderer** (plus native screenshots and dialogs above); it does not +drive or read the main process. + +## Falling back to CDP + +Prefer the `agent` commands above — use them first. Only when they cannot accomplish the task (e.g. +you need a CDP domain they don't expose, such as tracing, coverage, emulation, or precise input +timing) fall back to the app's Chrome DevTools Protocol (CDP) endpoint with any CDP-compatible tool. +Under `npm run dev`/`npm run run` it is exposed at `http://localhost:9222` by default (override with +`--remote-debugging-port=`; for `agent launch`, enable it with `-- --remote-debugging-port=9222`). +Automate **this app** over that endpoint — do not launch a separate browser. + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..d2d7408 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,59 @@ +cmake_minimum_required(VERSION 3.21) + +# Configures your project. +project(native LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +set(PROJECT_ROOT_DIR ${CMAKE_CURRENT_SOURCE_DIR}) +cmake_path(ABSOLUTE_PATH PROJECT_ROOT_DIR NORMALIZE) + +# Imports the MōBrowser app configuration and setups the auxiliary targets, resources, etc. +# Important: Do not move this include above the project declaration. +include(${PROJECT_ROOT_DIR}/node_modules/@mobrowser/native/cmake/MoBrowser.cmake) + +# The source files of your app. +set(APP_SOURCES + src/native/main.cc + src/native/device_stack.h + src/native/device_stack.cc + src/native/device_service.h + src/native/device_service.cc +) + +# Defines the main target of the application. +add_library(mobrowser_lib STATIC ${APP_SOURCES} ${GENERATED_SOURCES}) + +mobrowser_configure_app_lib(mobrowser_lib) + +# Add shared third party dependencies and configuration for all platforms here. +# Example: +# find_package(example CONFIG REQUIRED) +# target_link_libraries(mobrowser_lib PRIVATE example::example) + +# Keep platform-specific dependencies inside the matching block below. +# Platform-only find_package calls, include directories, source files, compile definitions, +# and libraries should not be added outside these blocks. +if (OS_WIN) + # Add Windows-only dependencies, source files, definitions, and libraries here. + # find_package(example_windows CONFIG REQUIRED) + # target_sources(mobrowser_lib PRIVATE src/native/windows/example_windows.cc) + # target_include_directories(mobrowser_lib PRIVATE ${example_windows_INCLUDE_DIRS}) + # target_compile_definitions(mobrowser_lib PRIVATE USE_EXAMPLE_WINDOWS) + # target_link_libraries(mobrowser_lib PRIVATE example_windows::example_windows) +elseif (OS_MAC) + # Add macOS-only dependencies, source files, definitions, frameworks, and libraries here. + # find_package(example_macos CONFIG REQUIRED) + # target_sources(mobrowser_lib PRIVATE src/native/mac/example_macos.mm) + # target_include_directories(mobrowser_lib PRIVATE ${example_macos_INCLUDE_DIRS}) + # target_compile_definitions(mobrowser_lib PRIVATE USE_EXAMPLE_MACOS) + # target_link_libraries(mobrowser_lib PRIVATE example_macos::example_macos "-framework Foundation") +elseif (OS_LINUX) + # Add Linux-only dependencies, source files, definitions, and libraries here. + # find_package(example_linux CONFIG REQUIRED) + # target_sources(mobrowser_lib PRIVATE src/native/linux/example_linux.cc) + # target_include_directories(mobrowser_lib PRIVATE ${example_linux_INCLUDE_DIRS}) + # target_compile_definitions(mobrowser_lib PRIVATE USE_EXAMPLE_LINUX) + # target_link_libraries(mobrowser_lib PRIVATE example_linux::example_linux) +endif () diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..91e4181 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2026 TeamDev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 9fe3c64..f37fb0e 100644 --- a/README.md +++ b/README.md @@ -1 +1,104 @@ -# device +# MōDevice — a peripherals configuration demo + +MōDevice is a small reference application for configuring mice and keyboards, built with +[MōBrowser](https://teamdev.com/mobrowser/). It shows how a React interface communicates with a TypeScript main process +and a native C++ module that simulates the device backend. + +Device overview on Windows + +## What the demo shows + +- View mice and keyboards with their connection, battery, and firmware information. +- Find, add, and remove wireless devices. +- Assign actions to mouse buttons and adjust pointer and scrolling behavior. +- Remap keyboard keys and configure backlight effects, color, and brightness. +- Change the app theme, launch it at login, manage updates, and enable low-battery alerts. +- Keep paired devices and settings between launches. + +

+ Device overview on macOS + Mouse button configuration +

+ +

+ Keyboard lighting configuration + App settings +

+ +All devices are simulated by an in-memory C++ backend. This app does not detect or change real +peripherals connected to the computer. It is a reference demo, not production device software. + +The demo requires no account or API key. Packaged versions contact GitHub Releases to check for +updates, but device data and settings are not sent anywhere. + +## Requirements + +- macOS 14 or later on Apple silicon, or Windows 10 or later on 64-bit systems. +- [Node.js](https://nodejs.org/en/download/) 20.20.2, 22.22.2, or 24.14.1 and later. +- [MōBrowser](https://teamdev.com/mobrowser/) 2.13.0 or later. + +## Run from source + +```bash +npm install +npm run dev +``` + +To create a production build: + +```bash +npm run build +``` + +## Project structure + +- `src/native/device_stack.*` contains the simulated devices and is the main starting point for a + real hardware integration. +- `src/native/device_service.*` exposes the device stack to MōBrowser. +- `src/main/devices.ts` passes device data between C++ and the interface and saves the demo state. +- `src/renderer/gateway/devices.ts` is the interface's single entry point for device operations. +- `src/renderer/components/device/` contains the shared device screen and the separate mouse and + keyboard editors. +- `src/renderer/public/device-art/` contains the product images shown in the app. + +## Connect real devices + +Replace the simulated implementation in +[`src/native/device_stack.cc`](src/native/device_stack.cc) with calls to your device SDK or system +APIs. Keep the interface in [`src/native/device_stack.h`](src/native/device_stack.h) if its operations +fit your integration. + +The demo saves paired device IDs and settings in [`src/main/devices.ts`](src/main/devices.ts). If +your devices or SDK already store this data, remove that persistence and keep one source of truth. + +### Add capabilities and device types + +For another mouse or keyboard, reuse the existing screens where possible. + +- `src/native/device_stack.cc` reports the controls and value ranges supported by each device. +- `device-art.tsx` connects each `modelId` to its artwork, while + `src/renderer/public/device-art/` stores the image files. +- `mouse-device.ts` and `keyboard-device.ts` define the sections, actions, and labels shown for each + device type. +- `mouse-editor.tsx` and `keyboard-editor.tsx` render the controls used to change device settings. + +For a different device category, give it its own capabilities, settings, editor, and artwork. Keep +the shared device screen responsible only for layout and navigation. + +- `src/native/proto/devices.proto` and `src/renderer/proto/devices.proto` describe the capabilities + and settings shared between the app layers. +- A new device module in `src/renderer/components/device/` describes the sections and artwork + behavior for the device type. +- A new editor module in the same folder renders its settings controls. +- `device-presentation.ts` chooses the matching device module, and `control-editor.tsx` chooses its + editor. +- `device-art.tsx` renders the matching artwork from `src/renderer/public/device-art/`. + +## Download + +You can download the app from the [releases page](https://github.com/mo-browser-apps/device/releases). +macOS releases are code-signed and notarized by Apple; Windows releases are code-signed. + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/assets/app.icns b/assets/app.icns new file mode 100644 index 0000000..04f088f Binary files /dev/null and b/assets/app.icns differ diff --git a/assets/app.ico b/assets/app.ico new file mode 100644 index 0000000..07aab06 Binary files /dev/null and b/assets/app.ico differ diff --git a/assets/app.png b/assets/app.png new file mode 100644 index 0000000..f5c99d4 Binary files /dev/null and b/assets/app.png differ diff --git a/assets/entitlements.plist b/assets/entitlements.plist new file mode 100644 index 0000000..2741356 --- /dev/null +++ b/assets/entitlements.plist @@ -0,0 +1,17 @@ + + + + + com.apple.security.automation.apple-events + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.allow-dyld-environment-variables + + com.apple.security.cs.disable-library-validation + + + diff --git a/assets/screenshots/macos-devices.png b/assets/screenshots/macos-devices.png new file mode 100644 index 0000000..bd90115 Binary files /dev/null and b/assets/screenshots/macos-devices.png differ diff --git a/assets/screenshots/macos-keyboard-lighting.png b/assets/screenshots/macos-keyboard-lighting.png new file mode 100644 index 0000000..901423e Binary files /dev/null and b/assets/screenshots/macos-keyboard-lighting.png differ diff --git a/assets/screenshots/macos-mouse-buttons.png b/assets/screenshots/macos-mouse-buttons.png new file mode 100644 index 0000000..b23430e Binary files /dev/null and b/assets/screenshots/macos-mouse-buttons.png differ diff --git a/assets/screenshots/macos-settings.png b/assets/screenshots/macos-settings.png new file mode 100644 index 0000000..e564bd0 Binary files /dev/null and b/assets/screenshots/macos-settings.png differ diff --git a/assets/screenshots/windows-devices.png b/assets/screenshots/windows-devices.png new file mode 100644 index 0000000..11da2d1 Binary files /dev/null and b/assets/screenshots/windows-devices.png differ diff --git a/components.json b/components.json new file mode 100644 index 0000000..73f045d --- /dev/null +++ b/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/renderer/index.css", + "baseColor": "stone", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..48ca14d --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,30 @@ +import tsParser from '@typescript-eslint/parser'; +import tsPlugin from '@typescript-eslint/eslint-plugin'; +import reactHooks from 'eslint-plugin-react-hooks'; + +export default [ + { + ignores: ['build/**', 'dist/**', 'out/**', 'src/*/gen/**'], + }, + { + files: ['src/**/*.{ts,tsx}', 'vite.config.ts'], + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + parser: tsParser, + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + plugins: { + '@typescript-eslint': tsPlugin, + 'react-hooks': reactHooks, + }, + rules: { + ...tsPlugin.configs.recommended.rules, + ...reactHooks.configs.recommended.rules, + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + }, + }, +]; diff --git a/mobrowser.conf.json b/mobrowser.conf.json new file mode 100644 index 0000000..1a4b68d --- /dev/null +++ b/mobrowser.conf.json @@ -0,0 +1,79 @@ +{ + "app": { + "name": "MoDevice", + "version": { + "major": "1", + "minor": "0", + "patch": "0" + }, + "author": "TeamDev", + "copyright": "Copyright © 2026 TeamDev", + "description": "Configure your mice and keyboards.", + "locales": [ + "en-US" + ], + "trustedOrigins": [], + "schemes": [], + "bundle": { + "macOS": { + "icon": "assets/app.icns", + "bundleID": "com.teamdev.MoDevice", + "codesignIdentity": "${MAC_CODESIGN_IDENTITY}", + "codesignEntitlements": "assets/entitlements.plist", + "teamID": "${MAC_TEAM_ID}", + "appleID": "${MAC_APPLE_ID}", + "password": "${MAC_APPLE_PASSWORD}", + "installer": { + "dmg": { + "name": "", + "volumeName": "", + "volumeIcon": "assets/app.icns", + "eula": "", + "window": { + "textSize": 14, + "skipPrettifying": false, + "backgroundImage": "", + "position": { + "x": 500, + "y": 400 + }, + "size": { + "width": 600, + "height": 400 + }, + "icon": { + "size": 150, + "position": { + "x": 160, + "y": 160 + } + }, + "appDropLink": { + "x": 430, + "y": 160 + } + } + } + } + }, + "Windows": { + "icon": "assets/app.ico", + "signCommand": "", + "installer": { + "exe": { + "name": "", + "icon": "assets/app.ico", + "installationGif": "", + "packageId": "MoDevice", + "installationFolderName": "MoDevice", + "displayName": "MoDevice", + "deltaStrategy": "BestSpeed" + } + } + }, + "Linux": { + "icon": "assets/app.png" + } + } + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..27ef5cb --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3843 @@ +{ + "name": "modevice", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "modevice", + "version": "1.0.0", + "dependencies": { + "@mobrowser/api": "2.13.0", + "@mobrowser/cli": "2.13.0", + "@mobrowser/native": "2.13.0", + "@radix-ui/react-toggle": "^1.1.10", + "@radix-ui/react-toggle-group": "^1.1.11", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "lucide-react": "^0.503.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "tailwind-merge": "^3.2.0", + "tailwindcss-animate": "^1.0.7" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.1.2", + "@types/node": "^20.11.5", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "@typescript-eslint/eslint-plugin": "^8.65.0", + "@typescript-eslint/parser": "^8.65.0", + "@vitejs/plugin-react": "^6.0.0", + "autoprefixer": "^10.4.17", + "eslint": "^10.8.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.3", + "postcss": "^8.4.33", + "tailwindcss": "^4.1.4", + "tar-stream": "3.2.0", + "ts-proto": "^2.10.1", + "typescript": "^5.8.3", + "vite": "^8.0.0" + }, + "engines": { + "node": "^20.20.2 || ^22.22.2 || >=24.14.1" + }, + "optionalDependencies": { + "@mobrowser/sdk-darwin-arm64": "2.13.0", + "@mobrowser/sdk-linux-x64": "2.13.0", + "@mobrowser/sdk-win-x64": "2.13.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bufbuild/protobuf": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.13.0.tgz", + "integrity": "sha512-acq7c49vxfm1ggJ95P70TX7ABDM0vxr1SYD3BB0o0jnBLB4OAqeHyKuN+cD3w80gXEDQ2zxHpR6CUeA+O/aU9g==", + "dev": true, + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/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/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mobrowser/api": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@mobrowser/api/-/api-2.13.0.tgz", + "integrity": "sha512-/AEnR8ZXfe7FeM3wCelzkEFgtV35vTbVeBVMXKO6xfYrM2DibIsOh4p1Rx4+h9v9f078+d8HOHN/eFgd4De0Dg==" + }, + "node_modules/@mobrowser/cli": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@mobrowser/cli/-/cli-2.13.0.tgz", + "integrity": "sha512-fCjH29VJyv2RSGpX2+QC68Yt4IlauGeofEToUlR/bWzTSOlrlJlbxJkDSUmxRyxpLX4PTqaES7pHb+a2FK3CZg==", + "license": "MŌBROWSER", + "os": [ + "win32", + "linux", + "darwin" + ], + "dependencies": { + "adm-zip": "^0.5.10", + "tar-stream": "^3.1.7" + }, + "bin": { + "mobrowser": "cli.js" + }, + "optionalDependencies": { + "@mobrowser/sdk-darwin-arm64": "2.13.0", + "@mobrowser/sdk-linux-x64": "2.13.0", + "@mobrowser/sdk-win-x64": "2.13.0" + }, + "peerDependencies": { + "vite": ">=5.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/@mobrowser/cli/node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/@mobrowser/native": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@mobrowser/native/-/native-2.13.0.tgz", + "integrity": "sha512-hIejYlt/TorCuS75QpTPw/VMmmwT/8Qme2roTjvf+ZBVS5CoBFt7uQHR+bHESzQRReNd7BxkhBCpi38HsE+YEA==", + "license": "MŌBROWSER" + }, + "node_modules/@mobrowser/sdk-darwin-arm64": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@mobrowser/sdk-darwin-arm64/-/sdk-darwin-arm64-2.13.0.tgz", + "integrity": "sha512-duZkUk269Tr3/XB/paIFza3uxgGKtN+5Ar+EkhoKHfPzorWNlJ5yhndm+TmKRP+LKyFIgT3pdts7ALeV0nnEIQ==", + "hasInstallScript": true, + "license": "MŌBROWSER", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@mobrowser/sdk-linux-x64": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@mobrowser/sdk-linux-x64/-/sdk-linux-x64-2.13.0.tgz", + "integrity": "sha512-dEUhmpDIjsnYnjJ9hqNeL+ohfJwdYYUjz6lNQpTYuvfR8rC8qkKoJ+7nCzRXs3u273IHuScU/ViUXOB6yv2kMQ==", + "cpu": [ + "x64" + ], + "license": "MŌBROWSER", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@mobrowser/sdk-win-x64": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@mobrowser/sdk-win-x64/-/sdk-win-x64-2.13.0.tgz", + "integrity": "sha512-Ro2exvEbdH2tf5GYW2qLtuAr+fM8GYq5I4lIL+FPQ53jh1IaYqZ81vVdpCUv5M/r51+49p17NsrxDgsgWniOdA==", + "cpu": [ + "x64" + ], + "license": "MŌBROWSER", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz", + "integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", + "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz", + "integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.19.tgz", + "integrity": "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.18.tgz", + "integrity": "sha512-7lonPlKfSacd20GlOBx2ltuVKz9oqWYZz+oMQyOltw6t1y2nyftj2ZmwwUHYn49kqfDWcp8dNZm5NgV+5Z+mug==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.19.tgz", + "integrity": "sha512-OtnwuSVjd1Ofi+AdnvhsjQdyuhCDwYs1w9RyB5BN/OavXOVQo42SYqQjwUnbPnaiPFBpQ9aX70dWeee+v2oBLA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-toggle": "1.1.18", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", + "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", + "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.3.tgz", + "integrity": "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "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==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.3.tgz", + "integrity": "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "postcss": "^8.5.16", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", + "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/type-utils": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.65.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", + "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", + "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.65.0", + "@typescript-eslint/types": "^8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", + "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", + "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", + "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", + "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", + "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.65.0", + "@typescript-eslint/tsconfig-utils": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", + "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", + "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/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/@vitejs/plugin-react": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz", + "integrity": "sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "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/adm-zip": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz", + "integrity": "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==", + "license": "MIT", + "engines": { + "node": ">=14.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/autoprefixer": { + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz", + "integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.6", + "caniuse-lite": "^1.0.30001806", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "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/bare-events": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.4.tgz", + "integrity": "sha512-y1kC+ffIx/tPLdTE693uNjHfzTfr+ravR5tvWlMXe25nELbkqV400S71qHDwbkAQ1FVEZobB1NFRzFbCCcyBCQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.1.tgz", + "integrity": "sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/bare-stream": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.6.tgz", + "integrity": "sha512-iQxPClE07hETVpbRoX7JXX3v/ZQViCxe/SYCxylRLzdEx1xJAufPptfiOqR8tqiCtmbtMDANKWszzjLu1PMAZQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.4.tgz", + "integrity": "sha512-s4+sLr9mZ/CyqeRritFeYV/Zx73OAtmaHn6kkBS1XRoJn1hrg3xIDUcpicAEX68tkcIN0iBCgti31C8zxtkhsQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/case-anything": { + "version": "2.1.13", + "resolved": "https://registry.npmjs.org/case-anything/-/case-anything-2.1.13.tgz", + "integrity": "sha512-zlOQ80VrQ2Ue+ymH5OuM/DlDq64mEm+B9UTdHULv5osUMD6HalNTblf2b1u/m6QecjsnOkBpqVZ+XPwIVsy7Ng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/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/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==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dprint-node": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/dprint-node/-/dprint-node-1.0.8.tgz", + "integrity": "sha512-iVKnUtYfGrYcW1ZAlfR/F59cUVL8QIhWoBJoSjkkdua/dkWIgjZfiLMeTjiB06X0ZLkQ0M2C1VbUj/CxkIf1zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^1.0.3" + } + }, + "node_modules/dprint-node/node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.396", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.396.tgz", + "integrity": "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.24.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz", + "integrity": "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.3.tgz", + "integrity": "sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "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/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/eslint/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/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/espree/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/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/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/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.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-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.3.tgz", + "integrity": "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/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/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/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/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "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/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "devOptional": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.503.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.503.0.tgz", + "integrity": "sha512-HGGkdlPWQ0vTF8jJ5TdIqhQXZi6uh3LnNgfZ8MHiuxFfX3RZeA79r2MW2tHAZKlAVfoNE8esm3p+O6VkIvpj6w==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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/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==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "devOptional": 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.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "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/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "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/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/streamx": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", + "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "license": "MIT" + }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "license": "MIT", + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "devOptional": 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/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-poet": { + "version": "6.12.0", + "resolved": "https://registry.npmjs.org/ts-poet/-/ts-poet-6.12.0.tgz", + "integrity": "sha512-xo+iRNMWqyvXpFTaOAvLPA5QAWO6TZrSUs5s4Odaya3epqofBu/fMLHEWl8jPmjhA0s9sgj9sNvF1BmaQlmQkA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dprint-node": "^1.0.8" + } + }, + "node_modules/ts-proto": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/ts-proto/-/ts-proto-2.12.0.tgz", + "integrity": "sha512-ezMxg57ZiK/ZTW14U7y38+qyWHJr8cn8ELKuppANER666YnUteuNFO/mM1qI+9/wAHoTAfRadnIwtVdp1xw0jQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@bufbuild/protobuf": "^2.10.2", + "case-anything": "^2.1.13", + "ts-poet": "^6.12.0", + "ts-proto-descriptors": "2.1.0" + }, + "bin": { + "protoc-gen-ts_proto": "protoc-gen-ts_proto" + } + }, + "node_modules/ts-proto-descriptors": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-proto-descriptors/-/ts-proto-descriptors-2.1.0.tgz", + "integrity": "sha512-S5EZYEQ6L9KLFfjSRpZWDIXDV/W7tAj8uW7pLsihIxyr62EAVSiKuVPwE8iWnr849Bqa53enex1jhDUcpgquzA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@bufbuild/protobuf": "^2.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==", + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "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.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/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/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/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3102cf2 --- /dev/null +++ b/package.json @@ -0,0 +1,67 @@ +{ + "name": "modevice", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "mobrowser dev", + "dev:build": "mobrowser dev:build", + "build": "mobrowser build", + "pack": "mobrowser pack", + "gen": "mobrowser gen", + "mobrowser": "mobrowser", + "lint": "eslint . --max-warnings=0", + "typecheck": "tsc --noEmit && tsc -p tsconfig.node.json --noEmit" + }, + "dependencies": { + "@mobrowser/api": "2.13.0", + "@mobrowser/cli": "2.13.0", + "@mobrowser/native": "2.13.0", + "@radix-ui/react-toggle": "^1.1.10", + "@radix-ui/react-toggle-group": "^1.1.11", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "lucide-react": "^0.503.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "tailwind-merge": "^3.2.0", + "tailwindcss-animate": "^1.0.7" + }, + "optionalDependencies": { + "@mobrowser/sdk-darwin-arm64": "2.13.0", + "@mobrowser/sdk-linux-x64": "2.13.0", + "@mobrowser/sdk-win-x64": "2.13.0" + }, + "allowScripts": { + "@mobrowser/sdk-darwin-arm64@2.13.0": true, + "fsevents@2.3.3": true + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.1.2", + "@types/node": "^20.11.5", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "@typescript-eslint/eslint-plugin": "^8.65.0", + "@typescript-eslint/parser": "^8.65.0", + "@vitejs/plugin-react": "^6.0.0", + "autoprefixer": "^10.4.17", + "eslint": "^10.8.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.3", + "postcss": "^8.4.33", + "tailwindcss": "^4.1.4", + "tar-stream": "3.2.0", + "ts-proto": "^2.10.1", + "typescript": "^5.8.3", + "vite": "^8.0.0" + }, + "engines": { + "node": "^20.20.2 || ^22.22.2 || >=24.14.1" + }, + "overrides": { + "@mobrowser/cli": { + "adm-zip": "0.6.0", + "tar-stream": "3.1.7" + } + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..a7f73a2 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,5 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +} diff --git a/resources/imageTemplate.png b/resources/imageTemplate.png new file mode 100644 index 0000000..3ca432b Binary files /dev/null and b/resources/imageTemplate.png differ diff --git a/resources/imageTemplate@2x.png b/resources/imageTemplate@2x.png new file mode 100644 index 0000000..29ec54a Binary files /dev/null and b/resources/imageTemplate@2x.png differ diff --git a/src/main/devices.ts b/src/main/devices.ts new file mode 100644 index 0000000..e7e0e63 --- /dev/null +++ b/src/main/devices.ts @@ -0,0 +1,139 @@ +import { ipc, prefs } from '@mobrowser/api'; +import { native } from './gen/native'; +import { + LinkType as NativeLinkType, + type DeviceList as NativeDeviceList, +} from './gen/native/devices'; +import { DeviceEventsServiceDescriptor } from './gen/native_service'; +import { DevicesServiceDescriptor } from './gen/ipc_service'; +import { Settings as IpcSettings, type DeviceId } from './gen/devices'; +import { checkBatteries, persistPreferences } from './settings'; + +const STORED_SETTINGS_KEY = 'devices.settings'; +const STORED_PAIRED_IDS_KEY = 'devices.pairedIds'; + +/** + * Reads every saved device-settings snapshot from main-process preferences. + */ +function readStoredSettings(): Record { + return prefs.getObject>(STORED_SETTINGS_KEY, {}); +} + +/** + * Saves one device's complete settings snapshot without changing the others. + */ +function storeSettings(settings: IpcSettings): void { + prefs.setObject(STORED_SETTINGS_KEY, { + ...readStoredSettings(), + [settings.deviceId]: IpcSettings.toJSON(settings), + }); + persistPreferences(); +} + +/** + * Saves the IDs of devices currently managed by the native device stack. + */ +function storePairedDevices(devices: NativeDeviceList): void { + prefs.setArray( + STORED_PAIRED_IDS_KEY, + devices.devices.map((device) => device.id), + ); + persistPreferences(); +} + +/** + * Makes the native paired-device list match the IDs saved by the main process. + */ +async function restorePairedDevices(): Promise { + const paired = await native.deviceStack.List({}); + if (!prefs.hasArray(STORED_PAIRED_IDS_KEY)) { + storePairedDevices(paired); + return; + } + + const storedIds = new Set(prefs.getArray(STORED_PAIRED_IDS_KEY, [])); + const available = await native.deviceStack.Discover({}); + + for (const device of available.devices) { + if (storedIds.has(device.id)) { + await native.deviceStack.Pair({ id: device.id }); + } + } + for (const device of paired.devices) { + if (!storedIds.has(device.id) && device.link !== NativeLinkType.WIRED) { + await native.deviceStack.Forget({ id: device.id }); + } + } + + storePairedDevices(await native.deviceStack.List({})); +} + +/** + * Sends saved settings back to the native device stack when the app starts. + */ +async function restoreSettings(): Promise { + for (const storedSettings of Object.values(readStoredSettings())) { + try { + await native.deviceStack.ApplySettings(IpcSettings.fromJSON(storedSettings)); + } catch (error) { + console.warn('Could not restore device settings.', error); + } + } +} + +/** + * Connects renderer device requests to the native stack and keeps both sides in sync. + */ +export function startDevices(): void { + const deviceUpdates = ipc.registerService(DevicesServiceDescriptor); + const devicesReady = restorePairedDevices().then(restoreSettings); + + let restored = false; + void devicesReady + .then(async () => { + restored = true; + checkBatteries(await native.deviceStack.List({})); + }) + .catch((error: unknown) => console.warn('Could not check device batteries.', error)); + + ipc.registerService(DevicesServiceDescriptor, { + async List() { + await devicesReady; + return native.deviceStack.List({}); + }, + async Discover() { + await devicesReady; + return native.deviceStack.Discover({}); + }, + async Pair(request: DeviceId) { + await devicesReady; + await native.deviceStack.Pair(request); + storePairedDevices(await native.deviceStack.List({})); + return {}; + }, + async Forget(request: DeviceId) { + await devicesReady; + await native.deviceStack.Forget(request); + storePairedDevices(await native.deviceStack.List({})); + return {}; + }, + async GetSettings(request: DeviceId) { + await devicesReady; + return native.deviceStack.GetSettings(request); + }, + async ApplySettings(request: IpcSettings) { + await devicesReady; + await native.deviceStack.ApplySettings(request); + storeSettings(request); + return {}; + }, + }); + + native.registerService(DeviceEventsServiceDescriptor, { + async Changed(devices: NativeDeviceList) { + deviceUpdates.Watch(devices); + if (restored) checkBatteries(devices); + return {}; + }, + }); +} diff --git a/src/main/index.ts b/src/main/index.ts new file mode 100644 index 0000000..a72cfb8 --- /dev/null +++ b/src/main/index.ts @@ -0,0 +1,36 @@ +/** + * Starts the main process, connects its services, and opens the application window. + */ +import { app, BrowserWindow } from '@mobrowser/api'; +import { buildApplicationMenu } from './menu'; +import { startDevices } from './devices'; +import { startSettings } from './settings'; +import { checkForUpdates } from './updates'; +import * as process from 'node:process'; + +const isMac = process.platform === 'darwin'; +const WINDOW_SIZE = { width: 1000, height: 620 }; + +const win = new BrowserWindow({ + size: WINDOW_SIZE, + minimumSize: WINDOW_SIZE, + windowTitleVisible: false, + windowTitlebarVisible: !isMac, +}); + +startDevices(); +startSettings(win); + +win.browser.loadUrl(app.url); +win.browser.zoom.setEnabled(false); + +win.centerWindow(); +win.show(); + +if (isMac) { + app.setMenu(buildApplicationMenu()); +} + +void checkForUpdates(win).catch((error: unknown) => { + console.warn('Could not finish the application update check.', error); +}); diff --git a/src/main/menu.ts b/src/main/menu.ts new file mode 100644 index 0000000..ca59bb4 --- /dev/null +++ b/src/main/menu.ts @@ -0,0 +1,58 @@ +import { app, workspace, Menu, MenuItem, MenuWithRole } from '@mobrowser/api'; + +const DISPLAY_NAME = 'MōDevice'; +const REPOSITORY_URL = 'https://github.com/mo-browser-apps/device'; + +/** + * Shows app details in a native dialog and links to the source repository. + */ +async function showAbout(): Promise { + const result = await app.showMessageDialog({ + type: 'info', + message: `${DISPLAY_NAME} ${app.version}`, + informativeText: `${app.description}\n\nPowered by MōBrowser.\n\n${app.copyright}`, + buttons: [ + { label: 'Close', type: 'primary' }, + { label: 'Open GitHub Repository...', type: 'secondary' }, + ], + }); + if (result.button.type === 'secondary') { + workspace.openUrl(REPOSITORY_URL); + } +} + +/** + * Builds the native application menu used by the main process. + */ +export function buildApplicationMenu(): Menu { + const appMenu = new MenuWithRole({ + role: 'macAppMenu', + items: [ + new MenuItem({ + id: 'about', + label: `About ${DISPLAY_NAME}`, + action: () => void showAbout().catch(() => {}), + }), + 'separator', + 'macHideApp', + 'macHideOthers', + 'macShowAll', + 'separator', + new MenuItem({ + id: 'quit', + label: `Quit ${DISPLAY_NAME}`, + shortcut: 'CommandOrControl+Q', + action: () => app.quit(), + }), + ], + }); + + const editMenu = new MenuWithRole({ + role: 'editMenu', + items: ['undo', 'redo', 'separator', 'cut', 'copy', 'paste', 'selectAll'], + }); + + const windowMenu = new MenuWithRole({ role: 'windowMenu', items: ['minimizeWindow'] }); + + return new Menu({ items: [appMenu, editMenu, windowMenu] }); +} diff --git a/src/main/settings.ts b/src/main/settings.ts new file mode 100644 index 0000000..6fcec02 --- /dev/null +++ b/src/main/settings.ts @@ -0,0 +1,129 @@ +import { app, ipc, prefs, BrowserWindow, Notification, Theme } from '@mobrowser/api'; +import { native } from './gen/native'; +import { AppSettings } from './gen/app'; +import { AppServiceDescriptor } from './gen/ipc_service'; +import type { Device, DeviceList } from './gen/native/devices'; + +const THEME_KEY = 'app.theme'; +const LOW_BATTERY_ALERTS_KEY = 'app.lowBatteryAlerts'; +const AUTOMATIC_UPDATE_DOWNLOADS_KEY = 'app.automaticUpdateDownloads'; +const LOW_BATTERY = 20; + +const lowBattery = new Set(); + +const appEvents = ipc.registerService(AppServiceDescriptor); +let mainWindow: BrowserWindow; + +/** + * Writes the current main-process preferences to disk. + */ +export function persistPreferences(): void { + if (!prefs.persist()) { + console.warn('Could not write preferences.'); + } +} + +/** + * Reports whether available application updates should download without asking first. + */ +export function shouldDownloadUpdatesAutomatically(): boolean { + return prefs.getBoolean(AUTOMATIC_UPDATE_DOWNLOADS_KEY, true); +} + +/** + * Shows a system alert that opens the matching device when clicked. + */ +function notifyLowBattery(device: Device): void { + const { id } = device; + + const notification = new Notification({ + title: `${device.model} battery is low`, + body: `${device.battery}% remaining. Charge it soon.`, + silent: true, + }); + + notification.on('clicked', () => { + if (mainWindow.isMinimized) { + mainWindow.restore(); + } + mainWindow.focus(); + appEvents.OnOpenDevice({ id }); + }); + + notification.show(); +} + +/** + * Reports whether a device should currently show a low-battery alert. + */ +function isLow(device: Device): boolean { + return device.connected && device.hasBattery && !device.charging && device.battery <= LOW_BATTERY; +} + +/** + * Shows one alert when a managed device enters a low-battery state. + */ +export function checkBatteries(devices: DeviceList): void { + if (!prefs.getBoolean(LOW_BATTERY_ALERTS_KEY, false)) return; + + const currentDevices = new Set(); + + for (const device of devices.devices) { + currentDevices.add(device.id); + + if (isLow(device)) { + if (!lowBattery.has(device.id)) { + lowBattery.add(device.id); + notifyLowBattery(device); + } + } else { + lowBattery.delete(device.id); + } + } + + for (const deviceId of lowBattery) { + if (!currentDevices.has(deviceId)) lowBattery.delete(deviceId); + } +} + +/** + * Connects renderer settings requests to saved preferences and MōBrowser system APIs. + */ +export function startSettings(win: BrowserWindow): void { + mainWindow = win; + + app.setTheme(prefs.getString(THEME_KEY, 'system') as Theme); + + ipc.registerService(AppServiceDescriptor, { + async GetSettings() { + return { + theme: prefs.getString(THEME_KEY, 'system'), + launchAtLogin: app.loginItemSettings.openAtLogin, + lowBatteryAlerts: prefs.getBoolean(LOW_BATTERY_ALERTS_KEY, false), + automaticUpdateDownloads: shouldDownloadUpdatesAutomatically(), + }; + }, + + async ApplySettings(request: AppSettings) { + app.setTheme(request.theme as Theme); + + if (request.launchAtLogin !== app.loginItemSettings.openAtLogin) { + app.setLoginItemSettings({ openAtLogin: request.launchAtLogin }); + } + + const alertsWereOn = prefs.getBoolean(LOW_BATTERY_ALERTS_KEY, false); + + prefs.setString(THEME_KEY, request.theme); + prefs.setBoolean(LOW_BATTERY_ALERTS_KEY, request.lowBatteryAlerts); + prefs.setBoolean(AUTOMATIC_UPDATE_DOWNLOADS_KEY, request.automaticUpdateDownloads); + persistPreferences(); + + if (!request.lowBatteryAlerts) { + lowBattery.clear(); + } else if (!alertsWereOn) { + checkBatteries(await native.deviceStack.List({})); + } + return {}; + }, + }); +} diff --git a/src/main/updates.ts b/src/main/updates.ts new file mode 100644 index 0000000..56cade8 --- /dev/null +++ b/src/main/updates.ts @@ -0,0 +1,97 @@ +import { app, type BrowserWindow } from '@mobrowser/api'; +import * as process from 'node:process'; +import { shouldDownloadUpdatesAutomatically } from './settings'; + +const UPDATE_SOURCE = 'https://github.com/mo-browser-apps/device/releases/latest/download'; + +/** + * Shows a download failure after the user has chosen to install an update. + */ +async function showDownloadError(win: BrowserWindow, error?: string): Promise { + await app.showMessageDialog({ + parentWindow: win, + type: 'error', + title: 'Software Update', + message: 'The update could not be downloaded.', + informativeText: error ?? 'Please try again later.', + buttons: [{ label: 'Close', type: 'primary' }], + }); +} + +/** + * Checks GitHub Releases and guides the user through downloading and restarting. + */ +export async function checkForUpdates(win: BrowserWindow): Promise { + if (!app.packaged || (process.platform !== 'darwin' && process.platform !== 'win32')) return; + + let update; + try { + update = await app.checkForUpdate(UPDATE_SOURCE); + } catch (error) { + console.warn('Could not check for application updates.', error); + return; + } + + if (!update) return; + if (typeof update === 'string') { + console.warn('Could not check for application updates.', update); + return; + } + + const automaticDownload = shouldDownloadUpdatesAutomatically(); + if (!automaticDownload) { + const confirmation = await app.showMessageDialog({ + parentWindow: win, + type: 'info', + title: 'Software Update', + message: `Version ${update.version} is available.`, + informativeText: 'Would you like to download it now?', + buttons: [ + { label: 'Download', type: 'primary' }, + { label: 'Later', type: 'secondary' }, + ], + }); + + if (confirmation.button.type !== 'primary') { + update.dismiss(); + return; + } + } + + let download; + try { + download = await update.download(); + } catch (error) { + if (automaticDownload) { + console.warn('Could not download the application update.', error); + } else { + await showDownloadError(win, error instanceof Error ? error.message : undefined); + } + return; + } + + if (!download.success) { + if (automaticDownload) { + console.warn('Could not download the application update.', download.error); + } else { + await showDownloadError(win, download.error); + } + return; + } + + const restart = await app.showMessageDialog({ + parentWindow: win, + type: 'info', + title: 'Software Update', + message: 'The update is ready to install.', + informativeText: 'Restart the app to finish updating.', + buttons: [ + { label: 'Restart', type: 'primary' }, + { label: 'Later', type: 'secondary' }, + ], + }); + + if (restart.button.type === 'primary') { + app.restart(); + } +} diff --git a/src/native/device_service.cc b/src/native/device_service.cc new file mode 100644 index 0000000..46205a2 --- /dev/null +++ b/src/native/device_service.cc @@ -0,0 +1,72 @@ +#include "device_service.h" + +#include +#include + +#include "device_stack.h" +#include "gen/devices.rpc.h" +#include "gen/events.rpc.h" +#include "rpc.h" + +using google::protobuf::Empty; +using mo::rpc::Callback; + +namespace { + +class DeviceStackServiceImpl : public DeviceStackService { + public: + explicit DeviceStackServiceImpl(DeviceStack& stack) : stack_(stack) {} + + void List(const Empty*, Callback done) override { + std::move(done).Complete(stack_.List()); + } + + void Discover(const Empty*, Callback done) override { + std::move(done).Complete(stack_.Discover()); + } + + void Pair(const DeviceId* request, Callback done) override { + if (!stack_.Pair(request->id())) { + std::move(done).Reject("Cannot pair device: " + request->id()); + return; + } + std::move(done).Complete(Empty()); + } + + void Forget(const DeviceId* request, Callback done) override { + if (!stack_.Forget(request->id())) { + std::move(done).Reject("Cannot forget device: " + request->id()); + return; + } + std::move(done).Complete(Empty()); + } + + void GetSettings(const DeviceId* request, Callback done) override { + std::optional settings = stack_.GetSettings(request->id()); + if (!settings.has_value()) { + std::move(done).Reject("Unknown device: " + request->id()); + return; + } + std::move(done).Complete(std::move(*settings)); + } + + void ApplySettings(const Settings* request, Callback done) override { + if (!stack_.ApplySettings(*request)) { + std::move(done).Reject("Cannot apply settings to device: " + request->device_id()); + return; + } + std::move(done).Complete(Empty()); + } + + private: + DeviceStack& stack_; +}; + +} // namespace + +void RegisterDeviceStackService(DeviceStack& stack) { + stack.SetDevicesChangedHandler([](const DeviceList& devices) { + mo::rpc::device_events.Changed(devices, [](mo::rpc::Result) {}); + }); + mo::rpc::RegisterService(new DeviceStackServiceImpl(stack)); +} diff --git a/src/native/device_service.h b/src/native/device_service.h new file mode 100644 index 0000000..8c01da3 --- /dev/null +++ b/src/native/device_service.h @@ -0,0 +1,9 @@ +#ifndef DEVICE_SERVICE_H_ +#define DEVICE_SERVICE_H_ + +class DeviceStack; + +// Exposes DeviceStack through the MōBrowser native RPC contract. +void RegisterDeviceStackService(DeviceStack& stack); + +#endif // DEVICE_SERVICE_H_ diff --git a/src/native/device_stack.cc b/src/native/device_stack.cc new file mode 100644 index 0000000..66c4d0e --- /dev/null +++ b/src/native/device_stack.cc @@ -0,0 +1,233 @@ +#include "device_stack.h" + +#include +#include +#include +#include + +namespace { + +struct ButtonSeed { + std::string control; + std::string action; +}; + +struct MouseSeed { + std::string id; + std::string model_id; + std::string model; + LinkType link; + std::string firmware; + int battery; + bool charging; + std::vector buttons; + int min_dpi; + int max_dpi; + int dpi; +}; + +struct DeviceSeed { + Device device; + Settings settings; +}; + +template +auto FindById(Entries& entries, const std::string& device_id) { + return std::find_if(entries.begin(), entries.end(), + [&](const auto& entry) { return entry.device.id() == device_id; }); +} + +DeviceSeed MakeMouse(const MouseSeed& seed) { + Device device; + device.set_id(seed.id); + device.set_model_id(seed.model_id); + device.set_model(seed.model); + device.set_link(seed.link); + device.set_firmware(seed.firmware); + device.set_connected(true); + device.set_has_battery(true); + device.set_battery(seed.battery); + device.set_charging(seed.charging); + + MouseSpec* spec = device.mutable_mouse(); + spec->set_min_dpi(seed.min_dpi); + spec->set_max_dpi(seed.max_dpi); + + Settings settings; + settings.set_device_id(seed.id); + MouseSettings* mouse = settings.mutable_mouse(); + mouse->set_dpi(seed.dpi); + mouse->set_scroll_speed(3); + mouse->set_natural_scroll(false); + + for (const ButtonSeed& button : seed.buttons) { + spec->add_buttons(button.control); + Binding* binding = mouse->add_bindings(); + binding->set_control(button.control); + binding->set_action(button.action); + } + + return {std::move(device), std::move(settings)}; +} + +const char* const kRemappableKeys[] = { + "esc", "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "minus", "equal", "backspace", + "tab", "q", "w", "e", "r", "t", "y", "u", "i", "o", "p", "bracketleft", "bracketright", + "backslash", + "capslock", "a", "s", "d", "f", "g", "h", "j", "k", "l", "semicolon", "quote", "enter", + "shiftleft", "z", "x", "c", "v", "b", "n", "m", "comma", "period", "slash", "shiftright", + "ctrlleft", "metaleft", "altleft", "space", "altright", "menu", "ctrlright", +}; + +DeviceSeed MakeKeyboard() { + Device device; + device.set_id("compact-keyboard"); + device.set_model_id("compact-keyboard"); + device.set_model("Compact Keyboard"); + device.set_link(WIRED); + device.set_firmware("2.0.5"); + device.set_connected(true); + device.set_has_battery(false); + + KeyboardSpec* spec = device.mutable_keyboard(); + spec->set_backlight(true); + + Settings settings; + settings.set_device_id(device.id()); + KeyboardSettings* keyboard = settings.mutable_keyboard(); + keyboard->set_effect(STATIC); + keyboard->set_hue(35); + keyboard->set_brightness(70); + + for (const char* key : kRemappableKeys) { + spec->add_keys(key); + Binding* binding = keyboard->add_bindings(); + binding->set_control(key); + binding->set_action("default"); + } + + return {std::move(device), std::move(settings)}; +} + +} // namespace + +DeviceStack::DeviceStack() { + auto add = [](std::vector& paired, DeviceSeed seed) { + paired.push_back({std::move(seed.device), std::move(seed.settings)}); + }; + + add(paired_, MakeMouse({ + .id = "performance-mouse", + .model_id = "performance-mouse", + .model = "Performance Mouse", + .link = RECEIVER, + .firmware = "3.2.1", + .battery = 82, + .charging = true, + .buttons = {{"wheel", "middle-click"}, + {"back", "back"}, + {"forward", "forward"}, + {"gesture", "show-desktop"}}, + .min_dpi = 400, + .max_dpi = 8000, + .dpi = 1600, + })); + add(paired_, MakeMouse({ + .id = "travel-mouse", + .model_id = "travel-mouse", + .model = "Travel Mouse", + .link = BLUETOOTH, + .firmware = "1.4.0", + .battery = 15, + .charging = false, + .buttons = {{"wheel", "middle-click"}}, + .min_dpi = 800, + .max_dpi = 3200, + .dpi = 1200, + })); + add(paired_, MakeKeyboard()); + + // A second unit of the same mouse, discoverable so the pairing flow is + // usable on a fresh launch. + Entry nearby = paired_.front(); + nearby.device.set_id("performance-mouse-secondary"); + nearby.device.set_link(BLUETOOTH); + nearby.device.set_battery(68); + nearby.device.set_charging(false); + nearby.device.set_connected(false); + nearby.settings.set_device_id(nearby.device.id()); + available_.push_back(std::move(nearby)); +} + +DeviceList DeviceStack::List() const { + DeviceList list; + for (const Entry& entry : paired_) { + *list.add_devices() = entry.device; + } + return list; +} + +DeviceList DeviceStack::Discover() const { + DeviceList list; + for (const Entry& entry : available_) { + *list.add_devices() = entry.device; + } + return list; +} + +bool DeviceStack::Pair(const std::string& device_id) { + auto entry = FindById(available_, device_id); + if (entry == available_.end()) { + return false; + } + entry->device.set_connected(true); + paired_.push_back(std::move(*entry)); + available_.erase(entry); + PublishDevicesChanged(); + return true; +} + +bool DeviceStack::Forget(const std::string& device_id) { + auto entry = FindById(paired_, device_id); + if (entry == paired_.end() || entry->device.link() == WIRED) { + return false; + } + entry->device.set_connected(false); + available_.push_back(std::move(*entry)); + paired_.erase(entry); + PublishDevicesChanged(); + return true; +} + +std::optional DeviceStack::GetSettings(const std::string& device_id) const { + auto entry = FindById(paired_, device_id); + if (entry == paired_.end()) { + return std::nullopt; + } + return entry->settings; +} + +bool DeviceStack::ApplySettings(const Settings& settings) { + auto entry = FindById(paired_, settings.device_id()); + if (entry != paired_.end()) { + if (entry->settings.kind_case() != settings.kind_case()) { + return false; + } + entry->settings = settings; + return true; + } + + // Keep an unpaired device's customizations so adding it again does not reset it. + auto available = FindById(available_, settings.device_id()); + if (available == available_.end() || available->settings.kind_case() != settings.kind_case()) { + return false; + } + available->settings = settings; + return true; +} + +void DeviceStack::PublishDevicesChanged() const { + if (devices_changed_) { + devices_changed_(List()); + } +} diff --git a/src/native/device_stack.h b/src/native/device_stack.h new file mode 100644 index 0000000..7b5d095 --- /dev/null +++ b/src/native/device_stack.h @@ -0,0 +1,57 @@ +#ifndef DEVICE_STACK_H_ +#define DEVICE_STACK_H_ + +#include +#include +#include +#include +#include + +#include "gen/devices.pb.h" + +// Holds the simulated devices and their settings. +class DeviceStack { + public: + using DevicesChangedHandler = std::function; + + // Creates the managed demo devices and one nearby device available to pair. + DeviceStack(); + + // Returns every device currently managed by the stack. + DeviceList List() const; + + // Returns nearby devices that are available to pair. + DeviceList Discover() const; + + // Moves one nearby device into the managed device list. + bool Pair(const std::string& device_id); + + // Removes one wireless device from the managed device list. + bool Forget(const std::string& device_id); + + // Returns the current settings for one managed device. + std::optional GetSettings(const std::string& device_id) const; + + // Replaces the complete settings snapshot for one known device. + bool ApplySettings(const Settings& settings); + + // Connects native device-list changes to the main-process callback. + void SetDevicesChangedHandler(DevicesChangedHandler handler) { + devices_changed_ = std::move(handler); + } + + private: + struct Entry { + Device device; + Settings settings; + }; + + std::vector paired_; + std::vector available_; + DevicesChangedHandler devices_changed_; + + // Sends the latest managed-device snapshot to the registered callback. + void PublishDevicesChanged() const; +}; + +#endif // DEVICE_STACK_H_ diff --git a/src/native/main.cc b/src/native/main.cc new file mode 100644 index 0000000..9d1b290 --- /dev/null +++ b/src/native/main.cc @@ -0,0 +1,8 @@ +#include "device_service.h" +#include "device_stack.h" + +// Creates the native device stack and keeps it available for RPC calls. +void launch() { + static DeviceStack stack; + RegisterDeviceStackService(stack); +} diff --git a/src/native/proto/devices.proto b/src/native/proto/devices.proto new file mode 100644 index 0000000..d67698f --- /dev/null +++ b/src/native/proto/devices.proto @@ -0,0 +1,112 @@ +syntax = "proto3"; + +import "google/protobuf/empty.proto"; + +// MōBrowser generates native RPC and renderer IPC from separate proto roots. +// Keep this device model aligned with src/renderer/proto/devices.proto. The +// main-process bridge in src/main/devices.ts owns the handoff between them. + +// How the device communicates with the computer. +enum LinkType { + RECEIVER = 0; + BLUETOOTH = 1; + WIRED = 2; +} + +// Lighting effects supported by the demo keyboard. +enum LightEffect { + STATIC = 0; + BREATHING = 1; + WAVE = 2; +} + +message MouseSpec { + // Stable identifiers shared with Binding.control. + repeated string buttons = 1; + int32 min_dpi = 2; + int32 max_dpi = 3; +} + +message KeyboardSpec { + // Stable identifiers shared with Binding.control. + repeated string keys = 1; + bool backlight = 2; +} + +message Device { + string id = 1; + // Stable product identity shared by every unit of the same model. + string model_id = 11; + string model = 2; + LinkType link = 3; + string firmware = 4; + bool connected = 5; + bool has_battery = 6; + // Percentage from 0 to 100; ignored when has_battery is false. + int32 battery = 7; + bool charging = 10; + + // Hardware capabilities and device category. + oneof spec { + MouseSpec mouse = 8; + KeyboardSpec keyboard = 9; + } +} + +message DeviceId { + string id = 1; +} + +message DeviceList { + repeated Device devices = 1; +} + +// Strings keep vendor-specific controls and actions extensible without +// requiring schema changes. +message Binding { + string control = 1; + string action = 2; +} + +message MouseSettings { + repeated Binding bindings = 1; + int32 dpi = 2; + // Demo scale from 1 (slowest) to 5 (fastest). + int32 scroll_speed = 3; + bool natural_scroll = 4; +} + +message KeyboardSettings { + repeated Binding bindings = 1; + LightEffect effect = 2; + // Hue in degrees from 0 to 360. + int32 hue = 3; + // Percentage from 0 to 100. + int32 brightness = 4; +} + +message Settings { + string device_id = 1; + + // Must match the corresponding Device.spec variant. + oneof kind { + MouseSettings mouse = 2; + KeyboardSettings keyboard = 3; + } +} + +// Native RPC implemented by the replaceable device stack. +service DeviceStackService { + // All devices currently managed by the stack. + rpc List(google.protobuf.Empty) returns (DeviceList); + + // Nearby devices available to add. + rpc Discover(google.protobuf.Empty) returns (DeviceList); + + rpc Pair(DeviceId) returns (google.protobuf.Empty); + rpc Forget(DeviceId) returns (google.protobuf.Empty); + rpc GetSettings(DeviceId) returns (Settings); + + // Applies one complete snapshot; the main-process bridge owns persistence. + rpc ApplySettings(Settings) returns (google.protobuf.Empty); +} diff --git a/src/native/proto/events.proto b/src/native/proto/events.proto new file mode 100644 index 0000000..ddf44fc --- /dev/null +++ b/src/native/proto/events.proto @@ -0,0 +1,10 @@ +syntax = "proto3"; + +import "devices.proto"; +import "google/protobuf/empty.proto"; + +// Native-to-main callbacks emitted by the device stack. +service DeviceEventsService { + // Carries the complete managed-device snapshot after a change. + rpc Changed(DeviceList) returns (google.protobuf.Empty); +} diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx new file mode 100644 index 0000000..06f6443 --- /dev/null +++ b/src/renderer/App.tsx @@ -0,0 +1,128 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Home, type HomeFocusTarget } from '@/components/home/home'; +import { DeviceView } from '@/components/device/device-view'; +import { SettingsView } from '@/components/settings/settings-view'; +import { useDevices } from '@/gateway/devices'; +import { useAppSettings, useOpenDeviceRequests } from '@/gateway/settings'; + +const isMac = navigator.userAgent.includes('Mac'); + +interface HomeState { + scrollLeft: number; + focus: HomeFocusTarget; +} + +type Screen = + | { type: 'devices' } + | { type: 'settings' } + | { type: 'device'; deviceId: string }; + +/** + * Chooses the active screen and connects shared device and app settings to it. + */ +export default function App() { + const { devices, failed } = useDevices(); + const { settings, update } = useAppSettings(); + + const [screen, setScreen] = useState({ type: 'devices' }); + const [homeState, setHomeState] = useState({ + scrollLeft: 0, + focus: null, + }); + + const mainRef = useRef(null); + const restoreHomeFocus = useRef(false); + + const activeDevice = + screen.type === 'device' + ? (devices?.find((device) => device.id === screen.deviceId) ?? null) + : null; + const screenKey = screen.type === 'device' ? `${screen.type}:${screen.deviceId}` : screen.type; + + const theme = settings?.theme ?? 'system'; + useEffect(() => { + const media = matchMedia('(prefers-color-scheme: dark)'); + + const apply = () => { + const resolved = theme === 'system' ? (media.matches ? 'dark' : 'light') : theme; + document.documentElement.classList.remove('light', 'dark'); + document.documentElement.classList.add(resolved); + }; + + apply(); + if (theme !== 'system') return; + + media.addEventListener('change', apply); + return () => media.removeEventListener('change', apply); + }, [theme]); + + const openDevice = useCallback((deviceId: string, scrollLeft?: number) => { + setHomeState((current) => ({ + scrollLeft: scrollLeft ?? current.scrollLeft, + focus: { type: 'device', id: deviceId }, + })); + setScreen({ type: 'device', deviceId }); + }, []); + + useOpenDeviceRequests(openDevice); + + useEffect(() => { + if (screen.type === 'devices' && restoreHomeFocus.current) { + restoreHomeFocus.current = false; + return; + } + mainRef.current?.focus(); + }, [screen]); + + const openSettings = (scrollLeft: number) => { + setHomeState({ scrollLeft, focus: { type: 'settings' } }); + setScreen({ type: 'settings' }); + }; + + const backToDevices = () => { + restoreHomeFocus.current = true; + setScreen({ type: 'devices' }); + }; + + const handleDeviceRemoved = () => { + setHomeState((current) => ({ ...current, focus: null })); + setScreen({ type: 'devices' }); + }; + + return ( +
+ {isMac &&
} +
+ {failed && ( +

+ Lost contact with the device service. Restart the app to reconnect. +

+ )} + {devices !== null && ( +
+ {screen.type === 'settings' ? ( + + ) : activeDevice ? ( + + ) : ( + + )} +
+ )} +
+
+ ); +} diff --git a/src/renderer/components/art/device-art.tsx b/src/renderer/components/art/device-art.tsx new file mode 100644 index 0000000..2e6d6a1 --- /dev/null +++ b/src/renderer/components/art/device-art.tsx @@ -0,0 +1,111 @@ +import type { Device } from '@/gen/devices'; +import { cn } from '@/lib/utils'; +import { KeyboardArt, type Lighting } from './keyboard-art'; +import { MouseArt, type Callout, type MouseArtProfile } from './mouse-art'; + +interface DeviceArtwork { + detailSrc: string; + homeSrc: string; + alt: string; + aspectRatio: string; + mouseProfile?: MouseArtProfile; +} + +interface DeviceArtProps { + device: Device; + variant?: 'home' | 'detail'; + callouts?: Callout[]; + lighting?: Lighting | null; + selectedControl?: string | null; + onControlSelect?: (control: string) => void; + className?: string; +} + +const ARTWORK_BY_MODEL_ID: Record = { + 'performance-mouse': { + detailSrc: '/device-art/performance-mouse.webp', + homeSrc: '/device-art/performance-mouse-home.webp', + alt: 'Graphite wireless performance mouse', + aspectRatio: '3 / 2', + mouseProfile: 'performance', + }, + 'travel-mouse': { + detailSrc: '/device-art/travel-mouse.webp', + homeSrc: '/device-art/travel-mouse-home.webp', + alt: 'Stone-gray compact travel mouse', + aspectRatio: '3 / 2', + mouseProfile: 'travel', + }, + 'compact-keyboard': { + detailSrc: '/device-art/compact-keyboard.webp', + homeSrc: '/device-art/compact-keyboard-home.webp', + alt: 'Graphite compact keyboard', + aspectRatio: '821 / 479', + }, +}; + +/** + * Shows device artwork and adds interactive mouse or keyboard controls when requested. + */ +export function DeviceArt({ + device, + variant = 'detail', + callouts = [], + lighting, + selectedControl, + onControlSelect, + className, +}: DeviceArtProps) { + const artwork = ARTWORK_BY_MODEL_ID[device.modelId]; + if (!artwork) return null; + + const offlineClassName = !device.connected && 'grayscale opacity-45'; + + if (variant === 'home') { + return ( + + ); + } + + if (device.keyboard) { + return ( + + ); + } + + if (device.mouse) { + return ( + + ); + } + + return null; +} diff --git a/src/renderer/components/art/keyboard-art.tsx b/src/renderer/components/art/keyboard-art.tsx new file mode 100644 index 0000000..23659af --- /dev/null +++ b/src/renderer/components/art/keyboard-art.tsx @@ -0,0 +1,295 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { LightEffect, type KeyboardSettings, type KeyboardSpec } from '@/gen/devices'; +import { keyboardControlLabel } from '@/components/device/keyboard-device'; +import { cn } from '@/lib/utils'; + +const KEYBOARD_ROWS = [ + 'esc 1 2 3 4 5 6 7 8 9 0 minus equal backspace:2', + 'tab:1.5 q w e r t y u i o p bracketleft bracketright backslash:1.5', + 'capslock:1.75 a s d f g h j k l semicolon quote enter:2.25', + 'shiftleft:2.25 z x c v b n m comma period slash shiftright:2.75', + 'ctrlleft:1.25 metaleft:1.25 altleft:1.25 space:6.25 altright:1.25 fn:1.25 menu:1.25 ctrlright:1.25', +]; + +/** + * Provides the short labels drawn on special key caps. + */ +const KEY_FACE_LABELS: Record = { + esc: 'Esc', + minus: '−', + equal: '=', + backspace: '⌫', + tab: 'Tab', + bracketleft: '[', + bracketright: ']', + backslash: '\\', + capslock: 'Caps', + semicolon: ';', + quote: "'", + enter: 'Enter', + shiftleft: 'Shift', + comma: ',', + period: '.', + slash: '/', + shiftright: 'Shift', + ctrlleft: 'Ctrl', + metaleft: 'Win', + altleft: 'Alt', + space: '', + altright: 'Alt', + fn: 'Fn', + menu: 'Menu', + ctrlright: 'Ctrl', +}; + +const KEY_ROW_INSETS = ['1.4%', '1%', '0.55%', '0.2%', '0%']; + +/** + * Sets the length of one glow cycle and one wave across the keyboard. + */ +const LIGHTING_CYCLE_SECONDS = 2.4; + +interface KeyLayout { + id: string; + width: number; + waveAnimationDelay: string; +} + +const KEY_LAYOUT: KeyLayout[][] = KEYBOARD_ROWS.map((row) => { + const keys = row.split(' ').map((key) => { + const [id, width = '1'] = key.split(':'); + return { id, width: Number(width) }; + }); + + const totalWidth = keys.reduce((sum, key) => sum + key.width, 0); + let currentWidth = 0; + + return keys.map((key) => { + const centerOffset = (currentWidth + key.width / 2) / totalWidth; + currentWidth += key.width; + + return { + ...key, + waveAnimationDelay: `-${(centerOffset * LIGHTING_CYCLE_SECONDS).toFixed(2)}s`, + }; + }); +}); + +/** + * Builds the fixed grid geometry that keeps both keyboard layers aligned. + */ +const KEY_ROW_STYLES: CSSProperties[] = KEY_LAYOUT.map((row, index) => ({ + gridTemplateColumns: row.map(({ width }) => `${width}fr`).join(' '), + marginInline: KEY_ROW_INSETS[index], +})); + +export type Lighting = Pick; + +/** + * Returns the short label drawn on one key cap. + */ +function keyFaceLabel(id: string): string { + return KEY_FACE_LABELS[id] ?? id.toUpperCase(); +} + +/** + * Reports whether a lighting effect should animate the key layer. + */ +function isAnimated(effect: LightEffect): boolean { + return effect === LightEffect.BREATHING || effect === LightEffect.WAVE; +} + +/** + * Builds the glow style for one key from the current lighting settings. + */ +function lightingStyle( + { effect, hue, brightness }: Lighting, + keyLayout: KeyLayout, +): CSSProperties { + const intensity = brightness / 100; + const glowColor = (alpha: number, lightness = 55) => + `hsl(${hue} 100% ${lightness}% / ${(alpha * intensity).toFixed(3)})`; + + return { + mixBlendMode: 'screen', + boxShadow: [ + `0 ${1.5 * intensity}px ${2.5 * intensity}px ${0.25 * intensity}px ${glowColor(0.75, 62)}`, + `0 ${4 * intensity}px ${8 * intensity}px ${-1.5 * intensity}px ${glowColor(0.42)}`, + ].join(', '), + color: `hsl(${hue} 100% 72% / ${(0.38 + 0.47 * intensity).toFixed(3)})`, + textShadow: `0 1px ${2.5 * intensity}px ${glowColor(0.7, 65)}`, + animationDelay: effect === LightEffect.WAVE ? keyLayout.waveAnimationDelay : '0s', + animationDuration: `${LIGHTING_CYCLE_SECONDS}s`, + }; +} + +/** + * Draws the decorative label and glow for one key. + */ +function KeyFace({ + id, + lighting, + keyLayout, +}: { + id: string; + lighting: Lighting | null; + keyLayout: KeyLayout; +}) { + return ( + + {keyFaceLabel(id)} + + ); +} + +/** + * Adds the selectable hit target that sits over one key in the image. + */ +function KeyCap({ + id, + selectable, + selected, + onSelect, +}: { + id: string; + selectable: boolean; + selected: boolean; + onSelect?: (control: string) => void; +}) { + const keyName = keyboardControlLabel(id); + + return ( + + + ); +} + +/** + * Draws a mouse image with interactive labels for its reported controls. + */ +export function MouseArt({ + src, + alt, + aspect, + profile, + callouts, + selectedControl, + onControlSelect, + className, +}: MouseArtProps) { + const profileHotspots = HOTSPOTS_BY_PROFILE[profile]; + + return ( + + {alt} + + {callouts.map((callout) => { + const hotspot = profileHotspots[callout.id]; + if (!hotspot) return null; + + return ( + + ); + })} + + ); +} diff --git a/src/renderer/components/device-status.tsx b/src/renderer/components/device-status.tsx new file mode 100644 index 0000000..2f9a2a6 --- /dev/null +++ b/src/renderer/components/device-status.tsx @@ -0,0 +1,136 @@ +import { + BatteryCharging, + BatteryFull, + BatteryLow, + BatteryMedium, + Bluetooth, + Cable, + CircleHelp, + Usb, + type LucideIcon, +} from 'lucide-react'; +import { LinkType, type Device } from '@/gen/devices'; +import { cn } from '@/lib/utils'; + +export const LOW_BATTERY = 20; +const FULL_BATTERY = 90; + +interface ConnectionStatus { + label: string; + Icon: LucideIcon; +} + +interface BatteryStatus { + label: string; + Icon: LucideIcon; + toneClassName: string; +} + +interface DeviceStatusProps { + device: Device; + className?: string; +} + +const UNKNOWN_CONNECTION: ConnectionStatus = { + label: 'Unknown connection', + Icon: CircleHelp, +}; + +const CONNECTIONS: Record = { + [LinkType.RECEIVER]: { label: 'USB receiver', Icon: Usb }, + [LinkType.BLUETOOTH]: { label: 'Bluetooth', Icon: Bluetooth }, + [LinkType.WIRED]: { label: 'Wired', Icon: Cable }, + [LinkType.UNRECOGNIZED]: UNKNOWN_CONNECTION, +}; + +/** + * Turns a device link type into the connection name shown in the UI. + */ +export function connectionLabel(device: Device): string { + return (CONNECTIONS[device.link] ?? UNKNOWN_CONNECTION).label; +} + +/** + * Chooses the battery icon, label, and color for a battery reading. + */ +function getBatteryStatus(level: number, charging: boolean): BatteryStatus { + if (charging) { + return { + Icon: BatteryCharging, + toneClassName: 'text-status-charging', + label: `Battery ${level}%, charging`, + }; + } + if (level <= LOW_BATTERY) { + return { + Icon: BatteryLow, + toneClassName: 'text-status-low', + label: `Battery low, ${level}%`, + }; + } + return { + Icon: level >= FULL_BATTERY ? BatteryFull : BatteryMedium, + toneClassName: 'text-foreground/70', + label: `Battery ${level}%`, + }; +} + +/** + * Shows the icon and accessible label for a device's connection type. + */ +export function DeviceConnectionIcon({ device, className }: DeviceStatusProps) { + const connection = CONNECTIONS[device.link] ?? UNKNOWN_CONNECTION; + const label = device.connected ? connection.label : `${connection.label}, disconnected`; + + return ( + + + + ); +} + +/** + * Shows a wireless device's battery level when it is available. + */ +export function DeviceBatteryStatus({ device, className }: DeviceStatusProps) { + if (!device.connected || !device.hasBattery || device.link === LinkType.WIRED) return null; + + const batteryStatus = getBatteryStatus(device.battery, device.charging); + + return ( + + + {device.battery}% + + ); +} + +/** + * Combines the connection and battery indicators used on a device screen. + */ +export function DeviceStatus({ device, className }: DeviceStatusProps) { + return ( + + + + + ); +} diff --git a/src/renderer/components/device/control-editor.tsx b/src/renderer/components/device/control-editor.tsx new file mode 100644 index 0000000..b139c46 --- /dev/null +++ b/src/renderer/components/device/control-editor.tsx @@ -0,0 +1,65 @@ +import type { Device, Settings } from '@/gen/devices'; +import { DeviceInfo } from './device-info'; +import type { Segment } from './device-presentation'; +import { KeyboardEditor } from './keyboard-editor'; +import { MouseEditor } from './mouse-editor'; + +interface ControlEditorProps { + device: Device; + settings: Settings; + selected: string | null; + segment: Segment; + disabled: boolean; + onRemove: () => void; + onPreview: (settings: Settings) => void; + onCommit: (settings: Settings) => void; +} + +/** + * Chooses the settings panel that matches the device type and selected section. + */ +export function ControlEditor({ + device, + settings, + selected, + segment, + disabled, + onRemove, + onPreview, + onCommit, +}: ControlEditorProps) { + if (segment === 'info') { + return ; + } + + if (segment === 'keys' || segment === 'lighting') { + if (!settings.keyboard) return null; + + return ( + + ); + } + + if (!settings.mouse || !device.mouse) return null; + + return ( + + ); +} diff --git a/src/renderer/components/device/controls.ts b/src/renderer/components/device/controls.ts new file mode 100644 index 0000000..73267c9 --- /dev/null +++ b/src/renderer/components/device/controls.ts @@ -0,0 +1,33 @@ +import type { Binding } from '@/gen/devices'; + +/** + * Maps action IDs shared with the native stack to names shown in the renderer. + */ +const ACTION_LABELS: Record = { + default: 'Default', + 'middle-click': 'Middle click', + back: 'Back', + forward: 'Forward', + 'show-desktop': 'Show desktop', + copy: 'Copy', + paste: 'Paste', + undo: 'Undo', + 'play-pause': 'Play / pause', + 'volume-up': 'Volume up', + 'volume-down': 'Volume down', + disabled: 'Disabled', +}; + +/** + * Finds the action assigned to one control, or falls back to its default behavior. + */ +export function boundAction(bindings: Binding[], control: string): string { + return bindings.find((entry) => entry.control === control)?.action ?? ''; +} + +/** + * Turns an action ID into the name shown in editors and callouts. + */ +export function actionLabel(action: string): string { + return ACTION_LABELS[action] ?? action; +} diff --git a/src/renderer/components/device/device-info.tsx b/src/renderer/components/device/device-info.tsx new file mode 100644 index 0000000..f335e1b --- /dev/null +++ b/src/renderer/components/device/device-info.tsx @@ -0,0 +1,63 @@ +import { Trash2 } from 'lucide-react'; +import { LinkType, type Device } from '@/gen/devices'; +import { connectionLabel } from '@/components/device-status'; +import { BUTTON_OUTLINE, cn } from '@/lib/utils'; +import { SettingsSection } from './editor-controls'; + +interface DeviceInfoProps { + device: Device; + onRemove: () => void; +} + +/** + * Shows the device details reported by the native stack and offers removal when allowed. + */ +export function DeviceInfo({ device, onRemove }: DeviceInfoProps) { + const details: [label: string, value: string][] = [ + ['Connection', connectionLabel(device)], + ['Firmware', device.firmware], + ]; + + if (device.keyboard) { + details.push( + ['Remappable keys', String(device.keyboard.keys.length)], + ['Lighting', device.keyboard.backlight ? 'Supported' : 'Not supported'], + ); + } + if (device.mouse) { + details.push( + ['Remappable buttons', String(device.mouse.buttons.length)], + ['Sensor', `${device.mouse.minDpi}–${device.mouse.maxDpi} DPI`], + ); + } + + return ( +
+ +
+ {details.map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+
+ + {device.link !== LinkType.WIRED && ( + + )} +
+ ); +} diff --git a/src/renderer/components/device/device-presentation.ts b/src/renderer/components/device/device-presentation.ts new file mode 100644 index 0000000..6d89e83 --- /dev/null +++ b/src/renderer/components/device/device-presentation.ts @@ -0,0 +1,42 @@ +import type { Device, Settings } from '@/gen/devices'; +import type { Lighting } from '@/components/art/keyboard-art'; +import type { Callout } from '@/components/art/mouse-art'; +import { keyboardPresentation } from './keyboard-device'; +import { mousePresentation } from './mouse-device'; + +export type Segment = 'buttons' | 'movement' | 'keys' | 'lighting' | 'info'; +export type SegmentOption = readonly [segment: Segment, label: string]; + +interface DeviceArtState { + callouts: Callout[]; + lighting: Lighting | null; + canSelectControl: boolean; +} + +/** + * Describes the device-specific choices used by the shared device view. + */ +export interface DevicePresentation { + segments: readonly SegmentOption[]; + initialControl: string | null; + artState: (settings: Settings | null, segment: Segment) => DeviceArtState; +} + +const INFO_PRESENTATION: DevicePresentation = { + segments: [['info', 'Info']], + initialControl: null, + artState: () => ({ + callouts: [], + lighting: null, + canSelectControl: false, + }), +}; + +/** + * Chooses the presentation that matches the device category. + */ +export function presentationFor(device: Device): DevicePresentation { + if (device.mouse) return mousePresentation(device.mouse); + if (device.keyboard) return keyboardPresentation(device.keyboard); + return INFO_PRESENTATION; +} diff --git a/src/renderer/components/device/device-view.tsx b/src/renderer/components/device/device-view.tsx new file mode 100644 index 0000000..32311cf --- /dev/null +++ b/src/renderer/components/device/device-view.tsx @@ -0,0 +1,109 @@ +import { useState } from 'react'; +import { ArrowLeft } from 'lucide-react'; +import { DeviceArt } from '@/components/art/device-art'; +import { DeviceStatus } from '@/components/device-status'; +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; +import { useDeviceSettings } from '@/gateway/devices'; +import { BUTTON_ICON, TOGGLE_ITEM } from '@/lib/utils'; +import type { Device } from '@/gen/devices'; +import { ControlEditor } from './control-editor'; +import { presentationFor, type Segment } from './device-presentation'; +import { RemoveDeviceDialog } from './remove-device-dialog'; + +interface DeviceViewProps { + device: Device; + onBack: () => void; + onRemoved: () => void; +} + +/** + * Combines the artwork, status, and settings editor for one managed device. + */ +export function DeviceView({ device, onBack, onRemoved }: DeviceViewProps) { + const presentation = presentationFor(device); + const { settings, preview, commit } = useDeviceSettings(device.id); + + const [segment, setSegment] = useState(presentation.segments[0][0]); + const [selectedControl, setSelectedControl] = useState( + presentation.initialControl, + ); + const [removeDialogOpen, setRemoveDialogOpen] = useState(false); + + const artState = presentation.artState(settings, segment); + + const selectSegment = (value: string) => { + if (value) setSegment(value as Segment); + }; + + const openRemoveDialog = () => setRemoveDialogOpen(true); + const closeRemoveDialog = () => setRemoveDialogOpen(false); + + return ( +
+
+ +

{device.model}

+ + + +
+ + {settings && ( + <> + + {presentation.segments.map(([value, label]) => ( + + {label} + + ))} + + +
+
+ +
+ + +
+ + )} + + {removeDialogOpen && ( + + )} +
+ ); +} diff --git a/src/renderer/components/device/editor-controls.tsx b/src/renderer/components/device/editor-controls.tsx new file mode 100644 index 0000000..12d69b7 --- /dev/null +++ b/src/renderer/components/device/editor-controls.tsx @@ -0,0 +1,196 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { RotateCcw } from 'lucide-react'; +import type { Binding } from '@/gen/devices'; +import { BUTTON_OUTLINE, cn, FOCUS_RING } from '@/lib/utils'; +import { actionLabel, boundAction } from './controls'; + +/** + * Gives each group of device settings a consistent heading and spacing. + */ +export function SettingsSection({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

+ {title} +

+
{children}
+
+ ); +} + +interface SettingsSliderProps { + label: string; + display: ReactNode; + value: number; + min: number; + max: number; + step: number; + minLabel?: string; + maxLabel?: string; + description?: string; + trackStyle?: CSSProperties; + onPreview: (value: number) => void; + onCommit: () => void; +} + +/** + * Previews values while the user moves a slider and commits when the interaction ends. + */ +export function SettingsSlider({ + label, + display, + value, + min, + max, + step, + minLabel, + maxLabel, + description, + trackStyle, + onPreview, + onCommit, +}: SettingsSliderProps) { + return ( +
+
+ {label} + {display} +
+ onPreview(Number(event.target.value))} + onPointerUp={onCommit} + onKeyUp={onCommit} + className={cn( + 'h-1 w-full cursor-pointer appearance-none rounded-full bg-input', + '[&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none', + '[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary', + '[&::-webkit-slider-thumb]:shadow-sm', + FOCUS_RING, + )} + /> + {minLabel && maxLabel && ( +
+ {minLabel} + {maxLabel} +
+ )} + {description &&

{description}

} +
+ ); +} + +interface RadioOptionProps { + group: string; + value: string; + label: string; + selected: boolean; + onSelect: () => void; +} + +/** + * Shows one action or lighting choice as an accessible radio option. + */ +export function RadioOption({ + group, + value, + label, + selected, + onSelect, +}: RadioOptionProps) { + return ( + + ); +} + +interface BindingEditorProps { + control: string; + controlName: string; + bindings: Binding[]; + actions: string[]; + disabled: boolean; + resetLabel: string; + onReset: () => void; + onRebind: (bindings: Binding[]) => void; +} + +/** + * Edits the action assigned to the selected mouse button or keyboard key. + */ +export function BindingEditor({ + control, + controlName, + bindings, + actions, + disabled, + resetLabel, + onReset, + onRebind, +}: BindingEditorProps) { + const selectedAction = boundAction(bindings, control); + + const rebind = (action: string) => + onRebind(bindings.map((entry) => (entry.control === control ? { ...entry, action } : entry))); + + return ( +
+ {controlName} action +
+ +
+ {actions.map((action) => ( + rebind(action)} + /> + ))} +
+
+ + +
+
+ ); +} diff --git a/src/renderer/components/device/keyboard-device.ts b/src/renderer/components/device/keyboard-device.ts new file mode 100644 index 0000000..4a90da1 --- /dev/null +++ b/src/renderer/components/device/keyboard-device.ts @@ -0,0 +1,88 @@ +import { LightEffect, type Binding, type KeyboardSpec } from '@/gen/devices'; +import type { DevicePresentation, SegmentOption } from './device-presentation'; + +const KEY_LABELS: Record = { + esc: 'Esc', + minus: 'Minus', + equal: 'Equal', + backspace: 'Backspace', + tab: 'Tab', + bracketleft: 'Left bracket', + bracketright: 'Right bracket', + backslash: 'Backslash', + capslock: 'Caps Lock', + semicolon: 'Semicolon', + quote: 'Quote', + enter: 'Enter', + shiftleft: 'Left Shift', + shiftright: 'Right Shift', + comma: 'Comma', + period: 'Period', + slash: 'Slash', + ctrlleft: 'Left Ctrl', + ctrlright: 'Right Ctrl', + metaleft: 'Win', + altleft: 'Left Alt', + altright: 'Right Alt', + space: 'Space', + menu: 'Menu', +}; + +/** + * Lists the actions offered for keyboard keys. + */ +export const KEY_ACTIONS = [ + 'default', + 'back', + 'forward', + 'show-desktop', + 'copy', + 'paste', + 'undo', + 'play-pause', + 'volume-up', + 'volume-down', + 'disabled', +]; + +/** + * Lists the keyboard lighting effects in the order shown by the editor. + */ +export const KEYBOARD_EFFECTS: [effect: LightEffect, label: string][] = [ + [LightEffect.STATIC, 'Steady'], + [LightEffect.BREATHING, 'Pulse'], + [LightEffect.WAVE, 'Wave'], +]; + +/** + * Turns a keyboard control ID into the name shown in the UI. + */ +export function keyboardControlLabel(control: string): string { + return KEY_LABELS[control] ?? control.toUpperCase(); +} + +/** + * Restores every keyboard key to its normal typing behavior. + */ +export function resetKeyBindings(bindings: Binding[]): Binding[] { + return bindings.map((binding) => ({ ...binding, action: 'default' })); +} + +/** + * Provides the sections and artwork behavior for a keyboard. + */ +export function keyboardPresentation(spec: KeyboardSpec): DevicePresentation { + const segments: SegmentOption[] = [['keys', 'Keys']]; + if (spec.backlight) segments.push(['lighting', 'Lighting']); + segments.push(['info', 'Info']); + + return { + segments, + initialControl: spec.keys[0] ?? null, + artState: (settings, segment) => ({ + callouts: [], + lighting: spec.backlight ? (settings?.keyboard ?? null) : null, + canSelectControl: segment === 'keys', + }), + }; +} diff --git a/src/renderer/components/device/keyboard-editor.tsx b/src/renderer/components/device/keyboard-editor.tsx new file mode 100644 index 0000000..ccf7b86 --- /dev/null +++ b/src/renderer/components/device/keyboard-editor.tsx @@ -0,0 +1,126 @@ +import type { CSSProperties } from 'react'; +import type { KeyboardSettings, Settings } from '@/gen/devices'; +import { + BindingEditor, + RadioOption, + SettingsSection, + SettingsSlider, +} from './editor-controls'; +import { + KEY_ACTIONS, + KEYBOARD_EFFECTS, + keyboardControlLabel, + resetKeyBindings, +} from './keyboard-device'; + +/** + * Draws the color range behind the keyboard hue slider. + */ +const HUE_TRACK_STYLE: CSSProperties = { + background: `linear-gradient(to right, ${[0, 60, 120, 180, 240, 300, 360] + .map((hue) => `hsl(${hue} 95% 55%)`) + .join(', ')})`, +}; + +interface KeyboardEditorProps { + settings: Settings; + keyboard: KeyboardSettings; + selected: string | null; + segment: 'keys' | 'lighting'; + disabled: boolean; + onPreview: (settings: Settings) => void; + onCommit: (settings: Settings) => void; +} + +/** + * Shows key assignments or lighting settings for a keyboard. + */ +export function KeyboardEditor({ + settings, + keyboard, + selected, + segment, + disabled, + onPreview, + onCommit, +}: KeyboardEditorProps) { + const withKeyboardChanges = (changes: Partial): Settings => ({ + ...settings, + keyboard: { ...keyboard, ...changes }, + }); + + if (segment === 'keys') { + if (!selected) return null; + + return ( + + onCommit(withKeyboardChanges({ bindings: resetKeyBindings(keyboard.bindings) })) + } + onRebind={(bindings) => onCommit(withKeyboardChanges({ bindings }))} + /> + ); + } + + const commitCurrentSettings = () => onCommit(settings); + + return ( +
+ +
+ {KEYBOARD_EFFECTS.map(([effect, label]) => ( + onCommit(withKeyboardChanges({ effect }))} + /> + ))} +
+
+ + + + + + onPreview(withKeyboardChanges({ brightness }))} + onCommit={commitCurrentSettings} + /> + +
+ ); +} diff --git a/src/renderer/components/device/mouse-device.ts b/src/renderer/components/device/mouse-device.ts new file mode 100644 index 0000000..069a121 --- /dev/null +++ b/src/renderer/components/device/mouse-device.ts @@ -0,0 +1,83 @@ +import type { Binding, MouseSettings, MouseSpec } from '@/gen/devices'; +import { actionLabel, boundAction } from './controls'; +import type { DevicePresentation, Segment } from './device-presentation'; + +const MOUSE_CONTROL_LABELS: Record = { + wheel: 'Wheel', + back: 'Back', + forward: 'Forward', + gesture: 'Thumb button', +}; + +/** + * Lists the actions offered for mouse buttons. + */ +export const MOUSE_ACTIONS = [ + 'middle-click', + 'back', + 'forward', + 'show-desktop', + 'copy', + 'paste', + 'undo', + 'play-pause', + 'volume-up', + 'volume-down', + 'disabled', +]; + +const DEFAULT_MOUSE_ACTIONS: Record = { + wheel: 'middle-click', + back: 'back', + forward: 'forward', + gesture: 'show-desktop', +}; + +/** + * Turns a mouse control ID into the name shown in the UI. + */ +export function mouseControlLabel(control: string): string { + return MOUSE_CONTROL_LABELS[control] ?? control.toUpperCase(); +} + +/** + * Restores the known default action for every mouse button. + */ +export function resetMouseBindings(bindings: Binding[]): Binding[] { + return bindings.map((binding) => ({ + ...binding, + action: DEFAULT_MOUSE_ACTIONS[binding.control] ?? binding.action, + })); +} + +/** + * Builds the labels shown beside the interactive mouse artwork. + */ +function buttonCallouts(spec: MouseSpec, settings: MouseSettings | undefined, segment: Segment) { + if (!settings || segment !== 'buttons') return []; + + return spec.buttons.map((control) => ({ + id: control, + name: mouseControlLabel(control), + value: actionLabel(boundAction(settings.bindings, control)), + })); +} + +/** + * Provides the sections and artwork behavior for a mouse. + */ +export function mousePresentation(spec: MouseSpec): DevicePresentation { + return { + segments: [ + ['buttons', 'Buttons'], + ['movement', 'Movement'], + ['info', 'Info'], + ], + initialControl: spec.buttons[0] ?? null, + artState: (settings, segment) => ({ + callouts: buttonCallouts(spec, settings?.mouse, segment), + lighting: null, + canSelectControl: segment === 'buttons', + }), + }; +} diff --git a/src/renderer/components/device/mouse-editor.tsx b/src/renderer/components/device/mouse-editor.tsx new file mode 100644 index 0000000..77193fd --- /dev/null +++ b/src/renderer/components/device/mouse-editor.tsx @@ -0,0 +1,104 @@ +import type { MouseSettings, MouseSpec, Settings } from '@/gen/devices'; +import { Switch } from '@/components/switch'; +import { BindingEditor, SettingsSection, SettingsSlider } from './editor-controls'; +import { MOUSE_ACTIONS, mouseControlLabel, resetMouseBindings } from './mouse-device'; + +const DPI_STEP = 100; +const SCROLL_SPEED_LABELS = ['Very slow', 'Slow', 'Medium', 'Fast', 'Very fast']; + +interface MouseEditorProps { + settings: Settings; + mouse: MouseSettings; + spec: MouseSpec; + selected: string | null; + segment: 'buttons' | 'movement'; + disabled: boolean; + onPreview: (settings: Settings) => void; + onCommit: (settings: Settings) => void; +} + +/** + * Shows button assignments or movement settings for a mouse. + */ +export function MouseEditor({ + settings, + mouse, + spec, + selected, + segment, + disabled, + onPreview, + onCommit, +}: MouseEditorProps) { + const withMouseChanges = (changes: Partial): Settings => ({ + ...settings, + mouse: { ...mouse, ...changes }, + }); + + if (segment === 'buttons') { + if (!selected) return null; + + return ( + + onCommit(withMouseChanges({ bindings: resetMouseBindings(mouse.bindings) })) + } + onRebind={(bindings) => onCommit(withMouseChanges({ bindings }))} + /> + ); + } + + const commitCurrentSettings = () => onCommit(settings); + + return ( +
+ + onPreview(withMouseChanges({ dpi }))} + onCommit={commitCurrentSettings} + /> + + + + onPreview(withMouseChanges({ scrollSpeed }))} + onCommit={commitCurrentSettings} + /> + +
+ + Reverse direction + + onCommit(withMouseChanges({ naturalScroll }))} + /> +
+
+
+ ); +} diff --git a/src/renderer/components/device/remove-device-dialog.tsx b/src/renderer/components/device/remove-device-dialog.tsx new file mode 100644 index 0000000..a19919c --- /dev/null +++ b/src/renderer/components/device/remove-device-dialog.tsx @@ -0,0 +1,61 @@ +import { useState } from 'react'; +import { Modal } from '@/components/modal'; +import { forgetDevice } from '@/gateway/devices'; +import type { Device } from '@/gen/devices'; +import { BUTTON_DESTRUCTIVE, BUTTON_OUTLINE } from '@/lib/utils'; + +interface RemoveDeviceDialogProps { + device: Device; + onClose: () => void; + onRemoved?: () => void; +} + +/** + * Confirms removal, then asks the main process to forget the device. + */ +export function RemoveDeviceDialog({ device, onClose, onRemoved }: RemoveDeviceDialogProps) { + const [isRemoving, setIsRemoving] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + const removeDevice = () => { + setIsRemoving(true); + setErrorMessage(null); + void forgetDevice(device.id) + .then(() => { + onRemoved?.(); + onClose(); + }) + .catch(() => { + setErrorMessage(`Could not remove ${device.model}.`); + setIsRemoving(false); + }); + }; + + return ( + +
+ {errorMessage &&

{errorMessage}

} +
+ + +
+
+
+ ); +} diff --git a/src/renderer/components/home/add-device-dialog.tsx b/src/renderer/components/home/add-device-dialog.tsx new file mode 100644 index 0000000..fbff1dc --- /dev/null +++ b/src/renderer/components/home/add-device-dialog.tsx @@ -0,0 +1,117 @@ +import { useEffect, useState } from 'react'; +import { LoaderCircle, RotateCw } from 'lucide-react'; +import { DeviceArt } from '@/components/art/device-art'; +import { connectionLabel } from '@/components/device-status'; +import { Modal } from '@/components/modal'; +import { discoverDevices, pairDevice } from '@/gateway/devices'; +import type { Device } from '@/gen/devices'; +import { BUTTON_OUTLINE, BUTTON_PRIMARY, cn } from '@/lib/utils'; + +interface AddDeviceDialogProps { + onClose: () => void; + onPaired: (deviceId: string) => void; +} + +/** + * Finds nearby devices and asks the main process to pair the selected one. + */ +export function AddDeviceDialog({ + onClose, + onPaired, +}: AddDeviceDialogProps) { + const [nearbyDevices, setNearbyDevices] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + const [pairingDeviceId, setPairingDeviceId] = useState(null); + const isPairing = pairingDeviceId !== null; + + const loadNearbyDevices = () => { + void discoverDevices() + .then(setNearbyDevices) + .catch(() => { + setNearbyDevices([]); + setErrorMessage('Could not search for devices.'); + }); + }; + + useEffect(loadNearbyDevices, []); + + const searchAgain = () => { + setNearbyDevices(null); + setErrorMessage(null); + loadNearbyDevices(); + }; + + const connectDevice = (device: Device) => { + setPairingDeviceId(device.id); + setErrorMessage(null); + void pairDevice(device.id) + .then(() => { + onPaired(device.id); + onClose(); + }) + .catch(() => setErrorMessage(`Could not connect ${device.model}.`)) + .finally(() => setPairingDeviceId(null)); + }; + + return ( + +
+ {nearbyDevices === null ? ( +
+ + Searching for devices… +
+ ) : nearbyDevices.length > 0 ? ( +
    + {nearbyDevices.map((device) => ( +
  • + + + + + {device.model} + + {connectionLabel(device)} + + + +
  • + ))} +
+ ) : ( +
+

No devices found

+

+ Make sure the device is ready to connect. +

+ +
+ )} + + {errorMessage &&

{errorMessage}

} +
+
+ ); +} diff --git a/src/renderer/components/home/home.tsx b/src/renderer/components/home/home.tsx new file mode 100644 index 0000000..bacd455 --- /dev/null +++ b/src/renderer/components/home/home.tsx @@ -0,0 +1,283 @@ +import { useLayoutEffect, useRef, useState } from 'react'; +import { ChevronLeft, ChevronRight, Plus, Settings, X } from 'lucide-react'; +import { LinkType, type Device } from '@/gen/devices'; +import { DeviceArt } from '@/components/art/device-art'; +import { DeviceBatteryStatus, DeviceConnectionIcon } from '@/components/device-status'; +import { RemoveDeviceDialog } from '@/components/device/remove-device-dialog'; +import { useCarousel } from '@/lib/use-carousel'; +import { BUTTON_OUTLINE, cn, FOCUS_RING } from '@/lib/utils'; +import { AddDeviceDialog } from './add-device-dialog'; + +export type HomeFocusTarget = + | { type: 'device'; id: string } + | { type: 'settings' } + | null; + +interface HomeProps { + devices: Device[]; + initialScrollLeft: number; + restoreFocus: HomeFocusTarget; + onOpen: (deviceId: string, scrollLeft: number) => void; + onOpenSettings: (scrollLeft: number) => void; +} + +/** + * Finds a device card so focus and scroll position can be restored after navigation. + */ +function findDeviceCard(carousel: HTMLElement | null, deviceId: string) { + return carousel?.querySelector(`[data-device-id="${CSS.escape(deviceId)}"]`); +} + +/** + * Shows one managed device with its artwork, status, and available actions. + */ +function DeviceCard({ + device, + onOpen, + onRemove, +}: { + device: Device; + onOpen: () => void; + onRemove?: () => void; +}) { + return ( +
+ + + {onRemove && ( + + )} +
+ ); +} + +/** + * Moves the device carousel backward or forward by one card. + */ +function CarouselControl({ + direction, + disabled, + onClick, +}: { + direction: 'previous' | 'next'; + disabled: boolean; + onClick: () => void; +}) { + const isPrevious = direction === 'previous'; + const Icon = isPrevious ? ChevronLeft : ChevronRight; + + return ( + + ); +} + +/** + * Shows managed devices and opens the pairing, removal, and settings workflows. + */ +export function Home({ + devices, + initialScrollLeft, + restoreFocus, + onOpen, + onOpenSettings, +}: HomeProps) { + const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); + const [deviceToRemove, setDeviceToRemove] = useState(null); + const pairedDeviceIdRef = useRef(null); + const settingsButtonRef = useRef(null); + const { + carouselRef, + hasOverflow, + canScrollPrevious, + canScrollNext, + scrollByItem, + } = useCarousel(initialScrollLeft, devices.length); + + useLayoutEffect(() => { + if (!restoreFocus) return; + + const focusTarget = + restoreFocus.type === 'device' + ? findDeviceCard(carouselRef.current, restoreFocus.id) + : settingsButtonRef.current; + focusTarget?.focus({ preventScroll: true }); + }, [carouselRef, restoreFocus]); + + useLayoutEffect(() => { + const pairedDeviceId = pairedDeviceIdRef.current; + if (!pairedDeviceId) return; + + const pairedDeviceCard = findDeviceCard(carouselRef.current, pairedDeviceId); + if (!pairedDeviceCard) return; + + pairedDeviceIdRef.current = null; + pairedDeviceCard.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }); + pairedDeviceCard.focus({ preventScroll: true }); + }, [carouselRef, devices]); + + const openDevice = (deviceId: string) => { + onOpen(deviceId, carouselRef.current?.scrollLeft ?? 0); + }; + + const openSettings = () => { + onOpenSettings(carouselRef.current?.scrollLeft ?? 0); + }; + + return ( +
+
+

Devices

+
+ + +
+
+ + {devices.length > 0 ? ( +
+ + + {canScrollPrevious && ( + + ); +} diff --git a/src/renderer/components/modal.tsx b/src/renderer/components/modal.tsx new file mode 100644 index 0000000..255018a --- /dev/null +++ b/src/renderer/components/modal.tsx @@ -0,0 +1,84 @@ +import { useEffect, useId, useRef, type ReactNode } from 'react'; +import { X } from 'lucide-react'; +import { cn, FOCUS_RING } from '@/lib/utils'; + +interface ModalProps { + title: ReactNode; + description: ReactNode; + role?: 'alertdialog'; + busy?: boolean; + onClose: () => void; + children: ReactNode; + className?: string; +} + +/** + * Shows app content in the browser's native dialog layer. + * It handles focus, Escape, and busy-state closing for its caller. + */ +export function Modal({ + title, + description, + role, + busy, + onClose, + children, + className, +}: ModalProps) { + const dialogRef = useRef(null); + const titleId = useId(); + const descriptionId = useId(); + + useEffect(() => { + const dialog = dialogRef.current; + dialog?.showModal(); + return () => dialog?.close(); + }, []); + + const requestClose = () => { + if (!busy) onClose(); + }; + + return ( + { + event.preventDefault(); + requestClose(); + }} + className={cn( + 'm-auto w-130 max-w-[calc(100vw-3rem)] rounded-2xl border bg-card p-0 text-card-foreground', + 'shadow-2xl backdrop:bg-black/45 backdrop:backdrop-blur-[2px]', + className, + )} + > +
+
+

+ {title} +

+

+ {description} +

+
+ +
+ {children} +
+ ); +} diff --git a/src/renderer/components/settings/settings-view.tsx b/src/renderer/components/settings/settings-view.tsx new file mode 100644 index 0000000..51ef512 --- /dev/null +++ b/src/renderer/components/settings/settings-view.tsx @@ -0,0 +1,153 @@ +import { useId, type ReactNode } from 'react'; +import { ArrowLeft } from 'lucide-react'; +import { LOW_BATTERY } from '@/components/device-status'; +import { Switch } from '@/components/switch'; +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; +import type { AppSettings } from '@/gen/app'; +import { BUTTON_ICON, TOGGLE_ITEM } from '@/lib/utils'; + +const THEME_OPTIONS = [ + ['system', 'System'], + ['light', 'Light'], + ['dark', 'Dark'], +] as const; + +interface SettingRowProps { + title: string; + description: string; + renderControl: (labelId: string) => ReactNode; +} + +interface SettingsViewProps { + settings: AppSettings | null; + update: (changes: Partial) => void; + onBack: () => void; +} + +/** + * Groups related application settings under one heading. + */ +function SettingsSection({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

+ {title} +

+
+ {children} +
+
+ ); +} + +/** + * Pairs a setting's title and description with its interactive control. + */ +function SettingRow({ + title, + description, + renderControl, +}: SettingRowProps) { + const titleId = useId(); + + return ( +
+
+

+ {title} +

+

{description}

+
+ {renderControl(titleId)} +
+ ); +} + +/** + * Shows app-wide settings and sends each change back to the main process. + */ +export function SettingsView({ + settings, + update: updateSettings, + onBack, +}: SettingsViewProps) { + return ( +
+
+
+ +

Settings

+
+ + {settings && ( +
+ + ( + + updateSettings({ automaticUpdateDownloads }) + } + /> + )} + /> + ( + updateSettings({ launchAtLogin })} + /> + )} + /> + + + + ( + theme && updateSettings({ theme })} + className="shrink-0 rounded-lg bg-muted p-1" + > + {THEME_OPTIONS.map(([value, label]) => ( + + {label} + + ))} + + )} + /> + + + + ( + updateSettings({ lowBatteryAlerts })} + /> + )} + /> + +
+ )} +
+
+ ); +} diff --git a/src/renderer/components/switch.tsx b/src/renderer/components/switch.tsx new file mode 100644 index 0000000..0c27b5f --- /dev/null +++ b/src/renderer/components/switch.tsx @@ -0,0 +1,30 @@ +import { cn, FOCUS_RING } from '@/lib/utils'; + +interface SwitchProps { + checked: boolean; + labelId: string; + onChange: (checked: boolean) => void; +} + +/** + * Provides the shared accessible on/off control used by settings editors. + */ +export function Switch({ checked, labelId, onChange }: SwitchProps) { + return ( + onChange(event.target.checked)} + className={cn( + 'relative h-5.5 w-9.5 shrink-0 cursor-pointer appearance-none rounded-full bg-input', + 'transition-colors checked:bg-primary disabled:cursor-default', + 'before:absolute before:left-0.5 before:top-0.5 before:size-4.5 before:rounded-full', + 'before:bg-white before:shadow-sm before:transition-transform', + 'checked:before:translate-x-4', + FOCUS_RING, + )} + /> + ); +} diff --git a/src/renderer/components/ui/toggle-group.tsx b/src/renderer/components/ui/toggle-group.tsx new file mode 100644 index 0000000..f58ca2e --- /dev/null +++ b/src/renderer/components/ui/toggle-group.tsx @@ -0,0 +1,67 @@ +"use client" + +import * as React from "react" +import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group" +import { type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" +import { toggleVariants } from "@/components/ui/toggle" + +const ToggleGroupContext = React.createContext< + VariantProps +>({ + size: "default", + variant: "default", +}) + +/** + * Groups related toggle choices and shares their style with every item. + */ +const ToggleGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, size, children, ...props }, ref) => ( + + + {children} + + +)) + +ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName + +/** + * Renders one choice using the style supplied by its toggle group. + */ +const ToggleGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, children, variant, size, ...props }, ref) => { + const context = React.useContext(ToggleGroupContext) + + return ( + + {children} + + ) +}) + +ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName + +export { ToggleGroup, ToggleGroupItem } diff --git a/src/renderer/components/ui/toggle.tsx b/src/renderer/components/ui/toggle.tsx new file mode 100644 index 0000000..19ddb06 --- /dev/null +++ b/src/renderer/components/ui/toggle.tsx @@ -0,0 +1,46 @@ +import * as React from "react" +import * as TogglePrimitive from "@radix-ui/react-toggle" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const toggleVariants = cva( + "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 gap-2", + { + variants: { + variant: { + default: "bg-transparent", + outline: + "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground", + }, + size: { + default: "h-10 px-3 min-w-10", + sm: "h-9 px-2.5 min-w-9", + lg: "h-11 px-5 min-w-11", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +/** + * Wraps the Radix toggle with the shared renderer styles and size variants. + */ +const Toggle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, size, ...props }, ref) => ( + +)) + +Toggle.displayName = TogglePrimitive.Root.displayName + +export { Toggle, toggleVariants } diff --git a/src/renderer/gateway/devices.ts b/src/renderer/gateway/devices.ts new file mode 100644 index 0000000..129249f --- /dev/null +++ b/src/renderer/gateway/devices.ts @@ -0,0 +1,89 @@ +import { useCallback, useEffect, useState } from 'react'; +import { ipc } from '@/gen/ipc'; +import type { Device, Settings } from '@/gen/devices'; + +/** + * Requests the devices that the native stack currently offers for pairing. + */ +export async function discoverDevices(): Promise { + return (await ipc.devices.Discover({})).devices; +} + +/** + * Asks the main process to add one discovered device. + */ +export async function pairDevice(deviceId: string): Promise { + await ipc.devices.Pair({ id: deviceId }); +} + +/** + * Asks the main process to remove one managed device. + */ +export async function forgetDevice(deviceId: string): Promise { + await ipc.devices.Forget({ id: deviceId }); +} + +/** + * Keeps the renderer's device list in sync with complete snapshots from the main process. + * It loads the current list first, then listens for later changes. + */ +export function useDevices(): { devices: Device[] | null; failed: boolean } { + const [devices, setDevices] = useState(null); + const [failed, setFailed] = useState(false); + + useEffect(() => { + const subscription = ipc.devices.Watch({}).subscribe({ + next: (deviceList) => setDevices(deviceList.devices), + error: (error: unknown) => { + console.error('Device stream failed.', error); + setFailed(true); + }, + }); + + ipc.devices + .List({}) + .then((deviceList) => + setDevices((currentDevices) => currentDevices ?? deviceList.devices), + ) + .catch((error: unknown) => { + console.error('Could not list devices.', error); + setFailed(true); + }); + + return () => subscription.unsubscribe(); + }, []); + + return { devices, failed }; +} + +/** + * Loads one device's settings from the main process. + * It previews changes locally and sends committed values back through IPC. + */ +export function useDeviceSettings(deviceId: string) { + const [settings, setSettings] = useState(null); + + useEffect(() => { + let isActive = true; + + ipc.devices + .GetSettings({ id: deviceId }) + .then((storedSettings) => { + if (isActive) setSettings(storedSettings); + }) + .catch((error: unknown) => console.error('Could not read device settings.', error)); + + return () => { + isActive = false; + }; + }, [deviceId]); + + const commitSettings = useCallback((nextSettings: Settings) => { + setSettings(nextSettings); + void ipc.devices.ApplySettings(nextSettings).catch((error: unknown) => { + console.error('Could not apply device settings.', error); + }); + }, []); + + return { settings, preview: setSettings, commit: commitSettings }; +} diff --git a/src/renderer/gateway/settings.ts b/src/renderer/gateway/settings.ts new file mode 100644 index 0000000..63d0786 --- /dev/null +++ b/src/renderer/gateway/settings.ts @@ -0,0 +1,50 @@ +import { useEffect, useState } from 'react'; +import { ipc } from '@/gen/ipc'; +import type { AppSettings } from '@/gen/app'; + +/** + * Listens for main-process requests to open a device, such as notification clicks. + */ +export function useOpenDeviceRequests(onOpenDevice: (deviceId: string) => void): void { + useEffect(() => { + const subscription = ipc.app.OnOpenDevice({}).subscribe({ + next: ({ id }) => onOpenDevice(id), + error: (error: unknown) => console.error('Open-device request stream failed.', error), + }); + return () => subscription.unsubscribe(); + }, [onOpenDevice]); +} + +/** + * Loads app settings from the main process and sends each update back through IPC. + */ +export function useAppSettings() { + const [settings, setSettings] = useState(null); + + useEffect(() => { + let isActive = true; + + ipc.app + .GetSettings({}) + .then((storedSettings) => { + if (isActive) setSettings(storedSettings); + }) + .catch((error: unknown) => console.error('Could not read app settings.', error)); + + return () => { + isActive = false; + }; + }, []); + + const updateSettings = (changes: Partial) => { + if (!settings) return; + + const nextSettings = { ...settings, ...changes }; + setSettings(nextSettings); + void ipc.app.ApplySettings(nextSettings).catch((error: unknown) => { + console.error('Could not apply app settings.', error); + }); + }; + + return { settings, update: updateSettings }; +} diff --git a/src/renderer/index.css b/src/renderer/index.css new file mode 100644 index 0000000..cfa3b98 --- /dev/null +++ b/src/renderer/index.css @@ -0,0 +1,101 @@ +@import 'tailwindcss'; +@config '../../tailwind.config.js'; + +@layer base { + :root { + color-scheme: light; + + --background: 40 10% 97%; + --foreground: 30 8% 12%; + + --card: 40 20% 99%; + --card-foreground: 30 8% 12%; + + --popover: 40 20% 99%; + --popover-foreground: 30 8% 12%; + + --primary: 213 34% 42%; + --primary-foreground: 40 20% 98%; + + --secondary: 40 6% 93%; + --secondary-foreground: 30 8% 12%; + + --muted: 40 6% 93%; + --muted-foreground: 35 6% 42%; + + --accent: 40 6% 91%; + --accent-foreground: 30 8% 12%; + + --destructive: 0 72% 46%; + --destructive-foreground: 40 20% 98%; + + --status-charging: 145 38% 32%; + --status-low: 4 42% 42%; + + --border: 35 8% 87%; + --input: 35 8% 85%; + --ring: 213 34% 42%; + + --radius: 0.625rem; + } + + .dark { + color-scheme: dark; + + --background: 30 6% 7%; + --foreground: 40 8% 92%; + + --card: 30 5% 10%; + --card-foreground: 40 8% 92%; + + --popover: 30 5% 13%; + --popover-foreground: 40 8% 92%; + + --primary: 211 32% 62%; + --primary-foreground: 215 24% 10%; + + --secondary: 30 4% 16%; + --secondary-foreground: 40 8% 92%; + + --muted: 30 4% 16%; + --muted-foreground: 35 5% 60%; + + --accent: 30 4% 18%; + --accent-foreground: 40 8% 92%; + + --destructive: 0 72% 51%; + --destructive-foreground: 40 8% 96%; + + --status-charging: 145 30% 58%; + --status-low: 4 38% 62%; + + --border: 30 4% 18%; + --input: 30 4% 20%; + --ring: 211 32% 62%; + } + + * { + @apply border-border; + } + + body { + @apply bg-background text-foreground font-sans antialiased; + } +} + +@layer utilities { + .window-drag-region { + -webkit-app-region: drag; + } +} + +@keyframes key-glow { + 0%, + 100% { + opacity: 0.25; + } + + 50% { + opacity: 1; + } +} diff --git a/src/renderer/index.html b/src/renderer/index.html new file mode 100644 index 0000000..c40e5b1 --- /dev/null +++ b/src/renderer/index.html @@ -0,0 +1,13 @@ + + + + + + MōDevice + + + + +
+ + diff --git a/src/renderer/lib/use-carousel.ts b/src/renderer/lib/use-carousel.ts new file mode 100644 index 0000000..99e8d21 --- /dev/null +++ b/src/renderer/lib/use-carousel.ts @@ -0,0 +1,65 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; + +const SCROLL_EDGE_TOLERANCE = 2; + +/** + * Tracks the home screen's horizontal device list and moves it one card at a time. + * It remeasures when cards are added, removed, or resized. + */ +export function useCarousel(initialScrollLeft: number, itemCount: number) { + const carouselRef = useRef(null); + const [hasOverflow, setHasOverflow] = useState(false); + const [canScrollPrevious, setCanScrollPrevious] = useState(false); + const [canScrollNext, setCanScrollNext] = useState(false); + + const updateScrollState = useCallback(() => { + const carousel = carouselRef.current; + if (!carousel) return; + + const maxScrollLeft = Math.max(0, carousel.scrollWidth - carousel.clientWidth); + setHasOverflow(maxScrollLeft > SCROLL_EDGE_TOLERANCE); + setCanScrollPrevious(carousel.scrollLeft > SCROLL_EDGE_TOLERANCE); + setCanScrollNext(carousel.scrollLeft < maxScrollLeft - SCROLL_EDGE_TOLERANCE); + }, []); + + useLayoutEffect(() => { + const carousel = carouselRef.current; + if (!carousel) return; + + carousel.scrollLeft = initialScrollLeft; + updateScrollState(); + }, [initialScrollLeft, updateScrollState]); + + useEffect(() => { + const carousel = carouselRef.current; + if (!carousel) return; + + const track = carousel.firstElementChild; + const observer = new ResizeObserver(updateScrollState); + observer.observe(carousel); + if (track) observer.observe(track); + carousel.addEventListener('scroll', updateScrollState, { passive: true }); + updateScrollState(); + + return () => { + observer.disconnect(); + carousel.removeEventListener('scroll', updateScrollState); + }; + }, [itemCount, updateScrollState]); + + const scrollByItem = (direction: -1 | 1) => { + const carousel = carouselRef.current; + const track = carousel?.firstElementChild; + const firstItem = track?.firstElementChild; + if (!carousel || !track || !firstItem) return; + + const gap = Number.parseFloat(getComputedStyle(track).columnGap) || 0; + const prefersReducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches; + carousel.scrollBy({ + left: direction * (firstItem.getBoundingClientRect().width + gap), + behavior: prefersReducedMotion ? 'auto' : 'smooth', + }); + }; + + return { carouselRef, hasOverflow, canScrollPrevious, canScrollNext, scrollByItem }; +} diff --git a/src/renderer/lib/utils.ts b/src/renderer/lib/utils.ts new file mode 100644 index 0000000..b9e53e4 --- /dev/null +++ b/src/renderer/lib/utils.ts @@ -0,0 +1,43 @@ +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +/** + * Joins conditional class names and resolves conflicting Tailwind utilities. + */ +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +/** + * Gives interactive elements the same keyboard focus ring. + */ +export const FOCUS_RING = + 'focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring ' + + 'focus-visible:ring-offset-2 focus-visible:ring-offset-background'; + +/** + * Defines the shared size and behavior used by the button variants below. + */ +const BUTTON_BASE = + 'inline-flex h-9 items-center justify-center gap-2 rounded-lg px-4 text-sm font-medium ' + + `transition-colors disabled:cursor-default disabled:opacity-50 ${FOCUS_RING}`; + +export const BUTTON_PRIMARY = + `${BUTTON_BASE} bg-primary text-primary-foreground hover:bg-primary/90`; +export const BUTTON_OUTLINE = `${BUTTON_BASE} border hover:bg-accent`; +export const BUTTON_DESTRUCTIVE = + `${BUTTON_BASE} bg-destructive text-destructive-foreground hover:bg-destructive/90`; + +/** + * Styles borderless icon buttons such as the back button on each screen. + */ +export const BUTTON_ICON = + 'rounded-md p-1 text-muted-foreground transition-colors ' + + `hover:bg-accent hover:text-foreground ${FOCUS_RING}`; + +/** + * Styles each option in the segmented controls used across the app. + */ +export const TOGGLE_ITEM = + 'px-3 text-muted-foreground hover:bg-transparent hover:text-foreground ' + + 'data-[state=on]:bg-card data-[state=on]:text-foreground data-[state=on]:shadow-sm'; diff --git a/src/renderer/main.tsx b/src/renderer/main.tsx new file mode 100644 index 0000000..665c788 --- /dev/null +++ b/src/renderer/main.tsx @@ -0,0 +1,17 @@ +/** + * Starts the renderer process by mounting the React application into the page. + */ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App.tsx'; + +const rootElement = document.getElementById('root'); +if (!rootElement) { + throw new Error('Could not find the application root element.'); +} + +createRoot(rootElement).render( + + + , +); diff --git a/src/renderer/proto/app.proto b/src/renderer/proto/app.proto new file mode 100644 index 0000000..4b4a289 --- /dev/null +++ b/src/renderer/proto/app.proto @@ -0,0 +1,24 @@ +syntax = "proto3"; + +import "devices.proto"; +import "google/protobuf/empty.proto"; + +// Application-wide preferences owned and persisted by the main process. +message AppSettings { + string theme = 1; + bool launch_at_login = 2; + bool low_battery_alerts = 3; + bool automatic_update_downloads = 4; +} + +// Main-process application settings and renderer navigation events. +service AppService { + // Returns the complete current preference snapshot. + rpc GetSettings(google.protobuf.Empty) returns (AppSettings); + + // Applies and persists one complete preference snapshot. + rpc ApplySettings(AppSettings) returns (google.protobuf.Empty); + + // Future navigation requests, such as clicks on device notifications. + rpc OnOpenDevice(google.protobuf.Empty) returns (stream DeviceId); +} diff --git a/src/renderer/proto/devices.proto b/src/renderer/proto/devices.proto new file mode 100644 index 0000000..928d3f1 --- /dev/null +++ b/src/renderer/proto/devices.proto @@ -0,0 +1,114 @@ +syntax = "proto3"; + +import "google/protobuf/empty.proto"; + +// MōBrowser generates renderer IPC and native RPC from separate proto roots. +// Keep this device model aligned with src/native/proto/devices.proto. The +// main-process bridge in src/main/devices.ts owns the handoff between them. + +// How the device communicates with the computer. +enum LinkType { + RECEIVER = 0; + BLUETOOTH = 1; + WIRED = 2; +} + +// Lighting effects supported by the demo keyboard. +enum LightEffect { + STATIC = 0; + BREATHING = 1; + WAVE = 2; +} + +message MouseSpec { + // Stable identifiers shared with Binding.control. + repeated string buttons = 1; + int32 min_dpi = 2; + int32 max_dpi = 3; +} + +message KeyboardSpec { + // Stable identifiers shared with Binding.control. + repeated string keys = 1; + bool backlight = 2; +} + +message Device { + string id = 1; + // Stable product identity shared by every unit of the same model. + string model_id = 11; + string model = 2; + LinkType link = 3; + string firmware = 4; + bool connected = 5; + bool has_battery = 6; + // Percentage from 0 to 100; ignored when has_battery is false. + int32 battery = 7; + bool charging = 10; + + // Hardware capabilities and device category. + oneof spec { + MouseSpec mouse = 8; + KeyboardSpec keyboard = 9; + } +} + +message DeviceId { + string id = 1; +} + +message DeviceList { + repeated Device devices = 1; +} + +// Strings keep vendor-specific controls and actions extensible without +// requiring schema changes. +message Binding { + string control = 1; + string action = 2; +} + +message MouseSettings { + repeated Binding bindings = 1; + int32 dpi = 2; + // Demo scale from 1 (slowest) to 5 (fastest). + int32 scroll_speed = 3; + bool natural_scroll = 4; +} + +message KeyboardSettings { + repeated Binding bindings = 1; + LightEffect effect = 2; + // Hue in degrees from 0 to 360. + int32 hue = 3; + // Percentage from 0 to 100. + int32 brightness = 4; +} + +message Settings { + string device_id = 1; + + // Must match the corresponding Device.spec variant. + oneof kind { + MouseSettings mouse = 2; + KeyboardSettings keyboard = 3; + } +} + +service DevicesService { + // All devices currently managed by the app. + rpc List(google.protobuf.Empty) returns (DeviceList); + + // Future complete snapshots whenever that set changes. + rpc Watch(google.protobuf.Empty) returns (stream DeviceList); + + // Nearby devices available to add. + rpc Discover(google.protobuf.Empty) returns (DeviceList); + + rpc Pair(DeviceId) returns (google.protobuf.Empty); + rpc Forget(DeviceId) returns (google.protobuf.Empty); + rpc GetSettings(DeviceId) returns (Settings); + + // Applies and persists one complete device-settings snapshot. + rpc ApplySettings(Settings) returns (google.protobuf.Empty); +} diff --git a/src/renderer/public/device-art/compact-keyboard-home.webp b/src/renderer/public/device-art/compact-keyboard-home.webp new file mode 100644 index 0000000..6ef12a4 Binary files /dev/null and b/src/renderer/public/device-art/compact-keyboard-home.webp differ diff --git a/src/renderer/public/device-art/compact-keyboard.webp b/src/renderer/public/device-art/compact-keyboard.webp new file mode 100644 index 0000000..c1d72c4 Binary files /dev/null and b/src/renderer/public/device-art/compact-keyboard.webp differ diff --git a/src/renderer/public/device-art/performance-mouse-home.webp b/src/renderer/public/device-art/performance-mouse-home.webp new file mode 100644 index 0000000..b9febda Binary files /dev/null and b/src/renderer/public/device-art/performance-mouse-home.webp differ diff --git a/src/renderer/public/device-art/performance-mouse.webp b/src/renderer/public/device-art/performance-mouse.webp new file mode 100644 index 0000000..8f92dac Binary files /dev/null and b/src/renderer/public/device-art/performance-mouse.webp differ diff --git a/src/renderer/public/device-art/travel-mouse-home.webp b/src/renderer/public/device-art/travel-mouse-home.webp new file mode 100644 index 0000000..e04b11d Binary files /dev/null and b/src/renderer/public/device-art/travel-mouse-home.webp differ diff --git a/src/renderer/public/device-art/travel-mouse.webp b/src/renderer/public/device-art/travel-mouse.webp new file mode 100644 index 0000000..e5f8c1e Binary files /dev/null and b/src/renderer/public/device-art/travel-mouse.webp differ diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..b0e3962 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,58 @@ +import tailwindcssAnimate from 'tailwindcss-animate'; + +/** + * @type {import('tailwindcss').Config} + */ +export default { + darkMode: ['class'], + content: ['./src/**/*.{html,ts,tsx}'], + theme: { + extend: { + colors: { + border: 'hsl(var(--border) / )', + input: 'hsl(var(--input) / )', + ring: 'hsl(var(--ring) / )', + background: 'hsl(var(--background) / )', + foreground: 'hsl(var(--foreground) / )', + primary: { + DEFAULT: 'hsl(var(--primary) / )', + foreground: 'hsl(var(--primary-foreground) / )', + }, + secondary: { + DEFAULT: 'hsl(var(--secondary) / )', + foreground: 'hsl(var(--secondary-foreground) / )', + }, + destructive: { + DEFAULT: 'hsl(var(--destructive) / )', + foreground: 'hsl(var(--destructive-foreground) / )', + }, + status: { + charging: 'hsl(var(--status-charging) / )', + low: 'hsl(var(--status-low) / )', + }, + muted: { + DEFAULT: 'hsl(var(--muted) / )', + foreground: 'hsl(var(--muted-foreground) / )', + }, + accent: { + DEFAULT: 'hsl(var(--accent) / )', + foreground: 'hsl(var(--accent-foreground) / )', + }, + popover: { + DEFAULT: 'hsl(var(--popover) / )', + foreground: 'hsl(var(--popover-foreground) / )', + }, + card: { + DEFAULT: 'hsl(var(--card) / )', + foreground: 'hsl(var(--card-foreground) / )', + }, + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)', + }, + }, + }, + plugins: [tailwindcssAnimate], +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1528804 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,40 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "module": "ESNext", + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "@/*": [ + "./src/renderer/*", + "./src/main/*" + ] + }, + "moduleResolution": "bundler", + "esModuleInterop": true, + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": [ + "src/main", + "src/renderer" + ], + "references": [ + { + "path": "./tsconfig.node.json" + } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..b5a3431 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": [ + "vite.config.ts" + ] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..adab9e7 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,73 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import react from '@vitejs/plugin-react'; +import { defineConfig, type UserConfig } from 'vite'; + +const PROJECT_ROOT = fileURLToPath(new URL('.', import.meta.url)); + +export default defineConfig(({ mode }) => { + if (mode === 'main') { + return defineMainConfig(); + } + if (mode === 'renderer') { + return defineRendererConfig(); + } + throw new Error(`Unsupported Vite config mode: ${mode}`); +}); + +/** + * Builds the Node.js code that runs in the MōBrowser main process. + */ +function defineMainConfig(): UserConfig { + return { + root: path.resolve(PROJECT_ROOT, 'src/main'), + build: { + target: 'esnext', + outDir: path.resolve(PROJECT_ROOT, 'out/main'), + emptyOutDir: true, + sourcemap: true, + lib: { + entry: path.resolve(PROJECT_ROOT, 'src/main/index.ts'), + formats: ['es'], + fileName: () => 'index.js', + }, + }, + resolve: { + alias: { + '@': path.resolve(PROJECT_ROOT, 'src/main'), + }, + }, + server: { + forwardConsole: { + unhandledErrors: true, + logLevels: ['warn', 'error'], + }, + }, + }; +} + +/** + * Builds the React code that runs inside the application window. + */ +function defineRendererConfig(): UserConfig { + return { + root: path.resolve(PROJECT_ROOT, 'src/renderer'), + plugins: [react()], + build: { + outDir: path.resolve(PROJECT_ROOT, 'out/renderer'), + emptyOutDir: true, + sourcemap: true, + }, + resolve: { + alias: { + '@': path.resolve(PROJECT_ROOT, 'src/renderer'), + }, + }, + server: { + forwardConsole: { + unhandledErrors: true, + logLevels: ['warn', 'error'], + }, + }, + }; +}