diff --git a/.github/workflows/dashrate-ci.yml b/.github/workflows/dashrate-ci.yml
new file mode 100644
index 0000000..87ecffc
--- /dev/null
+++ b/.github/workflows/dashrate-ci.yml
@@ -0,0 +1,53 @@
+name: DashRate CI
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - 'example-apps/dashrate/**'
+ - '.github/workflows/dashrate-ci.yml'
+ pull_request:
+ branches: [main]
+ paths:
+ - 'example-apps/dashrate/**'
+ - '.github/workflows/dashrate-ci.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: dashrate-ci-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ check:
+ name: test + build
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ defaults:
+ run:
+ working-directory: example-apps/dashrate
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+ with:
+ node-version-file: .nvmrc
+ cache: npm
+ cache-dependency-path: example-apps/dashrate/package-lock.json
+
+ - name: Show Node/npm versions
+ run: |
+ node -v
+ npm -v
+
+ - name: Install
+ run: npm ci
+
+ - name: Test
+ run: npm test
+
+ # tsc -b runs as part of build, so this covers the typecheck too.
+ - name: Build
+ run: npm run build
diff --git a/.gitignore b/.gitignore
index 78a1af8..585eb45 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,4 +7,9 @@ node_modules/
example-apps/*/node_modules/
example-apps/*/dist/
example-apps/*/dist-ssr/
+example-apps/*/coverage/
example-apps/*/*.local
+
+# Example apps (Playwright artifacts)
+example-apps/*/playwright-report/
+example-apps/*/test-results/
diff --git a/example-apps/README.md b/example-apps/README.md
index be0602a..7af30ef 100644
--- a/example-apps/README.md
+++ b/example-apps/README.md
@@ -7,3 +7,4 @@ Stand-alone applications built on top of the same `@dashevo/evo-sdk` used by the
- [dashmint-lab/](./dashmint-lab/) — React + TypeScript + Vite SPA for minting, viewing, transferring, and trading NFT-style collectible cards on Dash Platform testnet. Shares the browser-safe SDK core (`setupDashClient-core.mjs`) with the Node tutorials at the repo root.
- [dashproof-lab/](./dashproof-lab/) — React + TypeScript + Vite proof-of-existence tutorial app that hashes files locally in the browser, anchors SHA-256 proofs on Dash Platform testnet, verifies files by hash, and reviews proof history by owner or chain ID. Also uses the shared browser-safe SDK core from the parent repo.
- [dashnote/](./dashnote/) — React + TypeScript + Vite notes app for Dash Platform testnet. Create, edit, and delete notes against a small `note` data contract; supports a "Remember Me" read-only browse mode, optimistic localStorage cache, and ships a single-file zero-build read-only companion at `dashnote-lite.html`. Also uses the shared browser-safe SDK core from the parent repo.
+- [dashrate/](./dashrate/) — React + TypeScript + Vite app for rating Platform tutorial resources on Dash Platform testnet, built to showcase Platform 4.0's relational document queries: provable `count`, grouped `count` (`GROUP BY`) for the per-star distribution, range counts, and `where` filtering. One review per identity per resource (editable, with document history); read-only browsing works without signing in. Also uses the shared browser-safe SDK core from the parent repo.
diff --git a/example-apps/dashrate/.prettierignore b/example-apps/dashrate/.prettierignore
new file mode 100644
index 0000000..007ea8a
--- /dev/null
+++ b/example-apps/dashrate/.prettierignore
@@ -0,0 +1,3 @@
+dist
+node_modules
+coverage
diff --git a/example-apps/dashrate/.prettierrc.json b/example-apps/dashrate/.prettierrc.json
new file mode 100644
index 0000000..3865a02
--- /dev/null
+++ b/example-apps/dashrate/.prettierrc.json
@@ -0,0 +1,4 @@
+{
+ "printWidth": 80,
+ "trailingComma": "all"
+}
diff --git a/example-apps/dashrate/CLAUDE.md b/example-apps/dashrate/CLAUDE.md
new file mode 100644
index 0000000..69c85c1
--- /dev/null
+++ b/example-apps/dashrate/CLAUDE.md
@@ -0,0 +1,80 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code when working in [example-apps/dashrate/](.).
+
+## Project Overview
+
+React + TypeScript + Vite app for rating and reviewing Dash Platform resources (tutorials and example apps) on testnet. A review has a required integer `rating` (1–5), an optional `reviewText`, and a `resourceId` pointing at a catalog entry. One review per identity per resource (enforced by a unique index) — saving again edits the existing document. The shell is a four-view app (`resources` / `my-reviews` / `settings` / `how`): the Resources view is a sidebar resource list + a detail panel showing the aggregate rating, a per-star distribution histogram, a review form, and recent reviews. Read-only browse works without auth; writing a review requires signing in with a mnemonic in Settings.
+
+This app is the showcase for Platform 4.0's relational query features — provable `count`, grouped `count` (`GROUP BY`), range counts, and `where` filtering. See [SDK query patterns](#sdk-query-patterns).
+
+## Commands
+
+- `npm run dev` — start Vite dev server
+- `npm run build` — typecheck (`tsc -b`) then bundle
+- `npm run lint` — ESLint
+- `npm run test` — Vitest suite in [test/](test/) (unit, component, and hook tests)
+- `npm run test:coverage` — Vitest suite under v8 coverage
+- `npm run test:e2e` — Playwright suite in [test/e2e/](test/e2e/) (auto-boots Vite on :5182)
+- `npm run test:e2e:ui` — Playwright with the interactive UI runner
+- `npm run format` / `format:check` — Prettier
+- `npm run preview` — serve production build locally
+
+## Architecture
+
+`App.tsx` is the orchestrator: it owns the cross-cutting state and handlers, calls the data hooks, and renders one presentational view component per `view`. The layers:
+
+- **[src/dash/](src/dash/)** — one file per Platform SDK concern, each with a leading JSDoc block naming the SDK method it wraps: [contract.ts](src/dash/contract.ts) (schema + register/store contract ID), [queries.ts](src/dash/queries.ts) (all reads — count, grouped count, document query, normalization, summary derivation), [review.ts](src/dash/review.ts) (create/replace a review), [history.ts](src/dash/history.ts) (document revision history), [resolveDpnsName.ts](src/dash/resolveDpnsName.ts) (DPNS username lookup for reviewer names, via `sdk.dpns.username`), [types.ts](src/dash/types.ts) (shared SDK type aliases incl. the `DashSdk` shape), [sdkModule.ts](src/dash/sdkModule.ts) (cached dynamic `import("@dashevo/evo-sdk")`), [sdkCore.ts](src/dash/sdkCore.ts) (cached dynamic `import` of the shared core at `../../../../setupDashClient-core.mjs`, exposing `loadSdkCore()`), [client.ts](src/dash/client.ts) / [keyManager.ts](src/dash/keyManager.ts) (re-export `createClient` / `IdentityKeyManager` from that shared core).
+- **[src/catalog/resources.ts](src/catalog/resources.ts)** — the hardcoded `RESOURCES` list (tutorials + example apps). Each has `id` (used as `resourceId`), `title`, `category`, `summary`, `href`. Resources are compile-time static — there's no user-facing "add a resource" flow.
+- **[src/App.tsx](src/App.tsx)** — owns top-level state (session, `contractId`/`contractInput`, `selectedResourceId`, `view`, sign-in form fields, `history`, `status`, `busy`), the action handlers (`handleSignIn`, `handleSaveReview`, `handleRegisterContract`, `handleLoadHistory`, contract submit/clear, sign-out, resource select, edit-my-review), and the `connectReadOnly` read-only SDK factory. It loads the shared SDK core via [sdkCore.ts](src/dash/sdkCore.ts)'s `loadSdkCore()`.
+- **[src/hooks/](src/hooks/)** — data-fetching hooks, each returning state plus refresh callbacks: [useResourceRatings.ts](src/hooks/useResourceRatings.ts) (per-resource summaries/distributions, recent reviews, the review-composer state — `rating`/`hoverRating`/`reviewText`/`mySelectedReview` — `reviewFilter`, and the `loadResourceData`/`refreshReviews` effects with request-id guards against stale responses), [useMyReviews.ts](src/hooks/useMyReviews.ts) (the signed-in user's reviews + derived average, loaded lazily when the `my-reviews` view is active), [useDpnsNames.ts](src/hooks/useDpnsNames.ts) (best-effort DPNS name cache keyed by owner ID, resolved lazily for reviewers in view).
+- **[src/components/](src/components/)** — presentational components, props-only (no SDK imports): the four view shells [ResourcesView](src/components/ResourcesView.tsx), [MyReviewsView](src/components/MyReviewsView.tsx), [SettingsView](src/components/SettingsView.tsx), [HowItWorks](src/components/HowItWorks.tsx); [TopNav](src/components/TopNav.tsx) (owns the `View` type); [AppNotices](src/components/AppNotices.tsx) (status banner + "no contract" notice); and the pieces [ReviewForm](src/components/ReviewForm.tsx), [RecentReviews](src/components/RecentReviews.tsx), [ReviewRow](src/components/ReviewRow.tsx), [MyReviewCard](src/components/MyReviewCard.tsx), [ReviewHistory](src/components/ReviewHistory.tsx), [StarMeter](src/components/StarMeter.tsx) (partial-fill star renderer).
+- **[src/session/types.ts](src/session/types.ts)** — the `Session` shape (`{ sdk, keyManager, identityId }`) shared by App and the hooks.
+- **[src/lib/](src/lib/)** — pure utilities, no SDK references: [logger.ts](src/lib/logger.ts) (`Logger`/`LogLevel` types, `errorMessage`, `consoleLogger`), [format.ts](src/lib/format.ts) (`formatAverage`, `formatDate`, `shortId`), [ratings.ts](src/lib/ratings.ts) (`RATING_ROWS`, `emptySummary`/`emptyDistribution`, `ownerLabel`, `reviewCountLine`, text `stars`).
+- **[test/](test/)** — Vitest + Testing Library, flat directory, named after the subject. Three kinds: unit tests over `src/dash/` and `src/lib/` that stub the `DashSdk` shape (`*.test.ts`); component tests rendered with Testing Library (`*.test.tsx`); and hook tests via `renderHook`. Default Vitest env is `node`; component/hook tests opt into DOM with a `// @vitest-environment jsdom` pragma at the top of the file (the vitest `include` covers `**/*.test.{ts,tsx}`). Component/hook tests **mock the SDK loaders** (`vi.mock("../src/dash/sdkModule", …)` / `vi.mock("../src/dash/sdkCore", …)`) so the 8 MB bundle never imports — never let a test pull `@dashevo/evo-sdk` into the jsdom process. [tsconfig.app.json](tsconfig.app.json) includes `test`, so `tsc -b` (run by `build`) strict-typechecks the `.test.tsx` files — keep mock factories and stub props fully typed. `npm run test:coverage` runs the suite under v8 coverage.
+- **[test/e2e/](test/e2e/)** — Playwright specs plus shared `fixtures.ts`, driven by [playwright.config.ts](playwright.config.ts), which auto-starts `npx vite` on port 5182. Two projects (`chromium-desktop` / `chromium-mobile`) so every spec exercises both layouts. Runs against real testnet — no SDK mocks. The specs are read-only shell smoke tests ([smoke](test/e2e/smoke.spec.ts) — navigation, browse a resource, How-it-works; [settings](test/e2e/settings.spec.ts) — the sign-in form renders/gates without signing in); they assert rendering, not live rating data. Run locally via `npm run test:e2e`. [tsconfig.app.json](tsconfig.app.json)'s `exclude: ["test/e2e"]` keeps these out of the app `tsc -b` (Playwright typechecks them itself).
+
+## Review contract
+
+Schema lives in [src/dash/contract.ts](src/dash/contract.ts) as `REVIEW_SCHEMAS`. One document type, `review`:
+
+- `resourceId` — required string, 1–63 chars, position 0
+- `rating` — required integer, 1–5, position 1
+- `reviewText` — optional string, max 1000 chars, position 2
+- `$createdAt`, `$updatedAt` — required (Platform-managed)
+- `documentsMutable: true`, `documentsKeepHistory: true`, `canBeDeleted: false` — reviews are editable, keep revision history, and can't be deleted
+
+Indices:
+
+- `ownerAndResource` — unique (`$ownerId`, `resourceId`); enforces one review per identity per resource
+- `ownerReviews` — (`$ownerId`, `$updatedAt`); lists a user's reviews
+- `resourceRatingAggregate` — (`resourceId`), `countable`; total review count per resource
+- `resourceRatingDistribution` — (`resourceId`, `rating`), `countable` + `rangeCountable: true`; backs the grouped rating distribution AND the `rating == N` filter
+
+`DEFAULT_CONTRACT_ID` is `BdgTqaTAPYMyhp1WdeWdcvYSgoD7AuJ7tVCaCSXyQgyP`. Overrides are stored under `localStorage['dashrate.contractId']`. Settings can register a fresh contract and switch to it; the contract-ID input is controlled and auto-fills on register.
+
+## SDK query patterns
+
+This app deliberately demonstrates the relational query surface. The query types in play:
+
+- **Total count** — `sdk.documents.count({ where: [["resourceId","==",id]] })` over the single-property `resourceRatingAggregate` index. Ungrouped result is a one-entry `Map` keyed `""`; read with `firstMapValue`. (`getRatingCount` in [queries.ts](src/dash/queries.ts).)
+- **Grouped distribution count** — `sdk.documents.count({ where: [["resourceId","==",id], ["rating","between",[1,5]]], groupBy: ["rating"], orderBy: [["rating","asc"]] })` over `resourceRatingDistribution`. Returns one entry per present rating. The average is **derived in JS** from these per-star counts (`summaryFromDistribution`) — there is no `sum`/`average` query. (`getRatingDistribution` in [queries.ts](src/dash/queries.ts).)
+- **Filter by rating** — `listResourceReviews` adds `["rating","==",N]` to the `where` (a point lookup covered by `[resourceId, rating]`). Server-side on purpose, to demonstrate `where` filtering and stay correct past the fetch limit.
+- **Document query / history** — `sdk.documents.query` for the review list; `sdk.documents.history` for revisions.
+
+`normalizeReviews` / `normalizeSingleReview` in [queries.ts](src/dash/queries.ts) flatten whatever shape `query`/`get` returns (array, Map, plain object) into `ReviewRecord[]`.
+
+## Performance — load-anchor rules
+
+Same as the sibling apps: the `@dashevo/evo-sdk` browser bundle is ~8 MB and must stay off the boot critical path. **Never add a top-level value import from `@dashevo/evo-sdk`** to any file reachable from `App.tsx` — go through [sdkModule.ts](src/dash/sdkModule.ts)'s cached dynamic import (type-only imports are fine). The shared core is loaded via [sdkCore.ts](src/dash/sdkCore.ts)'s `loadSdkCore()` — two distinct loaders, don't merge. The `modulePreload.resolveDependencies` filter in [vite.config.ts](vite.config.ts) strips the `evo-sdk` chunk so Vite doesn't inject a ` ` that re-blocks first paint. The synchronous exports of [contract.ts](src/dash/contract.ts) (`REVIEW_SCHEMAS`, `loadStoredContractId`, `saveContractId`, `clearStoredContractId`, `DEFAULT_CONTRACT_ID`) must stay synchronous — they run during initial render before the SDK loads.
+
+## Gotchas
+
+- **Grouped-count map keys are raw index-key bytes, NOT the value.** `count` with `groupBy: ["rating"]` returns a `Map` keyed by the hex of the property's order-preserving index-key encoding, not the integer. For a small positive integer that's the sign-flipped single byte `0x80 | value` → rating 5 is key `"85"`, rating 1 is `"81"` (verified against the live contract — it is _not_ an 8-byte big-endian form). `ratingKeyHex(r) = (0x80 | r).toString(16)` in [queries.ts](src/dash/queries.ts) re-encodes each known rating to look it up. The SDK exposes no decoder, so the client encodes the values it's looking for rather than decoding what comes back.
+- **`rangeCountable` is a separate flag from `countable`, required for range/grouped counts.** A range count (the `between` on `rating` that drives the grouped distinct walk) needs `rangeCountable: true` on the index, with the range field as the **last** index property. A `countable`-only index fails at query time with `range count requires a range_countable: true index whose last property matches the range field`.
+- **Don't mix `summable` and a deeper count-only index on a shared prefix** — it registers fine but breaks every document insert (`NotCountedOrSummed-wrapping is only supported for the six sum-bearing tree variants`). `resourceRatingAggregate` is intentionally count-only (no `summable`) so its `resourceId` value tree isn't count+sum and can host `resourceRatingDistribution`'s count-only `rating` continuation. The average is derived from the distribution instead of a `sum`/`average` query. Full analysis: [dashpay/platform#3960](https://github.com/dashpay/platform/issues/3960).
+- **`between` value is a 2-element array, inclusive.** `["rating","between",[1,5]]` matches `1 <= rating <= 5`. The drive expects exactly two bounds.
+- **A document query's `orderBy` field must be the serving index's TRAILING property — even for equality filters.** The index matcher (`Index::matches`) reserves the order-by field from the _back_ of the index. So filtering reviews by rating (`where resourceId== AND rating==`) must use `orderBy: [["rating","asc"]]` (the last property of `[resourceId, rating]`); ordering by `resourceId` there strips `rating` from the usable prefix and the query is rejected as `where clause on non indexed property … query must be for valid indexes`. `listResourceReviews` switches `orderBy` based on whether a `ratingFilter` is set. (Server `orderBy` only drives index selection here; the list is re-sorted client-side by `createdAt`.)
+- **Update flow** (`saveReview` replacing an existing review) bumps the revision off the fetched document; the unique `ownerAndResource` index means a second save edits rather than duplicates.
+- **The contract-ID input is controlled** (`contractInput` state in `App.tsx`). Register/Use/Clear all sync it; an uncontrolled `defaultValue` would not reflect a freshly-registered ID.
+- The Evo SDK WASM bundle is ~8 MB; that's expected, not a build error. See [Performance](#performance--load-anchor-rules).
diff --git a/example-apps/dashrate/README.md b/example-apps/dashrate/README.md
new file mode 100644
index 0000000..b29ed54
--- /dev/null
+++ b/example-apps/dashrate/README.md
@@ -0,0 +1,61 @@
+# DashRate
+
+DashRate is a React + TypeScript + Vite example app for rating Platform tutorial resources on Dash
+Platform testnet.
+
+It is intentionally small and centered on Platform v4's relational document queries:
+
+- `sdk.documents.query` for resource reviews, identity reviews, and existing-review lookup
+- `sdk.documents.count` for the total review count per resource (a plain countable index)
+- `sdk.documents.count` with `groupBy: ["rating"]` for the per-star rating distribution — the
+ count/sum/average shown per resource is derived in JS from this one grouped count, not a separate
+ `sum`/`average` query
+- a `where rating == N` clause for filtering reviews by star rating
+- `sdk.documents.history` for review edit history
+
+Users sign in with a mnemonic only. After signing in, they can register a local testnet contract,
+paste an existing DashRate contract ID, create one review per resource, edit that review, and
+inspect its document history. Read-only browsing (resources, aggregates, reviews) works without
+signing in.
+
+## Quick start
+
+```bash
+npm install
+npm run dev
+```
+
+Other scripts:
+
+```bash
+npm run build
+npm run test
+npm run test:coverage
+npm run lint
+npm run preview
+```
+
+## Contract
+
+The app defines one mutable document type, `review`, in
+[`src/dash/contract.ts`](./src/dash/contract.ts). Each identity can review a resource once, enforced
+by the unique `$ownerId + resourceId` index. Saving a review creates the document on first use and
+replaces the same document on later edits, with document history retained by `documentsKeepHistory`.
+
+The read paths are intentionally index-shaped:
+
+- resource detail and recent reviews query by `resourceId`
+- My reviews queries by `$ownerId` and sorts by `$updatedAt`
+- edit detection queries by `$ownerId + resourceId`
+- the total review count uses the standalone `resourceId` index (`countable: "countable"`)
+- the rating distribution and the `rating == N` filter use the compound `resourceId + rating` index
+ (`countable: "countable"` plus `rangeCountable: true`)
+
+Neither aggregate index uses `summable`: the count/sum/average shown per resource is computed in JS
+from the grouped distribution count, so a single grouped `count` query backs both the histogram and
+the average.
+
+`DEFAULT_CONTRACT_ID` is set to a published testnet DashRate contract
+(`BdgTqaTAPYMyhp1WdeWdcvYSgoD7AuJ7tVCaCSXyQgyP`), so fresh installs can read aggregates and reviews
+immediately. The active ID is stored under `localStorage['dashrate.contractId']`; clearing it falls
+back to this default. Register your own contract from the Settings tab to override it.
diff --git a/example-apps/dashrate/eslint.config.js b/example-apps/dashrate/eslint.config.js
new file mode 100644
index 0000000..df5877e
--- /dev/null
+++ b/example-apps/dashrate/eslint.config.js
@@ -0,0 +1,28 @@
+import js from "@eslint/js";
+import globals from "globals";
+import reactHooks from "eslint-plugin-react-hooks";
+import reactRefresh from "eslint-plugin-react-refresh";
+import tseslint from "typescript-eslint";
+
+export default tseslint.config(
+ { ignores: ["dist", "coverage", "playwright-report", "test-results"] },
+ {
+ extends: [js.configs.recommended, ...tseslint.configs.recommended],
+ files: ["**/*.{ts,tsx}"],
+ languageOptions: {
+ ecmaVersion: 2022,
+ globals: globals.browser,
+ },
+ plugins: {
+ "react-hooks": reactHooks,
+ "react-refresh": reactRefresh,
+ },
+ rules: {
+ ...reactHooks.configs.recommended.rules,
+ "react-refresh/only-export-components": [
+ "warn",
+ { allowConstantExport: true },
+ ],
+ },
+ },
+);
diff --git a/example-apps/dashrate/index.html b/example-apps/dashrate/index.html
new file mode 100644
index 0000000..8a62f02
--- /dev/null
+++ b/example-apps/dashrate/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ DashRate
+
+
+
+
+
+
diff --git a/example-apps/dashrate/package-lock.json b/example-apps/dashrate/package-lock.json
new file mode 100644
index 0000000..b57d967
--- /dev/null
+++ b/example-apps/dashrate/package-lock.json
@@ -0,0 +1,4267 @@
+{
+ "name": "dashrate",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "dashrate",
+ "version": "0.0.0",
+ "dependencies": {
+ "@dashevo/evo-sdk": "4.0.0-rc.2",
+ "react": "^19.2.4",
+ "react-dom": "^19.2.4"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.39.4",
+ "@playwright/test": "^1.59.1",
+ "@testing-library/react": "^16.3.2",
+ "@types/node": "^24.12.2",
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.1",
+ "@vitest/coverage-v8": "^4.1.5",
+ "eslint": "^9.39.4",
+ "eslint-plugin-react-hooks": "^7.0.1",
+ "eslint-plugin-react-refresh": "^0.5.2",
+ "globals": "^17.4.0",
+ "jsdom": "^26.1.0",
+ "prettier": "^3.6.2",
+ "typescript": "~6.0.2",
+ "typescript-eslint": "^8.58.0",
+ "vite": "^8.0.4",
+ "vitest": "^4.1.5"
+ },
+ "engines": {
+ "node": ">=22.22.0 <22.23.0"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
+ "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^2.1.3",
+ "@csstools/css-color-parser": "^3.0.9",
+ "@csstools/css-parser-algorithms": "^3.0.4",
+ "@csstools/css-tokenizer": "^3.0.3",
+ "lru-cache": "^10.4.3"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "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/generator": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
+ "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
+ "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.7"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
+ "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
+ "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+ "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
+ "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
+ "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
+ "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^5.1.0",
+ "@csstools/css-calc": "^2.1.4"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
+ "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
+ "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@dashevo/evo-sdk": {
+ "version": "4.0.0-rc.2",
+ "resolved": "https://registry.npmjs.org/@dashevo/evo-sdk/-/evo-sdk-4.0.0-rc.2.tgz",
+ "integrity": "sha512-SR823jh4OE19dxNbRPMukGZXj7MTa01af0rXIW1MvO/khN5Mn9Wp4nzzPRRPGXzG74RA46bimyJAhFxqdUz0eQ==",
+ "dependencies": {
+ "@dashevo/wasm-sdk": "4.0.0-rc.2"
+ },
+ "engines": {
+ "node": ">=18.18"
+ }
+ },
+ "node_modules/@dashevo/wasm-sdk": {
+ "version": "4.0.0-rc.2",
+ "resolved": "https://registry.npmjs.org/@dashevo/wasm-sdk/-/wasm-sdk-4.0.0-rc.2.tgz",
+ "integrity": "sha512-nQH7qVcCz28ePj9TJD7NNfrqEs+YlxtSmUxnCcU8HYZ9A9i3dgW0kU9tCo4RO6mUbnEEausymfy/2rc6nXIhhQ==",
+ "engines": {
+ "node": ">=18.18"
+ }
+ },
+ "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==",
+ "dev": true,
+ "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==",
+ "dev": true,
+ "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==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.9.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
+ "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/config-array": {
+ "version": "0.21.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz",
+ "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/object-schema": "^2.1.7",
+ "debug": "^4.3.1",
+ "minimatch": "^3.1.5"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/config-helpers": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
+ "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/core": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
+ "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/eslintrc": {
+ "version": "3.3.5",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz",
+ "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^6.14.0",
+ "debug": "^4.3.2",
+ "espree": "^10.0.1",
+ "globals": "^14.0.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.1.1",
+ "minimatch": "^3.1.5",
+ "strip-json-comments": "^3.1.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/globals": {
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
+ "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "9.39.4",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz",
+ "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ }
+ },
+ "node_modules/@eslint/object-schema": {
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
+ "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/plugin-kit": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
+ "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0",
+ "levn": "^0.4.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@humanfs/core": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
+ "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/types": "^0.15.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node": {
+ "version": "0.16.8",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
+ "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/core": "^0.19.2",
+ "@humanfs/types": "^0.15.0",
+ "@humanwhocodes/retry": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/types": {
+ "version": "0.15.0",
+ "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
+ "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/retry": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
+ "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.2"
+ },
+ "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.137.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz",
+ "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.61.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
+ "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.61.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.2.tgz",
+ "integrity": "sha512-2cZ+7xRS+DBcuJBJKnfzsbleumJhBqSlJVpuzHC0nTqfd3QQ7Vx2/x5YR/D7cBamKSeWplwo82Fn9lqYUDEMfA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.2.tgz",
+ "integrity": "sha512-RkPMJnygxsgOYdkfqgpwY0/Fzm8d0VQe6HGU2/B00Xa9eqdLbrII+DOKAodbJAn3ZL1AJxGHkZRPYazgGY6Ljw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.2.tgz",
+ "integrity": "sha512-Uiczh6vFhwyfd7WNe7Q7mCA4KxAiLdz7jPE/WGizfRpIieoyFuNVMmM8HqZ9HwudTkY6/AeMQwlNJ9NJijguWw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.2.tgz",
+ "integrity": "sha512-+TpdtTRgHiJFjCVFbw311SuLk3KfytPOQQn+VlAEv+gBxYPtL7E6JS9e/tk+8CwxhIZvemJKo4rTKgfWNsKkkA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.2.tgz",
+ "integrity": "sha512-4lv1/tkmi7ueIVHnyreaOeUpiZP26BH9rRy6hoYfR9310A2B9nUEVRDvBx69vx64Nr3eTPPRkyciqJJs+j9Jmw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.2.tgz",
+ "integrity": "sha512-gBSUVO0eaWgw1JMjK3gB8BMlX2Mk148s2lTiVT3e9vjVxbl7UDfMWWY8CfIaaqiXuM9fVTMxIpUz6CAo/B6Vlw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.2.tgz",
+ "integrity": "sha512-LjQP/iZLBu8o8PjIfk4x3At0/mT6h282pvz8Z5LAyhGbu/kDezyO7ea62rF5uoqmgnIYqbN/MqJ3Si3Aymi7xQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.2.tgz",
+ "integrity": "sha512-X/7bVLWelEsbyWDUSXt7zVsTniLLPIY2n1rH58qr78l9i7MNbbxBWD8gI2vRfBWf4NUXJCUuQnfZDsp32LqsfQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.2.tgz",
+ "integrity": "sha512-gb6dYKW/1KDorGXyy48glEBJs/sxVSC5pcVrox/pFGV4mvwSFeg2sK5L2tRkVsVlh7kueqOgg4GEcuipJcGuKg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.2.tgz",
+ "integrity": "sha512-JY4w85pU3iAiJVMh5nuk4/Mh9GjMsupe8MrIN53rwxAZW64GKrWeJBuN6SxQg9QTU5uB1cxyhDzW8jqRn1EABw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.2.tgz",
+ "integrity": "sha512-xvpA7o5KCYLB0Rwscmuylb1/zHHSUx4g4xilm4prC5jP76pEUlzBmMbgpbh7bVDbId4NcfT96gN5i6mE6UDaiw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.2.tgz",
+ "integrity": "sha512-p/ts6KBLjuk49Bp21XH77poQGt02iNz7ChgHep7tudPOaLinR/De/RHdxF8w8Yj4r/bF/bqXwH6PZrB2sA+Nvw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.2.tgz",
+ "integrity": "sha512-VMu/wmrZ9hJzYlRhbw7jK5PODlugyKZ5mOdX78+lS8OvuFkWNQdz1pFLrI2p3P0pjXOmUZ7B48o5VnMH9QOGtg==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "1.11.1",
+ "@emnapi/runtime": "1.11.1",
+ "@napi-rs/wasm-runtime": "^1.1.5"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.2.tgz",
+ "integrity": "sha512-xtUJqs8qEkuSviS0n1tsohaPuz3a1SPhZywOji4Oo+sgrJs8daEDMZ0QtqL0OS7dx8PoVpg2J/ZZycPY5I2+Zg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.2.tgz",
+ "integrity": "sha512-85YiLQqjUKgSO/Zjnf9e0XIn5Ymrh1fLDWBeAkZqpuBR/3R8TpfoHXuyblqyQrftSSgWO9qpcHN8mkyKsLraoA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
+ "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@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==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "24.13.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
+ "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.18.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==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "8.62.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.0.tgz",
+ "integrity": "sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.12.2",
+ "@typescript-eslint/scope-manager": "8.62.0",
+ "@typescript-eslint/type-utils": "8.62.0",
+ "@typescript-eslint/utils": "8.62.0",
+ "@typescript-eslint/visitor-keys": "8.62.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.62.0",
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
+ "version": "7.0.5",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
+ "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "8.62.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.0.tgz",
+ "integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "8.62.0",
+ "@typescript-eslint/types": "8.62.0",
+ "@typescript-eslint/typescript-estree": "8.62.0",
+ "@typescript-eslint/visitor-keys": "8.62.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.62.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.0.tgz",
+ "integrity": "sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.62.0",
+ "@typescript-eslint/types": "^8.62.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.62.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.0.tgz",
+ "integrity": "sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.62.0",
+ "@typescript-eslint/visitor-keys": "8.62.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.62.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.0.tgz",
+ "integrity": "sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==",
+ "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.62.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.0.tgz",
+ "integrity": "sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.62.0",
+ "@typescript-eslint/typescript-estree": "8.62.0",
+ "@typescript-eslint/utils": "8.62.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.62.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.0.tgz",
+ "integrity": "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==",
+ "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.62.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.0.tgz",
+ "integrity": "sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.62.0",
+ "@typescript-eslint/tsconfig-utils": "8.62.0",
+ "@typescript-eslint/types": "8.62.0",
+ "@typescript-eslint/visitor-keys": "8.62.0",
+ "debug": "^4.4.3",
+ "minimatch": "^10.2.2",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/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/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
+ "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/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/@typescript-eslint/typescript-estree/node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "8.62.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.0.tgz",
+ "integrity": "sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.62.0",
+ "@typescript-eslint/types": "8.62.0",
+ "@typescript-eslint/typescript-estree": "8.62.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.62.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.0.tgz",
+ "integrity": "sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.62.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.3",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz",
+ "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==",
+ "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/@vitest/coverage-v8": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz",
+ "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@bcoe/v8-coverage": "^1.0.2",
+ "@vitest/utils": "4.1.9",
+ "ast-v8-to-istanbul": "^1.0.0",
+ "istanbul-lib-coverage": "^3.2.2",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-reports": "^3.2.0",
+ "magicast": "^0.5.2",
+ "obug": "^2.1.1",
+ "std-env": "^4.0.0-rc.1",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@vitest/browser": "4.1.9",
+ "vitest": "4.1.9"
+ },
+ "peerDependenciesMeta": {
+ "@vitest/browser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
+ "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.9",
+ "@vitest/utils": "4.1.9",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
+ "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.9",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
+ "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
+ "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.9",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
+ "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.9",
+ "@vitest/utils": "4.1.9",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
+ "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
+ "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.9",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "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/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
+ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz",
+ "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.31",
+ "estree-walker": "^3.0.3",
+ "js-tokens": "^10.0.0"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
+ "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.38",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz",
+ "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
+ "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.4",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz",
+ "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==",
+ "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.38",
+ "caniuse-lite": "^1.0.30001799",
+ "electron-to-chromium": "^1.5.376",
+ "node-releases": "^2.0.48",
+ "update-browserslist-db": "^1.2.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001799",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
+ "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "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/cssstyle": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
+ "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^3.2.0",
+ "rrweb-cssom": "^0.8.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/data-urls": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
+ "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.377",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.377.tgz",
+ "integrity": "sha512-cH1jZgJHoezfTnKfKwnScpHywTFVnJUNITDPREFdhNjiuD502+QFpG0Qk7G8jhsV/f+CEAFlIrzP1fT+IMb92g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
+ "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "9.39.4",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz",
+ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.8.0",
+ "@eslint-community/regexpp": "^4.12.1",
+ "@eslint/config-array": "^0.21.2",
+ "@eslint/config-helpers": "^0.4.2",
+ "@eslint/core": "^0.17.0",
+ "@eslint/eslintrc": "^3.3.5",
+ "@eslint/js": "9.39.4",
+ "@eslint/plugin-kit": "^0.4.1",
+ "@humanfs/node": "^0.16.6",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@humanwhocodes/retry": "^0.4.2",
+ "@types/estree": "^1.0.6",
+ "ajv": "^6.14.0",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.6",
+ "debug": "^4.3.2",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^8.4.0",
+ "eslint-visitor-keys": "^4.2.1",
+ "espree": "^10.4.0",
+ "esquery": "^1.5.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",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.1.5",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "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": "8.4.0",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
+ "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/espree": {
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
+ "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.15.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^4.2.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
+ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/file-entry-cache": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.4"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
+ "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/globals": {
+ "version": "17.7.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz",
+ "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/hermes-estree": {
+ "version": "0.25.1",
+ "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
+ "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/hermes-parser": {
+ "version": "0.25.1",
+ "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
+ "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hermes-estree": "0.25.1"
+ }
+ },
+ "node_modules/html-encoding-sniffer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
+ "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-encoding": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/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/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/istanbul-lib-coverage": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
+ "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/jsdom": {
+ "version": "26.1.0",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
+ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssstyle": "^4.2.1",
+ "data-urls": "^5.0.0",
+ "decimal.js": "^10.5.0",
+ "html-encoding-sniffer": "^4.0.0",
+ "http-proxy-agent": "^7.0.2",
+ "https-proxy-agent": "^7.0.6",
+ "is-potential-custom-element-name": "^1.0.1",
+ "nwsapi": "^2.2.16",
+ "parse5": "^7.2.1",
+ "rrweb-cssom": "^0.8.0",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^5.1.1",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^7.0.0",
+ "whatwg-encoding": "^3.1.1",
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.1.1",
+ "ws": "^8.18.0",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lodash.merge": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/magicast": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz",
+ "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.3",
+ "@babel/types": "^7.29.0",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/make-dir/node_modules/semver": {
+ "version": "7.8.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/minimatch": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "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.15",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
+ "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.48",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz",
+ "integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/nwsapi": {
+ "version": "2.2.24",
+ "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz",
+ "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/obug": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
+ "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/parse5": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.61.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
+ "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.61.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.61.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
+ "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/playwright/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.15",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
+ "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.12",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/prettier": {
+ "version": "3.8.4",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz",
+ "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/pretty-format/node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/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.7",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
+ "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.7",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
+ "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.7"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.2.tgz",
+ "integrity": "sha512-x0CrQQqCXWGeI8dTvFfN/Dnv3yMKT9hv5jFjlOreKAx9wqLq9wz7VvLLHyaAXC90/CpggTu9SisSbsJJTPSjNQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.137.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.2",
+ "@rolldown/binding-darwin-arm64": "1.1.2",
+ "@rolldown/binding-darwin-x64": "1.1.2",
+ "@rolldown/binding-freebsd-x64": "1.1.2",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.1.2",
+ "@rolldown/binding-linux-arm64-gnu": "1.1.2",
+ "@rolldown/binding-linux-arm64-musl": "1.1.2",
+ "@rolldown/binding-linux-ppc64-gnu": "1.1.2",
+ "@rolldown/binding-linux-s390x-gnu": "1.1.2",
+ "@rolldown/binding-linux-x64-gnu": "1.1.2",
+ "@rolldown/binding-linux-x64-musl": "1.1.2",
+ "@rolldown/binding-openharmony-arm64": "1.1.2",
+ "@rolldown/binding-wasm32-wasi": "1.1.2",
+ "@rolldown/binding-win32-arm64-msvc": "1.1.2",
+ "@rolldown/binding-win32-x64-msvc": "1.1.2"
+ }
+ },
+ "node_modules/rrweb-cssom": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
+ "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
+ "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/strip-json-comments": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
+ "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
+ "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^6.1.86"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
+ "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tough-cookie": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
+ "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^6.1.32"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
+ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/ts-api-utils": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
+ "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.12"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/typescript-eslint": {
+ "version": "8.62.0",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.0.tgz",
+ "integrity": "sha512-8QxXi+ZACKX0kaqO4gY8kn0RSD9gFfaHDWwjqtEN48aWCBkX4MJaufWN+c3BzlrXLOxfywDL8CaoqUwcRq4j4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/eslint-plugin": "8.62.0",
+ "@typescript-eslint/parser": "8.62.0",
+ "@typescript-eslint/typescript-estree": "8.62.0",
+ "@typescript-eslint/utils": "8.62.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
+ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz",
+ "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.32.0",
+ "picomatch": "^4.0.4",
+ "postcss": "^8.5.15",
+ "rolldown": "~1.1.2",
+ "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/vitest": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
+ "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.9",
+ "@vitest/mocker": "4.1.9",
+ "@vitest/pretty-format": "4.1.9",
+ "@vitest/runner": "4.1.9",
+ "@vitest/snapshot": "4.1.9",
+ "@vitest/spy": "4.1.9",
+ "@vitest/utils": "4.1.9",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.9",
+ "@vitest/browser-preview": "4.1.9",
+ "@vitest/browser-webdriverio": "4.1.9",
+ "@vitest/coverage-istanbul": "4.1.9",
+ "@vitest/coverage-v8": "4.1.9",
+ "@vitest/ui": "4.1.9",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/whatwg-encoding": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+ "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "0.6.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
+ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "^5.1.0",
+ "webidl-conversions": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.21.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
+ "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/zod": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/zod-validation-error": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
+ "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.0 || ^4.0.0"
+ }
+ }
+ }
+}
diff --git a/example-apps/dashrate/package.json b/example-apps/dashrate/package.json
new file mode 100644
index 0000000..5046a92
--- /dev/null
+++ b/example-apps/dashrate/package.json
@@ -0,0 +1,48 @@
+{
+ "name": "dashrate",
+ "displayName": "DashRate",
+ "description": "Rate Platform tutorial resources with Dash Platform v4 aggregate queries and review history.",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "engines": {
+ "node": ">=22.22.0 <22.23.0"
+ },
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "format": "prettier --write .",
+ "format:check": "prettier --check .",
+ "lint": "eslint .",
+ "test": "vitest run",
+ "test:coverage": "vitest run --coverage",
+ "test:e2e": "playwright test",
+ "test:e2e:ui": "playwright test --ui",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@dashevo/evo-sdk": "4.0.0-rc.2",
+ "react": "^19.2.4",
+ "react-dom": "^19.2.4"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.39.4",
+ "@playwright/test": "^1.59.1",
+ "@testing-library/react": "^16.3.2",
+ "@types/node": "^24.12.2",
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.1",
+ "@vitest/coverage-v8": "^4.1.5",
+ "eslint": "^9.39.4",
+ "jsdom": "^26.1.0",
+ "eslint-plugin-react-hooks": "^7.0.1",
+ "eslint-plugin-react-refresh": "^0.5.2",
+ "globals": "^17.4.0",
+ "prettier": "^3.6.2",
+ "typescript": "~6.0.2",
+ "typescript-eslint": "^8.58.0",
+ "vite": "^8.0.4",
+ "vitest": "^4.1.5"
+ }
+}
diff --git a/example-apps/dashrate/playwright.config.ts b/example-apps/dashrate/playwright.config.ts
new file mode 100644
index 0000000..00bbfda
--- /dev/null
+++ b/example-apps/dashrate/playwright.config.ts
@@ -0,0 +1,38 @@
+import { defineConfig, devices } from "@playwright/test";
+
+const PORT = 5182;
+
+export default defineConfig({
+ testDir: "./test/e2e",
+ testMatch: "**/*.spec.ts",
+ fullyParallel: false,
+ forbidOnly: !!process.env.CI,
+ retries: 1,
+ workers: 1,
+ timeout: 30_000,
+ expect: { timeout: 7_500 },
+ reporter: process.env.CI ? "list" : [["list"], ["html", { open: "never" }]],
+
+ use: {
+ baseURL: `http://localhost:${PORT}`,
+ trace: "retain-on-failure",
+ },
+
+ projects: [
+ {
+ name: "chromium-desktop",
+ use: { ...devices["Desktop Chrome"] },
+ },
+ {
+ name: "chromium-mobile",
+ use: { ...devices["Pixel 7"] },
+ },
+ ],
+
+ webServer: {
+ command: `npx vite --port ${PORT} --strictPort`,
+ url: `http://localhost:${PORT}`,
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000,
+ },
+});
diff --git a/example-apps/dashrate/src/App.tsx b/example-apps/dashrate/src/App.tsx
new file mode 100644
index 0000000..8833ee9
--- /dev/null
+++ b/example-apps/dashrate/src/App.tsx
@@ -0,0 +1,322 @@
+import { useCallback, useMemo, useState } from "react";
+import { findResource, RESOURCES } from "./catalog/resources";
+import { AppNotices } from "./components/AppNotices";
+import { HowItWorks } from "./components/HowItWorks";
+import { MyReviewsView } from "./components/MyReviewsView";
+import { ResourcesView } from "./components/ResourcesView";
+import { SettingsView } from "./components/SettingsView";
+import { TopNav, type View } from "./components/TopNav";
+import {
+ clearStoredContractId,
+ loadStoredContractId,
+ registerContract,
+ saveContractId,
+} from "./dash/contract";
+import { fetchReviewHistory, type ReviewHistoryEntry } from "./dash/history";
+import type { ReviewRecord } from "./dash/queries";
+import { saveReview } from "./dash/review";
+import { loadSdkCore } from "./dash/sdkCore";
+import type { DashKeyManager, DashSdk } from "./dash/types";
+import { useDpnsNames } from "./hooks/useDpnsNames";
+import { useMyReviews } from "./hooks/useMyReviews";
+import { useResourceRatings } from "./hooks/useResourceRatings";
+import { consoleLogger, errorMessage, type LogLevel } from "./lib/logger";
+import type { Session } from "./session/types";
+
+export default function App() {
+ const [contractId, setContractId] = useState(loadStoredContractId);
+ const [contractInput, setContractInput] = useState(contractId);
+ const [session, setSession] = useState(null);
+ const [selectedResourceId, setSelectedResourceId] = useState(RESOURCES[0].id);
+ const [view, setView] = useState("resources");
+ const [mnemonic, setMnemonic] = useState("");
+ const [identityIndex, setIdentityIndex] = useState("0");
+ const [showAdvanced, setShowAdvanced] = useState(false);
+ const [history, setHistory] = useState([]);
+ const [status, setStatus] = useState("");
+ const [busy, setBusy] = useState(false);
+
+ const selectedResource = useMemo(
+ () => findResource(selectedResourceId) ?? RESOURCES[0],
+ [selectedResourceId],
+ );
+
+ const log = useCallback((message: string, level: LogLevel = "info") => {
+ consoleLogger(message, level);
+ if (level !== "info") setStatus(message);
+ }, []);
+
+ const connectReadOnly = useCallback(async (): Promise => {
+ const { createClient } = await loadSdkCore();
+ return (await createClient("testnet")) as unknown as DashSdk;
+ }, []);
+
+ const ratings = useResourceRatings({
+ contractId,
+ session,
+ selectedResourceId,
+ connectReadOnly,
+ log,
+ setStatus,
+ });
+
+ const myReviewsState = useMyReviews({
+ contractId,
+ enabled: view === "my-reviews",
+ session,
+ log,
+ });
+
+ const dpnsNames = useDpnsNames({
+ reviews: ratings.reviews,
+ myReviews: myReviewsState.myReviews,
+ session,
+ connectReadOnly,
+ });
+
+ async function handleSignIn(event: React.FormEvent) {
+ event.preventDefault();
+ setBusy(true);
+ setStatus("Connecting...");
+ try {
+ const { createClient, IdentityKeyManager } = await loadSdkCore();
+ const sdk = (await createClient("testnet")) as unknown as DashSdk;
+ const keyManager = (await IdentityKeyManager.create({
+ sdk: sdk as never,
+ mnemonic: mnemonic.trim(),
+ network: "testnet",
+ identityIndex: Number(identityIndex) || 0,
+ })) as unknown as DashKeyManager;
+ const identityId = String(keyManager.identityId ?? "");
+ if (!identityId) throw new Error("No identity found for this mnemonic.");
+ setSession({ sdk, keyManager, identityId });
+ setMnemonic("");
+ setStatus("");
+ } catch (err) {
+ setStatus(`Sign-in failed: ${errorMessage(err)}`);
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function handleSaveReview(event: React.FormEvent) {
+ event.preventDefault();
+ if (!session) {
+ setStatus("Sign in before saving a review.");
+ return;
+ }
+ if (!contractId) {
+ setStatus("Register or paste a DashRate contract ID first.");
+ return;
+ }
+ if (ratings.rating === null) {
+ setStatus("Choose a star rating before saving your review.");
+ return;
+ }
+
+ setBusy(true);
+ setStatus("");
+ try {
+ await saveReview({
+ sdk: session.sdk,
+ keyManager: session.keyManager,
+ contractId,
+ resourceId: selectedResource.id,
+ rating: ratings.rating,
+ reviewText: ratings.reviewText,
+ log,
+ });
+ await ratings.loadResourceData(session.sdk);
+ await myReviewsState.refreshMyReviews(session);
+ await ratings.refreshReviews(session.sdk);
+ setStatus("");
+ } catch (err) {
+ setStatus(`Save failed: ${errorMessage(err)}`);
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function handleRegisterContract() {
+ if (!session) {
+ setStatus("Sign in before registering a contract.");
+ return;
+ }
+ setBusy(true);
+ setStatus("");
+ try {
+ const id = await registerContract({
+ sdk: session.sdk,
+ keyManager: session.keyManager,
+ log,
+ });
+ setContractId(id);
+ setContractInput(id);
+ setStatus(`Registered new contract: ${id}`);
+ } catch (err) {
+ setStatus(`Registration failed: ${errorMessage(err)}`);
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function handleLoadHistory(reviewId: string) {
+ if (!session || !contractId) return;
+ setBusy(true);
+ setStatus("Loading review history...");
+ try {
+ const entries = await fetchReviewHistory({
+ sdk: session.sdk,
+ contractId,
+ reviewId,
+ });
+ setHistory(entries);
+ setStatus("");
+ } catch (err) {
+ setStatus(`History failed: ${errorMessage(err)}`);
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ function handleContractSubmit(event: React.FormEvent) {
+ event.preventDefault();
+ const nextId = contractInput.trim();
+ if (!nextId) return;
+ saveContractId(nextId);
+ setContractId(nextId);
+ setContractInput(nextId);
+ myReviewsState.setMyReviews([]);
+ }
+
+ function clearContract() {
+ clearStoredContractId();
+ setContractId("");
+ setContractInput("");
+ setHistory([]);
+ myReviewsState.setMyReviews([]);
+ ratings.setMySelectedReview(null);
+ }
+
+ function signOut() {
+ setSession(null);
+ myReviewsState.setMyReviews([]);
+ ratings.setMySelectedReview(null);
+ setHistory([]);
+ }
+
+ function handleSelectResource(resourceId: string) {
+ setSelectedResourceId(resourceId);
+ ratings.setReviewFilter(null);
+ setHistory([]);
+ }
+
+ function handleEditMyReview(review: ReviewRecord) {
+ setSelectedResourceId(review.resourceId);
+ ratings.setReviewFilter(null);
+ ratings.setMySelectedReview(review);
+ ratings.setRating(review.rating);
+ ratings.setHoverRating(null);
+ ratings.setReviewText(review.reviewText);
+ setHistory([]);
+ setView("resources");
+ }
+
+ return (
+
+
+
+
+
+ {view === "resources" && (
+ setView("settings")}
+ onRatingChange={ratings.setRating}
+ onHoverRatingChange={ratings.setHoverRating}
+ onReviewTextChange={ratings.setReviewText}
+ onLoadHistory={() => {
+ if (history.length > 0) {
+ setHistory([]);
+ return;
+ }
+ if (ratings.mySelectedReview) {
+ void handleLoadHistory(ratings.mySelectedReview.id);
+ }
+ }}
+ />
+ )}
+
+ {view === "my-reviews" && (
+
+ )}
+
+ {view === "settings" && (
+
+ )}
+
+ {view === "how" && }
+
+
+
+ );
+}
diff --git a/example-apps/dashrate/src/catalog/resources.ts b/example-apps/dashrate/src/catalog/resources.ts
new file mode 100644
index 0000000..a321d84
--- /dev/null
+++ b/example-apps/dashrate/src/catalog/resources.ts
@@ -0,0 +1,64 @@
+export interface RatedResource {
+ id: string;
+ title: string;
+ category: "Tutorial" | "Example App" | "Reference";
+ summary: string;
+ href: string;
+}
+
+export const RESOURCES: RatedResource[] = [
+ {
+ id: "intro",
+ title: "Platform Introduction",
+ category: "Tutorial",
+ summary: "The starting point for connecting to Dash Platform testnet.",
+ href: "https://docs.dash.org/projects/platform/en/stable/docs/tutorials/introduction.html",
+ },
+ {
+ id: "identities-names",
+ title: "Identities and Names",
+ category: "Tutorial",
+ summary: "Create identities, fund them, and register DPNS names.",
+ href: "../../1-Identities-and-Names/",
+ },
+ {
+ id: "contracts-documents",
+ title: "Contracts and Documents",
+ category: "Tutorial",
+ summary:
+ "Register contracts and submit, query, update, and delete documents.",
+ href: "../../2-Contracts-and-Documents/",
+ },
+ {
+ id: "tokens",
+ title: "Tokens",
+ category: "Tutorial",
+ summary: "Register, mint, transfer, and burn Platform tokens.",
+ href: "../../3-Tokens/",
+ },
+ {
+ id: "dashnote",
+ title: "Dashnote",
+ category: "Example App",
+ summary: "A notes app that demonstrates mutable documents and history.",
+ href: "../dashnote/",
+ },
+ {
+ id: "dashmint-lab",
+ title: "DashMint Lab",
+ category: "Example App",
+ summary: "NFT-style collectible documents with token-gated creation.",
+ href: "../dashmint-lab/",
+ },
+ {
+ id: "dashproof-lab",
+ title: "DashProof Lab",
+ category: "Example App",
+ summary: "Proof-of-existence anchoring and verification on Platform.",
+ href: "../dashproof-lab/",
+ },
+];
+
+export function findResource(resourceId: string): RatedResource | undefined {
+ return RESOURCES.find((resource) => resource.id === resourceId);
+}
diff --git a/example-apps/dashrate/src/components/AppNotices.tsx b/example-apps/dashrate/src/components/AppNotices.tsx
new file mode 100644
index 0000000..47105f5
--- /dev/null
+++ b/example-apps/dashrate/src/components/AppNotices.tsx
@@ -0,0 +1,19 @@
+export function AppNotices({
+ status,
+ hasContract,
+}: {
+ status: string;
+ hasContract: boolean;
+}) {
+ return (
+ <>
+ {status && {status}
}
+ {!hasContract && (
+
+ No default contract is bundled yet. Sign in and register a DashRate
+ contract, or paste an existing contract ID in Settings.
+
+ )}
+ >
+ );
+}
diff --git a/example-apps/dashrate/src/components/HowItWorks.tsx b/example-apps/dashrate/src/components/HowItWorks.tsx
new file mode 100644
index 0000000..88c0f63
--- /dev/null
+++ b/example-apps/dashrate/src/components/HowItWorks.tsx
@@ -0,0 +1,39 @@
+export function HowItWorks() {
+ return (
+
+ How it works
+
+ DashRate stores one mutable review document per identity
+ and resource. The unique $ownerId + resourceId index
+ prevents duplicate reviews by the same identity, so saving again edits
+ the existing document instead of creating a second one.
+
+
+
+ documents.query loads recent reviews by{" "}
+ resourceId, My reviews by $ownerId, and the
+ current user's review by $ownerId + resourceId. Adding a{" "}
+ rating == N clause filters the list to a single star
+ value — covered by the [resourceId, rating] index.
+
+
+ documents.count on the countable resourceId{" "}
+ index returns a resource's total review count — the basic count
+ pattern.
+
+
+ documents.count with groupBy: ["rating"]{" "}
+ returns one count per star value — the rating distribution shown as
+ bars. The rating between [1, 5] range over the countable{" "}
+ [resourceId, rating] index drives the grouped walk. The
+ average is computed from these per-star counts, so no separate{" "}
+ sum / average query is needed.
+
+
+ documents.history shows how a user's review changed
+ across revisions because the document type keeps history.
+
+
+
+ );
+}
diff --git a/example-apps/dashrate/src/components/MyReviewCard.tsx b/example-apps/dashrate/src/components/MyReviewCard.tsx
new file mode 100644
index 0000000..a5cbe79
--- /dev/null
+++ b/example-apps/dashrate/src/components/MyReviewCard.tsx
@@ -0,0 +1,45 @@
+import { findResource } from "../catalog/resources";
+import type { ReviewRecord } from "../dash/queries";
+import { formatDate } from "../lib/format";
+import { stars } from "../lib/ratings";
+
+export function MyReviewCard({
+ review,
+ onEdit,
+}: {
+ review: ReviewRecord;
+ onEdit: (review: ReviewRecord) => void;
+}) {
+ const resource = findResource(review.resourceId);
+ const title = resource?.title ?? review.resourceId;
+ return (
+
+
+
+ {resource && {resource.category} }
+ {title}
+
+
+ {stars(review.rating)}
+ edited {formatDate(review.updatedAt ?? review.createdAt)}
+
+
+ {review.reviewText || "No written review."}
+
+
+ );
+}
diff --git a/example-apps/dashrate/src/components/MyReviewsView.tsx b/example-apps/dashrate/src/components/MyReviewsView.tsx
new file mode 100644
index 0000000..fe482d7
--- /dev/null
+++ b/example-apps/dashrate/src/components/MyReviewsView.tsx
@@ -0,0 +1,56 @@
+import type { ReviewRecord } from "../dash/queries";
+import type { Session } from "../session/types";
+import { formatAverage } from "../lib/format";
+import { ownerLabel } from "../lib/ratings";
+import { MyReviewCard } from "./MyReviewCard";
+
+export function MyReviewsView({
+ session,
+ dpnsNames,
+ myReviews,
+ myReviewsLoading,
+ myReviewsAverage,
+ onEdit,
+}: {
+ session: Session | null;
+ dpnsNames: Record;
+ myReviews: ReviewRecord[];
+ myReviewsLoading: boolean;
+ myReviewsAverage: number | null;
+ onEdit: (review: ReviewRecord) => void;
+}) {
+ return (
+
+
+
+
My reviews
+ {session && (
+
+ {ownerLabel(session.identityId, dpnsNames)}
+
+ )}
+
+ {session && myReviews.length > 0 && (
+
+ {myReviews.length.toString()}{" "}
+ {myReviews.length === 1 ? "review" : "reviews"} · you average{" "}
+ {formatAverage(myReviewsAverage ?? 0)}
+
+ )}
+
+ {!session ? (
+ Sign in to see reviews written by your identity.
+ ) : myReviewsLoading ? (
+ Loading your reviews...
+ ) : myReviews.length === 0 ? (
+ No reviews from this identity yet.
+ ) : (
+
+ {myReviews.map((review) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/example-apps/dashrate/src/components/RecentReviews.tsx b/example-apps/dashrate/src/components/RecentReviews.tsx
new file mode 100644
index 0000000..fc1e01c
--- /dev/null
+++ b/example-apps/dashrate/src/components/RecentReviews.tsx
@@ -0,0 +1,60 @@
+import type { ReviewRecord } from "../dash/queries";
+import { ownerLabel } from "../lib/ratings";
+import { ReviewRow } from "./ReviewRow";
+
+export function RecentReviews({
+ reviews,
+ reviewFilter,
+ loadingRatings,
+ dpnsNames,
+ onClearFilter,
+}: {
+ reviews: ReviewRecord[];
+ reviewFilter: number | null;
+ loadingRatings: boolean;
+ dpnsNames: Record;
+ onClearFilter: () => void;
+}) {
+ return (
+
+
+
+ {reviewFilter == null ? "Recent reviews" : `${reviewFilter}★ reviews`}
+
+ {reviewFilter != null && (
+
+ Clear filter
+
+ )}
+
+ {reviews.length === 0 ? (
+ loadingRatings ? (
+
+
+ Loading reviews…
+
+ ) : (
+
+ {reviewFilter == null
+ ? "No reviews yet."
+ : `No ${reviewFilter}★ reviews yet.`}
+
+ )
+ ) : (
+
+ {reviews.map((review) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/example-apps/dashrate/src/components/ResourcesView.tsx b/example-apps/dashrate/src/components/ResourcesView.tsx
new file mode 100644
index 0000000..79cf4a0
--- /dev/null
+++ b/example-apps/dashrate/src/components/ResourcesView.tsx
@@ -0,0 +1,231 @@
+import { useRef, type FormEvent } from "react";
+import { RESOURCES, type RatedResource } from "../catalog/resources";
+import type {
+ RatingDistribution,
+ RatingSummary,
+ ReviewRecord,
+} from "../dash/queries";
+import type { ReviewHistoryEntry } from "../dash/history";
+import {
+ emptyDistribution,
+ emptySummary,
+ RATING_ROWS,
+ reviewCountLine,
+} from "../lib/ratings";
+import { formatAverage } from "../lib/format";
+import { StarMeter } from "./StarMeter";
+import { ReviewForm } from "./ReviewForm";
+import { RecentReviews } from "./RecentReviews";
+
+export function ResourcesView({
+ selectedResource,
+ summaries,
+ distributions,
+ reviews,
+ reviewFilter,
+ loadingRatings,
+ history,
+ signedIn,
+ busy,
+ contractId,
+ rating,
+ hoverRating,
+ reviewText,
+ hasSelectedReview,
+ dpnsNames,
+ onSelectResource,
+ onReviewFilterChange,
+ onSaveReview,
+ onOpenSettings,
+ onRatingChange,
+ onHoverRatingChange,
+ onReviewTextChange,
+ onLoadHistory,
+}: {
+ selectedResource: RatedResource;
+ summaries: Record;
+ distributions: Record;
+ reviews: ReviewRecord[];
+ reviewFilter: number | null;
+ loadingRatings: boolean;
+ history: ReviewHistoryEntry[];
+ signedIn: boolean;
+ busy: boolean;
+ contractId: string;
+ rating: number | null;
+ hoverRating: number | null;
+ reviewText: string;
+ hasSelectedReview: boolean;
+ dpnsNames: Record;
+ onSelectResource: (resourceId: string) => void;
+ onReviewFilterChange: (rating: number | null) => void;
+ onSaveReview: (event: FormEvent) => void;
+ onOpenSettings: () => void;
+ onRatingChange: (rating: number) => void;
+ onHoverRatingChange: (rating: number | null) => void;
+ onReviewTextChange: (text: string) => void;
+ onLoadHistory: () => void;
+}) {
+ const detailRef = useRef(null);
+
+ // On the stacked mobile layout the resource list sits above the detail
+ // panel, so a tap leaves the rating/reviews off-screen. Scroll the detail
+ // into view there; on the two-column desktop layout both are already
+ // visible, so leave the scroll position alone.
+ function handleSelectResource(resourceId: string) {
+ onSelectResource(resourceId);
+ if (!window.matchMedia("(max-width: 820px)").matches) return;
+ const detail = detailRef.current;
+ if (!detail) return;
+ // The sticky topbar wraps to a variable height on mobile (brand stacks
+ // above the nav, and the nav itself wraps to 1–2 lines by viewport
+ // width). Measure its real height so the scroll offset matches the
+ // wrapped header instead of a hardcoded guess that over/undershoots.
+ const topbar = document.querySelector(".topbar");
+ detail.style.scrollMarginTop = topbar
+ ? `${topbar.offsetHeight + 12}px`
+ : "";
+ detail.scrollIntoView({ behavior: "smooth", block: "start" });
+ }
+
+ const selectedSummary =
+ summaries[selectedResource.id] ?? emptySummary(selectedResource.id);
+ const selectedDistribution =
+ distributions[selectedResource.id] ?? emptyDistribution();
+ const distributionMax = RATING_ROWS.reduce((max, value) => {
+ const count = selectedDistribution[value] ?? 0n;
+ return count > max ? count : max;
+ }, 0n);
+
+ return (
+
+
+ {RESOURCES.map((resource) => {
+ const summary = summaries[resource.id] ?? emptySummary(resource.id);
+ return (
+ handleSelectResource(resource.id)}
+ >
+ {resource.category}
+ {resource.title}
+
+ {summary.count > 0n && (
+
+ )}
+ {reviewCountLine(summary)}
+
+
+ );
+ })}
+
+
+
+
+
{selectedResource.category}
+
+
{selectedResource.title}
+
+
+
{selectedResource.summary}
+
+
+ {selectedSummary.average === null
+ ? "—"
+ : formatAverage(selectedSummary.average)}
+
+
+
+ {reviewCountLine(selectedSummary)}
+
+
+ {selectedSummary.count > 0n && (
+
+ {RATING_ROWS.map((value) => {
+ const count = selectedDistribution[value] ?? 0n;
+ const widthPercent =
+ distributionMax > 0n
+ ? Number((count * 100n) / distributionMax)
+ : 0;
+ const active = reviewFilter === value;
+ return (
+
+
+ onReviewFilterChange(active ? null : value)
+ }
+ >
+ {value}★
+
+ 0n ? "histogram-bar" : "histogram-bar empty"
+ }
+ style={{ width: `${widthPercent}%` }}
+ />
+
+
+ {count.toString()} {count === 1n ? "review" : "reviews"}
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+ onReviewFilterChange(null)}
+ />
+
+
+ );
+}
diff --git a/example-apps/dashrate/src/components/ReviewForm.tsx b/example-apps/dashrate/src/components/ReviewForm.tsx
new file mode 100644
index 0000000..171c435
--- /dev/null
+++ b/example-apps/dashrate/src/components/ReviewForm.tsx
@@ -0,0 +1,117 @@
+import type { FormEvent } from "react";
+import type { ReviewHistoryEntry } from "../dash/history";
+import { ReviewHistory } from "./ReviewHistory";
+
+export function ReviewForm({
+ signedIn,
+ busy,
+ contractId,
+ rating,
+ hoverRating,
+ reviewText,
+ hasSelectedReview,
+ history,
+ onSubmit,
+ onOpenSettings,
+ onRatingChange,
+ onHoverRatingChange,
+ onReviewTextChange,
+ onLoadHistory,
+}: {
+ signedIn: boolean;
+ busy: boolean;
+ contractId: string;
+ rating: number | null;
+ hoverRating: number | null;
+ reviewText: string;
+ hasSelectedReview: boolean;
+ history: ReviewHistoryEntry[];
+ onSubmit: (event: FormEvent) => void;
+ onOpenSettings: () => void;
+ onRatingChange: (rating: number) => void;
+ onHoverRatingChange: (rating: number | null) => void;
+ onReviewTextChange: (text: string) => void;
+ onLoadHistory: () => void;
+}) {
+ const historyOpen = history.length > 0;
+ const displayRating = hoverRating ?? rating ?? 0;
+
+ return (
+
+ );
+}
diff --git a/example-apps/dashrate/src/components/ReviewHistory.tsx b/example-apps/dashrate/src/components/ReviewHistory.tsx
new file mode 100644
index 0000000..70f6e2e
--- /dev/null
+++ b/example-apps/dashrate/src/components/ReviewHistory.tsx
@@ -0,0 +1,31 @@
+import type { ReviewHistoryEntry } from "../dash/history";
+import { formatDate } from "../lib/format";
+
+export function ReviewHistory({ history }: { history: ReviewHistoryEntry[] }) {
+ if (history.length === 0) return null;
+
+ return (
+
+ );
+}
diff --git a/example-apps/dashrate/src/components/ReviewRow.tsx b/example-apps/dashrate/src/components/ReviewRow.tsx
new file mode 100644
index 0000000..bfa80b7
--- /dev/null
+++ b/example-apps/dashrate/src/components/ReviewRow.tsx
@@ -0,0 +1,29 @@
+import type { ReviewRecord } from "../dash/queries";
+import { formatDate } from "../lib/format";
+import { stars } from "../lib/ratings";
+
+export function ReviewRow({
+ review,
+ ownerName,
+}: {
+ review: ReviewRecord;
+ ownerName: string;
+}) {
+ return (
+
+
+ {ownerName}
+
+ ·
+
+ {stars(review.rating)}
+
+
+ {review.reviewText || "No written review."}
+
+
+ {formatDate(review.updatedAt ?? review.createdAt)}
+
+
+ );
+}
diff --git a/example-apps/dashrate/src/components/SettingsView.tsx b/example-apps/dashrate/src/components/SettingsView.tsx
new file mode 100644
index 0000000..b336342
--- /dev/null
+++ b/example-apps/dashrate/src/components/SettingsView.tsx
@@ -0,0 +1,165 @@
+import type { FormEvent } from "react";
+import type { Session } from "../session/types";
+import { shortId } from "../lib/format";
+
+export function SettingsView({
+ session,
+ dpnsNames,
+ busy,
+ mnemonic,
+ identityIndex,
+ showAdvanced,
+ contractId,
+ contractInput,
+ onMnemonicChange,
+ onIdentityIndexChange,
+ onShowAdvancedChange,
+ onContractInputChange,
+ onSignIn,
+ onSignOut,
+ onContractSubmit,
+ onClearContract,
+ onRegisterContract,
+}: {
+ session: Session | null;
+ dpnsNames: Record;
+ busy: boolean;
+ mnemonic: string;
+ identityIndex: string;
+ showAdvanced: boolean;
+ contractId: string;
+ contractInput: string;
+ onMnemonicChange: (mnemonic: string) => void;
+ onIdentityIndexChange: (identityIndex: string) => void;
+ onShowAdvancedChange: (showAdvanced: boolean) => void;
+ onContractInputChange: (contractId: string) => void;
+ onSignIn: (event: FormEvent) => void;
+ onSignOut: () => void;
+ onContractSubmit: (event: FormEvent) => void;
+ onClearContract: () => void;
+ onRegisterContract: () => void;
+}) {
+ return (
+
+ Settings
+
+
+ onShowAdvancedChange(!showAdvanced)}
+ >
+ {showAdvanced ? "▾" : "▸"} Advanced
+ settings
+
+
+ {showAdvanced && (
+
+ )}
+
+ );
+}
diff --git a/example-apps/dashrate/src/components/StarMeter.tsx b/example-apps/dashrate/src/components/StarMeter.tsx
new file mode 100644
index 0000000..6dd8dbc
--- /dev/null
+++ b/example-apps/dashrate/src/components/StarMeter.tsx
@@ -0,0 +1,31 @@
+import { formatAverage } from "../lib/format";
+
+export function StarMeter({
+ value,
+ className,
+}: {
+ value: number | null;
+ className?: string;
+}) {
+ const fillPercent = value === null ? 0 : Math.max(0, Math.min(5, value)) * 20;
+ const label =
+ value === null ? "No rating yet" : `${formatAverage(value)} out of 5`;
+ return (
+
+
+ ★★★★★
+
+
+ ★★★★★
+
+
+ );
+}
diff --git a/example-apps/dashrate/src/components/TopNav.tsx b/example-apps/dashrate/src/components/TopNav.tsx
new file mode 100644
index 0000000..9480d82
--- /dev/null
+++ b/example-apps/dashrate/src/components/TopNav.tsx
@@ -0,0 +1,48 @@
+export type View = "resources" | "my-reviews" | "settings" | "how";
+
+export function TopNav({
+ view,
+ onViewChange,
+}: {
+ view: View;
+ onViewChange: (view: View) => void;
+}) {
+ return (
+
+
+ D
+
DashRate
+
+
+ onViewChange("resources")}
+ >
+ Resources
+
+ onViewChange("my-reviews")}
+ >
+ My reviews
+
+ onViewChange("settings")}
+ >
+ Settings
+
+ onViewChange("how")}
+ >
+ How it works
+
+
+
+ );
+}
diff --git a/example-apps/dashrate/src/dash/client.ts b/example-apps/dashrate/src/dash/client.ts
new file mode 100644
index 0000000..367990f
--- /dev/null
+++ b/example-apps/dashrate/src/dash/client.ts
@@ -0,0 +1,6 @@
+/**
+ * Browser-safe Dash client entrypoint shared with the repo's Node tutorials.
+ *
+ * SDK method: EvoSDK.testnetTrusted() + sdk.connect()
+ */
+export { createClient } from "../../../../setupDashClient-core.mjs";
diff --git a/example-apps/dashrate/src/dash/contract.ts b/example-apps/dashrate/src/dash/contract.ts
new file mode 100644
index 0000000..c7ca4a1
--- /dev/null
+++ b/example-apps/dashrate/src/dash/contract.ts
@@ -0,0 +1,146 @@
+/**
+ * DashRate review data contract: schema definition + registration.
+ *
+ * SDK methods:
+ * new DataContract({ ownerId, identityNonce, schemas, fullValidation })
+ * sdk.contracts.publish({ dataContract, identityKey, signer })
+ */
+import { errorMessage, type Logger } from "../lib/logger";
+import { loadSdkModule } from "./sdkModule";
+import type { DashKeyManager, DashSdk } from "./types";
+
+export const REVIEW_SCHEMAS = {
+ review: {
+ type: "object",
+ documentsMutable: true,
+ documentsKeepHistory: true,
+ canBeDeleted: false,
+ properties: {
+ resourceId: {
+ type: "string",
+ minLength: 1,
+ maxLength: 63,
+ position: 0,
+ },
+ rating: {
+ type: "integer",
+ minimum: 1,
+ maximum: 5,
+ position: 1,
+ },
+ reviewText: {
+ type: "string",
+ maxLength: 1000,
+ position: 2,
+ },
+ },
+ required: ["$createdAt", "$updatedAt", "resourceId", "rating"],
+ additionalProperties: false,
+ indices: [
+ {
+ name: "ownerAndResource",
+ unique: true,
+ properties: [{ $ownerId: "asc" }, { resourceId: "asc" }],
+ },
+ {
+ name: "ownerReviews",
+ properties: [{ $ownerId: "asc" }, { $updatedAt: "asc" }],
+ },
+ {
+ // Total review count per resource — the basic countable-index + count() pattern.
+ // Count-only: no `summable`, because a count+sum value tree at this shared `resourceId`
+ // prefix can't host the count-only `rating` continuation that resourceRatingDistribution
+ // needs (the drive rejects the insert). See dashpay/platform#3960
+ name: "resourceRatingAggregate",
+ properties: [{ resourceId: "asc" }],
+ countable: "countable",
+ },
+ {
+ // Grouped/range count for the rating distribution (count GROUP BY
+ // rating) and the `rating == N` filter. `rating` is the last
+ // property and carries the range walk via rangeCountable.
+ name: "resourceRatingDistribution",
+ properties: [{ resourceId: "asc" }, { rating: "asc" }],
+ countable: "countable",
+ rangeCountable: true,
+ },
+ ],
+ },
+} as const;
+
+const STORAGE_KEY = "dashrate.contractId";
+
+export const DEFAULT_CONTRACT_ID =
+ "BdgTqaTAPYMyhp1WdeWdcvYSgoD7AuJ7tVCaCSXyQgyP";
+
+export function loadStoredContractId(): string {
+ try {
+ return localStorage.getItem(STORAGE_KEY) || DEFAULT_CONTRACT_ID;
+ } catch {
+ return DEFAULT_CONTRACT_ID;
+ }
+}
+
+export function saveContractId(contractId: string): void {
+ localStorage.setItem(STORAGE_KEY, contractId);
+}
+
+export function clearStoredContractId(): void {
+ localStorage.removeItem(STORAGE_KEY);
+}
+
+export async function registerContract({
+ sdk,
+ keyManager,
+ log,
+}: {
+ sdk: DashSdk;
+ keyManager: DashKeyManager;
+ log?: Logger;
+}): Promise {
+ log?.("Registering DashRate review contract...");
+ const { identity, identityKey, signer } = await keyManager.getAuth();
+ const identityNonce = await sdk.identities.nonce(identity.id.toString());
+ const { DataContract } = await loadSdkModule();
+ const dataContract = new DataContract({
+ ownerId: identity.id,
+ identityNonce: (identityNonce || 0n) + 1n,
+ schemas: REVIEW_SCHEMAS,
+ fullValidation: true,
+ });
+
+ (
+ dataContract as unknown as {
+ setConfig: (config: Record) => void;
+ }
+ ).setConfig({
+ canBeDeleted: false,
+ readonly: false,
+ keepsHistory: false,
+ documentsKeepHistoryContractDefault: false,
+ documentsMutableContractDefault: true,
+ documentsCanBeDeletedContractDefault: false,
+ });
+
+ let published: {
+ id?: string | { toString(): string };
+ toJSON?: () => { id?: string };
+ };
+ try {
+ published = await sdk.contracts.publish({
+ dataContract,
+ identityKey,
+ signer,
+ });
+ } catch (err) {
+ throw new Error(`Contract publish failed: ${errorMessage(err)}`);
+ }
+ const contractId = published.id?.toString() || published.toJSON?.()?.id;
+ if (!contractId) {
+ throw new Error("Contract publish returned no ID.");
+ }
+
+ saveContractId(contractId);
+ log?.(`DashRate contract registered: ${contractId}`, "success");
+ return contractId;
+}
diff --git a/example-apps/dashrate/src/dash/history.ts b/example-apps/dashrate/src/dash/history.ts
new file mode 100644
index 0000000..18f10e2
--- /dev/null
+++ b/example-apps/dashrate/src/dash/history.ts
@@ -0,0 +1,58 @@
+/**
+ * Review history query helper.
+ *
+ * SDK method:
+ * sdk.documents.history({ dataContractId, documentTypeName, documentId, startAtMs, limit })
+ */
+import type { DashDocumentLike, DashReviewQueryJson, DashSdk } from "./types";
+
+export const REVIEW_HISTORY_LIMIT = 10;
+
+export interface ReviewHistoryEntry {
+ blockTimeMs: number;
+ revision: number;
+ rating: number;
+ reviewText: string;
+}
+
+export function normalizeHistory(
+ history: Map,
+): ReviewHistoryEntry[] {
+ return Array.from(history.entries())
+ .map(([blockTimeKey, document]) => {
+ const json: DashReviewQueryJson =
+ typeof document.toJSON === "function"
+ ? (document.toJSON() as DashReviewQueryJson)
+ : (document as DashReviewQueryJson);
+ return {
+ blockTimeMs: Number(blockTimeKey),
+ revision: Number(json.$revision ?? document.revision ?? 0),
+ rating: Number(json.rating ?? 0),
+ reviewText: typeof json.reviewText === "string" ? json.reviewText : "",
+ };
+ })
+ .sort((left, right) => right.blockTimeMs - left.blockTimeMs);
+}
+
+export async function fetchReviewHistory({
+ sdk,
+ contractId,
+ reviewId,
+ startAtMs = 0,
+ limit = REVIEW_HISTORY_LIMIT,
+}: {
+ sdk: DashSdk;
+ contractId: string;
+ reviewId: string;
+ startAtMs?: number;
+ limit?: number;
+}): Promise {
+ const history = await sdk.documents.history({
+ dataContractId: contractId,
+ documentTypeName: "review",
+ documentId: reviewId,
+ startAtMs,
+ limit: Math.min(Math.max(1, limit), REVIEW_HISTORY_LIMIT),
+ });
+ return normalizeHistory(history);
+}
diff --git a/example-apps/dashrate/src/dash/keyManager.ts b/example-apps/dashrate/src/dash/keyManager.ts
new file mode 100644
index 0000000..5fe124c
--- /dev/null
+++ b/example-apps/dashrate/src/dash/keyManager.ts
@@ -0,0 +1,4 @@
+/**
+ * Mnemonic-only identity key manager from the shared browser-safe SDK core.
+ */
+export { IdentityKeyManager } from "../../../../setupDashClient-core.mjs";
diff --git a/example-apps/dashrate/src/dash/queries.ts b/example-apps/dashrate/src/dash/queries.ts
new file mode 100644
index 0000000..2618452
--- /dev/null
+++ b/example-apps/dashrate/src/dash/queries.ts
@@ -0,0 +1,336 @@
+/**
+ * Read-side queries and aggregate helpers for DashRate reviews.
+ *
+ * SDK methods:
+ * sdk.documents.query(...)
+ * sdk.documents.count(...) // plain and grouped (GROUP BY rating)
+ *
+ * The count/sum/average shown per resource is derived in JS from the grouped
+ * count distribution — there is no `sum`/`average` query (see
+ * summaryFromDistribution).
+ */
+import { consoleLogger, errorMessage, type Logger } from "../lib/logger";
+import type {
+ DashDocumentLike,
+ DashReviewQueryDocument,
+ DashReviewQueryJson,
+ DashReviewQueryResults,
+ DashSdk,
+} from "./types";
+
+export interface ReviewRecord {
+ id: string;
+ ownerId: string;
+ resourceId: string;
+ rating: number;
+ reviewText: string;
+ createdAt: number | null;
+ updatedAt: number | null;
+ revision: number;
+}
+
+export interface RatingSummary {
+ resourceId: string;
+ count: bigint;
+ sum: bigint;
+ average: number | null;
+}
+
+/** Per-star review counts for a resource, keyed by rating value 1–5. */
+export type RatingDistribution = Record;
+
+const REVIEW_LIMIT = 50;
+
+/** The valid rating values, used to build distribution lookup keys. */
+const RATING_VALUES = [1, 2, 3, 4, 5] as const;
+
+export function firstMapValue(map: Map): T | undefined {
+ return map.values().next().value as T | undefined;
+}
+
+/**
+ * Index-key for an integer rating in a grouped `count` result map.
+ *
+ * Platform encodes an integer index key in its order-preserving form: the
+ * sign bit is flipped so negatives sort before positives. For a small
+ * positive rating that's a single byte `0x80 | rating`, hex-encoded — e.g.
+ * rating 5 → `0x85` → "85". (Verified against the live contract: a grouped
+ * count over ratings returns keys "81".."85", NOT 8-byte big-endian.) The
+ * client builds the expected keys by encoding each known rating rather than
+ * decoding the returned keys.
+ */
+export function ratingKeyHex(rating: number): string {
+ return (0x80 | rating).toString(16);
+}
+
+function toTimestamp(
+ value: DashReviewQueryJson["$createdAt"] | DashReviewQueryJson["$updatedAt"],
+): number | null {
+ if (typeof value === "number" && Number.isFinite(value)) return value;
+ if (typeof value === "bigint") return Number(value);
+ if (typeof value === "string" && value) {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : null;
+ }
+ return null;
+}
+
+function toNumber(value: unknown, fallback = 0): number {
+ if (typeof value === "number" && Number.isFinite(value)) return value;
+ if (typeof value === "bigint") return Number(value);
+ if (typeof value === "string" && value) {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : fallback;
+ }
+ return fallback;
+}
+
+function toReview(
+ id: string | null,
+ raw: DashReviewQueryDocument,
+): ReviewRecord {
+ const json: DashReviewQueryJson =
+ typeof raw?.toJSON === "function" ? raw.toJSON() : raw;
+ return {
+ id: String(id ?? json.$id ?? json.id ?? ""),
+ ownerId: String(json.$ownerId ?? ""),
+ resourceId: String(json.resourceId ?? ""),
+ rating: toNumber(json.rating),
+ reviewText: typeof json.reviewText === "string" ? json.reviewText : "",
+ createdAt: toTimestamp(json.$createdAt),
+ updatedAt: toTimestamp(json.$updatedAt),
+ revision: toNumber(json.$revision, toNumber(raw.revision)),
+ };
+}
+
+export function normalizeReviews(
+ results: DashReviewQueryResults,
+): ReviewRecord[] {
+ if (Array.isArray(results)) {
+ return results
+ .filter(Boolean)
+ .map((doc) => toReview(null, doc as DashReviewQueryDocument));
+ }
+ const entries =
+ results instanceof Map ? Object.fromEntries(results) : results;
+ return Object.entries(entries)
+ .filter(([, doc]) => Boolean(doc))
+ .map(([id, doc]) => toReview(id, doc as DashReviewQueryDocument));
+}
+
+export function normalizeSingleReview(
+ id: string,
+ raw: DashDocumentLike | undefined,
+): ReviewRecord | null {
+ if (!raw) return null;
+ return toReview(id, raw as DashReviewQueryDocument);
+}
+
+/**
+ * Build a RatingSummary from a rating distribution. The per-star counts
+ * carry everything the summary needs, exactly for the 1–5 integer scale:
+ * count = Σ dist[r]
+ * sum = Σ (r × dist[r])
+ * average = sum / count
+ * So no separate `sum`/`average` query is needed — the grouped count that
+ * draws the histogram also yields the average. (Dropping `summable` from
+ * the contract is what avoids the index-aggregation conflict.)
+ */
+export function summaryFromDistribution(
+ resourceId: string,
+ distribution: RatingDistribution,
+): RatingSummary {
+ let count = 0n;
+ let sum = 0n;
+ for (const rating of RATING_VALUES) {
+ const c = distribution[rating] ?? 0n;
+ count += c;
+ sum += BigInt(rating) * c;
+ }
+ const average = count > 0n ? Number(sum) / Number(count) : null;
+ return { resourceId, count, sum, average };
+}
+
+function resourceAggregateQuery(contractId: string, resourceId: string) {
+ return {
+ dataContractId: contractId,
+ documentTypeName: "review",
+ where: [["resourceId", "==", resourceId]],
+ orderBy: [["resourceId", "asc"]] as [string, "asc" | "desc"][],
+ };
+}
+
+/**
+ * Total review count for a resource — a plain ungrouped `count()` over the
+ * single-property `resourceRatingAggregate` index. This is the basic
+ * countable-index pattern (contrast with getRatingDistribution's grouped
+ * count). The ungrouped result is a one-entry map keyed by "" — read it
+ * with firstMapValue.
+ */
+export async function getRatingCount({
+ sdk,
+ contractId,
+ resourceId,
+ log = consoleLogger,
+}: {
+ sdk: DashSdk;
+ contractId: string;
+ resourceId: string;
+ log?: Logger;
+}): Promise {
+ const query = resourceAggregateQuery(contractId, resourceId);
+ try {
+ const counts = await sdk.documents.count(query);
+ return firstMapValue(counts) ?? 0n;
+ } catch (err) {
+ log(`Count query failed for ${resourceId}: ${errorMessage(err)}`, "error");
+ throw err;
+ }
+}
+
+/**
+ * Per-star review counts for a resource, from a single grouped count
+ * query: `count` GROUP BY `rating` over the `[resourceId, rating]`
+ * index. The `between` range on `rating` is what puts the query in
+ * RangeDistinct mode (one map entry per distinct rating); the
+ * `resourceId == X` equality prefix scopes it to this resource.
+ */
+export async function getRatingDistribution({
+ sdk,
+ contractId,
+ resourceId,
+ log = consoleLogger,
+}: {
+ sdk: DashSdk;
+ contractId: string;
+ resourceId: string;
+ log?: Logger;
+}): Promise {
+ try {
+ const counts = await sdk.documents.count({
+ dataContractId: contractId,
+ documentTypeName: "review",
+ where: [
+ ["resourceId", "==", resourceId],
+ ["rating", "between", [1, 5]],
+ ],
+ orderBy: [["rating", "asc"]],
+ groupBy: ["rating"],
+ });
+ const distribution: RatingDistribution = {};
+ for (const rating of RATING_VALUES) {
+ distribution[rating] = counts.get(ratingKeyHex(rating)) ?? 0n;
+ }
+ return distribution;
+ } catch (err) {
+ log(
+ `Distribution query failed for ${resourceId}: ${errorMessage(err)}`,
+ "error",
+ );
+ throw err;
+ }
+}
+
+export async function listResourceReviews({
+ sdk,
+ contractId,
+ resourceId,
+ ratingFilter,
+ limit = REVIEW_LIMIT,
+ log = consoleLogger,
+}: {
+ sdk: DashSdk;
+ contractId: string;
+ resourceId: string;
+ ratingFilter?: number;
+ limit?: number;
+ log?: Logger;
+}): Promise {
+ // Filtering by rating is done server-side via a `rating == N` clause —
+ // the data is already fetched, so this is to demonstrate Platform
+ // `where`-filtering and to stay correct past the fetch limit.
+ //
+ // The `orderBy` must align with the index that serves the query, and
+ // its field must be the index's trailing property (the matcher reserves
+ // the order-by field from the back of the index). So:
+ // - no filter → resourceId equality on [resourceId] → order by resourceId
+ // - filter → resourceId+rating equalities on [resourceId, rating] →
+ // order by rating (the last index property)
+ // Ordering by resourceId in the filtered case strips `rating` out of the
+ // usable index prefix and the query is rejected as "non indexed".
+ const where: unknown[][] = [["resourceId", "==", resourceId]];
+ let orderBy: [string, "asc" | "desc"][] = [["resourceId", "asc"]];
+ if (ratingFilter != null) {
+ where.push(["rating", "==", ratingFilter]);
+ orderBy = [["rating", "asc"]];
+ }
+ const results = await sdk.documents.query({
+ dataContractId: contractId,
+ documentTypeName: "review",
+ where,
+ orderBy,
+ limit,
+ });
+ const reviews = normalizeReviews(results).sort(
+ (left, right) => (right.createdAt ?? 0) - (left.createdAt ?? 0),
+ );
+ log(`Loaded ${reviews.length} reviews for ${resourceId}.`);
+ return reviews;
+}
+
+export async function listMyReviews({
+ sdk,
+ contractId,
+ ownerId,
+ limit = REVIEW_LIMIT,
+ log = consoleLogger,
+}: {
+ sdk: DashSdk;
+ contractId: string;
+ ownerId: string;
+ limit?: number;
+ log?: Logger;
+}): Promise {
+ const results = await sdk.documents.query({
+ dataContractId: contractId,
+ documentTypeName: "review",
+ where: [["$ownerId", "==", ownerId]],
+ orderBy: [
+ ["$ownerId", "asc"],
+ ["$updatedAt", "asc"],
+ ],
+ limit,
+ });
+ const reviews = normalizeReviews(results).sort(
+ (left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0),
+ );
+ log(`Loaded ${reviews.length} reviews for identity ${ownerId}.`);
+ return reviews;
+}
+
+export async function findMyReviewForResource({
+ sdk,
+ contractId,
+ resourceId,
+ ownerId,
+}: {
+ sdk: DashSdk;
+ contractId: string;
+ resourceId: string;
+ ownerId: string;
+}): Promise {
+ const results = await sdk.documents.query({
+ dataContractId: contractId,
+ documentTypeName: "review",
+ where: [
+ ["$ownerId", "==", ownerId],
+ ["resourceId", "==", resourceId],
+ ],
+ orderBy: [
+ ["$ownerId", "asc"],
+ ["resourceId", "asc"],
+ ],
+ limit: 1,
+ });
+ return normalizeReviews(results)[0] ?? null;
+}
diff --git a/example-apps/dashrate/src/dash/resolveDpnsName.ts b/example-apps/dashrate/src/dash/resolveDpnsName.ts
new file mode 100644
index 0000000..27f5b02
--- /dev/null
+++ b/example-apps/dashrate/src/dash/resolveDpnsName.ts
@@ -0,0 +1,23 @@
+/**
+ * Resolves the DPNS username registered to an identity, with the `.dash`
+ * TLD stripped for display.
+ *
+ * SDK method: sdk.dpns.username(identityId)
+ *
+ * Returns null if the identity has no name registered, the lookup fails,
+ * or the SDK returns a non-string value.
+ */
+import type { DashSdk } from "./types";
+
+export async function resolveDpnsName(
+ sdk: DashSdk,
+ identityId: string,
+): Promise {
+ try {
+ const result = await sdk.dpns.username(identityId);
+ if (typeof result !== "string" || result.length === 0) return null;
+ return result.endsWith(".dash") ? result.slice(0, -5) : result;
+ } catch {
+ return null;
+ }
+}
diff --git a/example-apps/dashrate/src/dash/review.ts b/example-apps/dashrate/src/dash/review.ts
new file mode 100644
index 0000000..49657f7
--- /dev/null
+++ b/example-apps/dashrate/src/dash/review.ts
@@ -0,0 +1,107 @@
+/**
+ * Create or update one review per identity/resource pair.
+ *
+ * SDK methods:
+ * sdk.documents.query(...)
+ * sdk.documents.create(...)
+ * sdk.documents.get(...)
+ * sdk.documents.replace(...)
+ */
+import { PLATFORM_VERSION_OVERRIDE } from "../../../../platformVersion.mjs";
+import type { Logger } from "../lib/logger";
+import { loadSdkModule } from "./sdkModule";
+import { findMyReviewForResource } from "./queries";
+import type { DashKeyManager, DashSdk } from "./types";
+
+export interface SaveReviewParams {
+ sdk: DashSdk;
+ keyManager: DashKeyManager;
+ contractId: string;
+ resourceId: string;
+ rating: number;
+ reviewText: string;
+ log?: Logger;
+}
+
+function normalizeRating(rating: number): number {
+ if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
+ throw new Error("Rating must be an integer from 1 to 5.");
+ }
+ return rating;
+}
+
+export async function saveReview({
+ sdk,
+ keyManager,
+ contractId,
+ resourceId,
+ rating,
+ reviewText,
+ log,
+}: SaveReviewParams): Promise {
+ const normalizedRating = normalizeRating(rating);
+ const trimmedText = reviewText.trim();
+ const { identity, identityKey, signer } = await keyManager.getAuth();
+ const ownerId = identity.id.toString();
+ const existing = await findMyReviewForResource({
+ sdk,
+ contractId,
+ resourceId,
+ ownerId,
+ });
+ const { Document } = await loadSdkModule();
+
+ if (!existing) {
+ log?.("Creating review...");
+ const document = new Document({
+ properties: {
+ resourceId,
+ rating: normalizedRating,
+ ...(trimmedText ? { reviewText: trimmedText } : {}),
+ },
+ documentTypeName: "review",
+ dataContractId: contractId,
+ ownerId: identity.id,
+ });
+
+ await sdk.documents.create({ document, identityKey, signer });
+ const json =
+ typeof document.toJSON === "function"
+ ? (document.toJSON(PLATFORM_VERSION_OVERRIDE) as Record<
+ string,
+ unknown
+ >)
+ : {};
+ const reviewId = String(json.$id ?? json.id ?? "");
+ if (!reviewId) throw new Error("Created review returned no ID.");
+ log?.("Review created.", "success");
+ return reviewId;
+ }
+
+ log?.("Updating review...");
+ const networkDoc = await sdk.documents.get(contractId, "review", existing.id);
+ if (!networkDoc) {
+ throw new Error(`Review ${existing.id} not found.`);
+ }
+ const revision = BigInt(networkDoc.revision ?? 0) + 1n;
+ const replacement = new Document({
+ properties: {
+ resourceId,
+ rating: normalizedRating,
+ ...(trimmedText ? { reviewText: trimmedText } : {}),
+ },
+ documentTypeName: "review",
+ dataContractId: contractId,
+ ownerId: identity.id,
+ id: existing.id,
+ revision,
+ });
+
+ await sdk.documents.replace({
+ document: replacement,
+ identityKey,
+ signer,
+ });
+ log?.("Review updated.", "success");
+ return existing.id;
+}
diff --git a/example-apps/dashrate/src/dash/sdkCore.ts b/example-apps/dashrate/src/dash/sdkCore.ts
new file mode 100644
index 0000000..088ba8a
--- /dev/null
+++ b/example-apps/dashrate/src/dash/sdkCore.ts
@@ -0,0 +1,15 @@
+type SdkCore = typeof import("../../../../setupDashClient-core.mjs");
+
+let sdkCorePromise: Promise | null = null;
+
+export function loadSdkCore(): Promise {
+ if (!sdkCorePromise) {
+ sdkCorePromise = import("../../../../setupDashClient-core.mjs").catch(
+ (err) => {
+ sdkCorePromise = null;
+ throw err;
+ },
+ );
+ }
+ return sdkCorePromise;
+}
diff --git a/example-apps/dashrate/src/dash/sdkModule.ts b/example-apps/dashrate/src/dash/sdkModule.ts
new file mode 100644
index 0000000..a36e965
--- /dev/null
+++ b/example-apps/dashrate/src/dash/sdkModule.ts
@@ -0,0 +1,13 @@
+type SdkModule = typeof import("@dashevo/evo-sdk");
+
+let promise: Promise | null = null;
+
+export function loadSdkModule(): Promise {
+ if (!promise) {
+ promise = import("@dashevo/evo-sdk").catch((err) => {
+ promise = null;
+ throw err;
+ });
+ }
+ return promise;
+}
diff --git a/example-apps/dashrate/src/dash/types.ts b/example-apps/dashrate/src/dash/types.ts
new file mode 100644
index 0000000..9e3ec75
--- /dev/null
+++ b/example-apps/dashrate/src/dash/types.ts
@@ -0,0 +1,127 @@
+import type {
+ Identity,
+ IdentityPublicKey,
+ IdentitySigner,
+} from "@dashevo/evo-sdk";
+
+export interface DashAuth {
+ identity: Identity;
+ identityKey: IdentityPublicKey | undefined;
+ signer: IdentitySigner;
+}
+
+export interface DashKeyManager {
+ readonly identityId: string | null | undefined;
+ getAuth(): Promise;
+}
+
+export interface DashDocumentLike {
+ revision?: bigint | number | string;
+ toJSON?: (platformVersion?: number) => Record;
+ [key: string]: unknown;
+}
+
+export type DashReviewQueryResults =
+ | DashReviewQueryDocument[]
+ | Map
+ | Record;
+
+export interface DashSdk {
+ contracts: {
+ fetch(contractId: string): Promise<{
+ toJSON?: () => Record;
+ [key: string]: unknown;
+ } | null>;
+ publish(args: {
+ dataContract: unknown;
+ identityKey: IdentityPublicKey | undefined;
+ signer: IdentitySigner;
+ }): Promise<{
+ id?: string | { toString(): string };
+ toJSON?: () => { id?: string };
+ }>;
+ };
+ documents: {
+ query(args: {
+ dataContractId: string;
+ documentTypeName: string;
+ where?: unknown[][];
+ orderBy?: [string, "asc" | "desc"][];
+ limit?: number;
+ startAfter?: string;
+ }): Promise;
+ count(args: {
+ dataContractId: string;
+ documentTypeName: string;
+ where?: unknown[][];
+ orderBy?: [string, "asc" | "desc"][];
+ groupBy?: string[];
+ }): Promise>;
+ sum(
+ args: {
+ dataContractId: string;
+ documentTypeName: string;
+ where?: unknown[][];
+ orderBy?: [string, "asc" | "desc"][];
+ },
+ sumProperty: string,
+ ): Promise>;
+ average(
+ args: {
+ dataContractId: string;
+ documentTypeName: string;
+ where?: unknown[][];
+ orderBy?: [string, "asc" | "desc"][];
+ },
+ averageProperty: string,
+ ): Promise>;
+ get(
+ contractId: string,
+ documentTypeName: string,
+ documentId: string,
+ ): Promise;
+ history(args: {
+ dataContractId: string;
+ documentTypeName: string;
+ documentId: string;
+ startAtMs?: number;
+ limit?: number;
+ }): Promise>;
+ create(args: {
+ document: unknown;
+ identityKey: IdentityPublicKey | undefined;
+ signer: IdentitySigner;
+ }): Promise;
+ replace(args: {
+ document: unknown;
+ identityKey: IdentityPublicKey | undefined;
+ signer: IdentitySigner;
+ }): Promise;
+ };
+ identities: {
+ nonce(identityId: string): Promise;
+ };
+ dpns: {
+ username(identityId: string): Promise;
+ };
+ getWasmSdkConnected?: () => Promise<{
+ removeCachedContract(contractId: { free?: () => void }): boolean;
+ }>;
+}
+
+export interface DashReviewQueryJson extends Record {
+ $id?: string;
+ id?: string;
+ $ownerId?: string;
+ $createdAt?: number | string | bigint;
+ $updatedAt?: number | string | bigint;
+ $revision?: number | string | bigint;
+ resourceId?: string;
+ rating?: number | string | bigint;
+ reviewText?: string | null;
+}
+
+export interface DashReviewQueryDocument extends Record {
+ revision?: number | string | bigint;
+ toJSON?: () => DashReviewQueryJson;
+}
diff --git a/example-apps/dashrate/src/hooks/useDpnsNames.ts b/example-apps/dashrate/src/hooks/useDpnsNames.ts
new file mode 100644
index 0000000..10f4b60
--- /dev/null
+++ b/example-apps/dashrate/src/hooks/useDpnsNames.ts
@@ -0,0 +1,55 @@
+import { useEffect, useState } from "react";
+import { resolveDpnsName } from "../dash/resolveDpnsName";
+import type { DashSdk } from "../dash/types";
+import type { ReviewRecord } from "../dash/queries";
+import type { Session } from "../session/types";
+
+interface UseDpnsNamesArgs {
+ reviews: ReviewRecord[];
+ myReviews: ReviewRecord[];
+ session: Session | null;
+ connectReadOnly: () => Promise;
+}
+
+export function useDpnsNames({
+ reviews,
+ myReviews,
+ session,
+ connectReadOnly,
+}: UseDpnsNamesArgs): Record {
+ const [dpnsNames, setDpnsNames] = useState>({});
+
+ useEffect(() => {
+ const candidates = new Set();
+ for (const review of reviews) candidates.add(review.ownerId);
+ for (const review of myReviews) candidates.add(review.ownerId);
+ if (session) candidates.add(session.identityId);
+ const pending = [...candidates].filter((id) => id && !(id in dpnsNames));
+ if (pending.length === 0) return;
+
+ let cancelled = false;
+ (async () => {
+ try {
+ const activeSdk = session?.sdk ?? (await connectReadOnly());
+ const resolved = await Promise.all(
+ pending.map(
+ async (id) => [id, await resolveDpnsName(activeSdk, id)] as const,
+ ),
+ );
+ if (!cancelled) {
+ setDpnsNames((prev) => ({
+ ...prev,
+ ...Object.fromEntries(resolved),
+ }));
+ }
+ } catch {
+ // Name resolution is best-effort; the UI falls back to short IDs.
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [connectReadOnly, dpnsNames, myReviews, reviews, session]);
+
+ return dpnsNames;
+}
diff --git a/example-apps/dashrate/src/hooks/useMyReviews.ts b/example-apps/dashrate/src/hooks/useMyReviews.ts
new file mode 100644
index 0000000..7d9345d
--- /dev/null
+++ b/example-apps/dashrate/src/hooks/useMyReviews.ts
@@ -0,0 +1,77 @@
+import { useCallback, useEffect, useState } from "react";
+import { listMyReviews, type ReviewRecord } from "../dash/queries";
+import { errorMessage, type LogLevel } from "../lib/logger";
+import type { Session } from "../session/types";
+
+interface UseMyReviewsArgs {
+ contractId: string;
+ enabled: boolean;
+ session: Session | null;
+ log: (message: string, level?: LogLevel) => void;
+}
+
+export function useMyReviews({
+ contractId,
+ enabled,
+ session,
+ log,
+}: UseMyReviewsArgs) {
+ const [myReviews, setMyReviews] = useState([]);
+ const [myReviewsLoading, setMyReviewsLoading] = useState(false);
+ const myReviewsAverage =
+ myReviews.length === 0
+ ? null
+ : myReviews.reduce((total, review) => total + review.rating, 0) /
+ myReviews.length;
+
+ const fetchMyReviews = useCallback(
+ async (activeSession: Session): Promise => {
+ if (!contractId) return [];
+ return listMyReviews({
+ sdk: activeSession.sdk,
+ contractId,
+ ownerId: activeSession.identityId,
+ log,
+ });
+ },
+ [contractId, log],
+ );
+
+ const refreshMyReviews = useCallback(
+ async (activeSession = session) => {
+ if (!activeSession) return [];
+ const fetched = await fetchMyReviews(activeSession);
+ setMyReviews(fetched);
+ return fetched;
+ },
+ [fetchMyReviews, session],
+ );
+
+ useEffect(() => {
+ if (!enabled || !session || !contractId) return;
+ let cancelled = false;
+ (async () => {
+ setMyReviewsLoading(true);
+ try {
+ const fetched = await fetchMyReviews(session);
+ if (!cancelled) setMyReviews(fetched);
+ } catch (err) {
+ if (!cancelled) log(`My reviews failed: ${errorMessage(err)}`, "error");
+ } finally {
+ if (!cancelled) setMyReviewsLoading(false);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [contractId, enabled, fetchMyReviews, log, session]);
+
+ return {
+ myReviews,
+ setMyReviews,
+ myReviewsLoading,
+ myReviewsAverage,
+ fetchMyReviews,
+ refreshMyReviews,
+ };
+}
diff --git a/example-apps/dashrate/src/hooks/useResourceRatings.ts b/example-apps/dashrate/src/hooks/useResourceRatings.ts
new file mode 100644
index 0000000..0bcf368
--- /dev/null
+++ b/example-apps/dashrate/src/hooks/useResourceRatings.ts
@@ -0,0 +1,277 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { RESOURCES } from "../catalog/resources";
+import {
+ findMyReviewForResource,
+ getRatingCount,
+ getRatingDistribution,
+ listResourceReviews,
+ summaryFromDistribution,
+ type RatingDistribution,
+ type RatingSummary,
+ type ReviewRecord,
+} from "../dash/queries";
+import type { DashSdk } from "../dash/types";
+import { emptyDistribution, emptySummary } from "../lib/ratings";
+import {
+ consoleLogger,
+ errorMessage,
+ type Logger,
+ type LogLevel,
+} from "../lib/logger";
+import type { Session } from "../session/types";
+
+interface UseResourceRatingsArgs {
+ contractId: string;
+ session: Session | null;
+ selectedResourceId: string;
+ connectReadOnly: () => Promise;
+ log: (message: string, level?: LogLevel) => void;
+ setStatus: (message: string) => void;
+}
+
+export function useResourceRatings({
+ contractId,
+ session,
+ selectedResourceId,
+ connectReadOnly,
+ log,
+ setStatus,
+}: UseResourceRatingsArgs) {
+ const [summaries, setSummaries] = useState>({});
+ const [distributions, setDistributions] = useState<
+ Record
+ >({});
+ const [reviews, setReviews] = useState([]);
+ const [reviewFilter, setReviewFilter] = useState(null);
+ const [reviewsKeyShown, setReviewsKeyShown] = useState(
+ `${selectedResourceId}::${reviewFilter ?? "all"}`,
+ );
+ const [mySelectedReview, setMySelectedReview] = useState(
+ null,
+ );
+ const [rating, setRating] = useState(null);
+ const [hoverRating, setHoverRating] = useState(null);
+ const [reviewText, setReviewText] = useState("");
+ const [loadingRatings, setLoadingRatings] = useState(false);
+ const resourceRequestId = useRef(0);
+ const reviewsRequestId = useRef(0);
+ const previousContractId = useRef(contractId);
+
+ const reviewsKey = `${selectedResourceId}::${reviewFilter ?? "all"}`;
+ if (reviewsKeyShown !== reviewsKey) {
+ setReviewsKeyShown(reviewsKey);
+ setReviews([]);
+ }
+
+ const clearResourceData = useCallback(() => {
+ setSummaries(
+ Object.fromEntries(
+ RESOURCES.map((resource) => [resource.id, emptySummary(resource.id)]),
+ ),
+ );
+ setDistributions(
+ Object.fromEntries(
+ RESOURCES.map((resource) => [resource.id, emptyDistribution()]),
+ ),
+ );
+ setReviews([]);
+ setMySelectedReview(null);
+ }, []);
+
+ const loadResourceData = useCallback(
+ async (sdk?: DashSdk) => {
+ const requestId = ++resourceRequestId.current;
+ const isCurrentRequest = () => requestId === resourceRequestId.current;
+ const scopedLog: Logger = (message, level = "info") => {
+ if (isCurrentRequest()) log(message, level);
+ else consoleLogger(message, level);
+ };
+
+ if (!contractId) {
+ if (!isCurrentRequest()) return;
+ clearResourceData();
+ return;
+ }
+
+ const activeSdk = sdk ?? session?.sdk ?? (await connectReadOnly());
+ const perResource = await Promise.all(
+ RESOURCES.map(async (resource) => {
+ const [totalCount, distribution] = await Promise.all([
+ getRatingCount({
+ sdk: activeSdk,
+ contractId,
+ resourceId: resource.id,
+ log: scopedLog,
+ }),
+ getRatingDistribution({
+ sdk: activeSdk,
+ contractId,
+ resourceId: resource.id,
+ log: scopedLog,
+ }),
+ ]);
+ const summary = summaryFromDistribution(resource.id, distribution);
+ return {
+ resourceId: resource.id,
+ summary: { ...summary, count: totalCount },
+ distribution,
+ };
+ }),
+ );
+
+ if (!isCurrentRequest()) return;
+ setSummaries(
+ Object.fromEntries(
+ perResource.map(({ resourceId, summary }) => [resourceId, summary]),
+ ),
+ );
+ setDistributions(
+ Object.fromEntries(
+ perResource.map(({ resourceId, distribution }) => [
+ resourceId,
+ distribution,
+ ]),
+ ),
+ );
+ scopedLog(`Loaded ratings for ${perResource.length} resources.`);
+
+ if (session) {
+ const mine = await findMyReviewForResource({
+ sdk: activeSdk,
+ contractId,
+ resourceId: selectedResourceId,
+ ownerId: session.identityId,
+ });
+ if (!isCurrentRequest()) return;
+ setMySelectedReview(mine);
+ setRating(mine?.rating ?? null);
+ setHoverRating(null);
+ setReviewText(mine?.reviewText ?? "");
+ }
+ },
+ [
+ clearResourceData,
+ connectReadOnly,
+ contractId,
+ log,
+ selectedResourceId,
+ session,
+ ],
+ );
+
+ const refreshReviews = useCallback(
+ async (sdk?: DashSdk) => {
+ const requestId = ++reviewsRequestId.current;
+ const isCurrentRequest = () => requestId === reviewsRequestId.current;
+ const scopedLog: Logger = (message, level = "info") => {
+ if (isCurrentRequest()) log(message, level);
+ else consoleLogger(message, level);
+ };
+
+ if (!contractId) {
+ if (!isCurrentRequest()) return [];
+ setReviews([]);
+ return [];
+ }
+ const activeSdk = sdk ?? session?.sdk ?? (await connectReadOnly());
+ const list = await listResourceReviews({
+ sdk: activeSdk,
+ contractId,
+ resourceId: selectedResourceId,
+ ratingFilter: reviewFilter ?? undefined,
+ log: scopedLog,
+ });
+ if (isCurrentRequest()) setReviews(list);
+ return list;
+ },
+ [
+ connectReadOnly,
+ contractId,
+ log,
+ reviewFilter,
+ selectedResourceId,
+ session,
+ ],
+ );
+
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ setLoadingRatings(Boolean(contractId));
+ if (previousContractId.current !== contractId) {
+ previousContractId.current = contractId;
+ clearResourceData();
+ }
+ try {
+ await loadResourceData();
+ if (!cancelled) setStatus("");
+ } catch (err) {
+ if (!cancelled) setStatus(`Load failed: ${errorMessage(err)}`);
+ } finally {
+ if (!cancelled) setLoadingRatings(false);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [clearResourceData, contractId, loadResourceData, setStatus]);
+
+ useEffect(() => {
+ if (!contractId) return;
+ const requestId = ++reviewsRequestId.current;
+ const scopedLog: Logger = (message, level = "info") => {
+ if (requestId === reviewsRequestId.current) log(message, level);
+ else consoleLogger(message, level);
+ };
+ let cancelled = false;
+ (async () => {
+ try {
+ const activeSdk = session?.sdk ?? (await connectReadOnly());
+ const list = await listResourceReviews({
+ sdk: activeSdk,
+ contractId,
+ resourceId: selectedResourceId,
+ ratingFilter: reviewFilter ?? undefined,
+ log: scopedLog,
+ });
+ if (!cancelled && requestId === reviewsRequestId.current) {
+ setReviews(list);
+ }
+ } catch (err) {
+ if (!cancelled && requestId === reviewsRequestId.current) {
+ setStatus(`Reviews failed: ${errorMessage(err)}`);
+ }
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [
+ connectReadOnly,
+ contractId,
+ log,
+ reviewFilter,
+ selectedResourceId,
+ session,
+ setStatus,
+ ]);
+
+ return {
+ summaries,
+ distributions,
+ reviews,
+ reviewFilter,
+ setReviewFilter,
+ mySelectedReview,
+ setMySelectedReview,
+ rating,
+ setRating,
+ hoverRating,
+ setHoverRating,
+ reviewText,
+ setReviewText,
+ loadingRatings,
+ loadResourceData,
+ refreshReviews,
+ };
+}
diff --git a/example-apps/dashrate/src/lib/format.ts b/example-apps/dashrate/src/lib/format.ts
new file mode 100644
index 0000000..86ab53a
--- /dev/null
+++ b/example-apps/dashrate/src/lib/format.ts
@@ -0,0 +1,17 @@
+export function formatAverage(value: number | null): string {
+ if (value === null || !Number.isFinite(value)) return "-";
+ return value.toFixed(1);
+}
+
+export function formatDate(value: number | null): string {
+ if (!value) return "Unknown time";
+ return new Intl.DateTimeFormat(undefined, {
+ dateStyle: "medium",
+ timeStyle: "short",
+ }).format(new Date(value));
+}
+
+export function shortId(value: string): string {
+ if (value.length <= 14) return value;
+ return `${value.slice(0, 7)}...${value.slice(-6)}`;
+}
diff --git a/example-apps/dashrate/src/lib/logger.ts b/example-apps/dashrate/src/lib/logger.ts
new file mode 100644
index 0000000..47784dc
--- /dev/null
+++ b/example-apps/dashrate/src/lib/logger.ts
@@ -0,0 +1,87 @@
+export type LogLevel = "info" | "success" | "error";
+export type Logger = (message: string, level?: LogLevel) => void;
+
+export const consoleLogger: Logger = (message, level = "info") => {
+ const fn =
+ level === "error"
+ ? console.error
+ : level === "success"
+ ? console.log
+ : console.info;
+ fn(`[dashrate:${level}] ${message}`);
+};
+
+function readCborTextLength(
+ bytes: Uint8Array,
+ offset: number,
+): [number, number] | null {
+ const head = bytes[offset];
+ if (head >> 5 !== 3) return null;
+ const additional = head & 0x1f;
+ if (additional < 24) return [additional, offset + 1];
+ if (additional === 24) return [bytes[offset + 1], offset + 2];
+ if (additional === 25) {
+ return [(bytes[offset + 1] << 8) + bytes[offset + 2], offset + 3];
+ }
+ return null;
+}
+
+function readCborText(
+ bytes: Uint8Array,
+ offset: number,
+): [string, number] | null {
+ const lengthResult = readCborTextLength(bytes, offset);
+ if (!lengthResult) return null;
+ const [length, textOffset] = lengthResult;
+ const end = textOffset + length;
+ if (end > bytes.length) return null;
+ return [new TextDecoder().decode(bytes.slice(textOffset, end)), end];
+}
+
+function decodeBase64CborMessage(value: string): string | null {
+ if (!/^[A-Za-z0-9+/=_-]+$/.test(value) || value.length < 8) return null;
+ try {
+ const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
+ const binary = atob(normalized);
+ const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
+ if (bytes[0] !== 0xa1) return null;
+ const key = readCborText(bytes, 1);
+ if (!key || key[0] !== "message") return null;
+ const message = readCborText(bytes, key[1]);
+ return message?.[0] || null;
+ } catch {
+ return null;
+ }
+}
+
+export function errorMessage(err: unknown): string {
+ if (err instanceof Error && err.message) return err.message;
+ if (typeof err === "string") return decodeBase64CborMessage(err) ?? err;
+ if (err && typeof err === "object") {
+ const obj = err as Record;
+ if (typeof obj.message === "string" && obj.message) return obj.message;
+ if (typeof obj.message === "function") {
+ try {
+ const message = obj.message();
+ if (typeof message === "string" && message) return message;
+ } catch {
+ // Fall through to the other SDK/WASM shapes below.
+ }
+ }
+ if (typeof obj.name === "string" && obj.name) {
+ const parts = [obj.name];
+ if (typeof obj.kind === "string" && obj.kind) parts.push(obj.kind);
+ if (typeof obj.code === "number") parts.push(`code ${obj.code}`);
+ return parts.join(": ");
+ }
+ const stringified = String(err);
+ if (stringified && stringified !== "[object Object]") return stringified;
+ }
+ try {
+ const json = JSON.stringify(err);
+ if (json && json !== "{}") return json;
+ } catch {
+ // Fall through.
+ }
+ return "Unknown error";
+}
diff --git a/example-apps/dashrate/src/lib/ratings.ts b/example-apps/dashrate/src/lib/ratings.ts
new file mode 100644
index 0000000..cb5c746
--- /dev/null
+++ b/example-apps/dashrate/src/lib/ratings.ts
@@ -0,0 +1,39 @@
+import type { RatingDistribution, RatingSummary } from "../dash/queries";
+import { shortId } from "./format";
+
+export const RATING_ROWS = [5, 4, 3, 2, 1] as const;
+
+export const emptySummary = (resourceId: string): RatingSummary => ({
+ resourceId,
+ count: 0n,
+ sum: 0n,
+ average: null,
+});
+
+export const emptyDistribution = (): RatingDistribution => ({
+ 1: 0n,
+ 2: 0n,
+ 3: 0n,
+ 4: 0n,
+ 5: 0n,
+});
+
+export function ownerLabel(
+ ownerId: string,
+ dpnsNames: Record,
+): string {
+ const name = dpnsNames[ownerId];
+ return name ? name : shortId(ownerId);
+}
+
+export function reviewCountLine(summary: RatingSummary): string {
+ if (summary.count === 0n || summary.average === null) return "No reviews yet";
+ const noun = summary.count === 1n ? "review" : "reviews";
+ return `${summary.count.toString()} ${noun}`;
+}
+
+export function stars(value: number | null): string {
+ if (value === null) return "No rating";
+ const rounded = Math.round(value);
+ return "★★★★★".slice(0, rounded) + "☆☆☆☆☆".slice(0, 5 - rounded);
+}
diff --git a/example-apps/dashrate/src/main.tsx b/example-apps/dashrate/src/main.tsx
new file mode 100644
index 0000000..693ac2e
--- /dev/null
+++ b/example-apps/dashrate/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import App from "./App";
+import "./styles.css";
+
+createRoot(document.getElementById("root")!).render(
+
+
+ ,
+);
diff --git a/example-apps/dashrate/src/session/types.ts b/example-apps/dashrate/src/session/types.ts
new file mode 100644
index 0000000..1da04f6
--- /dev/null
+++ b/example-apps/dashrate/src/session/types.ts
@@ -0,0 +1,7 @@
+import type { DashKeyManager, DashSdk } from "../dash/types";
+
+export interface Session {
+ sdk: DashSdk;
+ keyManager: DashKeyManager;
+ identityId: string;
+}
diff --git a/example-apps/dashrate/src/styles.css b/example-apps/dashrate/src/styles.css
new file mode 100644
index 0000000..511a299
--- /dev/null
+++ b/example-apps/dashrate/src/styles.css
@@ -0,0 +1,1021 @@
+:root {
+ color: #17231f;
+ background: #eef1ed;
+ font-family:
+ "Public Sans",
+ ui-sans-serif,
+ system-ui,
+ -apple-system,
+ BlinkMacSystemFont,
+ "Segoe UI",
+ sans-serif;
+ font-synthesis: none;
+ line-height: 1.5;
+}
+
+html {
+ scrollbar-gutter: stable;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ padding: 48px 16px;
+}
+
+button,
+input,
+select,
+textarea {
+ font: inherit;
+}
+
+button,
+a {
+ border-radius: 10px;
+}
+
+button {
+ border: 0;
+ background: #2f856b;
+ color: white;
+ cursor: pointer;
+ font-weight: 700;
+ padding: 0.72rem 1rem;
+}
+
+button:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+}
+
+button:hover:not(:disabled) {
+ background: #21664f;
+}
+
+a {
+ color: #2f856b;
+ font-weight: 700;
+}
+
+code {
+ font-size: 0.9em;
+}
+
+.shell {
+ background: white;
+ border-radius: 16px;
+ box-shadow: 0 24px 60px rgb(23 35 31 / 0.14);
+ margin: 0 auto;
+ max-width: 1180px;
+ overflow: hidden;
+}
+
+.topbar,
+.row {
+ align-items: center;
+ display: flex;
+ gap: 12px;
+ justify-content: space-between;
+}
+
+/* Title and Open share one row; the rating line sits beneath the title. */
+.detail-title-row {
+ align-items: center;
+ display: flex;
+ gap: 24px;
+ justify-content: space-between;
+}
+
+.detail-title-row h2 {
+ min-width: 0;
+}
+
+.topbar {
+ border-bottom: 1px solid #e7ece7;
+ margin-bottom: 0;
+ padding: 18px 28px;
+}
+
+.topbar h1,
+.detail h2,
+.panel h2 {
+ margin: 0;
+}
+
+.brand {
+ align-items: center;
+ display: flex;
+ gap: 12px;
+}
+
+.brand h1,
+.detail h2,
+.panel h2,
+.detail-rating-score {
+ font-family:
+ "Space Grotesk", "Public Sans", ui-sans-serif, system-ui, sans-serif;
+}
+
+.brand h1 {
+ font-size: 1.25rem;
+ line-height: 1;
+}
+
+.brand-mark {
+ align-items: center;
+ background: #2f856b;
+ border-radius: 8px;
+ color: white;
+ display: inline-flex;
+ font-weight: 800;
+ height: 30px;
+ justify-content: center;
+ width: 30px;
+}
+
+.topbar nav {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 24px;
+}
+
+.topbar nav button {
+ background: transparent;
+ border-radius: 0;
+ color: #697771;
+ padding: 10px 0;
+ position: relative;
+}
+
+.topbar nav button:hover:not(:disabled),
+.topbar nav button.active {
+ background: transparent;
+ color: #2f856b;
+}
+
+.topbar nav button.active::after {
+ background: #2f856b;
+ bottom: 0;
+ content: "";
+ height: 3px;
+ left: 0;
+ position: absolute;
+ right: 0;
+}
+
+.review-form .row button + button,
+.settings button[type="button"] {
+ background: white;
+ box-shadow: inset 0 0 0 1px #dbe4dc;
+ color: #2f856b;
+}
+
+.detail-actions {
+ align-items: center;
+ display: flex;
+ gap: 12px;
+ justify-content: flex-end;
+}
+
+.field-note {
+ color: #697771;
+ font-size: 0.85rem;
+ margin: 0 0 12px;
+}
+
+.advanced-toggle {
+ align-items: center;
+ background: none;
+ box-shadow: none;
+ color: #2f856b;
+ display: inline-flex;
+ font-size: 0.85rem;
+ gap: 6px;
+ margin-top: 16px;
+ padding: 0;
+}
+
+/* One uniform card holding the optional identity index + contract controls.
+ The nested form sheds its `.settings form` card chrome (see
+ `.advanced-section form` below) so the group reads as a single panel,
+ not a box inside a box. */
+.advanced-section {
+ border-radius: 12px;
+ box-shadow: inset 0 0 0 1px #e7ece7;
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+ margin-top: 12px;
+ padding: 20px;
+}
+
+.advanced-section form {
+ border-radius: 0;
+ box-shadow: none;
+ padding: 0;
+}
+
+.advanced-section h3 {
+ margin-top: 0;
+}
+
+/* Identity index is a small numeric field, not a full-width input. */
+.advanced-index input {
+ max-width: 120px;
+}
+
+.advanced-index .field-note {
+ margin: 6px 0 0;
+}
+
+.inline-loading {
+ align-items: center;
+ color: #657268;
+ display: inline-flex;
+ font-size: 0.85rem;
+ gap: 6px;
+ min-height: 24px;
+}
+
+.mini-spinner {
+ animation: spin 0.8s linear infinite;
+ border: 2px solid #e1e8e2;
+ border-top-color: #2f856b;
+ border-radius: 999px;
+ display: inline-block;
+ height: 14px;
+ width: 14px;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+/* Visually hidden but exposed to screen readers — for labels that are
+ redundant visually (e.g. a placeholder-described field) but still needed
+ for assistive tech. */
+.sr-only {
+ border: 0;
+ clip: rect(0 0 0 0);
+ clip-path: inset(50%);
+ height: 1px;
+ margin: -1px;
+ overflow: hidden;
+ padding: 0;
+ position: absolute;
+ white-space: nowrap;
+ width: 1px;
+}
+
+.eyebrow,
+.muted,
+time {
+ color: #697771;
+ font-size: 0.85rem;
+}
+
+.eyebrow {
+ font-size: 0.78rem;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ margin: 0 0 4px;
+ text-transform: uppercase;
+}
+
+.status,
+.notice {
+ border-radius: 10px;
+ margin: 16px 28px 0;
+ padding: 12px 14px;
+}
+
+.status {
+ background: #e8f3ef;
+}
+
+.notice {
+ background: #f8f1dd;
+}
+
+.workspace {
+ align-items: start;
+ display: grid;
+ gap: 24px;
+ grid-template-columns: minmax(220px, 300px) 1fr;
+ padding: 24px 28px 30px;
+}
+
+.resource-list,
+.reviews {
+ display: grid;
+ gap: 10px;
+}
+
+.resource-list {
+ align-content: start;
+ /* Tighter than the shared 10px gap — with borders gone, the cards read
+ as one continuous list rather than separate tiles. */
+ gap: 4px;
+ grid-auto-rows: 104px;
+}
+
+.resource-card {
+ background: transparent;
+ /* Transparent border holds the same box size as the selected card so
+ selecting one doesn't nudge the layout. */
+ border: 1px solid transparent;
+ color: #17231f;
+ display: grid;
+ gap: 4px;
+ grid-template-rows: 18px 1fr 20px;
+ height: 100%;
+ min-width: 0;
+ padding: 14px 16px;
+ text-align: left;
+}
+
+/* Cards are s; override the global green button hover with a
+ subtle tint so hovering doesn't flood the whole card. */
+.resource-card:hover:not(:disabled) {
+ background: #f4f8f5;
+}
+
+.resource-card.selected {
+ background: #f0f6f2;
+ border-color: #b6d6c8;
+ box-shadow: 0 10px 26px rgb(47 133 107 / 0.12);
+}
+
+.resource-card.selected:hover:not(:disabled) {
+ background: #eaf2ed;
+}
+
+.resource-card .resource-category {
+ color: #8a958f;
+ font-size: 0.82rem;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ line-height: 18px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.resource-card strong {
+ align-self: center;
+ display: -webkit-box;
+ line-height: 1.25;
+ overflow: hidden;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+}
+
+.resource-card small {
+ color: #697771;
+ line-height: 1.35;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* Five stars with the gold fill clipped to the average, so 2.5 shows
+ as a half-filled third star. Track is the empty (gray) stars; fill is
+ the gold overlay sized by inline width. */
+.star-meter {
+ display: inline-block;
+ position: relative;
+ white-space: nowrap;
+ vertical-align: middle;
+}
+
+.star-meter-track {
+ color: #d4ddd6;
+}
+
+.star-meter-fill {
+ color: #e1aa2b;
+ inset: 0;
+ overflow: hidden;
+ position: absolute;
+}
+
+.resource-card .mini-stars {
+ margin-right: 4px;
+}
+
+.detail,
+.panel {
+ background: white;
+ border: 0;
+ border-radius: 0;
+ padding: 0;
+}
+
+.panel {
+ margin: 24px 28px 30px;
+}
+
+.detail > * + *,
+.panel > * + *,
+form > * + * {
+ margin-top: 24px;
+}
+
+.detail > p {
+ margin-bottom: 0;
+}
+
+.panel-head {
+ align-items: baseline;
+ display: flex;
+ gap: 16px;
+ justify-content: space-between;
+}
+
+.panel-head p {
+ color: #697771;
+ margin: 0;
+}
+
+.panel-head strong {
+ color: #17231f;
+}
+
+.panel-head p.panel-identity {
+ margin-top: 4px;
+}
+
+.resource-open {
+ box-shadow: inset 0 0 0 1px #dbe4dc;
+ padding: 0.55rem 0.85rem;
+ text-decoration: none;
+}
+
+/* Rating, your review, and recent reviews are sections of one resource.
+ A hairline rule plus generous space groups them — no per-section boxes.
+ 24px above the rule (from the sibling margin), 28px below to the heading. */
+.resource-section {
+ border-top: 1px solid #e7ece7;
+ padding-top: 28px;
+}
+
+/* Section headings sit flush against the section's top padding — drop the
+ default top margin so "Rate this resource" / "Review history" align
+ with "Recent reviews" (whose h3 is already reset in .review-list-head).
+ Trim the default ~18px bottom margin so content (e.g. the star picker)
+ sits close under the heading. */
+.resource-section > h3 {
+ margin-top: 0;
+ margin-bottom: 8px;
+}
+
+/* Aggregate rating sits directly under the title: score, stars, count
+ on one compact line so it reads as part of the heading. */
+.detail-rating {
+ align-items: baseline;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 8px;
+}
+
+.detail-rating-score {
+ font-size: 1.3rem;
+ line-height: 1;
+}
+
+.detail-rating-stars {
+ font-size: 1.05rem;
+ letter-spacing: 0;
+ line-height: 1;
+}
+
+.detail-rating-count {
+ font-size: 0.85rem;
+}
+
+/* Rating distribution: one row per star value (5→1), each a clickable
+ bar that filters the review list. Rows are s, so the global
+ green button style is overridden to a quiet, tappable row. */
+.rating-histogram {
+ display: grid;
+ gap: 4px;
+ list-style: none;
+ margin: 12px 0 0;
+ max-width: calc(360px + 10ch - 2rem);
+ padding: 0;
+}
+
+.histogram-row {
+ align-items: center;
+ background: transparent;
+ border-radius: 6px;
+ color: #485a52;
+ display: grid;
+ font-weight: 500;
+ gap: 10px;
+ grid-template-columns: 2.2rem minmax(0, 1fr) 10ch;
+ padding: 3px 6px;
+ text-align: left;
+ width: 100%;
+}
+
+.histogram-row:hover:not(:disabled),
+.histogram-row:focus-visible {
+ background: #f4f8f5;
+}
+
+.histogram-row.active {
+ background: #f0f6f2;
+ color: #17231f;
+}
+
+.histogram-label {
+ color: #485a52;
+ font-size: 0.85rem;
+ white-space: nowrap;
+}
+
+.histogram-track {
+ background: #eef2ee;
+ border-radius: 999px;
+ height: 8px;
+ overflow: hidden;
+}
+
+.histogram-bar {
+ background: #e1aa2b;
+ border-radius: 999px;
+ display: block;
+ height: 100%;
+ min-width: 2px;
+}
+
+.histogram-bar.empty {
+ min-width: 0;
+}
+
+.histogram-count {
+ font-size: 0.85rem;
+ opacity: 0;
+ text-align: right;
+ transition: opacity 0.12s ease;
+ white-space: nowrap;
+}
+
+.histogram-row:focus-visible .histogram-count,
+.histogram-row.active .histogram-count {
+ opacity: 1;
+}
+
+/* Hover-reveal only on devices with a real hovering pointer. On touch,
+ `:hover` latches when a tap/scroll passes over the row and stays stuck,
+ so the count would linger after scrolling — gate it behind a true hover
+ pointer and let touch rely on the active/focus rules above. */
+@media (hover: hover) {
+ .histogram-row:hover:not(:disabled) .histogram-count {
+ opacity: 1;
+ }
+}
+
+.review-rating,
+.star.active,
+.star:hover:not(:disabled),
+.star:focus-visible {
+ color: #e1aa2b;
+}
+
+.settings form {
+ border: 0;
+ border-radius: 12px;
+ box-shadow:
+ inset 0 0 0 1px #e7ece7,
+ 0 12px 32px rgb(23 35 31 / 0.05);
+ padding: 20px;
+}
+
+/* Signed-out prompt under "Your review": a soft tinted box with the
+ call to action on the left and a button that jumps to Settings. */
+.signin-cta {
+ align-items: center;
+ background: #f7faf8;
+ border: 1px dashed #d2ddd5;
+ border-radius: 12px;
+ display: flex;
+ gap: 16px;
+ justify-content: space-between;
+ padding: 16px 20px;
+}
+
+.signin-cta p {
+ color: #697771;
+ margin: 0;
+}
+
+.signin-cta-button {
+ flex: 0 0 auto;
+}
+
+label {
+ display: grid;
+ gap: 6px;
+}
+
+input,
+select,
+textarea {
+ border: 1px solid #dfe6e0;
+ border-radius: 10px;
+ padding: 0.72rem;
+ width: 100%;
+}
+
+input:focus,
+select:focus,
+textarea:focus {
+ background: #fff;
+ border-color: #2f856b;
+ box-shadow: 0 0 0 3px rgb(47 133 107 / 0.12);
+ outline: none;
+}
+
+textarea {
+ background: #fbfdfb;
+ line-height: 1.5;
+ min-height: 96px;
+ padding: 14px 16px;
+ resize: vertical;
+}
+
+.star-picker {
+ display: flex;
+ gap: 4px;
+ margin-top: 8px;
+}
+
+/* The form's default 24px child rhythm (`form > * + *`) leaves too big a gap
+ between the stars and the comment box — they're one review action, so pull
+ the textarea up. The sr-only between them is position:absolute, so
+ the textarea is the visible child that carries the gap. */
+.review-form textarea {
+ margin-top: 12px;
+}
+
+.star {
+ align-items: flex-start;
+ background: transparent;
+ border: 0;
+ color: #8b938b;
+ display: inline-flex;
+ font-size: 2rem;
+ height: 44px;
+ justify-content: center;
+ line-height: 1;
+ padding: 0;
+ width: 44px;
+}
+
+.star:hover:not(:disabled),
+.star:focus-visible,
+.star.active {
+ background: transparent;
+}
+
+/* Header above the review list: section title plus an active-filter
+ clear affordance. */
+.review-list-head {
+ align-items: baseline;
+ display: flex;
+ gap: 12px;
+ justify-content: space-between;
+}
+
+.review-list-head h3 {
+ margin: 0;
+}
+
+.filter-clear {
+ background: transparent;
+ box-shadow: inset 0 0 0 1px #dbe4dc;
+ color: #2f856b;
+ flex: 0 0 auto;
+ font-size: 0.82rem;
+ padding: 0.4rem 0.7rem;
+}
+
+.filter-clear:hover:not(:disabled) {
+ background: #f4f8f5;
+}
+
+/* A quiet text-link toggle for secondary display actions (e.g. show/hide
+ revision history) — not a peer to the form's primary buttons, so it drops
+ the global button chrome (fill, border-radius, padding). */
+.text-toggle {
+ background: transparent;
+ border-radius: 0;
+ color: #2f856b;
+ flex: 0 0 auto;
+ font-size: 0.82rem;
+ font-weight: 600;
+ padding: 0;
+}
+
+.text-toggle:hover:not(:disabled) {
+ background: transparent;
+ text-decoration: underline;
+}
+
+/* Revision history is secondary context for the user's own review, not a peer
+ section: nest it under "Your review" with a left rule and indent instead of
+ the full-width top divider that separates peer sections. */
+.review-history {
+ border-left: 2px solid #e7ece7;
+ margin-top: 16px;
+ padding-left: 16px;
+}
+
+.review-history .review-list {
+ margin-top: 4px;
+}
+
+.review-history .review-row {
+ padding: 10px 0;
+}
+
+/* A feed of reviews reads as one continuous list: rows separated by thin
+ dividers, with the identity and stars together on one line. */
+.review-list {
+ display: grid;
+ list-style: none;
+ margin: 12px 0 0;
+ padding: 0;
+}
+
+.review-row {
+ padding: 16px 0;
+}
+
+.review-row + .review-row {
+ border-top: 1px solid #eef2ee;
+}
+
+.review-row:first-child {
+ padding-top: 0;
+}
+
+.review-row-head {
+ align-items: baseline;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+/* History rows keep the revision label left and the date pushed right. */
+.review-row-head .review-row-meta {
+ margin-left: auto;
+}
+
+.review-row-sep {
+ color: #b6c1b8;
+}
+
+.review-row-owner {
+ font-weight: 600;
+}
+
+.review-row-text {
+ margin: 6px 0 0;
+}
+
+.review-row-meta {
+ color: #697771;
+ display: block;
+ font-size: 0.85rem;
+}
+
+.review-row > .review-row-meta {
+ margin-top: 4px;
+}
+
+.review-rating {
+ display: block;
+ font-size: 1.05rem;
+ letter-spacing: 0;
+ line-height: 1.2;
+}
+
+.my-review-card {
+ border-radius: 12px;
+ box-shadow:
+ inset 0 0 0 1px #e7ece7,
+ 0 12px 32px rgb(23 35 31 / 0.05);
+ display: grid;
+ gap: 14px;
+ padding: 18px;
+}
+
+.my-review-head {
+ align-items: flex-start;
+ display: flex;
+ gap: 16px;
+ justify-content: space-between;
+}
+
+.my-review-title {
+ display: grid;
+ gap: 2px;
+ min-width: 0;
+}
+
+.my-review-head span {
+ color: #8a958f;
+ font-size: 0.78rem;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+.my-review-head strong {
+ display: block;
+ font-size: 1.2rem;
+ line-height: 1.25;
+}
+
+.my-review-rating {
+ display: grid;
+ flex: 0 0 auto;
+ gap: 4px;
+ justify-items: end;
+ text-align: right;
+}
+
+.my-review-rating time {
+ font-size: 0.8rem;
+}
+
+.my-review-actions {
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.my-review-actions button,
+.my-review-actions .secondary-action {
+ font-size: 0.86rem;
+ padding: 0.55rem 0.85rem;
+}
+
+.secondary-action {
+ box-shadow: inset 0 0 0 1px #dbe4dc;
+ color: #2f856b;
+ text-decoration: none;
+}
+
+.my-review-rating .review-rating {
+ font-size: 1rem;
+}
+
+@media (max-width: 820px) {
+ body {
+ padding: 0;
+ }
+
+ .shell {
+ border-radius: 0;
+ min-height: 100vh;
+ /* The desktop shell clips overflow for its rounded corners, but that
+ traps position: sticky. Corners are square here, so clip only the
+ horizontal axis and let the sticky topbar stick vertically. */
+ overflow: visible;
+ overflow-x: clip;
+ }
+
+ .topbar {
+ align-items: flex-start;
+ /* Keep the nav reachable: the scroll-to-detail jump would otherwise
+ push these tabs off-screen on the stacked layout. */
+ background: white;
+ flex-direction: column;
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ }
+
+ .workspace {
+ grid-template-columns: 1fr;
+ padding: 18px;
+ }
+
+ /* The scroll-to-detail jump (ResourcesView) aligns the detail's top with
+ the viewport top; it sets scroll-margin-top inline from the sticky
+ topbar's measured height so the wrapped header doesn't cover the
+ heading. This is the fallback if that measurement is unavailable. */
+ .detail {
+ scroll-margin-top: 120px;
+ }
+
+ .topbar {
+ padding: 18px;
+ }
+
+ .panel {
+ margin: 18px;
+ }
+
+ .panel-head,
+ .my-review-head {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .my-review-rating {
+ justify-items: start;
+ text-align: left;
+ }
+
+ /* Notices use the desktop 28px topbar inset; pull them in to align with
+ the panels, which drop to an 18px margin here. */
+ .status,
+ .notice {
+ margin: 16px 18px 0;
+ }
+}
+
+/* Small-phone tier, layered under the 820px breakpoint: extra tightening of
+ spacing and a few row→column stacks that the wider breakpoint leaves alone
+ (those rules are right for tablet/landscape). */
+@media (max-width: 480px) {
+ .workspace {
+ gap: 18px;
+ padding: 16px 14px 24px;
+ }
+
+ .topbar {
+ padding: 14px;
+ }
+
+ .topbar nav {
+ gap: 12px 16px;
+ }
+
+ .panel {
+ margin: 14px;
+ }
+
+ .status,
+ .notice {
+ margin: 14px 14px 0;
+ }
+
+ /* Never let the distribution chart exceed a narrow viewport. */
+ .rating-histogram {
+ max-width: 100%;
+ }
+
+ /* Stack the title and the "Open ↗" action instead of squeezing them onto
+ one row. */
+ .detail-title-row {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ .detail-actions {
+ justify-content: flex-start;
+ }
+
+ /* Stack the signed-out prompt above its "Go to Settings" button. */
+ .signin-cta {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 12px;
+ }
+}
+
+.app-footer {
+ border-top: 1px solid rgb(23 35 31 / 0.1);
+ display: flex;
+ justify-content: center;
+ padding: 20px;
+}
+
+.app-footer a {
+ align-items: center;
+ color: rgb(23 35 31 / 0.55);
+ display: inline-flex;
+ font-size: 0.85rem;
+ font-weight: 600;
+ gap: 6px;
+ text-decoration: none;
+}
+
+.app-footer a:hover {
+ color: #2f856b;
+}
diff --git a/example-apps/dashrate/src/vite-env.d.ts b/example-apps/dashrate/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/example-apps/dashrate/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/example-apps/dashrate/test/App.test.tsx b/example-apps/dashrate/test/App.test.tsx
new file mode 100644
index 0000000..9a26290
--- /dev/null
+++ b/example-apps/dashrate/test/App.test.tsx
@@ -0,0 +1,480 @@
+// @vitest-environment jsdom
+
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { ReviewRecord } from "../src/dash/queries";
+import type { DashKeyManager, DashSdk } from "../src/dash/types";
+
+// --- Hoisted spies for the mocked modules -------------------------------
+
+const {
+ loadStoredContractId,
+ saveContractId,
+ clearStoredContractId,
+ registerContract,
+ saveReview,
+ fetchReviewHistory,
+ loadSdkCore,
+ createClient,
+ identityKeyManagerCreate,
+ ratingsState,
+ myReviewsState,
+} = vi.hoisted(() => ({
+ loadStoredContractId: vi.fn<() => string>(),
+ saveContractId: vi.fn(),
+ clearStoredContractId: vi.fn(),
+ registerContract: vi.fn().mockResolvedValue("new-contract-id"),
+ saveReview: vi.fn().mockResolvedValue("review-id"),
+ fetchReviewHistory: vi.fn().mockResolvedValue([]),
+ loadSdkCore: vi.fn(),
+ createClient: vi.fn(),
+ identityKeyManagerCreate: vi.fn(),
+ ratingsState: {
+ summaries: {},
+ distributions: {},
+ reviews: [] as ReviewRecord[],
+ reviewFilter: null,
+ setReviewFilter: vi.fn(),
+ mySelectedReview: null as ReviewRecord | null,
+ setMySelectedReview: vi.fn(),
+ rating: null as number | null,
+ setRating: vi.fn(),
+ hoverRating: null,
+ setHoverRating: vi.fn(),
+ reviewText: "",
+ setReviewText: vi.fn(),
+ loadingRatings: false,
+ loadResourceData: vi.fn().mockResolvedValue(undefined),
+ refreshReviews: vi.fn().mockResolvedValue([]),
+ },
+ myReviewsState: {
+ myReviews: [] as ReviewRecord[],
+ setMyReviews: vi.fn(),
+ myReviewsLoading: false,
+ myReviewsAverage: null,
+ fetchMyReviews: vi.fn().mockResolvedValue([]),
+ refreshMyReviews: vi.fn().mockResolvedValue([]),
+ },
+}));
+
+vi.mock("../src/dash/contract", () => ({
+ loadStoredContractId,
+ saveContractId,
+ clearStoredContractId,
+ registerContract,
+}));
+
+vi.mock("../src/dash/sdkCore", () => ({ loadSdkCore }));
+
+vi.mock("../src/dash/review", () => ({ saveReview }));
+
+vi.mock("../src/dash/history", () => ({ fetchReviewHistory }));
+
+vi.mock("../src/hooks/useResourceRatings", () => ({
+ useResourceRatings: () => ratingsState,
+}));
+
+vi.mock("../src/hooks/useMyReviews", () => ({
+ useMyReviews: () => myReviewsState,
+}));
+
+vi.mock("../src/hooks/useDpnsNames", () => ({
+ useDpnsNames: () => ({}),
+}));
+
+// View stubs surface the props App wires and expose buttons to trigger
+// orchestration handlers.
+vi.mock("../src/components/ResourcesView", () => ({
+ ResourcesView: ({
+ onSaveReview,
+ onLoadHistory,
+ }: {
+ onSaveReview: (event: { preventDefault: () => void }) => void;
+ onLoadHistory: () => void;
+ }) => (
+
+ onSaveReview({ preventDefault: () => {} })}
+ >
+ save review
+
+
+ load history
+
+
+ ),
+}));
+
+vi.mock("../src/components/MyReviewsView", () => ({
+ MyReviewsView: ({ onEdit }: { onEdit: (review: ReviewRecord) => void }) => (
+
+
+ onEdit({
+ id: "r1",
+ ownerId: "owner-1",
+ resourceId: "tokens",
+ rating: 4,
+ reviewText: "edit me",
+ createdAt: 1,
+ updatedAt: 2,
+ revision: 1,
+ })
+ }
+ >
+ edit my review
+
+
+ ),
+}));
+
+vi.mock("../src/components/SettingsView", () => ({
+ // Surface the session App passes down so tests can assert that sign-in
+ // actually called setSession — not merely that the SDK ran without error.
+ SettingsView: ({
+ session,
+ onSignIn,
+ onSignOut,
+ onClearContract,
+ onContractSubmit,
+ onRegisterContract,
+ }: {
+ session: { identityId: string } | null;
+ onSignIn: (event: { preventDefault: () => void }) => void;
+ onSignOut: () => void;
+ onClearContract: () => void;
+ onContractSubmit: (event: { preventDefault: () => void }) => void;
+ onRegisterContract: () => void;
+ }) => (
+
+
+ {session ? `signed-in:${session.identityId}` : "signed-out"}
+
+
onSignIn({ preventDefault: () => {} })}
+ >
+ do sign in
+
+
+ do sign out
+
+
+ do clear contract
+
+
onContractSubmit({ preventDefault: () => {} })}
+ >
+ do contract submit
+
+
+ do register contract
+
+
+ ),
+}));
+
+vi.mock("../src/components/HowItWorks", () => ({
+ HowItWorks: () =>
,
+}));
+
+const App = (await import("../src/App")).default;
+
+function setSignInSuccess(identityId: string | null) {
+ createClient.mockResolvedValue({} as DashSdk);
+ identityKeyManagerCreate.mockResolvedValue({
+ identityId,
+ } as unknown as DashKeyManager);
+ loadSdkCore.mockResolvedValue({
+ createClient,
+ IdentityKeyManager: { create: identityKeyManagerCreate },
+ });
+}
+
+/** Navigate to the Resources view (where the review form lives). */
+function goToResources() {
+ fireEvent.click(screen.getByRole("button", { name: /^resources$/i }));
+}
+
+/** Sign in through the Settings stub and wait for the session to land. */
+async function signIn(identityId = "owner-1") {
+ setSignInSuccess(identityId);
+ fireEvent.click(screen.getByRole("button", { name: /^settings$/i }));
+ fireEvent.click(screen.getByRole("button", { name: /do sign in/i }));
+ await waitFor(() => {
+ expect(screen.getByTestId("session-state").textContent).toBe(
+ `signed-in:${identityId}`,
+ );
+ });
+}
+
+beforeEach(() => {
+ // clearAllMocks wipes implementations too, so re-establish the async
+ // resolutions every test.
+ vi.clearAllMocks();
+ loadStoredContractId.mockReturnValue("stored-contract-id");
+ registerContract.mockResolvedValue("new-contract-id");
+ saveReview.mockResolvedValue("review-id");
+ fetchReviewHistory.mockResolvedValue([]);
+ ratingsState.loadResourceData.mockResolvedValue(undefined);
+ ratingsState.refreshReviews.mockResolvedValue([]);
+ myReviewsState.refreshMyReviews.mockResolvedValue([]);
+ ratingsState.rating = null;
+ ratingsState.reviews = [];
+ ratingsState.mySelectedReview = null;
+ myReviewsState.myReviews = [];
+});
+
+afterEach(() => {
+ cleanup();
+});
+
+describe("App view routing", () => {
+ it("shows the Resources view by default", () => {
+ render( );
+ expect(screen.getByTestId("resources-view")).toBeTruthy();
+ });
+
+ it("switches views via the top nav", () => {
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: /^how it works$/i }));
+ expect(screen.getByTestId("how-it-works")).toBeTruthy();
+
+ fireEvent.click(screen.getByRole("button", { name: /^settings$/i }));
+ expect(screen.getByTestId("settings-view")).toBeTruthy();
+
+ fireEvent.click(screen.getByRole("button", { name: /^my reviews$/i }));
+ expect(screen.getByTestId("my-reviews-view")).toBeTruthy();
+ });
+});
+
+describe("App contract notice", () => {
+ it("renders the no-contract notice when nothing is stored", () => {
+ loadStoredContractId.mockReturnValue("");
+ render( );
+ expect(screen.getByText(/No default contract is bundled yet/)).toBeTruthy();
+ });
+
+ it("hides the notice when a contract id is stored", () => {
+ render( );
+ expect(screen.queryByText(/No default contract is bundled yet/)).toBeNull();
+ });
+});
+
+describe("App sign-in", () => {
+ it("establishes a session on a successful sign in", async () => {
+ setSignInSuccess("owner-1");
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: /^settings$/i }));
+ expect(screen.getByTestId("session-state").textContent).toBe("signed-out");
+
+ fireEvent.click(screen.getByRole("button", { name: /do sign in/i }));
+
+ // setSession ran with the resolved identity — proven by the session the
+ // stub renders, not just by the SDK call returning without error.
+ await waitFor(() => {
+ expect(screen.getByTestId("session-state").textContent).toBe(
+ "signed-in:owner-1",
+ );
+ });
+ expect(identityKeyManagerCreate).toHaveBeenCalled();
+ expect(screen.queryByText(/Sign-in failed/)).toBeNull();
+ });
+
+ it("surfaces a status and stays signed out when no identity resolves", async () => {
+ setSignInSuccess("");
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: /^settings$/i }));
+ fireEvent.click(screen.getByRole("button", { name: /do sign in/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText(/Sign-in failed/)).toBeTruthy();
+ });
+ expect(screen.getByTestId("session-state").textContent).toBe("signed-out");
+ });
+
+ it("clears the session on sign out", async () => {
+ setSignInSuccess("owner-1");
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: /^settings$/i }));
+ fireEvent.click(screen.getByRole("button", { name: /do sign in/i }));
+ await waitFor(() => {
+ expect(screen.getByTestId("session-state").textContent).toBe(
+ "signed-in:owner-1",
+ );
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: /do sign out/i }));
+ expect(screen.getByTestId("session-state").textContent).toBe("signed-out");
+ });
+});
+
+describe("App save-review guards", () => {
+ it("asks the user to sign in before saving without a session", () => {
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: /save review/i }));
+ expect(screen.getByText("Sign in before saving a review.")).toBeTruthy();
+ expect(saveReview).not.toHaveBeenCalled();
+ });
+
+ it("asks for a contract when signed in without one", async () => {
+ loadStoredContractId.mockReturnValue("");
+ render( );
+ await signIn();
+ goToResources();
+ fireEvent.click(screen.getByRole("button", { name: /save review/i }));
+ expect(
+ screen.getByText("Register or paste a DashRate contract ID first."),
+ ).toBeTruthy();
+ expect(saveReview).not.toHaveBeenCalled();
+ });
+
+ it("asks for a star rating when none is chosen", async () => {
+ ratingsState.rating = null;
+ render( );
+ await signIn();
+ goToResources();
+ fireEvent.click(screen.getByRole("button", { name: /save review/i }));
+ expect(
+ screen.getByText("Choose a star rating before saving your review."),
+ ).toBeTruthy();
+ expect(saveReview).not.toHaveBeenCalled();
+ });
+});
+
+describe("App save-review success", () => {
+ it("saves then refreshes ratings, my-reviews, and the review list", async () => {
+ ratingsState.rating = 5;
+ render( );
+ await signIn();
+ goToResources();
+ fireEvent.click(screen.getByRole("button", { name: /save review/i }));
+
+ await waitFor(() => {
+ expect(saveReview).toHaveBeenCalledOnce();
+ });
+ expect(saveReview).toHaveBeenCalledWith(
+ expect.objectContaining({ contractId: "stored-contract-id", rating: 5 }),
+ );
+ // All three post-save refreshes fire.
+ expect(ratingsState.loadResourceData).toHaveBeenCalled();
+ expect(myReviewsState.refreshMyReviews).toHaveBeenCalled();
+ expect(ratingsState.refreshReviews).toHaveBeenCalled();
+ });
+
+ it("surfaces a status when the save fails", async () => {
+ ratingsState.rating = 5;
+ saveReview.mockRejectedValueOnce(new Error("write rejected"));
+ render( );
+ await signIn();
+ goToResources();
+ fireEvent.click(screen.getByRole("button", { name: /save review/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText(/Save failed: write rejected/)).toBeTruthy();
+ });
+ });
+});
+
+describe("App register contract", () => {
+ it("guards against registering without a session", () => {
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: /^settings$/i }));
+ fireEvent.click(
+ screen.getByRole("button", { name: /do register contract/i }),
+ );
+ expect(registerContract).not.toHaveBeenCalled();
+ expect(
+ screen.getByText("Sign in before registering a contract."),
+ ).toBeTruthy();
+ });
+
+ it("registers and switches to the new contract id", async () => {
+ render( );
+ await signIn();
+ fireEvent.click(
+ screen.getByRole("button", { name: /do register contract/i }),
+ );
+
+ await waitFor(() => {
+ expect(registerContract).toHaveBeenCalledOnce();
+ });
+ expect(
+ screen.getByText("Registered new contract: new-contract-id"),
+ ).toBeTruthy();
+ });
+});
+
+describe("App contract submit", () => {
+ it("persists and applies the pasted contract id", () => {
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: /^settings$/i }));
+ fireEvent.click(
+ screen.getByRole("button", { name: /do contract submit/i }),
+ );
+ // contractInput defaults to the stored id; submitting persists it and
+ // resets the my-reviews list.
+ expect(saveContractId).toHaveBeenCalledWith("stored-contract-id");
+ expect(myReviewsState.setMyReviews).toHaveBeenCalledWith([]);
+ });
+});
+
+describe("App load history", () => {
+ it("fetches history for the selected own review when none is shown", async () => {
+ ratingsState.mySelectedReview = {
+ id: "rev-1",
+ ownerId: "owner-1",
+ resourceId: "tokens",
+ rating: 4,
+ reviewText: "",
+ createdAt: 1,
+ updatedAt: 2,
+ revision: 2,
+ };
+ render( );
+ await signIn();
+ goToResources();
+ fireEvent.click(screen.getByRole("button", { name: /load history/i }));
+
+ await waitFor(() => {
+ expect(fetchReviewHistory).toHaveBeenCalledOnce();
+ });
+ expect(fetchReviewHistory).toHaveBeenCalledWith(
+ expect.objectContaining({ reviewId: "rev-1" }),
+ );
+ });
+});
+
+describe("App edit-my-review", () => {
+ it("switches to Resources and seeds the composer from the review", () => {
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: /^my reviews$/i }));
+ fireEvent.click(screen.getByRole("button", { name: /edit my review/i }));
+
+ expect(screen.getByTestId("resources-view")).toBeTruthy();
+ expect(ratingsState.setMySelectedReview).toHaveBeenCalled();
+ expect(ratingsState.setRating).toHaveBeenCalledWith(4);
+ expect(ratingsState.setReviewText).toHaveBeenCalledWith("edit me");
+ });
+});
+
+describe("App contract clearing", () => {
+ it("clears stored contract state", () => {
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: /^settings$/i }));
+ fireEvent.click(screen.getByRole("button", { name: /do clear contract/i }));
+ expect(clearStoredContractId).toHaveBeenCalledOnce();
+ expect(myReviewsState.setMyReviews).toHaveBeenCalledWith([]);
+ expect(ratingsState.setMySelectedReview).toHaveBeenCalledWith(null);
+ });
+});
diff --git a/example-apps/dashrate/test/AppNotices.test.tsx b/example-apps/dashrate/test/AppNotices.test.tsx
new file mode 100644
index 0000000..f1e68e4
--- /dev/null
+++ b/example-apps/dashrate/test/AppNotices.test.tsx
@@ -0,0 +1,41 @@
+// @vitest-environment jsdom
+
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it } from "vitest";
+
+import { AppNotices } from "../src/components/AppNotices";
+
+afterEach(() => {
+ cleanup();
+});
+
+describe("AppNotices", () => {
+ it("renders nothing when there is no status and a contract is set", () => {
+ const { container } = render( );
+ expect(container.querySelector(".status")).toBeNull();
+ expect(container.querySelector(".notice")).toBeNull();
+ });
+
+ it("shows the status text when present", () => {
+ render( );
+ const status = screen.getByText("Save failed: boom");
+ expect(status.className).toBe("status");
+ });
+
+ it("shows the no-contract notice when no contract is configured", () => {
+ const { container } = render( );
+ const notice = container.querySelector(".notice");
+ expect(notice).not.toBeNull();
+ expect(notice?.textContent).toContain("No default contract is bundled yet");
+ });
+
+ it("can show both the status and the no-contract notice", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector(".status")?.textContent).toBe(
+ "Connecting...",
+ );
+ expect(container.querySelector(".notice")).not.toBeNull();
+ });
+});
diff --git a/example-apps/dashrate/test/MyReviewCard.test.tsx b/example-apps/dashrate/test/MyReviewCard.test.tsx
new file mode 100644
index 0000000..a10710e
--- /dev/null
+++ b/example-apps/dashrate/test/MyReviewCard.test.tsx
@@ -0,0 +1,56 @@
+// @vitest-environment jsdom
+
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { MyReviewCard } from "../src/components/MyReviewCard";
+import type { ReviewRecord } from "../src/dash/queries";
+
+afterEach(() => {
+ cleanup();
+});
+
+function makeReview(overrides: Partial = {}): ReviewRecord {
+ return {
+ id: "review-1",
+ ownerId: "owner-1",
+ resourceId: "tokens",
+ rating: 5,
+ reviewText: "Loved it",
+ createdAt: 1_700_000_000_000,
+ updatedAt: 1_700_000_100_000,
+ revision: 1,
+ ...overrides,
+ };
+}
+
+describe("MyReviewCard", () => {
+ it("resolves the catalog title and links to the resource", () => {
+ render( {}} />);
+ // resourceId "tokens" resolves to the "Tokens" catalog entry.
+ expect(screen.getByText("Tokens")).toBeTruthy();
+ expect(screen.getByText("Tutorial")).toBeTruthy();
+ const link = screen.getByRole("link", { name: /open resource/i });
+ expect(link.getAttribute("target")).toBe("_blank");
+ expect(link.getAttribute("rel")).toBe("noopener noreferrer");
+ });
+
+ it("falls back to the raw resourceId and hides the link for unknown resources", () => {
+ render(
+ {}}
+ />,
+ );
+ expect(screen.getByText("mystery-resource")).toBeTruthy();
+ expect(screen.queryByRole("link", { name: /open resource/i })).toBeNull();
+ });
+
+ it("invokes onEdit with the review when Edit is clicked", () => {
+ const onEdit = vi.fn();
+ const review = makeReview();
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: /edit review/i }));
+ expect(onEdit).toHaveBeenCalledWith(review);
+ });
+});
diff --git a/example-apps/dashrate/test/MyReviewsView.test.tsx b/example-apps/dashrate/test/MyReviewsView.test.tsx
new file mode 100644
index 0000000..4fc3dd0
--- /dev/null
+++ b/example-apps/dashrate/test/MyReviewsView.test.tsx
@@ -0,0 +1,110 @@
+// @vitest-environment jsdom
+
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { ReviewRecord } from "../src/dash/queries";
+import type { Session } from "../src/session/types";
+
+const onEditSpy = vi.fn();
+
+// Stub MyReviewCard so the test targets the view shell's branching and can
+// count cards + verify the onEdit wiring without rendering the real card.
+vi.mock("../src/components/MyReviewCard", () => ({
+ MyReviewCard: ({
+ review,
+ onEdit,
+ }: {
+ review: ReviewRecord;
+ onEdit: (review: ReviewRecord) => void;
+ }) => (
+ onEdit(review)}
+ >
+ {review.id}
+
+ ),
+}));
+
+const { MyReviewsView } = await import("../src/components/MyReviewsView");
+
+const session = { identityId: "owner-1" } as unknown as Session;
+
+function makeReview(id: string, rating: number): ReviewRecord {
+ return {
+ id,
+ ownerId: "owner-1",
+ resourceId: "tokens",
+ rating,
+ reviewText: "",
+ createdAt: 1,
+ updatedAt: 2,
+ revision: 1,
+ };
+}
+
+type Props = Parameters[0];
+
+function renderView(overrides: Partial = {}) {
+ const props: Props = {
+ session,
+ dpnsNames: {},
+ myReviews: [],
+ myReviewsLoading: false,
+ myReviewsAverage: null,
+ onEdit: onEditSpy,
+ ...overrides,
+ };
+ return { props, ...render( ) };
+}
+
+beforeEach(() => {
+ onEditSpy.mockReset();
+});
+
+afterEach(() => {
+ cleanup();
+});
+
+describe("MyReviewsView", () => {
+ it("prompts to sign in when there is no session", () => {
+ renderView({ session: null });
+ expect(
+ screen.getByText("Sign in to see reviews written by your identity."),
+ ).toBeTruthy();
+ });
+
+ it("shows a loading message while fetching", () => {
+ renderView({ myReviewsLoading: true });
+ expect(screen.getByText("Loading your reviews...")).toBeTruthy();
+ });
+
+ it("shows the empty message when the identity has no reviews", () => {
+ renderView({ myReviews: [] });
+ expect(screen.getByText("No reviews from this identity yet.")).toBeTruthy();
+ });
+
+ it("renders a card per review with the count and average summary", () => {
+ renderView({
+ myReviews: [makeReview("a", 4), makeReview("b", 2)],
+ myReviewsAverage: 3,
+ });
+ expect(screen.getAllByTestId("my-review-card")).toHaveLength(2);
+ expect(screen.getByText(/2 reviews/)).toBeTruthy();
+ // Average renders via formatAverage (one decimal).
+ expect(screen.getByText("3.0")).toBeTruthy();
+ });
+
+ it("passes the clicked review through onEdit", () => {
+ const first = makeReview("a", 5);
+ const second = makeReview("b", 3);
+ renderView({ myReviews: [first, second] });
+ // Click the second card to prove the right review object is wired
+ // through, not just that some review reaches onEdit.
+ fireEvent.click(screen.getAllByTestId("my-review-card")[1]);
+ expect(onEditSpy).toHaveBeenCalledOnce();
+ expect(onEditSpy).toHaveBeenCalledWith(second);
+ });
+});
diff --git a/example-apps/dashrate/test/RecentReviews.test.tsx b/example-apps/dashrate/test/RecentReviews.test.tsx
new file mode 100644
index 0000000..5521f16
--- /dev/null
+++ b/example-apps/dashrate/test/RecentReviews.test.tsx
@@ -0,0 +1,98 @@
+// @vitest-environment jsdom
+
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { RecentReviews } from "../src/components/RecentReviews";
+import type { ReviewRecord } from "../src/dash/queries";
+
+afterEach(() => {
+ cleanup();
+});
+
+type Props = Parameters[0];
+
+function makeReview(overrides: Partial = {}): ReviewRecord {
+ return {
+ id: "review-1",
+ ownerId: "owner-1",
+ resourceId: "tokens",
+ rating: 4,
+ reviewText: "Nice",
+ createdAt: 1_700_000_000_000,
+ updatedAt: 1_700_000_100_000,
+ revision: 1,
+ ...overrides,
+ };
+}
+
+function renderList(overrides: Partial = {}) {
+ const props: Props = {
+ reviews: [],
+ reviewFilter: null,
+ loadingRatings: false,
+ dpnsNames: {},
+ onClearFilter: vi.fn(),
+ ...overrides,
+ };
+ return { props, ...render( ) };
+}
+
+describe("RecentReviews", () => {
+ it("shows a loading status while ratings load and the list is empty", () => {
+ renderList({ loadingRatings: true });
+ const status = screen.getByRole("status");
+ expect(status.textContent).toContain("Loading reviews");
+ });
+
+ it("shows the generic empty message with no filter", () => {
+ renderList();
+ expect(screen.getByText("No reviews yet.")).toBeTruthy();
+ expect(screen.getByText("Recent reviews")).toBeTruthy();
+ });
+
+ it("shows a filter-scoped heading and empty message when filtered", () => {
+ renderList({ reviewFilter: 3 });
+ expect(screen.getByText("3★ reviews")).toBeTruthy();
+ expect(screen.getByText("No 3★ reviews yet.")).toBeTruthy();
+ });
+
+ it("renders one row per review and labels owners via dpnsNames", () => {
+ const { container } = renderList({
+ reviews: [
+ makeReview({ id: "a" }),
+ makeReview({ id: "b", ownerId: "owner-2" }),
+ ],
+ dpnsNames: { "owner-1": "alice" },
+ });
+ expect(container.querySelectorAll(".review-row")).toHaveLength(2);
+ // owner-1 resolves to a DPNS name; owner-2 falls back to a short id.
+ expect(screen.getByText("alice")).toBeTruthy();
+ });
+
+ it("shows Clear filter only when a filter is set", () => {
+ const onClearFilter = vi.fn();
+ const { rerender } = render(
+ ,
+ );
+ expect(screen.queryByRole("button", { name: /clear filter/i })).toBeNull();
+
+ rerender(
+ ,
+ );
+ fireEvent.click(screen.getByRole("button", { name: /clear filter/i }));
+ expect(onClearFilter).toHaveBeenCalledOnce();
+ });
+});
diff --git a/example-apps/dashrate/test/ResourcesView.test.tsx b/example-apps/dashrate/test/ResourcesView.test.tsx
new file mode 100644
index 0000000..c8bbeed
--- /dev/null
+++ b/example-apps/dashrate/test/ResourcesView.test.tsx
@@ -0,0 +1,176 @@
+// @vitest-environment jsdom
+
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { RESOURCES } from "../src/catalog/resources";
+import type { RatingDistribution, RatingSummary } from "../src/dash/queries";
+
+// Stub the heavy leaf children so the test targets the ResourcesView shell:
+// the resource list, the detail head, and the histogram.
+vi.mock("../src/components/ReviewForm", () => ({
+ ReviewForm: () =>
,
+}));
+vi.mock("../src/components/RecentReviews", () => ({
+ RecentReviews: () =>
,
+}));
+vi.mock("../src/components/StarMeter", () => ({
+ StarMeter: ({ value }: { value: number | null }) => (
+ {String(value)}
+ ),
+}));
+
+const { ResourcesView } = await import("../src/components/ResourcesView");
+
+type Props = Parameters[0];
+
+const tokens = RESOURCES.find((r) => r.id === "tokens")!;
+
+function summary(overrides: Partial = {}): RatingSummary {
+ return {
+ resourceId: "tokens",
+ count: 0n,
+ sum: 0n,
+ average: null,
+ ...overrides,
+ };
+}
+
+function distribution(
+ overrides: Partial = {},
+): RatingDistribution {
+ return { 1: 0n, 2: 0n, 3: 0n, 4: 0n, 5: 0n, ...overrides };
+}
+
+function renderView(overrides: Partial = {}) {
+ const props: Props = {
+ selectedResource: tokens,
+ summaries: {},
+ distributions: {},
+ reviews: [],
+ reviewFilter: null,
+ loadingRatings: false,
+ history: [],
+ signedIn: false,
+ busy: false,
+ contractId: "c1",
+ rating: null,
+ hoverRating: null,
+ reviewText: "",
+ hasSelectedReview: false,
+ dpnsNames: {},
+ onSelectResource: vi.fn(),
+ onReviewFilterChange: vi.fn(),
+ onSaveReview: vi.fn(),
+ onOpenSettings: vi.fn(),
+ onRatingChange: vi.fn(),
+ onHoverRatingChange: vi.fn(),
+ onReviewTextChange: vi.fn(),
+ onLoadHistory: vi.fn(),
+ ...overrides,
+ };
+ return { props, ...render( ) };
+}
+
+beforeEach(() => {
+ // handleSelectResource calls window.matchMedia; jsdom doesn't implement it.
+ // Returning matches:false takes the desktop (no-scroll) branch.
+ vi.stubGlobal(
+ "matchMedia",
+ vi.fn().mockReturnValue({ matches: false } as MediaQueryList),
+ );
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ cleanup();
+});
+
+describe("ResourcesView resource list", () => {
+ it("renders one card per catalog resource and marks the selected one", () => {
+ const { container } = renderView();
+ expect(container.querySelectorAll(".resource-card")).toHaveLength(
+ RESOURCES.length,
+ );
+ const selected = container.querySelectorAll(".resource-card.selected");
+ expect(selected).toHaveLength(1);
+ expect(selected[0].textContent).toContain(tokens.title);
+ });
+
+ it("calls onSelectResource when a card is clicked", () => {
+ const onSelectResource = vi.fn();
+ const { container } = renderView({ onSelectResource });
+ const cards =
+ container.querySelectorAll(".resource-card");
+ fireEvent.click(cards[0]);
+ expect(onSelectResource).toHaveBeenCalledWith(RESOURCES[0].id);
+ });
+});
+
+describe("ResourcesView detail head", () => {
+ it("shows the resource metadata and an em dash when there is no average", () => {
+ renderView();
+ expect(screen.getByRole("heading", { name: tokens.title })).toBeTruthy();
+ expect(screen.getByText(tokens.summary)).toBeTruthy();
+ expect(screen.getByText("—")).toBeTruthy();
+ expect(screen.getAllByText("No reviews yet").length).toBeGreaterThan(0);
+ });
+
+ it("formats the average when reviews exist", () => {
+ renderView({
+ summaries: { tokens: summary({ count: 3n, sum: 12n, average: 4 }) },
+ distributions: { tokens: distribution({ 4: 2n, 5: 1n }) },
+ });
+ expect(screen.getByText("4.0")).toBeTruthy();
+ });
+});
+
+describe("ResourcesView histogram", () => {
+ it("is hidden when the selected resource has no reviews", () => {
+ const { container } = renderView();
+ expect(container.querySelector(".rating-histogram")).toBeNull();
+ });
+
+ it("renders five rows with aria-pressed reflecting the active filter", () => {
+ const { container } = renderView({
+ summaries: { tokens: summary({ count: 3n, average: 4.3 }) },
+ distributions: { tokens: distribution({ 4: 2n, 5: 1n }) },
+ reviewFilter: 5,
+ });
+ const rows = container.querySelectorAll(".histogram-row");
+ expect(rows).toHaveLength(5);
+ // RATING_ROWS render 5 first; the 5★ row is pressed when reviewFilter === 5.
+ expect(rows[0].getAttribute("aria-pressed")).toBe("true");
+ expect(rows[1].getAttribute("aria-pressed")).toBe("false");
+ });
+
+ it("selects a rating when clicking an inactive histogram row", () => {
+ const onReviewFilterChange = vi.fn();
+ const { container } = renderView({
+ summaries: { tokens: summary({ count: 3n, average: 4.3 }) },
+ distributions: { tokens: distribution({ 4: 2n, 5: 1n }) },
+ reviewFilter: null,
+ onReviewFilterChange,
+ });
+ const rows =
+ container.querySelectorAll(".histogram-row");
+ // Clicking the 5★ row (first) with no active filter selects 5.
+ fireEvent.click(rows[0]);
+ expect(onReviewFilterChange).toHaveBeenCalledWith(5);
+ });
+
+ it("clears the filter when clicking the already-active histogram row", () => {
+ const onReviewFilterChange = vi.fn();
+ const { container } = renderView({
+ summaries: { tokens: summary({ count: 3n, average: 4.3 }) },
+ distributions: { tokens: distribution({ 4: 2n, 5: 1n }) },
+ reviewFilter: 5,
+ onReviewFilterChange,
+ });
+ const rows =
+ container.querySelectorAll(".histogram-row");
+ // The 5★ row (first) is active; clicking it again clears the filter.
+ fireEvent.click(rows[0]);
+ expect(onReviewFilterChange).toHaveBeenCalledWith(null);
+ });
+});
diff --git a/example-apps/dashrate/test/ReviewForm.test.tsx b/example-apps/dashrate/test/ReviewForm.test.tsx
new file mode 100644
index 0000000..340f7b4
--- /dev/null
+++ b/example-apps/dashrate/test/ReviewForm.test.tsx
@@ -0,0 +1,142 @@
+// @vitest-environment jsdom
+
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import type { FormEvent } from "react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { ReviewForm } from "../src/components/ReviewForm";
+import type { ReviewHistoryEntry } from "../src/dash/history";
+
+afterEach(() => {
+ cleanup();
+});
+
+type Props = Parameters[0];
+
+function renderForm(overrides: Partial = {}) {
+ const props: Props = {
+ signedIn: true,
+ busy: false,
+ contractId: "c1",
+ rating: null,
+ hoverRating: null,
+ reviewText: "",
+ hasSelectedReview: false,
+ history: [],
+ onSubmit: vi.fn(),
+ onOpenSettings: vi.fn(),
+ onRatingChange: vi.fn(),
+ onHoverRatingChange: vi.fn(),
+ onReviewTextChange: vi.fn(),
+ onLoadHistory: vi.fn(),
+ ...overrides,
+ };
+ return { props, ...render( ) };
+}
+
+describe("ReviewForm (signed out)", () => {
+ it("shows the sign-in CTA and no rating picker", () => {
+ const onOpenSettings = vi.fn();
+ renderForm({ signedIn: false, onOpenSettings });
+ expect(screen.getByText("Sign in to review this resource")).toBeTruthy();
+ expect(screen.queryByRole("radiogroup")).toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: /^sign in$/i }));
+ expect(onOpenSettings).toHaveBeenCalledOnce();
+ });
+});
+
+describe("ReviewForm (signed in)", () => {
+ it("renders five rating radios and reports clicks", () => {
+ const onRatingChange = vi.fn();
+ renderForm({ onRatingChange });
+ const radios = screen.getAllByRole("radio");
+ expect(radios).toHaveLength(5);
+ fireEvent.click(radios[3]);
+ expect(onRatingChange).toHaveBeenCalledWith(4);
+ });
+
+ it("reflects the selected rating via aria-checked", () => {
+ renderForm({ rating: 3 });
+ const radios = screen.getAllByRole("radio");
+ expect(radios[2].getAttribute("aria-checked")).toBe("true");
+ expect(radios[0].getAttribute("aria-checked")).toBe("false");
+ });
+
+ it("reports hover on enter/focus and clears on leave/blur", () => {
+ const onHoverRatingChange = vi.fn();
+ renderForm({ onHoverRatingChange });
+ const radios = screen.getAllByRole("radio");
+ fireEvent.mouseEnter(radios[1]);
+ expect(onHoverRatingChange).toHaveBeenCalledWith(2);
+ fireEvent.blur(radios[1]);
+ expect(onHoverRatingChange).toHaveBeenCalledWith(null);
+ });
+
+ it("forwards textarea input and caps its length", () => {
+ const onReviewTextChange = vi.fn();
+ renderForm({ onReviewTextChange });
+ const textarea = screen.getByPlaceholderText(/share what worked/i);
+ expect(textarea.getAttribute("maxLength")).toBe("1000");
+ fireEvent.change(textarea, { target: { value: "neat" } });
+ expect(onReviewTextChange).toHaveBeenCalledWith("neat");
+ });
+
+ it("disables Save until a contract and rating are present", () => {
+ const noRating = renderForm({ rating: null });
+ expect(
+ (noRating.getByText("Save review") as HTMLButtonElement).disabled,
+ ).toBe(true);
+ cleanup();
+ const noContract = renderForm({ rating: 4, contractId: "" });
+ expect(
+ (noContract.getByText("Save review") as HTMLButtonElement).disabled,
+ ).toBe(true);
+ cleanup();
+ const ready = renderForm({ rating: 4, contractId: "c1" });
+ expect((ready.getByText("Save review") as HTMLButtonElement).disabled).toBe(
+ false,
+ );
+ });
+
+ it("submits the form", () => {
+ const onSubmit = vi.fn((event: FormEvent) => event.preventDefault());
+ const { container } = renderForm({ rating: 4, onSubmit });
+ const form = container.querySelector("form");
+ if (!form) throw new Error("form not found");
+ fireEvent.submit(form);
+ expect(onSubmit).toHaveBeenCalledOnce();
+ });
+
+ it("shows the closed history control and calls onLoadHistory when clicked", () => {
+ const onLoadHistory = vi.fn();
+ renderForm({ hasSelectedReview: true, onLoadHistory });
+
+ const toggle = screen.getByRole("button", {
+ name: /show previous versions/i,
+ });
+ expect(toggle.getAttribute("aria-expanded")).toBe("false");
+ // No history is rendered while closed.
+ expect(screen.queryByText(/^Revision/)).toBeNull();
+
+ fireEvent.click(toggle);
+ expect(onLoadHistory).toHaveBeenCalledOnce();
+ });
+
+ it("renders the open state with the history list for a non-empty history", () => {
+ const history: ReviewHistoryEntry[] = [
+ {
+ blockTimeMs: 1_700_000_000_000,
+ revision: 1,
+ rating: 5,
+ reviewText: "v1",
+ },
+ ];
+ renderForm({ hasSelectedReview: true, history });
+
+ const toggle = screen.getByRole("button", {
+ name: /hide previous versions/i,
+ });
+ expect(toggle.getAttribute("aria-expanded")).toBe("true");
+ expect(screen.getByText("Revision 1: 5 stars")).toBeTruthy();
+ });
+});
diff --git a/example-apps/dashrate/test/ReviewHistory.test.tsx b/example-apps/dashrate/test/ReviewHistory.test.tsx
new file mode 100644
index 0000000..7e922c9
--- /dev/null
+++ b/example-apps/dashrate/test/ReviewHistory.test.tsx
@@ -0,0 +1,38 @@
+// @vitest-environment jsdom
+
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it } from "vitest";
+
+import { ReviewHistory } from "../src/components/ReviewHistory";
+import type { ReviewHistoryEntry } from "../src/dash/history";
+
+afterEach(() => {
+ cleanup();
+});
+
+const entries: ReviewHistoryEntry[] = [
+ {
+ blockTimeMs: 1_700_000_100_000,
+ revision: 2,
+ rating: 4,
+ reviewText: "Better now",
+ },
+ { blockTimeMs: 1_700_000_000_000, revision: 1, rating: 5, reviewText: "" },
+];
+
+describe("ReviewHistory", () => {
+ it("renders nothing for an empty history", () => {
+ const { container } = render( );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("renders one row per revision with rating and a text fallback", () => {
+ const { container } = render( );
+ expect(container.querySelectorAll(".review-row")).toHaveLength(2);
+ expect(screen.getByText("Revision 2: 4 stars")).toBeTruthy();
+ expect(screen.getByText("Revision 1: 5 stars")).toBeTruthy();
+ expect(screen.getByText("Better now")).toBeTruthy();
+ // Empty reviewText falls back to the placeholder.
+ expect(screen.getByText("No review text.")).toBeTruthy();
+ });
+});
diff --git a/example-apps/dashrate/test/ReviewRow.test.tsx b/example-apps/dashrate/test/ReviewRow.test.tsx
new file mode 100644
index 0000000..ec31a99
--- /dev/null
+++ b/example-apps/dashrate/test/ReviewRow.test.tsx
@@ -0,0 +1,45 @@
+// @vitest-environment jsdom
+
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it } from "vitest";
+
+import { ReviewRow } from "../src/components/ReviewRow";
+import type { ReviewRecord } from "../src/dash/queries";
+
+afterEach(() => {
+ cleanup();
+});
+
+function makeReview(overrides: Partial = {}): ReviewRecord {
+ return {
+ id: "review-1",
+ ownerId: "owner-1",
+ resourceId: "tokens",
+ rating: 4,
+ reviewText: "Solid walkthrough",
+ createdAt: 1_700_000_000_000,
+ updatedAt: 1_700_000_100_000,
+ revision: 1,
+ ...overrides,
+ };
+}
+
+describe("ReviewRow", () => {
+ it("shows the owner name, star string, and review text", () => {
+ const { container } = render(
+ ,
+ );
+ expect(screen.getByText("alice")).toBeTruthy();
+ expect(screen.getByText("Solid walkthrough")).toBeTruthy();
+ // rating 4 → four filled, one empty star.
+ expect(screen.getByText("★★★★☆")).toBeTruthy();
+ expect(container.querySelector("time")).not.toBeNull();
+ });
+
+ it("falls back to a placeholder when there is no written review", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("No written review.")).toBeTruthy();
+ });
+});
diff --git a/example-apps/dashrate/test/SettingsView.test.tsx b/example-apps/dashrate/test/SettingsView.test.tsx
new file mode 100644
index 0000000..3681589
--- /dev/null
+++ b/example-apps/dashrate/test/SettingsView.test.tsx
@@ -0,0 +1,144 @@
+// @vitest-environment jsdom
+
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { SettingsView } from "../src/components/SettingsView";
+import type { Session } from "../src/session/types";
+
+afterEach(() => {
+ cleanup();
+});
+
+type Props = Parameters[0];
+
+const longId = "ABCDEFGHIJKLMNOPQRSTUVWXYZ123456789";
+const session = { identityId: longId } as unknown as Session;
+
+function makeProps(overrides: Partial = {}): Props {
+ return {
+ session: null,
+ dpnsNames: {},
+ busy: false,
+ mnemonic: "",
+ identityIndex: "0",
+ showAdvanced: false,
+ contractId: "",
+ contractInput: "",
+ onMnemonicChange: vi.fn(),
+ onIdentityIndexChange: vi.fn(),
+ onShowAdvancedChange: vi.fn(),
+ onContractInputChange: vi.fn(),
+ onSignIn: vi.fn((event) => event.preventDefault()),
+ onSignOut: vi.fn(),
+ onContractSubmit: vi.fn((event) => event.preventDefault()),
+ onClearContract: vi.fn(),
+ onRegisterContract: vi.fn(),
+ ...overrides,
+ };
+}
+
+function renderView(overrides: Partial = {}) {
+ const props = makeProps(overrides);
+ return { props, ...render( ) };
+}
+
+describe("SettingsView (signed out)", () => {
+ it("disables Sign in until a mnemonic is present and forwards input", () => {
+ const onMnemonicChange = vi.fn();
+ const { rerender, getByRole } = renderView({ onMnemonicChange });
+ expect(
+ (getByRole("button", { name: /^sign in$/i }) as HTMLButtonElement)
+ .disabled,
+ ).toBe(true);
+
+ const textarea = screen.getByRole("textbox");
+ fireEvent.change(textarea, { target: { value: "alpha bravo" } });
+ expect(onMnemonicChange).toHaveBeenCalledWith("alpha bravo");
+
+ rerender( );
+ expect(
+ (getByRole("button", { name: /^sign in$/i }) as HTMLButtonElement)
+ .disabled,
+ ).toBe(false);
+ });
+
+ it("shows the Dash bridge link with safe rel attributes", () => {
+ renderView();
+ const link = screen.getByRole("link", { name: /dash bridge/i });
+ expect(link.getAttribute("rel")).toBe("noopener noreferrer");
+ });
+
+ it("submits sign-in on Enter without Shift", () => {
+ const onSignIn = vi.fn((event) => event.preventDefault());
+ renderView({ mnemonic: "alpha bravo", onSignIn });
+ fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" });
+ expect(onSignIn).toHaveBeenCalledOnce();
+ });
+});
+
+describe("SettingsView (signed in)", () => {
+ it("renders the short identity id and a working sign-out button", () => {
+ const onSignOut = vi.fn();
+ renderView({ session, onSignOut });
+ // shortId truncates the 35-char id.
+ expect(screen.getByText("ABCDEFG...456789")).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: /sign out/i }));
+ expect(onSignOut).toHaveBeenCalledOnce();
+ });
+
+ it("prefers the DPNS name when one is known", () => {
+ renderView({ session, dpnsNames: { [longId]: "alice" } });
+ expect(screen.getByText("alice")).toBeTruthy();
+ });
+});
+
+describe("SettingsView advanced section", () => {
+ it("toggles the advanced section open", () => {
+ const onShowAdvancedChange = vi.fn();
+ renderView({ onShowAdvancedChange });
+ const toggle = screen.getByRole("button", { name: /advanced settings/i });
+ expect(toggle.getAttribute("aria-expanded")).toBe("false");
+ fireEvent.click(toggle);
+ expect(onShowAdvancedChange).toHaveBeenCalledWith(true);
+ });
+
+ it("reveals the identity-index input and contract form when expanded and signed out", () => {
+ const onClearContract = vi.fn();
+ renderView({
+ showAdvanced: true,
+ contractId: "c1",
+ contractInput: "c1",
+ onClearContract,
+ });
+ expect(screen.getByRole("spinbutton")).toBeTruthy(); // identity index input
+ expect(screen.getByText("Current:")).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: /^clear$/i }));
+ expect(onClearContract).toHaveBeenCalledOnce();
+ // Register new is disabled without a session.
+ expect(
+ (
+ screen.getByRole("button", {
+ name: /register new/i,
+ }) as HTMLButtonElement
+ ).disabled,
+ ).toBe(true);
+ });
+
+ it("hides the identity-index input when signed in", () => {
+ renderView({
+ session,
+ showAdvanced: true,
+ contractId: "c1",
+ contractInput: "c1",
+ });
+ expect(screen.queryByRole("spinbutton")).toBeNull();
+ expect(
+ (
+ screen.getByRole("button", {
+ name: /register new/i,
+ }) as HTMLButtonElement
+ ).disabled,
+ ).toBe(false);
+ });
+});
diff --git a/example-apps/dashrate/test/StarMeter.test.tsx b/example-apps/dashrate/test/StarMeter.test.tsx
new file mode 100644
index 0000000..595069a
--- /dev/null
+++ b/example-apps/dashrate/test/StarMeter.test.tsx
@@ -0,0 +1,55 @@
+// @vitest-environment jsdom
+
+import { cleanup, render } from "@testing-library/react";
+import { afterEach, describe, expect, it } from "vitest";
+
+import { StarMeter } from "../src/components/StarMeter";
+
+afterEach(() => {
+ cleanup();
+});
+
+function fill(container: HTMLElement): HTMLElement {
+ const el = container.querySelector(".star-meter-fill");
+ if (!el) throw new Error("star-meter-fill not found");
+ return el;
+}
+
+describe("StarMeter", () => {
+ it("renders the empty state for a null value", () => {
+ const { container } = render( );
+ const meter = container.querySelector(".star-meter");
+ expect(meter?.getAttribute("aria-label")).toBe("No rating yet");
+ expect(meter?.getAttribute("role")).toBe("img");
+ expect(fill(container).style.width).toBe("0%");
+ });
+
+ it("fills proportionally and labels the average for a whole value", () => {
+ const { container } = render( );
+ expect(fill(container).style.width).toBe("60%");
+ expect(
+ container.querySelector(".star-meter")?.getAttribute("aria-label"),
+ ).toBe("3.0 out of 5");
+ });
+
+ it("supports partial fills", () => {
+ const { container } = render( );
+ expect(fill(container).style.width).toBe("90%");
+ });
+
+ it("clamps values above 5 and below 0", () => {
+ const high = render( );
+ expect(fill(high.container).style.width).toBe("100%");
+ cleanup();
+ const low = render( );
+ expect(fill(low.container).style.width).toBe("0%");
+ });
+
+ it("appends an extra className to the base class", () => {
+ const { container } = render(
+ ,
+ );
+ const meter = container.querySelector(".star-meter");
+ expect(meter?.className).toBe("star-meter mini-stars");
+ });
+});
diff --git a/example-apps/dashrate/test/TopNav.test.tsx b/example-apps/dashrate/test/TopNav.test.tsx
new file mode 100644
index 0000000..d3dffc0
--- /dev/null
+++ b/example-apps/dashrate/test/TopNav.test.tsx
@@ -0,0 +1,54 @@
+// @vitest-environment jsdom
+
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { TopNav, type View } from "../src/components/TopNav";
+
+afterEach(() => {
+ cleanup();
+});
+
+const NAV: { label: RegExp; view: View }[] = [
+ { label: /^resources$/i, view: "resources" },
+ { label: /^my reviews$/i, view: "my-reviews" },
+ { label: /^settings$/i, view: "settings" },
+ { label: /^how it works$/i, view: "how" },
+];
+
+describe("TopNav", () => {
+ it("renders the brand heading and all four nav buttons", () => {
+ render( {}} />);
+ expect(screen.getByRole("heading", { level: 1 }).textContent).toBe(
+ "DashRate",
+ );
+ for (const { label } of NAV) {
+ // getByRole throws if the named button is missing; assert it's a real,
+ // enabled rather than just truthy.
+ const button = screen.getByRole("button", { name: label });
+ expect(button.tagName).toBe("BUTTON");
+ expect((button as HTMLButtonElement).disabled).toBe(false);
+ }
+ });
+
+ it("marks only the active view with aria-current and the active class", () => {
+ render( {}} />);
+ const settings = screen.getByRole("button", { name: /^settings$/i });
+ expect(settings.getAttribute("aria-current")).toBe("page");
+ expect(settings.className).toBe("active");
+
+ const resources = screen.getByRole("button", { name: /^resources$/i });
+ expect(resources.getAttribute("aria-current")).toBeNull();
+ expect(resources.className).toBe("");
+ });
+
+ it("calls onViewChange with the matching view for each button", () => {
+ const onViewChange = vi.fn();
+ render( );
+ for (const { label, view } of NAV) {
+ fireEvent.click(screen.getByRole("button", { name: label }));
+ expect(onViewChange).toHaveBeenCalledWith(view);
+ }
+ expect(onViewChange).toHaveBeenCalledTimes(NAV.length);
+ });
+});
diff --git a/example-apps/dashrate/test/contract.test.ts b/example-apps/dashrate/test/contract.test.ts
new file mode 100644
index 0000000..0ae266d
--- /dev/null
+++ b/example-apps/dashrate/test/contract.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from "vitest";
+import { REVIEW_SCHEMAS } from "../src/dash/contract";
+
+describe("DashRate review contract schema", () => {
+ it("keeps review documents mutable with history enabled", () => {
+ expect(REVIEW_SCHEMAS.review.documentsMutable).toBe(true);
+ expect(REVIEW_SCHEMAS.review.documentsKeepHistory).toBe(true);
+ expect(REVIEW_SCHEMAS.review.canBeDeleted).toBe(false);
+ });
+
+ it("defines rating fields and aggregate-friendly indices", () => {
+ expect(REVIEW_SCHEMAS.review.properties.resourceId.type).toBe("string");
+ expect(REVIEW_SCHEMAS.review.properties.resourceId.maxLength).toBe(63);
+ expect(REVIEW_SCHEMAS.review.properties.rating.minimum).toBe(1);
+ expect(REVIEW_SCHEMAS.review.properties.rating.maximum).toBe(5);
+
+ const indexNames = REVIEW_SCHEMAS.review.indices.map((index) => index.name);
+ expect(indexNames).toContain("ownerAndResource");
+ expect(indexNames).toContain("ownerReviews");
+ expect(indexNames).toContain("resourceRatingAggregate");
+ expect(indexNames).toContain("resourceRatingDistribution");
+ const aggregate = REVIEW_SCHEMAS.review.indices.find(
+ (index) => index.name === "resourceRatingAggregate",
+ );
+ expect(aggregate).toMatchObject({
+ properties: [{ resourceId: "asc" }],
+ countable: "countable",
+ });
+ // Must stay count-only: a `summable` here makes the resourceId value
+ // tree count+sum, which conflicts with the count-only rating
+ // continuation of resourceRatingDistribution and breaks all inserts.
+ expect(aggregate).not.toHaveProperty("summable");
+ });
+
+ it("indexes [resourceId, rating] count-only for grouped distribution", () => {
+ // Backs the grouped `count` GROUP BY rating (distribution) and the
+ // `rating == N` filter. Count-only (no summable) so it shares the
+ // resourceId prefix with resourceRatingAggregate without conflict.
+ const distribution = REVIEW_SCHEMAS.review.indices.find(
+ (index) => index.name === "resourceRatingDistribution",
+ );
+ expect(distribution).toMatchObject({
+ properties: [{ resourceId: "asc" }, { rating: "asc" }],
+ countable: "countable",
+ rangeCountable: true,
+ });
+ expect(distribution).not.toHaveProperty("summable");
+ });
+});
diff --git a/example-apps/dashrate/test/e2e/fixtures.ts b/example-apps/dashrate/test/e2e/fixtures.ts
new file mode 100644
index 0000000..418a3f9
--- /dev/null
+++ b/example-apps/dashrate/test/e2e/fixtures.ts
@@ -0,0 +1,50 @@
+/**
+ * Shared Playwright fixtures for dashrate E2E tests. Runs against real
+ * testnet — no SDK mocks.
+ *
+ * The base `page` fixture navigates to `/` and waits for the resource list to
+ * render. The shell is interactive before the ~8 MB SDK loads, so the wait
+ * anchors on the first resource card (rendered immediately from the static
+ * catalog) rather than on network-dependent text.
+ */
+import { test as base, expect, type Page } from "@playwright/test";
+
+interface AppFixture {
+ page: Page;
+}
+
+export const test = base.extend({
+ page: async ({ page }, provide) => {
+ await page.goto("/");
+ await expect(
+ page.getByRole("heading", { name: "DashRate", level: 1 }),
+ ).toBeVisible({ timeout: 60_000 });
+ await expect(
+ page.locator('aside[aria-label="Tutorial resources"] button').first(),
+ ).toBeVisible({ timeout: 60_000 });
+ await provide(page);
+ },
+});
+
+export { expect, type Page };
+
+/** Click a top-nav button by its label. */
+export async function navTo(
+ page: Page,
+ label: "Resources" | "My reviews" | "Settings" | "How it works",
+) {
+ await page
+ .locator('nav[aria-label="Primary navigation"]')
+ .getByRole("button", { name: label, exact: true })
+ .first()
+ .click();
+}
+
+/** Select a resource card by its visible title. */
+export async function selectResource(page: Page, title: string) {
+ await page
+ .locator('aside[aria-label="Tutorial resources"] button')
+ .filter({ hasText: title })
+ .first()
+ .click();
+}
diff --git a/example-apps/dashrate/test/e2e/settings.spec.ts b/example-apps/dashrate/test/e2e/settings.spec.ts
new file mode 100644
index 0000000..b0ff502
--- /dev/null
+++ b/example-apps/dashrate/test/e2e/settings.spec.ts
@@ -0,0 +1,42 @@
+import { test, expect, navTo } from "./fixtures";
+
+// Settings checks: the sign-in form renders and gates correctly. These do not
+// sign in — they assert form rendering and the Advanced disclosure only.
+
+test.describe("settings", () => {
+ test("renders the login form with a gated Sign in button", async ({
+ page,
+ }) => {
+ await navTo(page, "Settings");
+
+ await expect(
+ page.getByRole("heading", { name: /^settings$/i }),
+ ).toBeVisible();
+
+ const mnemonic = page.getByRole("textbox");
+ await expect(mnemonic).toBeVisible();
+
+ const signIn = page.getByRole("button", { name: /^sign in$/i });
+ await expect(signIn).toBeDisabled();
+ await mnemonic.fill("alpha bravo charlie");
+ await expect(signIn).toBeEnabled();
+ });
+
+ test("advanced section reveals the identity index and contract form", async ({
+ page,
+ }) => {
+ await navTo(page, "Settings");
+
+ const advanced = page.getByRole("button", { name: /advanced settings/i });
+ await expect(advanced).toHaveAttribute("aria-expanded", "false");
+ await advanced.click();
+ await expect(advanced).toHaveAttribute("aria-expanded", "true");
+
+ // Signed out: the identity-index input and the contract sub-form appear.
+ await expect(page.locator('input[type="number"]')).toBeVisible();
+ await expect(page.getByText(/^Current:/)).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: /use contract/i }),
+ ).toBeVisible();
+ });
+});
diff --git a/example-apps/dashrate/test/e2e/smoke.spec.ts b/example-apps/dashrate/test/e2e/smoke.spec.ts
new file mode 100644
index 0000000..18d36cb
--- /dev/null
+++ b/example-apps/dashrate/test/e2e/smoke.spec.ts
@@ -0,0 +1,77 @@
+import { test, expect, navTo, selectResource } from "./fixtures";
+
+// Shell smoke tests: assert navigation and static rendering of the read-only
+// UI. They do NOT assert on live rating data (counts/distributions vary with
+// the network); query correctness is covered by the src/dash/queries.ts unit
+// tests. Runs under both chromium-desktop and chromium-mobile projects.
+
+test.describe("boot", () => {
+ test("page title is DashRate", async ({ page }) => {
+ await expect(page).toHaveTitle(/DashRate/i);
+ });
+
+ test("brand and primary navigation render", async ({ page }) => {
+ await expect(
+ page.getByRole("heading", { name: "DashRate", level: 1 }),
+ ).toBeVisible();
+ const nav = page.locator('nav[aria-label="Primary navigation"]');
+ for (const label of [
+ "Resources",
+ "My reviews",
+ "Settings",
+ "How it works",
+ ]) {
+ await expect(
+ nav.getByRole("button", { name: label, exact: true }),
+ ).toBeVisible();
+ }
+ });
+});
+
+test.describe("tab navigation", () => {
+ test("switches between Resources and How it works", async ({ page }) => {
+ await navTo(page, "How it works");
+ await expect(
+ page.getByRole("heading", { name: /how it works/i }),
+ ).toBeVisible();
+ // The How-it-works copy documents the SDK query surface.
+ await expect(page.getByText("documents.count").first()).toBeVisible();
+
+ await navTo(page, "Resources");
+ await expect(
+ page.locator('aside[aria-label="Tutorial resources"]'),
+ ).toBeVisible();
+ });
+});
+
+test.describe("browse a resource", () => {
+ test("selecting a resource renders its detail head and the rating-stats block", async ({
+ page,
+ }) => {
+ await selectResource(page, "Tokens");
+
+ // The detail head shows the selected resource title.
+ await expect(
+ page.getByRole("heading", { name: "Tokens", level: 2 }),
+ ).toBeVisible();
+
+ // Aggregate rating block renders (score is "—" or a number depending on
+ // live testnet data) with a StarMeter.
+ const stats = page.locator('[aria-label="Aggregate rating stats"]');
+ await expect(stats).toBeVisible();
+ await expect(stats.getByRole("img")).toBeVisible();
+
+ // The review section header is always present; the count varies with
+ // live data, so assert the section, not a specific number.
+ await expect(
+ page.getByRole("heading", { name: /recent reviews/i }),
+ ).toBeVisible();
+ });
+
+ test("the review form prompts sign-in when signed out", async ({ page }) => {
+ await selectResource(page, "Tokens");
+ await expect(
+ page.getByText("Sign in to review this resource"),
+ ).toBeVisible();
+ });
+});
diff --git a/example-apps/dashrate/test/format.test.ts b/example-apps/dashrate/test/format.test.ts
new file mode 100644
index 0000000..67e6466
--- /dev/null
+++ b/example-apps/dashrate/test/format.test.ts
@@ -0,0 +1,62 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { formatAverage, formatDate, shortId } from "../src/lib/format";
+
+describe("formatAverage", () => {
+ it("renders a one-decimal average", () => {
+ expect(formatAverage(4)).toBe("4.0");
+ expect(formatAverage(4.25)).toBe("4.3"); // toFixed rounds half up
+ expect(formatAverage(3.333333)).toBe("3.3");
+ });
+
+ it("renders a dash for null or non-finite values", () => {
+ expect(formatAverage(null)).toBe("-");
+ expect(formatAverage(Number.NaN)).toBe("-");
+ expect(formatAverage(Number.POSITIVE_INFINITY)).toBe("-");
+ });
+});
+
+describe("shortId", () => {
+ it("returns short ids unchanged at or below the 14-char threshold", () => {
+ expect(shortId("")).toBe("");
+ expect(shortId("abc")).toBe("abc");
+ // exactly 14 chars — the boundary, still returned whole
+ expect(shortId("12345678901234")).toBe("12345678901234");
+ });
+
+ it("truncates longer ids to 7 + ... + last 6", () => {
+ // 15 chars — just over the threshold
+ expect(shortId("123456789012345")).toBe("1234567...012345");
+ expect(shortId("63LaKWuFq9p8x2y4z6wcDgLY")).toBe("63LaKWu...wcDgLY");
+ });
+});
+
+describe("formatDate", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("labels missing timestamps without formatting", () => {
+ expect(formatDate(null)).toBe("Unknown time");
+ expect(formatDate(0)).toBe("Unknown time");
+ });
+
+ it("formats a real timestamp via Intl with medium date / short time", () => {
+ // formatDate hardcodes locale `undefined` + no timezone, so the rendered
+ // string is environment-dependent. Stub Intl.DateTimeFormat to verify the
+ // contract deterministically: the right options are passed, the Date is
+ // built from the ms value, and the formatter's output is returned verbatim.
+ const format = vi.fn().mockReturnValue("Nov 14, 2023, 10:13 PM");
+ const ctor = vi
+ .spyOn(Intl, "DateTimeFormat")
+ .mockImplementation(function mockFormatter() {
+ return { format } as unknown as Intl.DateTimeFormat;
+ } as unknown as typeof Intl.DateTimeFormat);
+
+ expect(formatDate(1_700_000_000_000)).toBe("Nov 14, 2023, 10:13 PM");
+ expect(ctor).toHaveBeenCalledWith(undefined, {
+ dateStyle: "medium",
+ timeStyle: "short",
+ });
+ expect(format).toHaveBeenCalledWith(new Date(1_700_000_000_000));
+ });
+});
diff --git a/example-apps/dashrate/test/history.test.ts b/example-apps/dashrate/test/history.test.ts
new file mode 100644
index 0000000..73907d7
--- /dev/null
+++ b/example-apps/dashrate/test/history.test.ts
@@ -0,0 +1,113 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ REVIEW_HISTORY_LIMIT,
+ fetchReviewHistory,
+ normalizeHistory,
+} from "../src/dash/history";
+import type { DashSdk } from "../src/dash/types";
+
+describe("DashRate history normalization", () => {
+ it("sorts timestamp-keyed review history newest first", () => {
+ const history = normalizeHistory(
+ new Map([
+ [
+ 1000n,
+ {
+ revision: 1,
+ toJSON: () => ({ rating: 3, reviewText: "Initial" }),
+ },
+ ],
+ [
+ 2000n,
+ {
+ revision: 2,
+ toJSON: () => ({ rating: 5, reviewText: "Updated" }),
+ },
+ ],
+ ]),
+ );
+
+ expect(history).toEqual([
+ {
+ blockTimeMs: 2000,
+ revision: 2,
+ rating: 5,
+ reviewText: "Updated",
+ },
+ {
+ blockTimeMs: 1000,
+ revision: 1,
+ rating: 3,
+ reviewText: "Initial",
+ },
+ ]);
+ });
+});
+
+describe("fetchReviewHistory limit clamp", () => {
+ function sdkCapturingLimit() {
+ const historyMock = vi.fn().mockResolvedValue(new Map());
+ const sdk = { documents: { history: historyMock } } as unknown as DashSdk;
+ return { sdk, historyMock };
+ }
+
+ it("clamps an oversized limit down to REVIEW_HISTORY_LIMIT", async () => {
+ const { sdk, historyMock } = sdkCapturingLimit();
+ await fetchReviewHistory({
+ sdk,
+ contractId: "c1",
+ reviewId: "doc-1",
+ limit: 999,
+ });
+ expect(historyMock.mock.calls[0][0].limit).toBe(REVIEW_HISTORY_LIMIT);
+ });
+
+ it.each([0, -1, -100])(
+ "clamps a non-positive limit %p up to 1",
+ async (limit) => {
+ const { sdk, historyMock } = sdkCapturingLimit();
+ await fetchReviewHistory({
+ sdk,
+ contractId: "c1",
+ reviewId: "doc-1",
+ limit,
+ });
+ expect(historyMock.mock.calls[0][0].limit).toBe(1);
+ },
+ );
+
+ it("passes an in-range limit through unchanged", async () => {
+ const { sdk, historyMock } = sdkCapturingLimit();
+ await fetchReviewHistory({
+ sdk,
+ contractId: "c1",
+ reviewId: "doc-1",
+ limit: 5,
+ });
+ expect(historyMock.mock.calls[0][0].limit).toBe(5);
+ });
+
+ it("forwards the full history query shape (contract, type, id, startAtMs)", async () => {
+ const { sdk, historyMock } = sdkCapturingLimit();
+ await fetchReviewHistory({
+ sdk,
+ contractId: "c1",
+ reviewId: "doc-1",
+ startAtMs: 1234,
+ limit: 5,
+ });
+ expect(historyMock).toHaveBeenCalledWith({
+ dataContractId: "c1",
+ documentTypeName: "review",
+ documentId: "doc-1",
+ startAtMs: 1234,
+ limit: 5,
+ });
+ });
+
+ it("defaults startAtMs to 0 when omitted", async () => {
+ const { sdk, historyMock } = sdkCapturingLimit();
+ await fetchReviewHistory({ sdk, contractId: "c1", reviewId: "doc-1" });
+ expect(historyMock.mock.calls[0][0].startAtMs).toBe(0);
+ });
+});
diff --git a/example-apps/dashrate/test/logger.test.ts b/example-apps/dashrate/test/logger.test.ts
new file mode 100644
index 0000000..329eeda
--- /dev/null
+++ b/example-apps/dashrate/test/logger.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect, it } from "vitest";
+import { errorMessage } from "../src/lib/logger";
+
+describe("errorMessage", () => {
+ it("extracts standard errors and plain strings", () => {
+ expect(errorMessage(new Error("boom"))).toBe("boom");
+ expect(errorMessage("plain")).toBe("plain");
+ });
+
+ it("calls function-style SDK messages", () => {
+ expect(
+ errorMessage({
+ message: () => "wasm says no",
+ __wbg_ptr: 123,
+ }),
+ ).toBe("wasm says no");
+ });
+
+ it("leaves pointer-only wasm objects visible as extraction failures", () => {
+ expect(errorMessage({ __wbg_ptr: 4639936 })).toBe('{"__wbg_ptr":4639936}');
+ });
+
+ it("extracts base64 CBOR SDK message strings", () => {
+ expect(
+ errorMessage(
+ "oWdtZXNzYWdleKxzdG9yYWdlOiBkcml2ZTogbm90IHN1cHBvcnRlZCBlcnJvcjogTm90Q291bnRlZE9yU3VtbWVkLXdyYXBwaW5nIGlzIG9ubHkgc3VwcG9ydGVkIGZvciB0aGUgc2l4IHN1bS1iZWFyaW5nIHRyZWUgdmFyaWFudHMg4oCUIHNlZSBgZm9yX2tub3duX3BhdGhfa2V5X2VtcHR5X25vdF9zdW1tZWRfdHJlZWAu",
+ ),
+ ).toContain("NotCountedOrSummed-wrapping");
+ });
+
+ it("returns a base64-shaped string as-is when it is not CBOR", () => {
+ // Passes the structural gate (base64 charset, length >= 8) but the first
+ // byte isn't a 1-entry CBOR map, so decode bails and the raw string wins.
+ expect(errorMessage("YWJjZGVmZ2g")).toBe("YWJjZGVmZ2g");
+ });
+
+ it("reads a plain object message string", () => {
+ expect(errorMessage({ message: "plain object boom" })).toBe(
+ "plain object boom",
+ );
+ });
+
+ it("falls through a function-style message that throws", () => {
+ // message() throwing must not crash extraction; falls to the name/kind
+ // shape below it.
+ expect(
+ errorMessage({
+ message: () => {
+ throw new Error("nope");
+ },
+ name: "ProtocolError",
+ kind: "Overflow",
+ code: 42,
+ }),
+ ).toBe("ProtocolError: Overflow: code 42");
+ });
+
+ it("composes name/kind/code for structured WASM errors", () => {
+ expect(errorMessage({ name: "StateError", kind: "NotFound" })).toBe(
+ "StateError: NotFound",
+ );
+ expect(errorMessage({ name: "StateError" })).toBe("StateError");
+ });
+
+ it("returns 'Unknown error' for undefined (not JSON-serializable)", () => {
+ // JSON.stringify(undefined) is undefined, so the final fallback wins.
+ expect(errorMessage(undefined)).toBe("Unknown error");
+ });
+
+ it("falls back to the JSON form for null", () => {
+ // null is not an object, so the object branch is skipped and
+ // JSON.stringify(null) — "null" — is returned.
+ expect(errorMessage(null)).toBe("null");
+ });
+
+ it("returns 'Unknown error' for an empty object", () => {
+ // No message/name, String() is "[object Object]", and JSON.stringify is
+ // "{}" — both rejected, so the final fallback wins.
+ expect(errorMessage({})).toBe("Unknown error");
+ });
+});
diff --git a/example-apps/dashrate/test/queries.test.ts b/example-apps/dashrate/test/queries.test.ts
new file mode 100644
index 0000000..7a8b6f7
--- /dev/null
+++ b/example-apps/dashrate/test/queries.test.ts
@@ -0,0 +1,375 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ findMyReviewForResource,
+ getRatingCount,
+ getRatingDistribution,
+ listMyReviews,
+ listResourceReviews,
+ normalizeReviews,
+ normalizeSingleReview,
+ ratingKeyHex,
+ summaryFromDistribution,
+} from "../src/dash/queries";
+import type { DashSdk } from "../src/dash/types";
+
+describe("DashRate query normalization", () => {
+ it("normalizes review query maps", () => {
+ const reviews = normalizeReviews(
+ new Map([
+ [
+ "doc-1",
+ {
+ toJSON: () => ({
+ $ownerId: "owner-1",
+ resourceId: "tokens",
+ rating: 4,
+ reviewText: "Useful",
+ $createdAt: 100,
+ $updatedAt: 150,
+ $revision: 2,
+ }),
+ },
+ ],
+ ]),
+ );
+
+ expect(reviews).toEqual([
+ {
+ id: "doc-1",
+ ownerId: "owner-1",
+ resourceId: "tokens",
+ rating: 4,
+ reviewText: "Useful",
+ createdAt: 100,
+ updatedAt: 150,
+ revision: 2,
+ },
+ ]);
+ });
+
+ it("normalizes an array of documents, taking ids from $id", () => {
+ const reviews = normalizeReviews([
+ { toJSON: () => ({ $id: "doc-a", $ownerId: "o", rating: 5 }) },
+ { toJSON: () => ({ $id: "doc-b", $ownerId: "o", rating: 1 }) },
+ ]);
+
+ expect(reviews.map((review) => review.id)).toEqual(["doc-a", "doc-b"]);
+ expect(reviews.map((review) => review.rating)).toEqual([5, 1]);
+ });
+
+ it("normalizes a plain-object map keyed by document id", () => {
+ const reviews = normalizeReviews({
+ "doc-1": { toJSON: () => ({ $ownerId: "owner-1", rating: 3 }) },
+ });
+
+ expect(reviews).toEqual([
+ {
+ id: "doc-1",
+ ownerId: "owner-1",
+ resourceId: "",
+ rating: 3,
+ reviewText: "",
+ createdAt: null,
+ updatedAt: null,
+ revision: 0,
+ },
+ ]);
+ });
+
+ it("skips null/undefined documents in any shape", () => {
+ expect(
+ normalizeReviews([
+ null as never,
+ { toJSON: () => ({ $id: "doc-a", rating: 4 }) },
+ ]),
+ ).toHaveLength(1);
+ expect(
+ normalizeReviews({
+ empty: undefined,
+ "doc-b": { toJSON: () => ({ rating: 2 }) },
+ }),
+ ).toHaveLength(1);
+ });
+
+ it("normalizes (or rejects) a single review", () => {
+ expect(normalizeSingleReview("doc-1", undefined)).toBeNull();
+ const review = normalizeSingleReview("doc-1", {
+ toJSON: () => ({ $ownerId: "owner-1", rating: 5 }),
+ });
+ expect(review).toMatchObject({
+ id: "doc-1",
+ ownerId: "owner-1",
+ rating: 5,
+ });
+ });
+
+ it("derives count, sum and average from a rating distribution", () => {
+ // 2×3 + 5×4 + 8×5 = 6 + 20 + 40 = 66 over 15 reviews → 4.4 avg.
+ const summary = summaryFromDistribution("dashnote", {
+ 1: 0n,
+ 2: 0n,
+ 3: 2n,
+ 4: 5n,
+ 5: 8n,
+ });
+
+ expect(summary).toEqual({
+ resourceId: "dashnote",
+ count: 15n,
+ sum: 66n,
+ average: 4.4,
+ });
+ });
+
+ it("degrades to zeros for an all-zero distribution (no reviews)", () => {
+ const summary = summaryFromDistribution("tokens", {
+ 1: 0n,
+ 2: 0n,
+ 3: 0n,
+ 4: 0n,
+ 5: 0n,
+ });
+
+ expect(summary).toEqual({
+ resourceId: "tokens",
+ count: 0n,
+ sum: 0n,
+ average: null,
+ });
+ });
+});
+
+describe("DashRate rating distribution", () => {
+ it("encodes a rating as its order-preserving sign-flipped hex key", () => {
+ // 0x80 | rating — verified against the live contract's grouped count.
+ expect(ratingKeyHex(1)).toBe("81");
+ expect(ratingKeyHex(5)).toBe("85");
+ });
+
+ it("maps grouped count keys back to ratings, defaulting absent to 0", async () => {
+ // Grouped `count` returns one entry per PRESENT rating, keyed by the
+ // order-preserving hex key. Ratings with no reviews (1, 2) are absent.
+ const countMock = vi.fn().mockResolvedValue(
+ new Map([
+ [ratingKeyHex(3), 2n],
+ [ratingKeyHex(4), 5n],
+ [ratingKeyHex(5), 8n],
+ ]),
+ );
+ const sdk = { documents: { count: countMock } } as unknown as DashSdk;
+
+ const distribution = await getRatingDistribution({
+ sdk,
+ contractId: "contract-1",
+ resourceId: "tokens",
+ log: () => {},
+ });
+
+ expect(distribution).toEqual({ 1: 0n, 2: 0n, 3: 2n, 4: 5n, 5: 8n });
+
+ // Verify the query shape: between-range on rating + groupBy drives
+ // the RangeDistinct grouped count, scoped by the resourceId equality.
+ expect(countMock).toHaveBeenCalledWith({
+ dataContractId: "contract-1",
+ documentTypeName: "review",
+ where: [
+ ["resourceId", "==", "tokens"],
+ ["rating", "between", [1, 5]],
+ ],
+ orderBy: [["rating", "asc"]],
+ groupBy: ["rating"],
+ });
+ });
+});
+
+describe("DashRate review list filtering", () => {
+ const reviewDoc = (rating: number) => ({
+ toJSON: () => ({
+ $ownerId: "owner-1",
+ resourceId: "dashnote",
+ rating,
+ reviewText: "",
+ $createdAt: 1,
+ $updatedAt: 1,
+ $revision: 1,
+ }),
+ });
+
+ it("orders by resourceId when unfiltered (served by [resourceId])", async () => {
+ const queryMock = vi.fn().mockResolvedValue([reviewDoc(3), reviewDoc(5)]);
+ const sdk = { documents: { query: queryMock } } as unknown as DashSdk;
+
+ await listResourceReviews({
+ sdk,
+ contractId: "c1",
+ resourceId: "dashnote",
+ log: () => {},
+ });
+
+ expect(queryMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: [["resourceId", "==", "dashnote"]],
+ orderBy: [["resourceId", "asc"]],
+ }),
+ );
+ });
+
+ it("orders by rating (the trailing index field) when filtered", async () => {
+ // orderBy MUST be the index's last property; ordering by resourceId
+ // here strips rating from the usable prefix and the query is rejected.
+ const queryMock = vi.fn().mockResolvedValue([reviewDoc(5)]);
+ const sdk = { documents: { query: queryMock } } as unknown as DashSdk;
+
+ await listResourceReviews({
+ sdk,
+ contractId: "c1",
+ resourceId: "dashnote",
+ ratingFilter: 5,
+ log: () => {},
+ });
+
+ expect(queryMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: [
+ ["resourceId", "==", "dashnote"],
+ ["rating", "==", 5],
+ ],
+ orderBy: [["rating", "asc"]],
+ }),
+ );
+ });
+});
+
+describe("DashRate getRatingCount", () => {
+ it("reads the single total from the ungrouped (empty-key) count map", async () => {
+ const countMock = vi.fn().mockResolvedValue(new Map([["", 7n]]));
+ const sdk = { documents: { count: countMock } } as unknown as DashSdk;
+
+ const total = await getRatingCount({
+ sdk,
+ contractId: "c1",
+ resourceId: "tokens",
+ log: () => {},
+ });
+
+ expect(total).toBe(7n);
+ expect(countMock).toHaveBeenCalledWith({
+ dataContractId: "c1",
+ documentTypeName: "review",
+ where: [["resourceId", "==", "tokens"]],
+ orderBy: [["resourceId", "asc"]],
+ });
+ });
+
+ it("returns 0n when the count map is empty", async () => {
+ const countMock = vi.fn().mockResolvedValue(new Map());
+ const sdk = { documents: { count: countMock } } as unknown as DashSdk;
+
+ expect(
+ await getRatingCount({
+ sdk,
+ contractId: "c1",
+ resourceId: "tokens",
+ log: () => {},
+ }),
+ ).toBe(0n);
+ });
+});
+
+describe("DashRate listMyReviews", () => {
+ const reviewDoc = (updatedAt: number) => ({
+ toJSON: () => ({
+ $ownerId: "owner-1",
+ resourceId: "tokens",
+ rating: 4,
+ reviewText: "",
+ $createdAt: 1,
+ $updatedAt: updatedAt,
+ $revision: 1,
+ }),
+ });
+
+ it("queries by owner and re-sorts results by updatedAt descending", async () => {
+ // Server orderBy is ascending; the helper re-sorts newest-first in JS.
+ const queryMock = vi
+ .fn()
+ .mockResolvedValue([reviewDoc(100), reviewDoc(300), reviewDoc(200)]);
+ const sdk = { documents: { query: queryMock } } as unknown as DashSdk;
+
+ const reviews = await listMyReviews({
+ sdk,
+ contractId: "c1",
+ ownerId: "owner-1",
+ log: () => {},
+ });
+
+ expect(reviews.map((review) => review.updatedAt)).toEqual([300, 200, 100]);
+ expect(queryMock).toHaveBeenCalledWith({
+ dataContractId: "c1",
+ documentTypeName: "review",
+ where: [["$ownerId", "==", "owner-1"]],
+ orderBy: [
+ ["$ownerId", "asc"],
+ ["$updatedAt", "asc"],
+ ],
+ limit: 50,
+ });
+ });
+});
+
+describe("DashRate findMyReviewForResource", () => {
+ const reviewDoc = {
+ toJSON: () => ({
+ $id: "doc-1",
+ $ownerId: "owner-1",
+ resourceId: "tokens",
+ rating: 4,
+ reviewText: "",
+ $createdAt: 1,
+ $updatedAt: 1,
+ $revision: 1,
+ }),
+ };
+
+ it("queries by owner+resource with limit 1 and returns the match", async () => {
+ // saveReview relies on this to decide create vs. update.
+ const queryMock = vi.fn().mockResolvedValue([reviewDoc]);
+ const sdk = { documents: { query: queryMock } } as unknown as DashSdk;
+
+ const review = await findMyReviewForResource({
+ sdk,
+ contractId: "c1",
+ resourceId: "tokens",
+ ownerId: "owner-1",
+ });
+
+ expect(review?.id).toBe("doc-1");
+ expect(queryMock).toHaveBeenCalledWith({
+ dataContractId: "c1",
+ documentTypeName: "review",
+ where: [
+ ["$ownerId", "==", "owner-1"],
+ ["resourceId", "==", "tokens"],
+ ],
+ orderBy: [
+ ["$ownerId", "asc"],
+ ["resourceId", "asc"],
+ ],
+ limit: 1,
+ });
+ });
+
+ it("returns null when no review exists for the pair", async () => {
+ const queryMock = vi.fn().mockResolvedValue([]);
+ const sdk = { documents: { query: queryMock } } as unknown as DashSdk;
+
+ expect(
+ await findMyReviewForResource({
+ sdk,
+ contractId: "c1",
+ resourceId: "tokens",
+ ownerId: "owner-1",
+ }),
+ ).toBeNull();
+ });
+});
diff --git a/example-apps/dashrate/test/resolveDpnsName.test.ts b/example-apps/dashrate/test/resolveDpnsName.test.ts
new file mode 100644
index 0000000..4cd9521
--- /dev/null
+++ b/example-apps/dashrate/test/resolveDpnsName.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it, vi } from "vitest";
+import { resolveDpnsName } from "../src/dash/resolveDpnsName";
+import type { DashSdk } from "../src/dash/types";
+
+function sdkWith(username: () => Promise): DashSdk {
+ return { dpns: { username: vi.fn(username) } } as unknown as DashSdk;
+}
+
+describe("resolveDpnsName", () => {
+ it("strips the .dash TLD from a registered name", async () => {
+ const sdk = sdkWith(async () => "alice.dash");
+ expect(await resolveDpnsName(sdk, "id-1")).toBe("alice");
+ });
+
+ it("returns a name without a .dash suffix unchanged", async () => {
+ const sdk = sdkWith(async () => "bob");
+ expect(await resolveDpnsName(sdk, "id-1")).toBe("bob");
+ });
+
+ it("returns null for an empty or non-string result", async () => {
+ expect(
+ await resolveDpnsName(
+ sdkWith(async () => ""),
+ "id-1",
+ ),
+ ).toBeNull();
+ expect(
+ await resolveDpnsName(
+ sdkWith(async () => null),
+ "id-1",
+ ),
+ ).toBeNull();
+ expect(
+ await resolveDpnsName(
+ sdkWith(async () => undefined),
+ "id-1",
+ ),
+ ).toBeNull();
+ expect(
+ await resolveDpnsName(
+ sdkWith(async () => 42 as unknown as string),
+ "id-1",
+ ),
+ ).toBeNull();
+ });
+
+ it("returns null when the lookup throws", async () => {
+ const sdk = sdkWith(async () => {
+ throw new Error("not found");
+ });
+ expect(await resolveDpnsName(sdk, "id-1")).toBeNull();
+ });
+});
diff --git a/example-apps/dashrate/test/resources.test.ts b/example-apps/dashrate/test/resources.test.ts
new file mode 100644
index 0000000..f316ca1
--- /dev/null
+++ b/example-apps/dashrate/test/resources.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from "vitest";
+import {
+ RESOURCES,
+ findResource,
+ type RatedResource,
+} from "../src/catalog/resources";
+
+const CATEGORIES: RatedResource["category"][] = [
+ "Tutorial",
+ "Example App",
+ "Reference",
+];
+
+describe("resource catalog integrity", () => {
+ it("has unique ids", () => {
+ // Each id is persisted into a review's resourceId and used as a lookup
+ // key, so a duplicate or shadowed id would be a real app bug.
+ const ids = RESOURCES.map((resource) => resource.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
+ it("ids fit the contract's resourceId field (1–63 chars)", () => {
+ // contract.ts caps resourceId at maxLength 63; an over-long catalog id
+ // would fail every review write for that resource.
+ for (const { id } of RESOURCES) {
+ expect(id.length).toBeGreaterThanOrEqual(1);
+ expect(id.length).toBeLessThanOrEqual(63);
+ }
+ });
+
+ it("every entry has non-empty display fields and a valid category", () => {
+ for (const resource of RESOURCES) {
+ expect(resource.title.trim()).not.toBe("");
+ expect(resource.summary.trim()).not.toBe("");
+ expect(resource.href.trim()).not.toBe("");
+ expect(CATEGORIES).toContain(resource.category);
+ }
+ });
+});
+
+describe("findResource", () => {
+ // The lookup App.tsx uses to resolve the selected resource and review titles.
+ it("returns the entry matching an id", () => {
+ const first = RESOURCES[0];
+ expect(findResource(first.id)).toBe(first);
+ });
+
+ it("returns undefined for an unknown id", () => {
+ expect(findResource("not-a-real-resource")).toBeUndefined();
+ });
+});
diff --git a/example-apps/dashrate/test/review.test.ts b/example-apps/dashrate/test/review.test.ts
new file mode 100644
index 0000000..afab00b
--- /dev/null
+++ b/example-apps/dashrate/test/review.test.ts
@@ -0,0 +1,209 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+// Mock the SDK module loader so the 8 MB WASM bundle is never imported, and
+// expose a fake Document that records the constructor args saveReview builds.
+const documentArgs: Record[] = [];
+
+class FakeDocument {
+ args: Record;
+
+ constructor(args: Record) {
+ this.args = args;
+ documentArgs.push(args);
+ }
+
+ toJSON() {
+ // The created document reports its id the way the real SDK does.
+ return { $id: "new-review-id", ...this.args };
+ }
+}
+
+vi.mock("../src/dash/sdkModule", () => ({
+ loadSdkModule: async () => ({ Document: FakeDocument }),
+}));
+
+// Control whether an existing review is found (create vs. update branch).
+const findMyReviewForResource = vi.fn();
+vi.mock("../src/dash/queries", () => ({ findMyReviewForResource }));
+
+// Import after the mocks are registered.
+const { saveReview } = await import("../src/dash/review");
+import type { DashKeyManager, DashSdk } from "../src/dash/types";
+
+function makeKeyManager(): DashKeyManager {
+ return {
+ identityId: "owner-1",
+ getAuth: async () => ({
+ identity: { id: { toString: () => "owner-1" } } as never,
+ identityKey: undefined,
+ signer: {} as never,
+ }),
+ };
+}
+
+function makeSdk(overrides: Partial = {}): DashSdk {
+ return {
+ documents: {
+ create: vi.fn().mockResolvedValue(undefined),
+ replace: vi.fn().mockResolvedValue(undefined),
+ get: vi.fn(),
+ ...overrides,
+ },
+ } as unknown as DashSdk;
+}
+
+beforeEach(() => {
+ documentArgs.length = 0;
+ findMyReviewForResource.mockReset();
+});
+
+describe("saveReview rating validation", () => {
+ it.each([0, 6, 2.5, Number.NaN])(
+ "rejects out-of-range or non-integer rating %p",
+ async (rating) => {
+ findMyReviewForResource.mockResolvedValue(null);
+ await expect(
+ saveReview({
+ sdk: makeSdk(),
+ keyManager: makeKeyManager(),
+ contractId: "c1",
+ resourceId: "tokens",
+ rating: rating as number,
+ reviewText: "",
+ }),
+ ).rejects.toThrow("Rating must be an integer from 1 to 5.");
+ },
+ );
+});
+
+describe("saveReview create branch", () => {
+ it("creates a new review when none exists and returns its id", async () => {
+ findMyReviewForResource.mockResolvedValue(null);
+ const sdk = makeSdk();
+
+ const id = await saveReview({
+ sdk,
+ keyManager: makeKeyManager(),
+ contractId: "c1",
+ resourceId: "tokens",
+ rating: 5,
+ reviewText: " Great ",
+ });
+
+ expect(id).toBe("new-review-id");
+ expect(sdk.documents.create).toHaveBeenCalledOnce();
+ expect(sdk.documents.replace).not.toHaveBeenCalled();
+ // The create-vs-update decision hinges on the existing-review lookup
+ // being scoped to this contract/resource/owner.
+ expect(findMyReviewForResource).toHaveBeenCalledWith(
+ expect.objectContaining({
+ contractId: "c1",
+ resourceId: "tokens",
+ ownerId: "owner-1",
+ }),
+ );
+ // reviewText is trimmed before storage.
+ expect(documentArgs[0]).toMatchObject({
+ documentTypeName: "review",
+ dataContractId: "c1",
+ properties: { resourceId: "tokens", rating: 5, reviewText: "Great" },
+ });
+ });
+
+ it("omits reviewText entirely when blank or whitespace-only", async () => {
+ findMyReviewForResource.mockResolvedValue(null);
+ const sdk = makeSdk();
+
+ await saveReview({
+ sdk,
+ keyManager: makeKeyManager(),
+ contractId: "c1",
+ resourceId: "tokens",
+ rating: 3,
+ reviewText: " ",
+ });
+
+ expect(documentArgs[0].properties).toEqual({
+ resourceId: "tokens",
+ rating: 3,
+ });
+ expect(documentArgs[0].properties).not.toHaveProperty("reviewText");
+ });
+});
+
+describe("saveReview update branch", () => {
+ it("replaces the existing review and bumps its revision", async () => {
+ findMyReviewForResource.mockResolvedValue({ id: "existing-id" });
+ const sdk = makeSdk({
+ get: vi.fn().mockResolvedValue({ revision: 4 }),
+ });
+
+ const id = await saveReview({
+ sdk,
+ keyManager: makeKeyManager(),
+ contractId: "c1",
+ resourceId: "tokens",
+ rating: 2,
+ reviewText: "Changed my mind",
+ });
+
+ expect(id).toBe("existing-id");
+ expect(sdk.documents.replace).toHaveBeenCalledOnce();
+ expect(sdk.documents.create).not.toHaveBeenCalled();
+ // The existing revision is read via get(contractId, type, id).
+ expect(sdk.documents.get).toHaveBeenCalledWith(
+ "c1",
+ "review",
+ "existing-id",
+ );
+ // Replacement keeps the existing id, bumps revision 4 → 5n, and carries
+ // the edited rating AND text through (a dropped update text would slip
+ // past an assertion that only checked resourceId/rating).
+ expect(documentArgs[0]).toMatchObject({
+ id: "existing-id",
+ revision: 5n,
+ properties: {
+ resourceId: "tokens",
+ rating: 2,
+ reviewText: "Changed my mind",
+ },
+ });
+ });
+
+ it("defaults a missing revision to 0 before bumping (→ 1n)", async () => {
+ findMyReviewForResource.mockResolvedValue({ id: "existing-id" });
+ const sdk = makeSdk({
+ get: vi.fn().mockResolvedValue({}),
+ });
+
+ await saveReview({
+ sdk,
+ keyManager: makeKeyManager(),
+ contractId: "c1",
+ resourceId: "tokens",
+ rating: 1,
+ reviewText: "",
+ });
+
+ expect(documentArgs[0].revision).toBe(1n);
+ });
+
+ it("throws when the existing review can't be fetched for replacement", async () => {
+ findMyReviewForResource.mockResolvedValue({ id: "existing-id" });
+ const sdk = makeSdk({
+ get: vi.fn().mockResolvedValue(undefined),
+ });
+
+ await expect(
+ saveReview({
+ sdk,
+ keyManager: makeKeyManager(),
+ contractId: "c1",
+ resourceId: "tokens",
+ rating: 4,
+ reviewText: "",
+ }),
+ ).rejects.toThrow("Review existing-id not found.");
+ expect(sdk.documents.replace).not.toHaveBeenCalled();
+ });
+});
diff --git a/example-apps/dashrate/test/useDpnsNames.test.tsx b/example-apps/dashrate/test/useDpnsNames.test.tsx
new file mode 100644
index 0000000..f977bf3
--- /dev/null
+++ b/example-apps/dashrate/test/useDpnsNames.test.tsx
@@ -0,0 +1,111 @@
+// @vitest-environment jsdom
+
+import { cleanup, renderHook, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { ReviewRecord } from "../src/dash/queries";
+import type { DashSdk } from "../src/dash/types";
+import type { Session } from "../src/session/types";
+
+const resolveDpnsName = vi.fn();
+vi.mock("../src/dash/resolveDpnsName", () => ({ resolveDpnsName }));
+
+const { useDpnsNames } = await import("../src/hooks/useDpnsNames");
+
+const readOnlySdk = { tag: "read-only" } as unknown as DashSdk;
+const sessionSdk = { tag: "session" } as unknown as DashSdk;
+
+function review(id: string, ownerId: string): ReviewRecord {
+ return {
+ id,
+ ownerId,
+ resourceId: "tokens",
+ rating: 4,
+ reviewText: "",
+ createdAt: 1,
+ updatedAt: 2,
+ revision: 1,
+ };
+}
+
+function makeArgs(overrides: Partial[0]> = {}) {
+ return {
+ reviews: [] as ReviewRecord[],
+ myReviews: [] as ReviewRecord[],
+ session: null as Session | null,
+ connectReadOnly: vi.fn().mockResolvedValue(readOnlySdk),
+ ...overrides,
+ };
+}
+
+beforeEach(() => {
+ resolveDpnsName.mockReset();
+});
+
+afterEach(() => {
+ cleanup();
+});
+
+describe("useDpnsNames", () => {
+ it("resolves names for reviewers and merges them into the map", async () => {
+ resolveDpnsName.mockImplementation(async (_sdk, id) =>
+ id === "owner-1" ? "alice" : null,
+ );
+ const { result } = renderHook(() =>
+ useDpnsNames(
+ makeArgs({ reviews: [review("a", "owner-1"), review("b", "owner-2")] }),
+ ),
+ );
+ await waitFor(() => {
+ expect(result.current).toEqual({ "owner-1": "alice", "owner-2": null });
+ });
+ });
+
+ it("uses the read-only sdk when there is no session", async () => {
+ resolveDpnsName.mockResolvedValue(null);
+ const connectReadOnly = vi.fn().mockResolvedValue(readOnlySdk);
+ renderHook(() =>
+ useDpnsNames(
+ makeArgs({ reviews: [review("a", "owner-1")], connectReadOnly }),
+ ),
+ );
+ await waitFor(() => expect(resolveDpnsName).toHaveBeenCalled());
+ expect(connectReadOnly).toHaveBeenCalled();
+ expect(resolveDpnsName).toHaveBeenCalledWith(readOnlySdk, "owner-1");
+ });
+
+ it("uses the session sdk and includes the signed-in identity", async () => {
+ resolveDpnsName.mockResolvedValue(null);
+ const connectReadOnly = vi.fn();
+ const session = { sdk: sessionSdk, identityId: "me" } as unknown as Session;
+ renderHook(() => useDpnsNames(makeArgs({ session, connectReadOnly })));
+ await waitFor(() => expect(resolveDpnsName).toHaveBeenCalled());
+ expect(connectReadOnly).not.toHaveBeenCalled();
+ expect(resolveDpnsName).toHaveBeenCalledWith(sessionSdk, "me");
+ });
+
+ it("does not re-resolve ids already in the cache", async () => {
+ resolveDpnsName.mockResolvedValue("alice");
+ const { result, rerender } = renderHook((props) => useDpnsNames(props), {
+ initialProps: makeArgs({ reviews: [review("a", "owner-1")] }),
+ });
+ await waitFor(() => expect(result.current).toEqual({ "owner-1": "alice" }));
+ resolveDpnsName.mockClear();
+
+ // Re-render with the same owner already resolved → no new lookups.
+ rerender(makeArgs({ reviews: [review("a", "owner-1")] }));
+ await Promise.resolve();
+ expect(resolveDpnsName).not.toHaveBeenCalled();
+ });
+
+ it("swallows resolution failures and keeps the prior map", async () => {
+ resolveDpnsName.mockRejectedValue(new Error("dpns down"));
+ const { result } = renderHook(() =>
+ useDpnsNames(makeArgs({ reviews: [review("a", "owner-1")] })),
+ );
+ // Give the effect a tick to run and reject.
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(result.current).toEqual({});
+ });
+});
diff --git a/example-apps/dashrate/test/useMyReviews.test.tsx b/example-apps/dashrate/test/useMyReviews.test.tsx
new file mode 100644
index 0000000..76157e6
--- /dev/null
+++ b/example-apps/dashrate/test/useMyReviews.test.tsx
@@ -0,0 +1,120 @@
+// @vitest-environment jsdom
+
+import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { ReviewRecord } from "../src/dash/queries";
+import type { Session } from "../src/session/types";
+
+const listMyReviews = vi.fn();
+vi.mock("../src/dash/queries", () => ({ listMyReviews }));
+
+const { useMyReviews } = await import("../src/hooks/useMyReviews");
+
+const session = { sdk: {}, identityId: "owner-1" } as unknown as Session;
+
+function review(id: string, rating: number): ReviewRecord {
+ return {
+ id,
+ ownerId: "owner-1",
+ resourceId: "tokens",
+ rating,
+ reviewText: "",
+ createdAt: 1,
+ updatedAt: 2,
+ revision: 1,
+ };
+}
+
+beforeEach(() => {
+ listMyReviews.mockReset();
+});
+
+afterEach(() => {
+ cleanup();
+});
+
+describe("useMyReviews", () => {
+ it("does not fetch when disabled", () => {
+ renderHook(() =>
+ useMyReviews({ contractId: "c1", enabled: false, session, log: vi.fn() }),
+ );
+ expect(listMyReviews).not.toHaveBeenCalled();
+ });
+
+ it("does not fetch without a session or contract", () => {
+ renderHook(() =>
+ useMyReviews({
+ contractId: "c1",
+ enabled: true,
+ session: null,
+ log: vi.fn(),
+ }),
+ );
+ renderHook(() =>
+ useMyReviews({ contractId: "", enabled: true, session, log: vi.fn() }),
+ );
+ expect(listMyReviews).not.toHaveBeenCalled();
+ });
+
+ it("fetches on mount and derives the average", async () => {
+ listMyReviews.mockResolvedValue([review("a", 4), review("b", 2)]);
+ const { result } = renderHook(() =>
+ useMyReviews({ contractId: "c1", enabled: true, session, log: vi.fn() }),
+ );
+
+ await waitFor(() => {
+ expect(result.current.myReviews).toHaveLength(2);
+ });
+ expect(result.current.myReviewsLoading).toBe(false);
+ expect(result.current.myReviewsAverage).toBe(3);
+ });
+
+ it("reports a null average with no reviews", async () => {
+ listMyReviews.mockResolvedValue([]);
+ const { result } = renderHook(() =>
+ useMyReviews({ contractId: "c1", enabled: true, session, log: vi.fn() }),
+ );
+ await waitFor(() => expect(result.current.myReviewsLoading).toBe(false));
+ expect(result.current.myReviewsAverage).toBeNull();
+ });
+
+ it("logs an error and does not throw when the fetch fails", async () => {
+ const log = vi.fn();
+ listMyReviews.mockRejectedValue(new Error("network down"));
+ const { result } = renderHook(() =>
+ useMyReviews({ contractId: "c1", enabled: true, session, log }),
+ );
+ await waitFor(() => {
+ expect(log).toHaveBeenCalledWith(
+ expect.stringContaining("My reviews failed"),
+ "error",
+ );
+ });
+ expect(result.current.myReviews).toHaveLength(0);
+ });
+
+ it("refreshMyReviews fetches and stores the list", async () => {
+ listMyReviews.mockResolvedValue([review("a", 5)]);
+ const { result } = renderHook(() =>
+ useMyReviews({ contractId: "c1", enabled: false, session, log: vi.fn() }),
+ );
+
+ let returned: ReviewRecord[] = [];
+ await act(async () => {
+ returned = await result.current.refreshMyReviews();
+ });
+ expect(returned).toHaveLength(1);
+ expect(result.current.myReviews).toHaveLength(1);
+ });
+
+ it("setMyReviews clears the list", async () => {
+ listMyReviews.mockResolvedValue([review("a", 5)]);
+ const { result } = renderHook(() =>
+ useMyReviews({ contractId: "c1", enabled: true, session, log: vi.fn() }),
+ );
+ await waitFor(() => expect(result.current.myReviews).toHaveLength(1));
+ act(() => result.current.setMyReviews([]));
+ expect(result.current.myReviews).toHaveLength(0);
+ });
+});
diff --git a/example-apps/dashrate/test/useResourceRatings.test.tsx b/example-apps/dashrate/test/useResourceRatings.test.tsx
new file mode 100644
index 0000000..b82a31c
--- /dev/null
+++ b/example-apps/dashrate/test/useResourceRatings.test.tsx
@@ -0,0 +1,204 @@
+// @vitest-environment jsdom
+
+import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { RESOURCES } from "../src/catalog/resources";
+import type {
+ RatingDistribution,
+ RatingSummary,
+ ReviewRecord,
+} from "../src/dash/queries";
+import type { DashSdk } from "../src/dash/types";
+import type { Session } from "../src/session/types";
+
+const getRatingCount = vi.fn();
+const getRatingDistribution = vi.fn();
+const summaryFromDistribution = vi.fn();
+const listResourceReviews = vi.fn();
+const findMyReviewForResource = vi.fn();
+
+vi.mock("../src/dash/queries", () => ({
+ getRatingCount,
+ getRatingDistribution,
+ summaryFromDistribution,
+ listResourceReviews,
+ findMyReviewForResource,
+}));
+
+const { useResourceRatings } = await import("../src/hooks/useResourceRatings");
+
+const fakeSdk = { tag: "fake" } as unknown as DashSdk;
+
+function distribution(): RatingDistribution {
+ return { 1: 0n, 2: 0n, 3: 0n, 4: 1n, 5: 0n };
+}
+
+function summary(resourceId: string): RatingSummary {
+ return { resourceId, count: 1n, sum: 4n, average: 4 };
+}
+
+function review(id: string): ReviewRecord {
+ return {
+ id,
+ ownerId: "owner-1",
+ resourceId: "tokens",
+ rating: 4,
+ reviewText: "",
+ createdAt: 1,
+ updatedAt: 2,
+ revision: 1,
+ };
+}
+
+function makeArgs(
+ overrides: Partial[0]> = {},
+) {
+ return {
+ contractId: "c1",
+ session: null as Session | null,
+ selectedResourceId: "tokens",
+ connectReadOnly: vi.fn().mockResolvedValue(fakeSdk),
+ log: vi.fn(),
+ setStatus: vi.fn(),
+ ...overrides,
+ };
+}
+
+/** A promise whose resolution is controlled externally, for ordering tests. */
+function deferred() {
+ let resolve!: (value: T) => void;
+ const promise = new Promise((r) => {
+ resolve = r;
+ });
+ return { promise, resolve };
+}
+
+beforeEach(() => {
+ getRatingCount.mockResolvedValue(1n);
+ getRatingDistribution.mockResolvedValue(distribution());
+ summaryFromDistribution.mockImplementation((resourceId: string) =>
+ summary(resourceId),
+ );
+ listResourceReviews.mockResolvedValue([]);
+ findMyReviewForResource.mockResolvedValue(null);
+});
+
+afterEach(() => {
+ vi.clearAllMocks();
+ cleanup();
+});
+
+describe("useResourceRatings initial load", () => {
+ it("loads summaries for every resource and clears status on success", async () => {
+ const args = makeArgs();
+ const { result } = renderHook((props) => useResourceRatings(props), {
+ initialProps: args,
+ });
+
+ await waitFor(() => {
+ expect(Object.keys(result.current.summaries)).toHaveLength(
+ RESOURCES.length,
+ );
+ });
+ expect(result.current.loadingRatings).toBe(false);
+ expect(args.setStatus).toHaveBeenCalledWith("");
+ // One count + distribution per resource.
+ expect(getRatingCount).toHaveBeenCalledTimes(RESOURCES.length);
+ });
+
+ it("resets to empty summaries and runs no queries without a contract", async () => {
+ const args = makeArgs({ contractId: "" });
+ const { result } = renderHook((props) => useResourceRatings(props), {
+ initialProps: args,
+ });
+
+ await waitFor(() => {
+ expect(Object.keys(result.current.summaries)).toHaveLength(
+ RESOURCES.length,
+ );
+ });
+ expect(result.current.summaries.tokens.count).toBe(0n);
+ expect(getRatingCount).not.toHaveBeenCalled();
+ });
+
+ it("populates the composer from the signed-in user's existing review", async () => {
+ findMyReviewForResource.mockResolvedValue({
+ ...review("mine"),
+ rating: 5,
+ reviewText: "loved it",
+ });
+ const session = {
+ sdk: fakeSdk,
+ identityId: "owner-1",
+ } as unknown as Session;
+ const { result } = renderHook((props) => useResourceRatings(props), {
+ initialProps: makeArgs({ session }),
+ });
+ await waitFor(() => {
+ expect(result.current.mySelectedReview?.id).toBe("mine");
+ });
+ expect(result.current.rating).toBe(5);
+ expect(result.current.reviewText).toBe("loved it");
+ });
+});
+
+describe("useResourceRatings stale-response guards", () => {
+ it("ignores a stale loadResourceData when a newer one supersedes it", async () => {
+ const { result } = renderHook((props) => useResourceRatings(props), {
+ initialProps: makeArgs(),
+ });
+ await waitFor(() => expect(result.current.loadingRatings).toBe(false));
+
+ // First call resolves AFTER the second; its writes must be dropped.
+ const first = deferred();
+ getRatingCount.mockReturnValueOnce(first.promise);
+
+ await act(async () => {
+ const stale = result.current.loadResourceData(fakeSdk); // request N
+ const fresh = result.current.loadResourceData(fakeSdk); // request N+1
+ first.resolve(99n); // late-resolve the stale call
+ await Promise.all([stale, fresh]);
+ });
+
+ // The fresh request used the default 1n count, not the stale 99n.
+ expect(result.current.summaries.tokens.count).toBe(1n);
+ });
+
+ it("ignores a stale refreshReviews when a newer one supersedes it", async () => {
+ const { result } = renderHook((props) => useResourceRatings(props), {
+ initialProps: makeArgs(),
+ });
+ await waitFor(() => expect(result.current.loadingRatings).toBe(false));
+
+ const staleList = deferred();
+ listResourceReviews.mockReturnValueOnce(staleList.promise);
+
+ await act(async () => {
+ const stale = result.current.refreshReviews(fakeSdk); // request N
+ const fresh = result.current.refreshReviews(fakeSdk); // request N+1
+ staleList.resolve([review("stale")]);
+ await Promise.all([stale, fresh]);
+ });
+
+ // The fresh (empty) result wins; the stale single-review list is dropped.
+ expect(result.current.reviews).toHaveLength(0);
+ });
+});
+
+describe("useResourceRatings composer setters", () => {
+ it("updates rating, review text, and filter", async () => {
+ const { result } = renderHook((props) => useResourceRatings(props), {
+ initialProps: makeArgs(),
+ });
+ await waitFor(() => expect(result.current.loadingRatings).toBe(false));
+
+ act(() => result.current.setRating(3));
+ act(() => result.current.setReviewText("hello"));
+ act(() => result.current.setReviewFilter(5));
+
+ expect(result.current.rating).toBe(3);
+ expect(result.current.reviewText).toBe("hello");
+ expect(result.current.reviewFilter).toBe(5);
+ });
+});
diff --git a/example-apps/dashrate/tsconfig.app.json b/example-apps/dashrate/tsconfig.app.json
new file mode 100644
index 0000000..1120176
--- /dev/null
+++ b/example-apps/dashrate/tsconfig.app.json
@@ -0,0 +1,22 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx"
+ },
+ "include": ["src", "test"],
+ "exclude": ["test/e2e"]
+}
diff --git a/example-apps/dashrate/tsconfig.json b/example-apps/dashrate/tsconfig.json
new file mode 100644
index 0000000..1ffef60
--- /dev/null
+++ b/example-apps/dashrate/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/example-apps/dashrate/tsconfig.node.json b/example-apps/dashrate/tsconfig.node.json
new file mode 100644
index 0000000..7bb8354
--- /dev/null
+++ b/example-apps/dashrate/tsconfig.node.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2022",
+ "lib": ["ES2022"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "Bundler",
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "noEmit": true
+ },
+ "include": ["vite.config.ts", "eslint.config.js"]
+}
diff --git a/example-apps/dashrate/vite.config.ts b/example-apps/dashrate/vite.config.ts
new file mode 100644
index 0000000..fe0bca3
--- /dev/null
+++ b/example-apps/dashrate/vite.config.ts
@@ -0,0 +1,36 @@
+import { defineConfig } from "vitest/config";
+import { fileURLToPath, URL } from "node:url";
+import react from "@vitejs/plugin-react";
+
+const evoSdkModulePath = fileURLToPath(
+ new URL(
+ "./node_modules/@dashevo/evo-sdk/dist/evo-sdk.module.js",
+ import.meta.url,
+ ),
+);
+
+export default defineConfig({
+ base: process.env.VITE_BASE_PATH || "/",
+ resolve: {
+ alias: {
+ "@dashevo/evo-sdk": evoSdkModulePath,
+ },
+ },
+ plugins: [react()],
+ build: {
+ modulePreload: {
+ resolveDependencies: (_filename, deps) =>
+ deps.filter((d) => !d.includes("evo-sdk")),
+ },
+ },
+ test: {
+ environment: "node",
+ include: ["test/**/*.test.{ts,tsx}"],
+ coverage: {
+ provider: "v8",
+ reporter: ["text", "html"],
+ include: ["src/**/*.{ts,tsx}"],
+ exclude: ["src/main.tsx", "src/**/*.d.ts"],
+ },
+ },
+});