diff --git a/example-apps/README.md b/example-apps/README.md
index e1d7958..be0602a 100644
--- a/example-apps/README.md
+++ b/example-apps/README.md
@@ -6,3 +6,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.
diff --git a/example-apps/dashnote/.gitignore b/example-apps/dashnote/.gitignore
new file mode 100644
index 0000000..e44841c
--- /dev/null
+++ b/example-apps/dashnote/.gitignore
@@ -0,0 +1,28 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+coverage
+*.local
+playwright-report
+test-results
+e2e/.auth
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/example-apps/dashnote/.prettierignore b/example-apps/dashnote/.prettierignore
new file mode 100644
index 0000000..d72b0f2
--- /dev/null
+++ b/example-apps/dashnote/.prettierignore
@@ -0,0 +1,4 @@
+dist
+node_modules
+coverage
+public/dashnote-lite.html
diff --git a/example-apps/dashnote/.prettierrc.json b/example-apps/dashnote/.prettierrc.json
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/example-apps/dashnote/.prettierrc.json
@@ -0,0 +1 @@
+{}
diff --git a/example-apps/dashnote/CLAUDE.md b/example-apps/dashnote/CLAUDE.md
new file mode 100644
index 0000000..d081179
--- /dev/null
+++ b/example-apps/dashnote/CLAUDE.md
@@ -0,0 +1,65 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code when working in [example-apps/dashnote/](.).
+
+## Project Overview
+
+React + TypeScript + Vite app for personal notes on Dash Platform testnet. Notes have an optional `title`, a required `message`, and Platform-managed `$createdAt` / `$updatedAt` / `$revision`. The UI is a flat recent-notes list plus a single editor/detail pane; auth is required to create/update/delete, but read-only browse works without a mnemonic.
+
+## 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/)
+- `npm run format` / `format:check` — Prettier
+- `npm run preview` — serve production build locally
+
+## Architecture
+
+- **[src/dash/](src/dash/)** — one file per Platform SDK operation. Each exports an async function with a leading JSDoc block naming the SDK method it wraps. No hooks, no UI wrappers — the SDK call is the function. Files in this folder always reference `@dashevo/evo-sdk` (or shared core re-exports / SDK-shape types). App-shell utilities that don't touch the SDK live in [src/lib/](src/lib/) instead.
+- **Shared SDK core** — [src/dash/client.ts](src/dash/client.ts) and [src/dash/keyManager.ts](src/dash/keyManager.ts) re-export `createClient` and `IdentityKeyManager` from `../../../../setupDashClient-core.mjs` (the canonical browser-safe core at the host repo root). No vendoring. The `@dashevo/evo-sdk` bare specifier is aliased in [vite.config.ts](vite.config.ts) to this app's locally installed browser bundle so the shared core resolves the SDK from here.
+- **[src/session/](src/session/)** — `SessionContext.tsx` provides the context (`status`, `error`, `sdk`, `keyManager`, `identityId`, `contractId`, `setContractId`, `log`, `login`, `enterReadOnly`, `logout`) and `useSession.ts` is the consumer hook. Mnemonic lives only in the keyManager closure — never in state, never in localStorage. The SDK + `IdentityKeyManager` are dynamically imported on first auth so the ~8MB WASM bundle doesn't block initial paint.
+- **[src/components/](src/components/)** — standard React. Modals/panels call `src/dash/` functions directly. Notable: [NotesWorkspace.tsx](src/components/NotesWorkspace.tsx) (two-pane list + editor with optimistic cache + background revalidation), [NoteEditor.tsx](src/components/NoteEditor.tsx) (fused title/body editor with byte-budget progress bar), [LoginModal.tsx](src/components/LoginModal.tsx) (paste-or-register contract flow).
+- **[src/hooks/](src/hooks/)** — app-specific hooks: [useTheme.ts](src/hooks/useTheme.ts) (dark mode toggle), [useMediaQuery.ts](src/hooks/useMediaQuery.ts) (`window.matchMedia` via `useSyncExternalStore`).
+- **[src/lib/](src/lib/)** — pure utilities, no SDK references: [logger.ts](src/lib/logger.ts) (`Logger` type + `errorMessage(err)`), [notesCache.ts](src/lib/notesCache.ts) (localStorage-backed note list keyed by identity + contract + network), [rememberedIdentity.ts](src/lib/rememberedIdentity.ts) (last-logged-in identity ID for read-only browse), [fieldLimits.ts](src/lib/fieldLimits.ts) (UTF-8 byte counters for title/message), [format.ts](src/lib/format.ts).
+- **[src/dash/types.ts](src/dash/types.ts)** — shared SDK types (`DashSdk`, `DashKeyManager`, query result shapes) used across every dash helper.
+- **[public/dashnote-lite.html](public/dashnote-lite.html)** — single-file zero-build companion. Read-only Recent notes (with optional owner filter) + Get-by-ID only, loads `@dashevo/evo-sdk` from `esm.sh`, and ships alongside the React app at `<...>/dashnote/dashnote-lite.html` (Vite copies `public/*` into `dist/`). Intentionally self-contained as a learning reference — don't import app code into it.
+- **[test/](test/)** — Vitest + Testing Library. All test files live in this flat directory and are named after the subject under test (e.g. `NotesWorkspace.test.tsx`, `SessionContext.test.tsx`, `notesCache.test.ts`) — they are **not** co-located next to source files, and the directory is **not** mirrored against `src/`. Default Vitest env is `node`; component tests opt into DOM with a `// @vitest-environment jsdom` pragma at the top of the file.
+
+## Note contract
+
+Schema lives in [src/dash/contract.ts](src/dash/contract.ts) as `NOTE_SCHEMAS`. One document type, `note`:
+
+- `title` — optional string, max 120 chars, position 0
+- `message` — required string, max 10000 chars, position 1
+- `$createdAt`, `$updatedAt` — required (Platform-managed)
+- Indices: `byOwnerUpdated` (`$ownerId`, `$updatedAt`) and `byOwnerCreated` (`$ownerId`, `$createdAt`)
+- `documentsMutable: true`, `canBeDeleted: true` — notes are editable and deletable
+
+`DEFAULT_CONTRACT_ID` is `8d6heK6CoskLBi6Rs7cChRG9RuckcZqZst28BdviBe8y`. Overrides are stored under `localStorage['dashnote.contractId']`. Settings can also register a fresh contract for the logged-in identity and immediately switch the app to it.
+
+## SDK Patterns
+
+- **Connect**: `await createClient("testnet")` from [client.ts](src/dash/client.ts) — re-exported from the shared core, which internally does `EvoSDK.testnetTrusted()` + `sdk.connect()`. App code never constructs the SDK directly.
+- **Key derivation**: `IdentityKeyManager` from the shared core; `keyManager.getAuth()` returns `{ identity, identityKey, signer }`
+- **Register contract**: `new DataContract({ ownerId, identityNonce, schemas, fullValidation })` then `sdk.contracts.publish({ dataContract, identityKey, signer })`. Nonce is `(sdk.identities.nonce(id) || 0n) + 1n`.
+- **Create note**: `sdk.documents.create({ document, identityKey, signer })` where `document = new Document({ properties: { title?, message }, documentTypeName: "note", dataContractId, ownerId })`
+- **Update note**: fetch existing via `sdk.documents.get(...)`, bump `revision = BigInt(existing.revision) + 1n`, then `sdk.documents.replace({ document, identityKey, signer })`
+- **Delete note**: `sdk.documents.delete({ document: { id, ownerId, dataContractId, documentTypeName: "note" }, identityKey, signer })`
+- **List my notes**: `sdk.documents.query({ dataContractId, documentTypeName: "note", where: [["$ownerId", "==", ownerId]], orderBy: [["$ownerId", "asc"], ["$updatedAt", "asc"]], limit })`
+- **Get one note**: `sdk.documents.get(contractId, "note", noteId)`
+
+`normalizeNotes()` and `normalizeSingleNote()` in [queries.ts](src/dash/queries.ts) flatten whatever shape `sdk.documents.query` / `sdk.documents.get` returns (array, Map, or plain object) into `NoteRecord[]` so UI code never branches on it.
+
+## Gotchas
+
+- Update flow **must** fetch the document first to get the current revision; submitting a replace with the wrong `revision` will fail the state transition. The pattern is `BigInt(existing.revision ?? 0) + 1n`.
+- `keepsHistory` on the contract is forced to `false`. `keepsHistory: true` triggers [dashpay/platform#3165](https://github.com/dashpay/platform/issues/3165) — `sdk.contracts.fetch()` returns undefined and breaks `sdk.documents.query` with "Data contract not found". v1 of dashnote shows revision metadata only — older note bodies are not reconstructable from the network.
+- Read-only mode (`session.status === "readonly"`) sets `keyManager` to `null`. Any write path (`createNote`, `updateNote`, `deleteNote`, `registerContract`) must guard for an authenticated session.
+- The notes cache in [src/lib/notesCache.ts](src/lib/notesCache.ts) is keyed by `identityId + contractId + network`. Switching identity, contract, or network invalidates the cache. Schema is versioned (`SCHEMA_VERSION = 1`); bumping it discards prior cached payloads.
+- Background revalidation runs every `BACKGROUND_REFRESH_MS` (30s); refocus revalidation is throttled to `FOCUS_REFRESH_MIN_MS` (10s). Both compare via `notesEqualByRevision` so identical results don't trigger re-renders.
+- Title/message length is enforced in **bytes**, not chars — emoji and non-ASCII multi-byte sequences eat budget. [src/lib/fieldLimits.ts](src/lib/fieldLimits.ts) is the source of truth; the editor's progress bar and the contract `maxLength` should stay in sync.
+- The `Logger` from [src/lib/logger.ts](src/lib/logger.ts) is plumbed through every dash helper. `level: "success"` and `level: "error"` also raise sonner toasts via `SessionContext.log`.
+- The Evo SDK WASM bundle is ~8MB; this is expected and not a build error.
+- `allowJs: true` in [tsconfig.app.json](tsconfig.app.json) so TypeScript can import the JSDoc-typed `.mjs` core at the host repo root.
diff --git a/example-apps/dashnote/README.md b/example-apps/dashnote/README.md
new file mode 100644
index 0000000..90b325c
--- /dev/null
+++ b/example-apps/dashnote/README.md
@@ -0,0 +1,122 @@
+# Dashnote — Dash Platform Notes
+
+`Dashnote` is a React + TypeScript + Vite example app for personal notes on Dash Platform testnet.
+
+The app stays close to the tutorial `note` contract shape, but extends it with an optional `title`, a required `message`, and required Platform timestamps. Notes are editable, deletable, and shown in a calm two-pane notebook UI.
+
+## Prerequisites
+
+- Node >= 20
+- A funded Dash Platform testnet identity (BIP-39 mnemonic + identity index) for write operations
+- Read-only mode works without any identity — visitors can read notes for any identity ID against the bundled contract
+
+## Quick start
+
+```bash
+npm install
+npm run dev
+```
+
+Other scripts:
+
+```bash
+npm run build # tsc -b && vite build
+npm run test # Vitest suite
+npm run lint # ESLint
+npm run format # Prettier (write)
+npm run format:check # Prettier (check only)
+npm run preview # Serve the production build
+```
+
+## Current app behavior
+
+- The app auto-connects in read-only mode on load against the bundled default contract.
+- Creating, editing, and deleting notes requires login with a testnet identity.
+- The primary screen is a flat recent-notes list plus a note editor/detail pane.
+- `title` is optional; `message` is required.
+- If `title` is blank, the UI uses the first non-empty line of `message`.
+- If both are blank, the note renders as `Untitled`.
+- Field length is enforced in **bytes**, not characters; the editor's progress bar reflects the UTF-8 budget.
+
+## Contract and Settings flow
+
+- The app ships with a bundled deployed note contract ID (`8d6heK6CoskLBi6Rs7cChRG9RuckcZqZst28BdviBe8y`) so read-only browse and verification flows work immediately on a fresh machine.
+- The login modal becomes a Settings modal after authentication.
+- Settings can:
+ - paste and reuse an existing note contract ID
+ - register a fresh Dashnote note contract on testnet
+ - switch the app to that newly registered contract immediately
+- Overrides persist under `localStorage['dashnote.contractId']`. Clearing storage falls back to the bundled default.
+
+## Contract schema notes
+
+- The schema in [`src/dash/contract.ts`](./src/dash/contract.ts) defines a single document type, `note`, with `title` (optional, max 120 chars), `message` (required, max 10000 chars), and required Platform-managed `$createdAt` / `$updatedAt`.
+- Indices: `byOwnerUpdated` (`$ownerId`, `$updatedAt`) and `byOwnerCreated` (`$ownerId`, `$createdAt`) — both are how the recent-notes list paginates and sorts.
+- `documentsMutable: true` and `canBeDeleted: true` — notes are editable and deletable.
+- `keepsHistory` is forced to `false`. `keepsHistory: true` triggers [dashpay/platform#3165](https://github.com/dashpay/platform/issues/3165), where `sdk.contracts.fetch()` returns undefined and breaks `sdk.documents.query` with "Data contract not found". This is why v1 only shows revision metadata, not previous versions of notes.
+
+## Platform operations at a glance
+
+Every SDK call lives in its own file under [`src/dash/`](./src/dash/). Open the file to see the full implementation with a JSDoc header naming the SDK method it wraps.
+
+| Operation | File | SDK method |
+| ---------------------- | ---------------------------------------------------- | --------------------------------------------- |
+| Connect to testnet | [`src/dash/client.ts`](./src/dash/client.ts) | `EvoSDK.testnetTrusted()` + `sdk.connect()` |
+| Derive identity keys | [`src/dash/keyManager.ts`](./src/dash/keyManager.ts) | wallet/key derivation helpers |
+| Register note contract | [`src/dash/contract.ts`](./src/dash/contract.ts) | `sdk.contracts.publish` |
+| Create a note | [`src/dash/createNote.ts`](./src/dash/createNote.ts) | `sdk.documents.create` |
+| Update a note | [`src/dash/updateNote.ts`](./src/dash/updateNote.ts) | `sdk.documents.get` + `sdk.documents.replace` |
+| Delete a note | [`src/dash/deleteNote.ts`](./src/dash/deleteNote.ts) | `sdk.documents.delete` |
+| List my notes | [`src/dash/queries.ts`](./src/dash/queries.ts) | `sdk.documents.query` |
+| Get one note | [`src/dash/queries.ts`](./src/dash/queries.ts) | `sdk.documents.get` |
+
+Update flow always fetches the document first to read its current revision, then submits a replace with `revision = BigInt(existing.revision ?? 0) + 1n`. Replays without bumping the revision are rejected by the state transition.
+
+Supporting files:
+
+- **[`src/dash/types.ts`](./src/dash/types.ts)** — shared SDK types (`DashSdk`, `DashKeyManager`, query result shapes) wired through every dash helper.
+- **[`src/lib/logger.ts`](./src/lib/logger.ts)** — `Logger` function type and `errorMessage(err)` helper. Plumbed through every dash call so progress messages stream to the activity log and `level: "success" | "error"` raise sonner toasts.
+- **[`src/lib/notesCache.ts`](./src/lib/notesCache.ts)** — localStorage-backed note list keyed by `identityId + contractId + network`. Powers optimistic paint on reload before background revalidation completes.
+- **[`src/lib/rememberedIdentity.ts`](./src/lib/rememberedIdentity.ts)** — last-logged-in identity ID for read-only browse. Never stores the mnemonic.
+
+## Reading the codebase
+
+Recommended order for understanding how the app works:
+
+1. **[`src/dash/`](./src/dash/)** — start here. One file per Platform operation, each with a JSDoc block naming the SDK method. Read [`createNote.ts`](./src/dash/createNote.ts) first (simplest write flow), then [`updateNote.ts`](./src/dash/updateNote.ts) (the fetch → bump revision → replace pattern).
+
+2. **[`src/dash/contract.ts`](./src/dash/contract.ts)** — the `note` schema, indices, and the `registerContract` / `ensureContract` helpers used by Settings.
+
+3. **[`src/session/SessionContext.tsx`](./src/session/SessionContext.tsx)** — manages the SDK connection, identity, contract ID, and activity log. The mnemonic never enters React state; it lives only inside the `keyManager` closure and is garbage-collected on logout. The consumer hook lives in [`useSession.ts`](./src/session/useSession.ts).
+
+4. **[`src/components/`](./src/components/)** — standard React UI. [`NotesWorkspace.tsx`](./src/components/NotesWorkspace.tsx) is the two-pane list + editor with optimistic cache and background revalidation. [`NoteEditor.tsx`](./src/components/NoteEditor.tsx) is the fused title/body editor with a UTF-8 byte-budget progress bar. [`LoginModal.tsx`](./src/components/LoginModal.tsx) wires the paste-or-register contract flow.
+
+5. **[`src/hooks/`](./src/hooks/)** — [`useTheme`](./src/hooks/useTheme.ts) for dark mode, [`useMediaQuery`](./src/hooks/useMediaQuery.ts) for the mobile-vs-desktop layout switch via `window.matchMedia`.
+
+6. **[`src/lib/`](./src/lib/)** — pure utilities, no SDK references: [`fieldLimits.ts`](./src/lib/fieldLimits.ts) (UTF-8 byte counters), [`format.ts`](./src/lib/format.ts), plus `logger.ts` / `notesCache.ts` / `rememberedIdentity.ts` described above.
+
+For deeper architecture and gotchas, see [`CLAUDE.md`](./CLAUDE.md).
+
+## Tests
+
+[`test/`](./test/) uses Vitest + Testing Library, flat-not-mirrored, named after the subject under test. The default Vitest environment is Node; component tests opt into jsdom per-file with `// @vitest-environment jsdom`. Run with `npm run test`.
+
+The suite covers:
+
+- contract schema and registration config
+- note query normalization and sorting
+- create / update / delete mutation helpers
+- note-title fallback formatting
+- notes cache load/save/clear and revision-equality
+- remembered identity persistence
+- notebook UI flows for auth gating, create, update, and delete
+
+## Tech stack
+
+- React 19
+- TypeScript
+- Vite 8
+- Tailwind CSS v4
+- Vitest 4 + Testing Library
+- `@dashevo/evo-sdk`
+- sonner (toasts)
diff --git a/example-apps/dashnote/eslint.config.js b/example-apps/dashnote/eslint.config.js
new file mode 100644
index 0000000..c91d769
--- /dev/null
+++ b/example-apps/dashnote/eslint.config.js
@@ -0,0 +1,23 @@
+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";
+import { defineConfig, globalIgnores } from "eslint/config";
+
+export default defineConfig([
+ globalIgnores(["coverage", "dist", "playwright-report", "test-results"]),
+ {
+ files: ["**/*.{ts,tsx}"],
+ extends: [
+ js.configs.recommended,
+ tseslint.configs.recommended,
+ reactHooks.configs.flat.recommended,
+ reactRefresh.configs.vite,
+ ],
+ languageOptions: {
+ ecmaVersion: 2020,
+ globals: globals.browser,
+ },
+ },
+]);
diff --git a/example-apps/dashnote/index.html b/example-apps/dashnote/index.html
new file mode 100644
index 0000000..10125fd
--- /dev/null
+++ b/example-apps/dashnote/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Dashnote — Dash Platform Notes
+
+
+
+
+
+
diff --git a/example-apps/dashnote/package-lock.json b/example-apps/dashnote/package-lock.json
new file mode 100644
index 0000000..5d8a80a
--- /dev/null
+++ b/example-apps/dashnote/package-lock.json
@@ -0,0 +1,4520 @@
+{
+ "name": "dashnote",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "dashnote",
+ "version": "0.0.0",
+ "dependencies": {
+ "@dashevo/evo-sdk": "3.1.0-dev.1",
+ "@tailwindcss/vite": "^4.2.2",
+ "react": "^19.2.4",
+ "react-dom": "^19.2.4",
+ "sonner": "^2.0.7",
+ "tailwindcss": "^4.2.2"
+ },
+ "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",
+ "dotenv": "^17.3.1",
+ "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"
+ }
+ },
+ "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.0",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
+ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
+ "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
+ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helpers": "^7.28.6",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@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.1",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
+ "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@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.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.28.6",
+ "@babel/helper-validator-option": "^7.27.1",
+ "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.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
+ "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
+ "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
+ "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
+ "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.28.6",
+ "@babel/parser": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
+ "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "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": "3.1.0-dev.1",
+ "resolved": "https://registry.npmjs.org/@dashevo/evo-sdk/-/evo-sdk-3.1.0-dev.1.tgz",
+ "integrity": "sha512-afVJhPXkgrg2vlLgyAgcWqUeugXhA5XkfssQZWQGNXlZkH7/22yB9sd37UX+tWtvoqEjxKzygryzTngt1SI44w==",
+ "dependencies": {
+ "@dashevo/wasm-sdk": "3.1.0-dev.1"
+ },
+ "engines": {
+ "node": ">=18.18"
+ }
+ },
+ "node_modules/@dashevo/wasm-sdk": {
+ "version": "3.1.0-dev.1",
+ "resolved": "https://registry.npmjs.org/@dashevo/wasm-sdk/-/wasm-sdk-3.1.0-dev.1.tgz",
+ "integrity": "sha512-s4ECro2+zCehfag7XVqbxrsDqOAZN2yMIGhMNjjeFesaOqOl1bUWffs3QMtEeuMZdYtKcZxUrlKsW1KlfLN/WQ==",
+ "engines": {
+ "node": ">=18.18"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.9.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
+ "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.1",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.9.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
+ "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
+ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+ "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.1",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
+ "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node": {
+ "version": "0.16.7",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
+ "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/core": "^0.19.1",
+ "@humanwhocodes/retry": "^0.4.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==",
+ "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==",
+ "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==",
+ "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==",
+ "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==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
+ "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.1"
+ },
+ "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.124.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz",
+ "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
+ "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.59.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz",
+ "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz",
+ "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz",
+ "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz",
+ "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz",
+ "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz",
+ "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz",
+ "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz",
+ "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz",
+ "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz",
+ "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz",
+ "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz",
+ "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz",
+ "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "1.9.2",
+ "@emnapi/runtime": "1.9.2",
+ "@napi-rs/wasm-runtime": "^1.1.3"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz",
+ "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz",
+ "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.7",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz",
+ "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==",
+ "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/@tailwindcss/node": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
+ "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "enhanced-resolve": "^5.19.0",
+ "jiti": "^2.6.1",
+ "lightningcss": "1.32.0",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.2.2"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz",
+ "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.2.2",
+ "@tailwindcss/oxide-darwin-arm64": "4.2.2",
+ "@tailwindcss/oxide-darwin-x64": "4.2.2",
+ "@tailwindcss/oxide-freebsd-x64": "4.2.2",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.2.2",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.2.2",
+ "@tailwindcss/oxide-linux-x64-musl": "4.2.2",
+ "@tailwindcss/oxide-wasm32-wasi": "4.2.2",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.2.2"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz",
+ "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz",
+ "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz",
+ "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz",
+ "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz",
+ "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz",
+ "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz",
+ "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz",
+ "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz",
+ "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz",
+ "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.8.1",
+ "@emnapi/runtime": "^1.8.1",
+ "@emnapi/wasi-threads": "^1.1.0",
+ "@napi-rs/wasm-runtime": "^1.1.1",
+ "@tybys/wasm-util": "^0.10.1",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz",
+ "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz",
+ "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/vite": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz",
+ "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==",
+ "license": "MIT",
+ "dependencies": {
+ "@tailwindcss/node": "4.2.2",
+ "@tailwindcss/oxide": "4.2.2",
+ "tailwindcss": "4.2.2"
+ },
+ "peerDependencies": {
+ "vite": "^5.2.0 || ^6 || ^7 || ^8"
+ }
+ },
+ "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.1",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
+ "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
+ "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.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "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.12.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
+ "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.16.0"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.14",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
+ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "8.58.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz",
+ "integrity": "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.12.2",
+ "@typescript-eslint/scope-manager": "8.58.2",
+ "@typescript-eslint/type-utils": "8.58.2",
+ "@typescript-eslint/utils": "8.58.2",
+ "@typescript-eslint/visitor-keys": "8.58.2",
+ "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.58.2",
+ "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.58.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz",
+ "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "8.58.2",
+ "@typescript-eslint/types": "8.58.2",
+ "@typescript-eslint/typescript-estree": "8.58.2",
+ "@typescript-eslint/visitor-keys": "8.58.2",
+ "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.58.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.2.tgz",
+ "integrity": "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.58.2",
+ "@typescript-eslint/types": "^8.58.2",
+ "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.58.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.2.tgz",
+ "integrity": "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.58.2",
+ "@typescript-eslint/visitor-keys": "8.58.2"
+ },
+ "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.58.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz",
+ "integrity": "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==",
+ "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.58.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.2.tgz",
+ "integrity": "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.58.2",
+ "@typescript-eslint/typescript-estree": "8.58.2",
+ "@typescript-eslint/utils": "8.58.2",
+ "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.58.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz",
+ "integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==",
+ "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.58.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz",
+ "integrity": "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.58.2",
+ "@typescript-eslint/tsconfig-utils": "8.58.2",
+ "@typescript-eslint/types": "8.58.2",
+ "@typescript-eslint/visitor-keys": "8.58.2",
+ "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.5",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
+ "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
+ "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.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "8.58.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.2.tgz",
+ "integrity": "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.58.2",
+ "@typescript-eslint/types": "8.58.2",
+ "@typescript-eslint/typescript-estree": "8.58.2"
+ },
+ "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.58.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz",
+ "integrity": "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.58.2",
+ "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.1",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
+ "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "1.0.0-rc.7"
+ },
+ "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.5",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz",
+ "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@bcoe/v8-coverage": "^1.0.2",
+ "@vitest/utils": "4.1.5",
+ "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.5",
+ "vitest": "4.1.5"
+ },
+ "peerDependenciesMeta": {
+ "@vitest/browser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
+ "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.5",
+ "@vitest/utils": "4.1.5",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz",
+ "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.5",
+ "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.5",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz",
+ "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz",
+ "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.5",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz",
+ "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.5",
+ "@vitest/utils": "4.1.5",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz",
+ "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz",
+ "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.5",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
+ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/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.14.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
+ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
+ "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.0",
+ "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz",
+ "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==",
+ "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.19",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz",
+ "integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==",
+ "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.14",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
+ "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.2",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
+ "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
+ "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.12",
+ "caniuse-lite": "^1.0.30001782",
+ "electron-to-chromium": "^1.5.328",
+ "node-releases": "^2.0.36",
+ "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.30001788",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz",
+ "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==",
+ "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==",
+ "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/dotenv": {
+ "version": "17.3.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
+ "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.339",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.339.tgz",
+ "integrity": "sha512-Is+0BBHJ4NrdpAYiperrmp53pLywG/yV/6lIMTAnhxvzj/Cmn5Q/ogSHC6AKe7X+8kPLxxFk0cs5oc/3j/fxIg==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.20.1",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
+ "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==",
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.3.0"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "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.0.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
+ "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
+ "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.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz",
+ "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==",
+ "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"
+ }
+ },
+ "node_modules/eslint-plugin-react-refresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz",
+ "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==",
+ "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==",
+ "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==",
+ "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.5.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-17.5.0.tgz",
+ "integrity": "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "license": "ISC"
+ },
+ "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/jiti": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
+ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
+ "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
+ "dev": true,
+ "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==",
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "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"
+ ],
+ "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"
+ ],
+ "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"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/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==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/magicast": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz",
+ "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.0",
+ "@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.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "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.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "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.37",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz",
+ "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nwsapi": {
+ "version": "2.2.23",
+ "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz",
+ "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/obug": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
+ "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT"
+ },
+ "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==",
+ "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==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
+ "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.59.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
+ "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
+ "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.10",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
+ "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
+ "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.11",
+ "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.3",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
+ "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
+ "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.5",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
+ "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.5",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
+ "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.5"
+ }
+ },
+ "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.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz",
+ "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==",
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.124.0",
+ "@rolldown/pluginutils": "1.0.0-rc.15"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.0.0-rc.15",
+ "@rolldown/binding-darwin-arm64": "1.0.0-rc.15",
+ "@rolldown/binding-darwin-x64": "1.0.0-rc.15",
+ "@rolldown/binding-freebsd-x64": "1.0.0-rc.15",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15",
+ "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15",
+ "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15",
+ "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15",
+ "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15",
+ "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15",
+ "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15",
+ "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15",
+ "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15",
+ "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15",
+ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15"
+ }
+ },
+ "node_modules/rolldown/node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz",
+ "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==",
+ "license": "MIT"
+ },
+ "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/sonner": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
+ "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
+ "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ }
+ },
+ "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==",
+ "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/tailwindcss": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
+ "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz",
+ "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "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.1.1",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz",
+ "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.16",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+ "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+ "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==",
+ "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.2",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
+ "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/typescript-eslint": {
+ "version": "8.58.2",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.2.tgz",
+ "integrity": "sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/eslint-plugin": "8.58.2",
+ "@typescript-eslint/parser": "8.58.2",
+ "@typescript-eslint/typescript-estree": "8.58.2",
+ "@typescript-eslint/utils": "8.58.2"
+ },
+ "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.16.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
+ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.0.8",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz",
+ "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==",
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.32.0",
+ "picomatch": "^4.0.4",
+ "postcss": "^8.5.8",
+ "rolldown": "1.0.0-rc.15",
+ "tinyglobby": "^0.2.15"
+ },
+ "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.1.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.5",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz",
+ "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.5",
+ "@vitest/mocker": "4.1.5",
+ "@vitest/pretty-format": "4.1.5",
+ "@vitest/runner": "4.1.5",
+ "@vitest/snapshot": "4.1.5",
+ "@vitest/spy": "4.1.5",
+ "@vitest/utils": "4.1.5",
+ "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.5",
+ "@vitest/browser-preview": "4.1.5",
+ "@vitest/browser-webdriverio": "4.1.5",
+ "@vitest/coverage-istanbul": "4.1.5",
+ "@vitest/coverage-v8": "4.1.5",
+ "@vitest/ui": "4.1.5",
+ "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.20.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
+ "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
+ "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.3.6",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
+ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
+ "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/dashnote/package.json b/example-apps/dashnote/package.json
new file mode 100644
index 0000000..9d84e4d
--- /dev/null
+++ b/example-apps/dashnote/package.json
@@ -0,0 +1,47 @@
+{
+ "name": "dashnote",
+ "displayName": "Dashnote",
+ "description": "Personal notes on Dash Platform testnet with editable note documents, timestamps, and revision metadata.",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "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",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@dashevo/evo-sdk": "3.1.0-dev.1",
+ "@tailwindcss/vite": "^4.2.2",
+ "react": "^19.2.4",
+ "react-dom": "^19.2.4",
+ "sonner": "^2.0.7",
+ "tailwindcss": "^4.2.2"
+ },
+ "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",
+ "dotenv": "^17.3.1",
+ "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"
+ }
+}
diff --git a/example-apps/dashnote/public/dashnote-lite.html b/example-apps/dashnote/public/dashnote-lite.html
new file mode 100644
index 0000000..4227bc0
--- /dev/null
+++ b/example-apps/dashnote/public/dashnote-lite.html
@@ -0,0 +1,394 @@
+
+
+
+
+
+
+ DashNote Lite
+
+
+
+
+
+
+
+
+ DashNote Lite
+ Testnet
+
+
+ Recent notes
+ Get by ID
+
+ Connecting…
+
+
+
+
+
+ Recent notes
+ The latest notes on the contract. Optionally filter by an owner identity ID.
+
+
+ Refresh
+
+
+
+
+
+ Get note by ID
+ Fetch a single note document by its $id.
+
+
+ Fetch
+
+
+
+
+
+ Contract:
+
+
+
+
+
+
+
diff --git a/example-apps/dashnote/src/App.tsx b/example-apps/dashnote/src/App.tsx
new file mode 100644
index 0000000..a751451
--- /dev/null
+++ b/example-apps/dashnote/src/App.tsx
@@ -0,0 +1,100 @@
+import { useEffect, useMemo, useState } from "react";
+import { Toaster } from "sonner";
+
+import { AppShell } from "./components/AppShell";
+import { HowItWorks } from "./components/HowItWorks";
+import { LoginModal } from "./components/LoginModal";
+import { NotesWorkspace } from "./components/NotesWorkspace";
+import { OperationResultNotice } from "./components/OperationResultNotice";
+import type { TopTab } from "./components/Tabs";
+import { useSession } from "./session/useSession";
+
+const screenCopy: Record = {
+ notes: {
+ title: "Personal notes on Dash Platform",
+ subtitle:
+ "Create, edit, and review note metadata with a simple two-pane notebook UI.",
+ },
+ "how-it-works": {
+ title: "How Dashnote works",
+ subtitle:
+ "See how the note contract, mutation helpers, and notebook UI line up with the tutorials.",
+ },
+};
+
+function App() {
+ const session = useSession();
+ const { status, sdk, enterReadOnly, viewAsRemembered } = session;
+ const [tab, setTab] = useState("notes");
+ const [loginOpen, setLoginOpen] = useState(false);
+
+ const mobileFullBleed = tab === "notes";
+
+ useEffect(() => {
+ if (status === "idle") void enterReadOnly();
+ else if (status === "browsing" && !sdk) void viewAsRemembered();
+ }, [enterReadOnly, viewAsRemembered, status, sdk]);
+
+ const header = useMemo(() => screenCopy[tab], [tab]);
+
+ return (
+ <>
+
+ setLoginOpen(true)}
+ mobileFullBleed={mobileFullBleed}
+ >
+
+
+
+ {session.error && (
+
+
+ {session.error}
+
+
+ )}
+
+ {tab === "notes" && (
+
setLoginOpen(true)} />
+ )}
+ {tab === "how-it-works" && }
+
+
+
+ setLoginOpen(false)} />
+ >
+ );
+}
+
+export default App;
diff --git a/example-apps/dashnote/src/components/AppShell.tsx b/example-apps/dashnote/src/components/AppShell.tsx
new file mode 100644
index 0000000..cfb0581
--- /dev/null
+++ b/example-apps/dashnote/src/components/AppShell.tsx
@@ -0,0 +1,246 @@
+import { useState, type ReactNode } from "react";
+
+import { useTheme } from "../hooks/useTheme";
+import type { SessionStatus } from "../session/SessionContext";
+import { IdentityCard } from "./IdentityCard";
+import { NavButton } from "./NavButton";
+import type { TopTab } from "./Tabs";
+
+interface AppShellProps {
+ tab: TopTab;
+ onTabChange: (tab: TopTab) => void;
+ status: SessionStatus;
+ identityId: string | null;
+ dpnsName: string | null;
+ contractId: string | null;
+ onLoginOpen: () => void;
+ children: ReactNode;
+ mobileFullBleed?: boolean;
+}
+
+function LogoAvatar() {
+ return (
+
+ );
+}
+
+function ThemeToggle() {
+ const { theme, toggle } = useTheme();
+ const isDark = theme === "dark";
+ return (
+
+ {isDark ? (
+
+
+
+
+ ) : (
+
+
+
+ )}
+
+ );
+}
+
+export function AppShell({
+ tab,
+ onTabChange,
+ status,
+ identityId,
+ dpnsName,
+ contractId,
+ onLoginOpen,
+ children,
+ mobileFullBleed = false,
+}: AppShellProps) {
+ const [drawerOpen, setDrawerOpen] = useState(false);
+ const closeDrawer = () => setDrawerOpen(false);
+
+ const nav = (
+
+ {
+ onTabChange("notes");
+ closeDrawer();
+ }}
+ />
+ {
+ onTabChange("how-it-works");
+ closeDrawer();
+ }}
+ />
+ {status !== "authenticated" && (
+ {
+ onLoginOpen();
+ closeDrawer();
+ }}
+ />
+ )}
+
+ );
+
+ return (
+
+
+
+
+
+
setDrawerOpen(!drawerOpen)}
+ aria-label={drawerOpen ? "Close menu" : "Open menu"}
+ title={drawerOpen ? "Close menu" : "Open menu"}
+ aria-expanded={drawerOpen}
+ className="flex h-8 w-8 items-center justify-center rounded-md text-ink-3 hover:text-ink"
+ >
+
+
+
+
+
+
+
+ {drawerOpen && (
+
+ )}
+
+
+
+
+
+
Dashnote
+
+ Duly Noted
+
+
+
+
+
+
+
+ {nav}
+
+
+ Notes are mutable documents on Dash Platform. Dashnote shows the
+ current note state plus created, updated, and revision metadata.
+
+
+
+ {
+ onLoginOpen();
+ closeDrawer();
+ }}
+ />
+
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
diff --git a/example-apps/dashnote/src/components/HowItWorks.tsx b/example-apps/dashnote/src/components/HowItWorks.tsx
new file mode 100644
index 0000000..d519af9
--- /dev/null
+++ b/example-apps/dashnote/src/components/HowItWorks.tsx
@@ -0,0 +1,53 @@
+export function HowItWorks() {
+ return (
+
+
+
+ Product model
+
+
+ Editable notes on a tutorial contract
+
+
+
+ Dashnote keeps the tutorial document type name as note,
+ then adds an optional title plus a required{" "}
+ message.
+
+
+ Each save uses document replacement, so the UI can show the current
+ revision number and the Platform-provided $createdAt{" "}
+ and $updatedAt timestamps.
+
+
+ v1 does not reconstruct earlier note bodies. History here means the
+ current document state plus metadata about when it was created and
+ last updated.
+
+
+
+
+
+
+ Code map
+
+
+
+ src/dash/contract.ts defines the note schema and
+ registration flow.
+
+
+ src/dash/createNote.ts,{" "}
+ src/dash/updateNote.ts, and{" "}
+ src/dash/deleteNote.ts each wrap one Platform mutation.
+
+
+ src/dash/queries.ts handles the note list and note
+ detail reads, while src/components/NotesWorkspace.tsx{" "}
+ owns the notebook UI state.
+
+
+
+
+ );
+}
diff --git a/example-apps/dashnote/src/components/IdentityCard.tsx b/example-apps/dashnote/src/components/IdentityCard.tsx
new file mode 100644
index 0000000..82c56db
--- /dev/null
+++ b/example-apps/dashnote/src/components/IdentityCard.tsx
@@ -0,0 +1,121 @@
+import type { SessionStatus } from "../session/SessionContext";
+import { truncateId } from "../lib/format";
+
+interface IdentityCardProps {
+ status: SessionStatus;
+ identityId: string | null;
+ dpnsName: string | null;
+ contractId: string | null;
+ onLoginClick: () => void;
+}
+
+function avatarGradient(seed: string | null): string {
+ if (!seed) {
+ return "conic-gradient(from 0deg, oklch(40% 0.02 260), oklch(30% 0.02 260))";
+ }
+ let hash = 0;
+ for (let i = 0; i < seed.length; i += 1) {
+ hash = (hash + seed.charCodeAt(i) * 37) % 360;
+ }
+ return `conic-gradient(from ${hash}deg, oklch(65% 0.15 ${hash}), oklch(50% 0.12 ${(hash + 120) % 360}), oklch(65% 0.15 ${hash}))`;
+}
+
+export function IdentityCard({
+ status,
+ identityId,
+ dpnsName,
+ contractId,
+ onLoginClick,
+}: IdentityCardProps) {
+ const isAuthed = status === "authenticated";
+ const isBrowsing = status === "browsing";
+ const isConnected = status === "readonly" || isAuthed || isBrowsing;
+
+ if (!isConnected) {
+ return (
+
+
+ Login
+
+
+
+
+ {status === "connecting"
+ ? "Connecting..."
+ : status === "error"
+ ? "Error"
+ : "Offline"}
+
+
+
+ );
+ }
+
+ return (
+
+ {(isAuthed || isBrowsing) && (
+ <>
+
+
+ {isAuthed ? "Signed in" : "Read-only"}
+
+
+ {isAuthed ? "Settings" : "Sign in"}
+
+
+
+
+
+
+ {dpnsName
+ ? `@${dpnsName}`
+ : identityId
+ ? truncateId(identityId, 6)
+ : "Identity"}
+
+
+ {dpnsName && identityId
+ ? truncateId(identityId, 6)
+ : contractId
+ ? `contract ${truncateId(contractId, 6)}`
+ : "No contract"}
+
+
+
+ >
+ )}
+
+
+
+
+ {isAuthed
+ ? "Authenticated"
+ : isBrowsing
+ ? "Browsing (read-only)"
+ : "Connected"}
+
+
+
+ );
+}
diff --git a/example-apps/dashnote/src/components/LoginModal.tsx b/example-apps/dashnote/src/components/LoginModal.tsx
new file mode 100644
index 0000000..cb6e2e2
--- /dev/null
+++ b/example-apps/dashnote/src/components/LoginModal.tsx
@@ -0,0 +1,391 @@
+import { useEffect, useState, type FormEvent } from "react";
+
+import { registerContract } from "../dash/contract";
+import { errorMessage } from "../lib/logger";
+import { useSession } from "../session/useSession";
+import { Modal } from "./Modal";
+import { OperationResultNotice } from "./OperationResultNotice";
+
+export interface LoginModalProps {
+ open: boolean;
+ onClose: () => void;
+}
+
+export function LoginModal({ open, onClose }: LoginModalProps) {
+ const session = useSession();
+ const [mnemonic, setMnemonic] = useState("");
+ const [identityIndex, setIdentityIndex] = useState("0");
+ const [contractInput, setContractInput] = useState(session.contractId ?? "");
+ const [error, setError] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
+ const [showAdvanced, setShowAdvanced] = useState(false);
+ const [registering, setRegistering] = useState(false);
+ const [rememberMe, setRememberMe] = useState(true);
+ const [useDifferentIdentity, setUseDifferentIdentity] = useState(false);
+ const loggedIn = session.status === "authenticated";
+ const showRememberedPanel = Boolean(
+ session.rememberedIdentityId && !useDifferentIdentity,
+ );
+
+ useEffect(() => {
+ setContractInput(session.contractId ?? "");
+ }, [session.contractId]);
+
+ useEffect(() => {
+ if (open) {
+ setRememberMe(true);
+ setUseDifferentIdentity(false);
+ setError(null);
+ setMnemonic("");
+ }
+ }, [open]);
+
+ function applyContractId() {
+ session.setContractId(contractInput.trim() || null);
+ }
+
+ async function handleRegisterContract() {
+ if (!session.sdk || !session.keyManager) return;
+ setError(null);
+ setRegistering(true);
+ try {
+ const contractId = await registerContract({
+ sdk: session.sdk,
+ keyManager: session.keyManager,
+ log: session.log,
+ });
+ session.setContractId(contractId);
+ setContractInput(contractId);
+ } catch (err) {
+ setError(errorMessage(err));
+ } finally {
+ setRegistering(false);
+ }
+ }
+
+ async function handleLogin(event: FormEvent) {
+ event.preventDefault();
+ setError(null);
+ setSubmitting(true);
+ try {
+ const index = Number.parseInt(identityIndex, 10);
+ await session.login(mnemonic, {
+ identityIndex: Number.isNaN(index) ? 0 : index,
+ rememberMe,
+ });
+ setMnemonic("");
+ onClose();
+ } catch (err) {
+ setError(errorMessage(err));
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ return (
+
+ {loggedIn ? (
+
+
+
+ Identity
+
+
+ {session.identityId ?? "—"}
+
+ {session.dpnsName && (
+
+ ✓ {session.dpnsName}.dash
+
+ )}
+
+ {
+ session.logout();
+ }}
+ className="font-medium text-accent-dim underline-offset-2 hover:text-accent hover:underline"
+ >
+ Use a different identity
+
+ {session.rememberedIdentityId && (
+ session.forgetIdentity()}
+ className="font-medium text-ink-3 underline-offset-2 hover:text-[color:var(--color-danger)] hover:underline"
+ >
+ Forget this device
+
+ )}
+
+
+
+
setShowAdvanced(!showAdvanced)}
+ className="flex items-center gap-1 self-start text-[11px] font-medium text-ink-3 transition hover:text-ink"
+ >
+
+ ▶
+
+ Advanced settings
+
+
+ {showAdvanced && (
+
+
+ Contract ID
+
+
setContractInput(event.target.value)}
+ placeholder="Paste a note contract ID or register a new one"
+ className="rounded-md border border-line bg-bg px-3 py-2 font-mono text-[12px] text-ink outline-none transition focus:border-accent-dim"
+ />
+
+
+ Use this ID
+
+
+ {registering ? "Registering…" : "Register new"}
+
+
+
+ Register deploys a fresh note contract to testnet and switches
+ Dashnote to it immediately.
+
+
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ {
+ session.logout();
+ onClose();
+ }}
+ className="flex-1 rounded-md border border-line-2 bg-transparent px-4 py-2 text-[13px] font-semibold text-ink-2 transition hover:border-accent-dim hover:text-ink"
+ >
+ Logout
+
+
+ Close
+
+
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/example-apps/dashnote/src/components/Modal.tsx b/example-apps/dashnote/src/components/Modal.tsx
new file mode 100644
index 0000000..55b70af
--- /dev/null
+++ b/example-apps/dashnote/src/components/Modal.tsx
@@ -0,0 +1,78 @@
+/**
+ * Minimal modal primitive. No library — plain conditional render,
+ * backdrop click dismiss, ESC dismiss. Shared by Login, Transfer,
+ * SetPrice, and Purchase modals.
+ */
+import { useEffect, useId, useRef, type ReactNode } from "react";
+
+export interface ModalProps {
+ open: boolean;
+ title: string;
+ onClose: () => void;
+ children: ReactNode;
+ footer?: ReactNode;
+ panelClassName?: string;
+}
+
+export function Modal({
+ open,
+ title,
+ onClose,
+ children,
+ footer,
+ panelClassName,
+}: ModalProps) {
+ const dialogRef = useRef(null);
+ const titleId = useId();
+
+ useEffect(() => {
+ if (!open) return;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") onClose();
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [open, onClose]);
+
+ useEffect(() => {
+ if (open) dialogRef.current?.focus();
+ }, [open]);
+
+ if (!open) return null;
+
+ return (
+
+
e.stopPropagation()}
+ >
+
+
+ {title}
+
+
+ ×
+
+
+
{children}
+ {footer &&
{footer}
}
+
+
+ );
+}
diff --git a/example-apps/dashnote/src/components/NavButton.tsx b/example-apps/dashnote/src/components/NavButton.tsx
new file mode 100644
index 0000000..302c743
--- /dev/null
+++ b/example-apps/dashnote/src/components/NavButton.tsx
@@ -0,0 +1,32 @@
+interface NavButtonProps {
+ label: string;
+ glyph: string;
+ active: boolean;
+ onClick: () => void;
+}
+
+export function NavButton({ label, glyph, active, onClick }: NavButtonProps) {
+ return (
+
+ {active && (
+
+ )}
+
+ {glyph}
+
+ {label}
+
+ );
+}
diff --git a/example-apps/dashnote/src/components/NoteEditor.tsx b/example-apps/dashnote/src/components/NoteEditor.tsx
new file mode 100644
index 0000000..4d2298d
--- /dev/null
+++ b/example-apps/dashnote/src/components/NoteEditor.tsx
@@ -0,0 +1,329 @@
+import type { NoteRecord } from "../dash/queries";
+import { FIELD_BYTE_LIMIT } from "../lib/fieldLimits";
+import { formatTimestamp } from "../lib/format";
+import { OperationResultNotice } from "./OperationResultNotice";
+
+interface NoteEditorProps {
+ selectedId: string | "new" | null;
+ note: NoteRecord | null;
+ title: string;
+ message: string;
+ onTitleChange: (value: string) => void;
+ onMessageChange: (value: string) => void;
+ onSave: () => void;
+ onDelete: () => void;
+ onBack: () => void;
+ loading: boolean;
+ saving: boolean;
+ deleting: boolean;
+ canEdit: boolean;
+ canDelete: boolean;
+ dirty: boolean;
+ messageBytes: number;
+ messageOversize: boolean;
+ contractReady: boolean;
+ error: string | null;
+ onOpenSettings: () => void;
+ isReadOnly?: boolean;
+ isDesktop: boolean;
+}
+
+export function NoteEditor({
+ selectedId,
+ note,
+ title,
+ message,
+ onTitleChange,
+ onMessageChange,
+ onSave,
+ onDelete,
+ onBack,
+ loading,
+ saving,
+ deleting,
+ canEdit,
+ canDelete,
+ dirty,
+ messageBytes,
+ messageOversize,
+ contractReady,
+ error,
+ onOpenSettings,
+ isReadOnly = false,
+ isDesktop,
+}: NoteEditorProps) {
+ const hasSelection = selectedId !== null;
+ const isNew = selectedId === "new";
+ const oversize = messageOversize;
+
+ return (
+
+
+ {hasSelection && (
+
+
+
+
+ Notes
+
+ )}
+ {isDesktop && (
+
+ {!hasSelection ? (
+
+ Select a note from the list
+
+ ) : loading && !note && !isNew ? (
+
+
+ Loading…
+
+ ) : null}
+
+ )}
+
+
+
+ {canDelete && (
+
+ {deleting ? "Deleting…" : "Delete"}
+
+ )}
+ {isReadOnly ? (
+
+ Sign in to edit
+
+ ) : (
+ hasSelection && (
+
+ {saving ? "Saving…" : isNew ? "Create note" : "Save"}
+
+ )
+ )}
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {!contractReady ? (
+
+
+
+ Open Settings to paste a contract ID or register a new Dashnote
+ contract before creating notes.
+
+
+ Open Settings
+
+
+
+ ) : !hasSelection ? (
+
+ Choose a note from the list or create a new one to start writing.
+
+ ) : loading && !note && !isNew ? (
+
+ ) : (
+ <>
+
+ onTitleChange(event.target.value)}
+ placeholder={isNew ? "New note title" : "Title"}
+ disabled={!canEdit}
+ className="w-full border-0 bg-transparent px-0 pt-0 pb-1 text-[28px] font-semibold leading-tight tracking-tight text-ink outline-none placeholder:text-ink-4 disabled:cursor-not-allowed disabled:text-ink-4"
+ />
+
+
+
+
+
+
+
+
+ Notes are stored publicly on Dash Platform — not encrypted.
+
+
+
+
+ {note && (
+ <>
+
+ Revision
+
+ {note.revision}
+
+
+
+ Created
+ {formatTimestamp(note.createdAt)}
+
+
+ Updated
+ {formatTimestamp(note.updatedAt)}
+
+ >
+ )}
+ {(messageBytes / FIELD_BYTE_LIMIT >= 0.75 || messageOversize) && (
+
+
+
+ )}
+
+
+ {canDelete && (
+
+
+
+
+
+
+
+ {deleting ? "Deleting…" : "Delete note"}
+
+
+ )}
+ >
+ )}
+
+
+ );
+}
+
+function FillBar({ bytes, limit }: { bytes: number; limit: number }) {
+ const pct = Math.min(100, (bytes / limit) * 100);
+ const over = bytes > limit;
+ const near = !over && pct >= 90;
+ const fill = over
+ ? "bg-[color:var(--color-danger)]"
+ : near
+ ? "bg-[color:var(--color-warning)]"
+ : "bg-accent";
+ const tooltip = over
+ ? `${bytes} / ${limit} bytes — over limit`
+ : `${bytes} / ${limit} bytes`;
+ return (
+
+ );
+}
diff --git a/example-apps/dashnote/src/components/NoteList.tsx b/example-apps/dashnote/src/components/NoteList.tsx
new file mode 100644
index 0000000..f2db34e
--- /dev/null
+++ b/example-apps/dashnote/src/components/NoteList.tsx
@@ -0,0 +1,224 @@
+import { useMemo, useState } from "react";
+
+import type { NoteRecord } from "../dash/queries";
+import {
+ formatCompactTimestamp,
+ formatRelativeTime,
+ noteDisplayTitle,
+ notePreview,
+} from "../lib/format";
+
+interface NoteListProps {
+ notes: NoteRecord[];
+ loading: boolean;
+ revalidating?: boolean;
+ selectedId: string | "new" | null;
+ onSelect: (noteId: string) => void;
+ onNew: () => void;
+ canCreate: boolean;
+}
+
+export function NoteList({
+ notes,
+ loading,
+ revalidating = false,
+ selectedId,
+ onSelect,
+ onNew,
+ canCreate,
+}: NoteListProps) {
+ const [search, setSearch] = useState("");
+
+ const filteredNotes = useMemo(() => {
+ const q = search.trim().toLowerCase();
+ if (!q) return notes;
+ return notes.filter((note) => {
+ const title = noteDisplayTitle(note).toLowerCase();
+ const body = (note.message ?? "").toLowerCase();
+ return title.includes(q) || body.includes(q);
+ });
+ }, [notes, search]);
+
+ const searching = search.trim().length > 0;
+
+ return (
+
+
+
+
+ My notes
+
+
+
+ {notes.length} {notes.length === 1 ? "note" : "notes"}
+
+ {revalidating && (
+
+
+
+
+
+ Refreshing…
+
+ )}
+
+
+ {canCreate && (
+
+ New note
+
+ )}
+
+
+
+
+
+ {loading && notes.length === 0 ? (
+
+ ) : filteredNotes.length === 0 ? (
+
+ {searching ? "No notes match that search." : "No notes yet."}
+
+ ) : (
+
+ {filteredNotes.map((note) => {
+ const active = selectedId === note.id;
+ return (
+
onSelect(note.id)}
+ className={`block w-full rounded-[18px] border px-3 py-3 text-left transition ${
+ active
+ ? "border-accent bg-surface-2 shadow-[0_16px_35px_-28px_rgba(0,0,0,0.5)]"
+ : "border-transparent bg-transparent hover:border-line hover:bg-surface-2"
+ }`}
+ >
+
+
+
+ {noteDisplayTitle(note)}
+
+
+ {notePreview(note.message)}
+
+
+
+
+ {formatRelativeTime(note.updatedAt)}
+
+
+ {formatCompactTimestamp(note.updatedAt)}
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+ {canCreate && (
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/example-apps/dashnote/src/components/NotesWorkspace.tsx b/example-apps/dashnote/src/components/NotesWorkspace.tsx
new file mode 100644
index 0000000..a3373a7
--- /dev/null
+++ b/example-apps/dashnote/src/components/NotesWorkspace.tsx
@@ -0,0 +1,731 @@
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+
+import { createNote } from "../dash/createNote";
+import { deleteNote } from "../dash/deleteNote";
+import { getNote, listMyNotes, type NoteRecord } from "../dash/queries";
+import { updateNote } from "../dash/updateNote";
+import { useMediaQuery } from "../hooks/useMediaQuery";
+import { byteLength, FIELD_BYTE_LIMIT } from "../lib/fieldLimits";
+import { errorMessage } from "../lib/logger";
+import {
+ BACKGROUND_REFRESH_MS,
+ FOCUS_REFRESH_MIN_MS,
+ loadCachedNotes,
+ notesEqualByRevision,
+ saveCachedNotes,
+} from "../lib/notesCache";
+import { useSession } from "../session/useSession";
+import { NoteEditor } from "./NoteEditor";
+import { NoteList } from "./NoteList";
+
+const NETWORK = "testnet" as const;
+const STALE_EDIT_WARNING =
+ "This note changed on the network. Your unsaved edits are still here — saving will overwrite the newer version.";
+
+type SelectedNoteId = string | "new" | null;
+
+export function NotesWorkspace({
+ onOpenSettings,
+}: {
+ onOpenSettings: () => void;
+}) {
+ const session = useSession();
+ const { status, sdk, keyManager, contractId, identityId, log } = session;
+ const isDesktop = useMediaQuery("(min-width: 768px)");
+
+ const initialCachedNotes =
+ identityId && contractId
+ ? (loadCachedNotes(identityId, contractId, NETWORK) ?? [])
+ : [];
+ // Seed the editor from the first cached note on desktop so the right pane
+ // paints with content on frame 1 instead of "No note selected" flashing
+ // through before the hydrate effect picks one.
+ const initialSelected =
+ isDesktop && initialCachedNotes.length > 0 ? initialCachedNotes[0] : null;
+
+ const [notes, setNotes] = useState(initialCachedNotes);
+ const [selectedId, setSelectedId] = useState(
+ initialSelected?.id ?? null,
+ );
+ const [title, setTitle] = useState(initialSelected?.title ?? "");
+ const [message, setMessage] = useState(initialSelected?.message ?? "");
+ const [baselineTitle, setBaselineTitle] = useState(
+ initialSelected?.title ?? "",
+ );
+ const [baselineMessage, setBaselineMessage] = useState(
+ initialSelected?.message ?? "",
+ );
+ const [selectedNote, setSelectedNote] = useState(
+ initialSelected,
+ );
+ const [listLoading, setListLoading] = useState(false);
+ const [detailLoading, setDetailLoading] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [deleting, setDeleting] = useState(false);
+ const [error, setError] = useState(null);
+ const [revalidating, setRevalidating] = useState(false);
+ const [editsReady, setEditsReady] = useState(false);
+ const [conflictWarning, setConflictWarning] = useState(null);
+ const lastRevalidatedAt = useRef(0);
+ const inFlightWriteRef = useRef(false);
+ // Monotonic token so a late listMyNotes() response from a previous
+ // identity/contract/session can't clobber state for the current one.
+ const reloadTokenRef = useRef(0);
+ // Mirror editor state in refs so revalidation routines can compare against
+ // the live values without participating in their dependency arrays (which
+ // would re-fire effects on every keystroke).
+ const titleRef = useRef("");
+ const messageRef = useRef("");
+ const baselineTitleRef = useRef("");
+ const baselineMessageRef = useRef("");
+ const selectedIdRef = useRef(null);
+ const notesRef = useRef([]);
+ useEffect(() => {
+ notesRef.current = notes;
+ }, [notes]);
+ useEffect(() => {
+ titleRef.current = title;
+ }, [title]);
+ useEffect(() => {
+ messageRef.current = message;
+ }, [message]);
+ useEffect(() => {
+ baselineTitleRef.current = baselineTitle;
+ }, [baselineTitle]);
+ useEffect(() => {
+ baselineMessageRef.current = baselineMessage;
+ }, [baselineMessage]);
+ useEffect(() => {
+ selectedIdRef.current = selectedId;
+ }, [selectedId]);
+
+ const isAuthed = status === "authenticated";
+ const isBrowsing = status === "browsing";
+ const canRead = isAuthed || isBrowsing;
+ const contractReady = Boolean(contractId);
+ const canMutate = Boolean(
+ isAuthed && sdk && keyManager && contractId && editsReady,
+ );
+ const dirty = title !== baselineTitle || message !== baselineMessage;
+ const messageBytes = byteLength(message);
+ const messageOversize = messageBytes > FIELD_BYTE_LIMIT;
+
+ const hasMeaningfulContent = useMemo(
+ () => Boolean(title.trim() || message.trim()),
+ [title, message],
+ );
+
+ const resetDraft = useCallback(() => {
+ setSelectedId("new");
+ setSelectedNote(null);
+ setTitle("");
+ setMessage("");
+ setBaselineTitle("");
+ setBaselineMessage("");
+ setError(null);
+ }, []);
+
+ const reloadNotes = useCallback(
+ async (preferredId?: SelectedNoteId) => {
+ const sessionTornDown =
+ !contractId ||
+ !identityId ||
+ (status !== "authenticated" && status !== "browsing");
+ if (sessionTornDown) {
+ setNotes([]);
+ setSelectedNote(null);
+ setSelectedId(null);
+ setTitle("");
+ setMessage("");
+ setBaselineTitle("");
+ setBaselineMessage("");
+ setEditsReady(false);
+ return;
+ }
+ if (!sdk) {
+ // SDK is still connecting after a remembered-identity rehydrate. Keep
+ // any cached notes on screen and wait for the effect to re-run once
+ // `sdk` lands in the deps array.
+ return;
+ }
+
+ const prevNotes = notesRef.current;
+ const hadNotes = prevNotes.length > 0;
+ if (!hadNotes) setListLoading(true);
+ setRevalidating(true);
+ setError(null);
+ reloadTokenRef.current += 1;
+ const myToken = reloadTokenRef.current;
+ const startedIdentityId = identityId;
+ const startedContractId = contractId;
+ try {
+ const nextNotes = await listMyNotes({
+ sdk,
+ contractId,
+ ownerId: identityId,
+ log,
+ });
+ // Bail if a newer reload started, or session keys changed under us.
+ if (
+ reloadTokenRef.current !== myToken ||
+ startedIdentityId !== identityId ||
+ startedContractId !== contractId
+ ) {
+ return;
+ }
+ lastRevalidatedAt.current = Date.now();
+ const changed = !notesEqualByRevision(prevNotes, nextNotes);
+ if (changed) {
+ setNotes(nextNotes);
+ saveCachedNotes(identityId, contractId, NETWORK, nextNotes);
+ // Reconcile the currently selected note. The list query already
+ // returned full bodies, so we don't need an extra getNote.
+ const sel = selectedIdRef.current;
+ if (typeof sel === "string" && sel !== "new") {
+ const before = prevNotes.find((n) => n.id === sel) ?? null;
+ const after = nextNotes.find((n) => n.id === sel) ?? null;
+ if (after && (!before || before.revision !== after.revision)) {
+ const nextTitle = after.title ?? "";
+ const nextMessage = after.message ?? "";
+ const wasDirty =
+ titleRef.current !== baselineTitleRef.current ||
+ messageRef.current !== baselineMessageRef.current;
+ setSelectedNote(after);
+ setBaselineTitle(nextTitle);
+ setBaselineMessage(nextMessage);
+ if (!wasDirty) {
+ setTitle(nextTitle);
+ setMessage(nextMessage);
+ setConflictWarning(null);
+ } else {
+ setConflictWarning(STALE_EDIT_WARNING);
+ }
+ } else if (after && !inFlightWriteRef.current) {
+ setSelectedNote(after);
+ }
+ }
+ }
+ setSelectedId((current) => {
+ if (preferredId === "new") return "new";
+ if (
+ typeof preferredId === "string" &&
+ nextNotes.some((note) => note.id === preferredId)
+ ) {
+ return preferredId;
+ }
+ if (
+ typeof current === "string" &&
+ current !== "new" &&
+ nextNotes.some((note) => note.id === current)
+ ) {
+ return current;
+ }
+ if (current === "new") return current;
+ return isDesktop ? (nextNotes[0]?.id ?? null) : null;
+ });
+ setEditsReady(true);
+ } catch (err) {
+ if (reloadTokenRef.current !== myToken) return;
+ setError(errorMessage(err));
+ if (!hadNotes) setNotes([]);
+ } finally {
+ if (reloadTokenRef.current === myToken) {
+ setListLoading(false);
+ setRevalidating(false);
+ }
+ }
+ },
+ [contractId, identityId, log, sdk, status, isDesktop],
+ );
+
+ // Hydrate from cache synchronously when identity/contract changes, then kick
+ // off background revalidation. Resets edit gate so saves can't go out against
+ // possibly-stale cached state until the chain confirms it.
+ useEffect(() => {
+ if (
+ !identityId ||
+ !contractId ||
+ (status !== "authenticated" && status !== "browsing")
+ ) {
+ setNotes([]);
+ setEditsReady(false);
+ lastRevalidatedAt.current = 0;
+ return;
+ }
+ const cached = loadCachedNotes(identityId, contractId, NETWORK);
+ if (cached && cached.length > 0) {
+ setNotes(cached);
+ // Sync the ref immediately so the revalidation that runs in this same
+ // turn sees `hadNotes=true` and won't wipe the list on a network error.
+ notesRef.current = cached;
+ // Auto-select the first cached note on desktop so the editor pane has
+ // something to show before listMyNotes resolves. Mobile keeps the list
+ // view as today.
+ if (isDesktop && selectedIdRef.current === null) {
+ setSelectedId(cached[0].id);
+ }
+ }
+ setEditsReady(false);
+ lastRevalidatedAt.current = 0;
+ void reloadNotes();
+ // reloadNotes intentionally omitted — it depends on `notes` and would
+ // re-trigger this effect on every list change. `sdk` is in the deps so the
+ // reload re-runs once a rehydrated session finishes connecting.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [identityId, contractId, status, sdk]);
+
+ const loadTokenRef = useRef(0);
+
+ const loadNoteDetail = useCallback(
+ async (noteId: string, hydrated: boolean) => {
+ if (!sdk || !contractId) return;
+ const token = ++loadTokenRef.current;
+ if (!hydrated) setDetailLoading(true);
+ try {
+ const note = await getNote({ sdk, contractId, noteId, log });
+ if (loadTokenRef.current !== token) return;
+ setSelectedNote(note);
+ if (!note) {
+ setTitle("");
+ setMessage("");
+ setBaselineTitle("");
+ setBaselineMessage("");
+ return;
+ }
+ // Fold the fresh note back into the list (and cache) so previews,
+ // ordering, and a future cold reload reflect the newest revision.
+ const prev = notesRef.current;
+ const idx = prev.findIndex((n) => n.id === note.id);
+ if (idx === -1 || prev[idx].revision !== note.revision) {
+ const merged =
+ idx === -1
+ ? [note, ...prev]
+ : prev.map((n, i) => (i === idx ? note : n));
+ setNotes(merged);
+ if (identityId && contractId) {
+ saveCachedNotes(identityId, contractId, NETWORK, merged);
+ }
+ }
+ const nextTitle = note.title ?? "";
+ const nextMessage = note.message ?? "";
+ const priorBaselineTitle = baselineTitleRef.current;
+ const priorBaselineMessage = baselineMessageRef.current;
+ const wasDirty =
+ titleRef.current !== priorBaselineTitle ||
+ messageRef.current !== priorBaselineMessage;
+ const chainChanged =
+ nextTitle !== priorBaselineTitle ||
+ nextMessage !== priorBaselineMessage;
+ setBaselineTitle(nextTitle);
+ setBaselineMessage(nextMessage);
+ if (!wasDirty) {
+ setTitle(nextTitle);
+ setMessage(nextMessage);
+ setConflictWarning(null);
+ } else if (chainChanged) {
+ setConflictWarning(STALE_EDIT_WARNING);
+ }
+ } catch (err) {
+ if (loadTokenRef.current === token) setError(errorMessage(err));
+ } finally {
+ if (loadTokenRef.current === token) setDetailLoading(false);
+ }
+ },
+ [contractId, identityId, log, sdk],
+ );
+
+ useEffect(() => {
+ if (selectedId === "new") {
+ setSelectedNote(null);
+ setConflictWarning(null);
+ return;
+ }
+ if (!selectedId || !sdk || !contractId) {
+ setSelectedNote(null);
+ return;
+ }
+ setConflictWarning(null);
+ const cached =
+ notesRef.current.find((note) => note.id === selectedId) ?? null;
+ if (cached) {
+ setSelectedNote(cached);
+ setTitle(cached.title ?? "");
+ setMessage(cached.message ?? "");
+ setBaselineTitle(cached.title ?? "");
+ setBaselineMessage(cached.message ?? "");
+ }
+ void loadNoteDetail(selectedId, Boolean(cached));
+ }, [contractId, loadNoteDetail, sdk, selectedId]);
+
+ // Background revalidation: refetch on tab focus (with throttle) and on a
+ // periodic interval while the tab is visible. Dropped if a save/delete is
+ // in flight to avoid clobbering post-write state with a pre-write list.
+ useEffect(() => {
+ if (
+ !sdk ||
+ !contractId ||
+ !identityId ||
+ (status !== "authenticated" && status !== "browsing")
+ ) {
+ return;
+ }
+
+ function maybeRefresh(throttleMs: number) {
+ if (document.hidden) return;
+ if (inFlightWriteRef.current) return;
+ if (Date.now() - lastRevalidatedAt.current < throttleMs) return;
+ void reloadNotes();
+ }
+
+ function onVisibility() {
+ if (document.visibilityState === "visible") {
+ maybeRefresh(FOCUS_REFRESH_MIN_MS);
+ }
+ }
+
+ document.addEventListener("visibilitychange", onVisibility);
+ const interval = window.setInterval(() => {
+ maybeRefresh(BACKGROUND_REFRESH_MS - 1_000);
+ }, BACKGROUND_REFRESH_MS);
+
+ return () => {
+ document.removeEventListener("visibilitychange", onVisibility);
+ window.clearInterval(interval);
+ };
+ }, [sdk, contractId, identityId, status, reloadNotes]);
+
+ function confirmDiscard(): boolean {
+ if (!dirty) return true;
+ return window.confirm("Discard unsaved changes?");
+ }
+
+ function handleSelect(noteId: string) {
+ if (!confirmDiscard()) return;
+ setSelectedId(noteId);
+ setError(null);
+ setConflictWarning(null);
+ }
+
+ function handleBack() {
+ if (!confirmDiscard()) return;
+ setSelectedId(null);
+ setSelectedNote(null);
+ setTitle("");
+ setMessage("");
+ setBaselineTitle("");
+ setBaselineMessage("");
+ setError(null);
+ setConflictWarning(null);
+ }
+
+ function handleNew() {
+ if (!canMutate) return;
+ if (!confirmDiscard()) return;
+ resetDraft();
+ }
+
+ async function handleSave() {
+ if (!sdk || !keyManager || !contractId || !isAuthed) return;
+ if (!hasMeaningfulContent) {
+ setError("Add a title or body before saving.");
+ return;
+ }
+ if (messageOversize) {
+ setError(
+ `Body exceeds the ${FIELD_BYTE_LIMIT}-byte field limit (${messageBytes} B).`,
+ );
+ return;
+ }
+
+ setSaving(true);
+ setError(null);
+ inFlightWriteRef.current = true;
+ // Snapshot what we're about to save — used for both the post-success
+ // baseline advance and the post-failure refresh.
+ const submittedTitle = title;
+ const submittedMessage = message;
+ try {
+ if (selectedId === "new" || selectedId === null) {
+ const noteId = await createNote({
+ sdk,
+ keyManager,
+ contractId,
+ title,
+ message,
+ log,
+ });
+ // Advance baselines so the post-save reload doesn't see wasDirty=true
+ // and trip the conflict detector against its own write.
+ baselineTitleRef.current = submittedTitle;
+ baselineMessageRef.current = submittedMessage;
+ setBaselineTitle(submittedTitle);
+ setBaselineMessage(submittedMessage);
+ await reloadNotes(noteId);
+ } else {
+ await updateNote({
+ sdk,
+ keyManager,
+ contractId,
+ noteId: selectedId,
+ title,
+ message,
+ log,
+ });
+ baselineTitleRef.current = submittedTitle;
+ baselineMessageRef.current = submittedMessage;
+ setBaselineTitle(submittedTitle);
+ setBaselineMessage(submittedMessage);
+ setConflictWarning(null);
+ await loadNoteDetail(selectedId, true);
+ await reloadNotes(selectedId);
+ }
+ } catch (err) {
+ setError(errorMessage(err));
+ // Save failed — chain may have moved (e.g. another window incremented
+ // the identity nonce by saving first). Refresh the note so the user
+ // sees what's actually on chain before they retry, and surface the
+ // conflict warning if the revision actually moved past what we held.
+ if (
+ selectedId !== "new" &&
+ selectedId !== null &&
+ sdk &&
+ contractId &&
+ selectedNote
+ ) {
+ try {
+ const latest = await getNote({
+ sdk,
+ contractId,
+ noteId: selectedId,
+ log,
+ });
+ if (latest && latest.revision !== selectedNote.revision) {
+ setSelectedNote(latest);
+ const latestTitle = latest.title ?? "";
+ const latestMessage = latest.message ?? "";
+ setBaselineTitle(latestTitle);
+ setBaselineMessage(latestMessage);
+ baselineTitleRef.current = latestTitle;
+ baselineMessageRef.current = latestMessage;
+ // The conflict warning is the actionable info ("your retry will
+ // overwrite"); the underlying nonce/network error is internal
+ // detail. Clear the error so the warning isn't masked.
+ setError(null);
+ // Fold the chain's content into the list/cache too.
+ const prev = notesRef.current;
+ const idx = prev.findIndex((n) => n.id === latest.id);
+ if (idx === -1 || prev[idx].revision !== latest.revision) {
+ const merged =
+ idx === -1
+ ? [latest, ...prev]
+ : prev.map((n, i) => (i === idx ? latest : n));
+ setNotes(merged);
+ if (identityId && contractId) {
+ saveCachedNotes(identityId, contractId, NETWORK, merged);
+ }
+ }
+ setConflictWarning(STALE_EDIT_WARNING);
+ }
+ } catch {
+ // Best effort — don't mask the original save error.
+ }
+ }
+ } finally {
+ inFlightWriteRef.current = false;
+ setSaving(false);
+ }
+ }
+
+ async function handleDelete() {
+ if (!sdk || !keyManager || !contractId || !isAuthed || !selectedId) return;
+ if (selectedId === "new") {
+ resetDraft();
+ return;
+ }
+ if (!window.confirm("Delete this note permanently?")) return;
+
+ setDeleting(true);
+ setError(null);
+ inFlightWriteRef.current = true;
+ try {
+ await deleteNote({
+ sdk,
+ keyManager,
+ contractId,
+ noteId: selectedId,
+ log,
+ });
+ await reloadNotes();
+ } catch (err) {
+ setError(errorMessage(err));
+ } finally {
+ inFlightWriteRef.current = false;
+ setDeleting(false);
+ }
+ }
+
+ return (
+
+ {!canRead ? (
+
+
+
+
+ }
+ title="Sign in to see your notes"
+ description="Dashnote stores notes against your testnet identity. Log in with a Dash Platform identity to create, edit, and review your notes."
+ actionLabel="Log in"
+ onAction={onOpenSettings}
+ secondaryHref="https://bridge.thepasta.org/"
+ secondaryLabel="Need an identity? Create one on Dash Bridge"
+ footnote="Notes are not private. They are stored publicly on Dash Platform."
+ />
+ ) : !contractReady ? (
+
+
+
+
+
+ }
+ title="Register or select a contract"
+ description="Open Settings to register a Dashnote note contract or paste a contract ID before creating notes."
+ actionLabel="Open Settings"
+ onAction={onOpenSettings}
+ />
+ ) : (
+
+
+ 0}
+ selectedId={selectedId}
+ onSelect={handleSelect}
+ onNew={handleNew}
+ canCreate={canMutate}
+ />
+
+
+ void handleSave()}
+ onDelete={() => void handleDelete()}
+ onBack={handleBack}
+ loading={detailLoading}
+ saving={saving}
+ deleting={deleting}
+ canEdit={canMutate}
+ canDelete={Boolean(
+ canMutate && selectedId && selectedId !== "new",
+ )}
+ isReadOnly={isBrowsing}
+ dirty={dirty}
+ messageBytes={messageBytes}
+ messageOversize={messageOversize}
+ contractReady={contractReady}
+ error={error ?? conflictWarning}
+ onOpenSettings={onOpenSettings}
+ />
+
+
+ )}
+
+ );
+}
+
+function EmptyState({
+ icon,
+ title,
+ description,
+ actionLabel,
+ onAction,
+ secondaryHref,
+ secondaryLabel,
+ footnote,
+}: {
+ icon: ReactNode;
+ title: string;
+ description: string;
+ actionLabel: string;
+ onAction: () => void;
+ secondaryHref?: string;
+ secondaryLabel?: string;
+ footnote?: string;
+}) {
+ return (
+
+
+
+ {icon}
+
+
+
{title}
+
{description}
+
+
+ {actionLabel}
+
+ {secondaryHref && secondaryLabel && (
+
+ {secondaryLabel}
+
+ )}
+
+ {footnote && (
+
+ {footnote}
+
+ )}
+
+ );
+}
diff --git a/example-apps/dashnote/src/components/OperationResultNotice.tsx b/example-apps/dashnote/src/components/OperationResultNotice.tsx
new file mode 100644
index 0000000..980e6bf
--- /dev/null
+++ b/example-apps/dashnote/src/components/OperationResultNotice.tsx
@@ -0,0 +1,37 @@
+import type { ReactNode } from "react";
+
+interface OperationResultNoticeProps {
+ tone?: "info" | "success" | "error";
+ title: string;
+ children: ReactNode;
+}
+
+const toneClass: Record<
+ NonNullable,
+ string
+> = {
+ info: "border-line bg-surface text-ink-2",
+ success:
+ "border-[oklch(38%_0.08_150)] bg-[oklch(24%_0.03_150)] text-ink max-md:bg-transparent max-md:rounded-none max-md:border-0 max-md:border-l-2 max-md:border-l-[oklch(38%_0.08_150)] max-md:px-3",
+ error:
+ "border-[oklch(30%_0.08_25)] bg-[oklch(22%_0.04_25)] text-[oklch(84%_0.08_25)] max-md:bg-transparent max-md:rounded-none max-md:border-0 max-md:border-l-2 max-md:border-l-[oklch(30%_0.08_25)] max-md:px-3",
+};
+
+export function OperationResultNotice({
+ tone = "info",
+ title,
+ children,
+}: OperationResultNoticeProps) {
+ return (
+
+
+ {title}
+
+
{children}
+
+ );
+}
diff --git a/example-apps/dashnote/src/components/Tabs.tsx b/example-apps/dashnote/src/components/Tabs.tsx
new file mode 100644
index 0000000..5919892
--- /dev/null
+++ b/example-apps/dashnote/src/components/Tabs.tsx
@@ -0,0 +1 @@
+export type TopTab = "notes" | "how-it-works";
diff --git a/example-apps/dashnote/src/dash/client.ts b/example-apps/dashnote/src/dash/client.ts
new file mode 100644
index 0000000..367990f
--- /dev/null
+++ b/example-apps/dashnote/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/dashnote/src/dash/contract.ts b/example-apps/dashnote/src/dash/contract.ts
new file mode 100644
index 0000000..7e7be7c
--- /dev/null
+++ b/example-apps/dashnote/src/dash/contract.ts
@@ -0,0 +1,156 @@
+/**
+ * Note data contract: schema definition + registration.
+ *
+ * SDK methods:
+ * sdk.contracts.publish({ dataContract, identityKey, signer })
+ * sdk.identities.nonce(identityId)
+ */
+import { DataContract, Identifier } from "@dashevo/evo-sdk";
+
+import type { Logger } from "../lib/logger";
+import type { DashKeyManager, DashSdk } from "./types";
+
+export const NOTE_SCHEMAS = {
+ note: {
+ type: "object",
+ documentsMutable: true,
+ canBeDeleted: true,
+ properties: {
+ title: {
+ type: "string",
+ maxLength: 120,
+ position: 0,
+ },
+ message: {
+ type: "string",
+ maxLength: 10000,
+ position: 1,
+ },
+ },
+ required: ["$createdAt", "$updatedAt", "message"],
+ additionalProperties: false,
+ indices: [
+ {
+ name: "byOwnerUpdated",
+ properties: [{ $ownerId: "asc" }, { $updatedAt: "asc" }],
+ },
+ {
+ name: "byOwnerCreated",
+ properties: [{ $ownerId: "asc" }, { $createdAt: "asc" }],
+ },
+ ],
+ },
+} as const;
+
+const STORAGE_KEY = "dashnote.contractId";
+
+/**
+ * Default contract ID baked into the tutorial so the notebook UI works on a
+ * fresh machine without registering a contract first. Users can override it
+ * in Settings or register their own.
+ */
+export const DEFAULT_CONTRACT_ID =
+ "8d6heK6CoskLBi6Rs7cChRG9RuckcZqZst28BdviBe8y";
+
+export function loadStoredContractId(): string | null {
+ try {
+ return localStorage.getItem(STORAGE_KEY) ?? DEFAULT_CONTRACT_ID;
+ } catch {
+ return DEFAULT_CONTRACT_ID;
+ }
+}
+
+export function saveContractId(id: string): void {
+ localStorage.setItem(STORAGE_KEY, id);
+}
+
+export function clearStoredContractId(): void {
+ localStorage.removeItem(STORAGE_KEY);
+}
+
+export async function refreshContractCache({
+ sdk,
+ contractId,
+}: {
+ sdk: DashSdk;
+ contractId: string;
+}): Promise {
+ if (!contractId || typeof sdk.getWasmSdkConnected !== "function") return;
+ const wasm = await sdk.getWasmSdkConnected();
+ if (!wasm || typeof wasm.removeCachedContract !== "function") return;
+ const identifier = new Identifier(contractId);
+ try {
+ wasm.removeCachedContract(identifier);
+ } finally {
+ identifier.free?.();
+ }
+}
+
+export async function registerContract({
+ sdk,
+ keyManager,
+ log,
+}: {
+ sdk: DashSdk;
+ keyManager: DashKeyManager;
+ log?: Logger;
+}): Promise {
+ log?.("Registering Dashnote note contract…");
+ const { identity, identityKey, signer } = await keyManager.getAuth();
+ const identityNonce = await sdk.identities.nonce(identity.id.toString());
+ const dataContract = new DataContract({
+ ownerId: identity.id,
+ identityNonce: (identityNonce || 0n) + 1n,
+ schemas: NOTE_SCHEMAS,
+ fullValidation: true,
+ });
+
+ (
+ dataContract as unknown as {
+ setConfig: (config: Record) => void;
+ }
+ ).setConfig({
+ canBeDeleted: false,
+ readonly: false,
+ // Must stay false: keepsHistory: true triggers dashpay/platform#3165 —
+ // sdk.contracts.fetch() returns undefined, breaking sdk.documents.query
+ // with "Data contract not found".
+ keepsHistory: false,
+ documentsKeepHistoryContractDefault: false,
+ documentsMutableContractDefault: true,
+ documentsCanBeDeletedContractDefault: true,
+ });
+
+ const published = await sdk.contracts.publish({
+ dataContract,
+ identityKey,
+ signer,
+ });
+ const contractId = published.id?.toString() || published.toJSON?.()?.id;
+ if (!contractId) {
+ throw new Error("Contract publish returned no ID.");
+ }
+
+ saveContractId(contractId);
+ log?.(`Dashnote contract registered: ${contractId}`, "success");
+ return contractId;
+}
+
+export async function ensureContract({
+ sdk,
+ keyManager,
+ existingId,
+ log,
+}: {
+ sdk: DashSdk;
+ keyManager: DashKeyManager;
+ existingId?: string | null;
+ log?: Logger;
+}): Promise {
+ const reused = existingId ?? loadStoredContractId();
+ if (reused) {
+ log?.(`Using saved contract ID: ${reused}`);
+ return reused;
+ }
+ return registerContract({ sdk, keyManager, log });
+}
diff --git a/example-apps/dashnote/src/dash/createNote.ts b/example-apps/dashnote/src/dash/createNote.ts
new file mode 100644
index 0000000..fb2af0e
--- /dev/null
+++ b/example-apps/dashnote/src/dash/createNote.ts
@@ -0,0 +1,57 @@
+/**
+ * Create a new note document.
+ *
+ * SDK method: sdk.documents.create({ document, identityKey, signer })
+ */
+import { Document } from "@dashevo/evo-sdk";
+
+import type { Logger } from "../lib/logger";
+import type { DashKeyManager, DashSdk } from "./types";
+
+export interface CreateNoteParams {
+ sdk: DashSdk;
+ keyManager: DashKeyManager;
+ contractId: string;
+ title?: string;
+ message: string;
+ log?: Logger;
+}
+
+export async function createNote({
+ sdk,
+ keyManager,
+ contractId,
+ title,
+ message,
+ log,
+}: CreateNoteParams): Promise {
+ log?.("Creating note…");
+ const { identity, identityKey, signer } = await keyManager.getAuth();
+ const trimmedTitle = title?.trim();
+ const document = new Document({
+ properties: {
+ ...(trimmedTitle ? { title: trimmedTitle } : {}),
+ message,
+ },
+ documentTypeName: "note",
+ dataContractId: contractId,
+ ownerId: identity.id,
+ });
+
+ await sdk.documents.create({
+ document,
+ identityKey,
+ signer,
+ });
+
+ const json =
+ typeof document.toJSON === "function"
+ ? (document.toJSON() as Record)
+ : {};
+ const noteId = String(json.$id ?? json.id ?? "");
+ if (!noteId) {
+ throw new Error("Created note returned no ID.");
+ }
+ log?.("Note created.", "success");
+ return noteId;
+}
diff --git a/example-apps/dashnote/src/dash/deleteNote.ts b/example-apps/dashnote/src/dash/deleteNote.ts
new file mode 100644
index 0000000..0ffe2a5
--- /dev/null
+++ b/example-apps/dashnote/src/dash/deleteNote.ts
@@ -0,0 +1,37 @@
+/**
+ * Delete a note document.
+ *
+ * SDK method: sdk.documents.delete({ document, identityKey, signer })
+ */
+import type { Logger } from "../lib/logger";
+import type { DashKeyManager, DashSdk } from "./types";
+
+export interface DeleteNoteParams {
+ sdk: DashSdk;
+ keyManager: DashKeyManager;
+ contractId: string;
+ noteId: string;
+ log?: Logger;
+}
+
+export async function deleteNote({
+ sdk,
+ keyManager,
+ contractId,
+ noteId,
+ log,
+}: DeleteNoteParams): Promise {
+ log?.(`Deleting note ${noteId}…`);
+ const { identity, identityKey, signer } = await keyManager.getAuth();
+ await sdk.documents.delete({
+ document: {
+ id: noteId,
+ ownerId: identity.id,
+ dataContractId: contractId,
+ documentTypeName: "note",
+ },
+ identityKey,
+ signer,
+ });
+ log?.("Note deleted.", "success");
+}
diff --git a/example-apps/dashnote/src/dash/keyManager.ts b/example-apps/dashnote/src/dash/keyManager.ts
new file mode 100644
index 0000000..8047e0e
--- /dev/null
+++ b/example-apps/dashnote/src/dash/keyManager.ts
@@ -0,0 +1,8 @@
+/**
+ * Re-export of IdentityKeyManager from the shared browser-safe SDK core.
+ *
+ * IdentityKeyManager derives the standard identity keys from a BIP39 mnemonic
+ * and exposes getAuth(), which is all this app needs for document creation and
+ * contract publication.
+ */
+export { IdentityKeyManager } from "../../../../setupDashClient-core.mjs";
diff --git a/example-apps/dashnote/src/dash/queries.ts b/example-apps/dashnote/src/dash/queries.ts
new file mode 100644
index 0000000..aca7ead
--- /dev/null
+++ b/example-apps/dashnote/src/dash/queries.ts
@@ -0,0 +1,134 @@
+/**
+ * Read-side queries against the note contract.
+ *
+ * SDK methods:
+ * sdk.documents.query({ dataContractId, documentTypeName, where, orderBy, limit })
+ * sdk.documents.get(contractId, documentTypeName, documentId)
+ */
+import type { Logger } from "../lib/logger";
+import type {
+ DashDocumentLike,
+ DashNoteQueryDocument,
+ DashNoteQueryJson,
+ DashNoteQueryResults,
+ DashSdk,
+} from "./types";
+
+const MAX_QUERY_LIMIT = 100;
+
+export interface NoteRecord {
+ id: string;
+ ownerId: string;
+ title: string | null;
+ message: string;
+ createdAt: number | null;
+ updatedAt: number | null;
+ revision: number;
+}
+
+function toTimestamp(
+ value: DashNoteQueryJson["$createdAt"] | DashNoteQueryJson["$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 toRevision(
+ value: number | string | bigint | undefined,
+ fallback?: number | string | bigint,
+): number {
+ const raw = value ?? fallback;
+ if (typeof raw === "number" && Number.isFinite(raw)) return raw;
+ if (typeof raw === "bigint") return Number(raw);
+ if (typeof raw === "string" && raw) {
+ const parsed = Number(raw);
+ return Number.isFinite(parsed) ? parsed : 0;
+ }
+ return 0;
+}
+
+function toNote(id: string | null, raw: DashNoteQueryDocument): NoteRecord {
+ const json: DashNoteQueryJson =
+ typeof raw?.toJSON === "function" ? raw.toJSON() : raw;
+ return {
+ id: String(id ?? json.$id ?? json.id ?? ""),
+ ownerId: String(json.$ownerId ?? ""),
+ title: typeof json.title === "string" ? json.title : null,
+ message: typeof json.message === "string" ? json.message : "",
+ createdAt: toTimestamp(json.$createdAt),
+ updatedAt: toTimestamp(json.$updatedAt),
+ revision: toRevision(json.$revision, raw.revision),
+ };
+}
+
+export function normalizeNotes(results: DashNoteQueryResults): NoteRecord[] {
+ if (Array.isArray(results)) {
+ return results
+ .filter(Boolean)
+ .map((doc) => toNote(null, doc as DashNoteQueryDocument));
+ }
+ const entries =
+ results instanceof Map ? Object.fromEntries(results) : results;
+ return Object.entries(entries)
+ .filter(([, doc]) => Boolean(doc))
+ .map(([id, doc]) => toNote(id, doc as DashNoteQueryDocument));
+}
+
+export function normalizeSingleNote(
+ id: string,
+ raw: DashDocumentLike | undefined,
+): NoteRecord | null {
+ if (!raw) return null;
+ return toNote(id, raw as DashNoteQueryDocument);
+}
+
+export async function listMyNotes({
+ sdk,
+ contractId,
+ ownerId,
+ limit = MAX_QUERY_LIMIT,
+ log,
+}: {
+ sdk: DashSdk;
+ contractId: string;
+ ownerId: string;
+ limit?: number;
+ log?: Logger;
+}): Promise {
+ log?.("Loading your notes…");
+ const results = await sdk.documents.query({
+ dataContractId: contractId,
+ documentTypeName: "note",
+ where: [["$ownerId", "==", ownerId]],
+ orderBy: [
+ ["$ownerId", "asc"],
+ ["$updatedAt", "asc"],
+ ],
+ limit,
+ });
+
+ return normalizeNotes(results).sort(
+ (left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0),
+ );
+}
+
+export async function getNote({
+ sdk,
+ contractId,
+ noteId,
+ log,
+}: {
+ sdk: DashSdk;
+ contractId: string;
+ noteId: string;
+ log?: Logger;
+}): Promise {
+ log?.(`Loading note ${noteId}…`);
+ const result = await sdk.documents.get(contractId, "note", noteId);
+ return normalizeSingleNote(noteId, result);
+}
diff --git a/example-apps/dashnote/src/dash/resolveDpnsName.ts b/example-apps/dashnote/src/dash/resolveDpnsName.ts
new file mode 100644
index 0000000..27f5b02
--- /dev/null
+++ b/example-apps/dashnote/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/dashnote/src/dash/types.ts b/example-apps/dashnote/src/dash/types.ts
new file mode 100644
index 0000000..a14ab66
--- /dev/null
+++ b/example-apps/dashnote/src/dash/types.ts
@@ -0,0 +1,105 @@
+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?: () => Record;
+ [key: string]: unknown;
+}
+
+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;
+ get(
+ contractId: string,
+ documentTypeName: string,
+ documentId: string,
+ ): Promise;
+ create(args: {
+ document: unknown;
+ identityKey: IdentityPublicKey | undefined;
+ signer: IdentitySigner;
+ }): Promise;
+ replace(args: {
+ document: unknown;
+ identityKey: IdentityPublicKey | undefined;
+ signer: IdentitySigner;
+ }): Promise;
+ delete(args: {
+ document: {
+ id: string;
+ ownerId: Identity["id"] | string;
+ dataContractId: string;
+ documentTypeName: string;
+ };
+ identityKey: IdentityPublicKey | undefined;
+ signer: IdentitySigner;
+ }): Promise;
+ };
+ identities: {
+ nonce(identityId: string): Promise;
+ };
+ dpns: {
+ username(identityId: string): Promise;
+ resolveName(name: string): Promise;
+ };
+ getWasmSdkConnected?: () => Promise<{
+ removeCachedContract(contractId: { free?: () => void }): boolean;
+ }>;
+}
+
+export interface DashNoteQueryJson extends Record {
+ $id?: string;
+ id?: string;
+ $ownerId?: string;
+ $createdAt?: number | string | bigint;
+ $updatedAt?: number | string | bigint;
+ $revision?: number | string | bigint;
+ title?: string | null;
+ message?: string;
+}
+
+export interface DashNoteQueryDocument extends Record {
+ revision?: number | string | bigint;
+ toJSON?: () => DashNoteQueryJson;
+}
+
+export type DashNoteQueryResults =
+ | DashNoteQueryDocument[]
+ | Map
+ | Record;
diff --git a/example-apps/dashnote/src/dash/updateNote.ts b/example-apps/dashnote/src/dash/updateNote.ts
new file mode 100644
index 0000000..7a227e0
--- /dev/null
+++ b/example-apps/dashnote/src/dash/updateNote.ts
@@ -0,0 +1,60 @@
+/**
+ * Update an existing note. Fetches the current document to bump its revision,
+ * then submits a replace state transition.
+ *
+ * SDK methods:
+ * sdk.documents.get(contractId, documentTypeName, documentId)
+ * sdk.documents.replace({ document, identityKey, signer })
+ */
+import { Document } from "@dashevo/evo-sdk";
+
+import type { Logger } from "../lib/logger";
+import type { DashKeyManager, DashSdk } from "./types";
+
+export interface UpdateNoteParams {
+ sdk: DashSdk;
+ keyManager: DashKeyManager;
+ contractId: string;
+ noteId: string;
+ title?: string;
+ message: string;
+ log?: Logger;
+}
+
+export async function updateNote({
+ sdk,
+ keyManager,
+ contractId,
+ noteId,
+ title,
+ message,
+ log,
+}: UpdateNoteParams): Promise {
+ log?.(`Saving note ${noteId}…`);
+ const { identity, identityKey, signer } = await keyManager.getAuth();
+ const existingDoc = await sdk.documents.get(contractId, "note", noteId);
+ if (!existingDoc) {
+ throw new Error(`Note ${noteId} not found.`);
+ }
+
+ const revision = BigInt(existingDoc.revision ?? 0) + 1n;
+ const trimmedTitle = title?.trim();
+ const document = new Document({
+ properties: {
+ ...(trimmedTitle ? { title: trimmedTitle } : {}),
+ message,
+ },
+ documentTypeName: "note",
+ dataContractId: contractId,
+ ownerId: identity.id,
+ revision,
+ id: noteId,
+ });
+
+ await sdk.documents.replace({
+ document,
+ identityKey,
+ signer,
+ });
+ log?.("Note saved.", "success");
+}
diff --git a/example-apps/dashnote/src/hooks/useMediaQuery.ts b/example-apps/dashnote/src/hooks/useMediaQuery.ts
new file mode 100644
index 0000000..1529177
--- /dev/null
+++ b/example-apps/dashnote/src/hooks/useMediaQuery.ts
@@ -0,0 +1,30 @@
+import { useCallback, useSyncExternalStore } from "react";
+
+export function useMediaQuery(query: string): boolean {
+ const subscribe = useCallback(
+ (onChange: () => void) => {
+ if (
+ typeof window === "undefined" ||
+ typeof window.matchMedia !== "function"
+ ) {
+ return () => {};
+ }
+ const mql = window.matchMedia(query);
+ mql.addEventListener("change", onChange);
+ return () => mql.removeEventListener("change", onChange);
+ },
+ [query],
+ );
+
+ const getSnapshot = () => {
+ if (
+ typeof window === "undefined" ||
+ typeof window.matchMedia !== "function"
+ ) {
+ return false;
+ }
+ return window.matchMedia(query).matches;
+ };
+
+ return useSyncExternalStore(subscribe, getSnapshot, () => false);
+}
diff --git a/example-apps/dashnote/src/hooks/useTheme.ts b/example-apps/dashnote/src/hooks/useTheme.ts
new file mode 100644
index 0000000..5e45567
--- /dev/null
+++ b/example-apps/dashnote/src/hooks/useTheme.ts
@@ -0,0 +1,72 @@
+import { useCallback, useSyncExternalStore } from "react";
+
+export type Theme = "light" | "dark";
+
+const STORAGE_KEY = "dashnote.theme";
+
+export function getInitialTheme(): Theme {
+ if (typeof window === "undefined") return "dark";
+ const stored = window.localStorage.getItem(STORAGE_KEY);
+ if (stored === "light" || stored === "dark") return stored;
+ if (window.matchMedia?.("(prefers-color-scheme: light)")?.matches) {
+ return "light";
+ }
+ return "dark";
+}
+
+export function applyTheme(theme: Theme): void {
+ if (typeof document === "undefined") return;
+ document.documentElement.dataset.theme = theme;
+}
+
+let current: Theme | null = null;
+const listeners = new Set<() => void>();
+
+function readCurrent(): Theme {
+ if (current !== null) return current;
+ if (typeof document !== "undefined") {
+ const fromDom = document.documentElement.dataset.theme;
+ if (fromDom === "light" || fromDom === "dark") {
+ current = fromDom;
+ return current;
+ }
+ }
+ current = getInitialTheme();
+ return current;
+}
+
+function setThemeStore(next: Theme): void {
+ current = next;
+ applyTheme(next);
+ if (typeof window !== "undefined") {
+ window.localStorage.setItem(STORAGE_KEY, next);
+ }
+ listeners.forEach((l) => l());
+}
+
+function subscribe(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+}
+
+// Test-only: clear the cached value so a fresh DOM/localStorage snapshot is read.
+export function __resetThemeStoreForTests(): void {
+ current = null;
+ listeners.clear();
+}
+
+export function useTheme() {
+ const theme = useSyncExternalStore(subscribe, readCurrent, () => "dark");
+
+ const setTheme = useCallback((next: Theme) => {
+ setThemeStore(next);
+ }, []);
+
+ const toggle = useCallback(() => {
+ setThemeStore(theme === "dark" ? "light" : "dark");
+ }, [theme]);
+
+ return { theme, setTheme, toggle };
+}
diff --git a/example-apps/dashnote/src/lib/fieldLimits.ts b/example-apps/dashnote/src/lib/fieldLimits.ts
new file mode 100644
index 0000000..26a1540
--- /dev/null
+++ b/example-apps/dashnote/src/lib/fieldLimits.ts
@@ -0,0 +1,11 @@
+export const FIELD_BYTE_LIMIT = 5120;
+
+const encoder = new TextEncoder();
+
+export function byteLength(value: string): number {
+ return encoder.encode(value).byteLength;
+}
+
+export function isOversize(value: string): boolean {
+ return byteLength(value) > FIELD_BYTE_LIMIT;
+}
diff --git a/example-apps/dashnote/src/lib/format.ts b/example-apps/dashnote/src/lib/format.ts
new file mode 100644
index 0000000..e695eb7
--- /dev/null
+++ b/example-apps/dashnote/src/lib/format.ts
@@ -0,0 +1,82 @@
+export function truncateId(
+ id: string | null | undefined,
+ head = 10,
+ tail = 8,
+): string {
+ if (!id) return "—";
+ if (id.length <= head + tail) return id;
+ return `${id.slice(0, head)}…${id.slice(-tail)}`;
+}
+
+export function formatTimestamp(timestamp: number | null | undefined): string {
+ if (!timestamp) return "Pending";
+ return new Date(timestamp).toLocaleString();
+}
+
+export function formatCompactTimestamp(
+ timestamp: number | null | undefined,
+): string {
+ if (!timestamp) return "Pending";
+ return new Date(timestamp).toLocaleString(undefined, {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ });
+}
+
+const relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, {
+ numeric: "auto",
+});
+
+const RELATIVE_DIVISIONS: Array<{
+ amount: number;
+ name: Intl.RelativeTimeFormatUnit;
+}> = [
+ { amount: 60, name: "second" },
+ { amount: 60, name: "minute" },
+ { amount: 24, name: "hour" },
+ { amount: 7, name: "day" },
+ { amount: 4.34524, name: "week" },
+ { amount: 12, name: "month" },
+ { amount: Number.POSITIVE_INFINITY, name: "year" },
+];
+
+export function formatRelativeTime(
+ timestamp: number | null | undefined,
+ fromNow: number = Date.now(),
+): string {
+ if (!timestamp) return "Pending";
+ let value = (timestamp - fromNow) / 1000;
+ for (const division of RELATIVE_DIVISIONS) {
+ if (Math.abs(value) < division.amount) {
+ return relativeTimeFormatter.format(Math.round(value), division.name);
+ }
+ value /= division.amount;
+ }
+ return "";
+}
+
+export function noteDisplayTitle({
+ title,
+ message,
+}: {
+ title?: string | null;
+ message?: string | null;
+}): string {
+ const trimmedTitle = title?.trim();
+ if (trimmedTitle) return trimmedTitle;
+ const firstBodyLine =
+ message
+ ?.split("\n")
+ .map((line) => line.trim())
+ .find(Boolean) ?? "";
+ return firstBodyLine || "Untitled";
+}
+
+export function notePreview(message: string | null | undefined): string {
+ const trimmed = message?.trim();
+ if (!trimmed) return "Empty note";
+ return trimmed.replace(/\s+/g, " ").slice(0, 140);
+}
diff --git a/example-apps/dashnote/src/lib/logger.ts b/example-apps/dashnote/src/lib/logger.ts
new file mode 100644
index 0000000..5544323
--- /dev/null
+++ b/example-apps/dashnote/src/lib/logger.ts
@@ -0,0 +1,21 @@
+/**
+ * Shared logger contract for tutorial-facing Platform operations.
+ */
+export type LogLevel = "info" | "success" | "error";
+
+export type Logger = (message: string, level?: LogLevel) => void;
+
+export function errorMessage(err: unknown): string {
+ if (err instanceof Error) return err.message;
+ if (typeof err === "string") return err;
+ if (err && typeof err === "object") {
+ const obj = err as Record;
+ if (typeof obj.message === "string") return obj.message;
+ try {
+ return JSON.stringify(err);
+ } catch {
+ return String(err);
+ }
+ }
+ return String(err);
+}
diff --git a/example-apps/dashnote/src/lib/notesCache.ts b/example-apps/dashnote/src/lib/notesCache.ts
new file mode 100644
index 0000000..8f4d863
--- /dev/null
+++ b/example-apps/dashnote/src/lib/notesCache.ts
@@ -0,0 +1,124 @@
+// Caches the user's note list (titles, bodies, revisions) in localStorage so a
+// returning visitor sees their notes paint instantly on reload, before the
+// background revalidation against Platform completes. Keyed by identity so
+// switching identities never mixes data. Invalidated when the contract or
+// network changes.
+import type { NoteRecord } from "../dash/queries";
+
+const STORAGE_PREFIX = "dashnote.notes.";
+const SCHEMA_VERSION = 1;
+
+export const BACKGROUND_REFRESH_MS = 30_000;
+export const FOCUS_REFRESH_MIN_MS = 10_000;
+
+type Network = "testnet" | "mainnet";
+
+interface CachedWorkspace {
+ version: typeof SCHEMA_VERSION;
+ identityId: string;
+ contractId: string;
+ network: Network;
+ cachedAt: number;
+ notes: NoteRecord[];
+}
+
+function storageKey(
+ identityId: string,
+ contractId: string,
+ network: Network,
+): string {
+ return `${STORAGE_PREFIX}${identityId}.${contractId}.${network}`;
+}
+
+// Prefix for every cache entry belonging to a single identity, regardless
+// of which contract/network it was scoped to. Used by clearCachedNotes
+// when the caller doesn't know (or care about) the contract+network.
+function identityPrefix(identityId: string): string {
+ return `${STORAGE_PREFIX}${identityId}.`;
+}
+
+export function loadCachedNotes(
+ identityId: string,
+ contractId: string,
+ network: Network,
+): NoteRecord[] | null {
+ if (!identityId || !contractId) return null;
+ const key = storageKey(identityId, contractId, network);
+ try {
+ const raw = localStorage.getItem(key);
+ if (!raw) return null;
+ const parsed = JSON.parse(raw) as Partial;
+ if (
+ parsed.version !== SCHEMA_VERSION ||
+ parsed.identityId !== identityId ||
+ parsed.contractId !== contractId ||
+ parsed.network !== network ||
+ !Array.isArray(parsed.notes)
+ ) {
+ localStorage.removeItem(key);
+ return null;
+ }
+ return parsed.notes as NoteRecord[];
+ } catch {
+ try {
+ localStorage.removeItem(key);
+ } catch {
+ // ignore
+ }
+ return null;
+ }
+}
+
+export function saveCachedNotes(
+ identityId: string,
+ contractId: string,
+ network: Network,
+ notes: NoteRecord[],
+): void {
+ if (!identityId || !contractId) return;
+ const payload: CachedWorkspace = {
+ version: SCHEMA_VERSION,
+ identityId,
+ contractId,
+ network,
+ cachedAt: Date.now(),
+ notes,
+ };
+ try {
+ localStorage.setItem(
+ storageKey(identityId, contractId, network),
+ JSON.stringify(payload),
+ );
+ } catch {
+ // ignore — quota or disabled storage
+ }
+}
+
+// Clears every cached workspace for `identityId` across all contract/network
+// combinations. Forget-identity / logout flows don't know which combos the
+// user has visited, so we sweep by prefix.
+export function clearCachedNotes(identityId: string): void {
+ if (!identityId) return;
+ try {
+ const prefix = identityPrefix(identityId);
+ const toRemove: string[] = [];
+ for (let i = 0; i < localStorage.length; i += 1) {
+ const k = localStorage.key(i);
+ if (k && k.startsWith(prefix)) toRemove.push(k);
+ }
+ toRemove.forEach((k) => localStorage.removeItem(k));
+ } catch {
+ // ignore
+ }
+}
+
+export function notesEqualByRevision(
+ a: NoteRecord[],
+ b: NoteRecord[],
+): boolean {
+ if (a.length !== b.length) return false;
+ for (let i = 0; i < a.length; i += 1) {
+ if (a[i].id !== b[i].id || a[i].revision !== b[i].revision) return false;
+ }
+ return true;
+}
diff --git a/example-apps/dashnote/src/lib/rememberedIdentity.ts b/example-apps/dashnote/src/lib/rememberedIdentity.ts
new file mode 100644
index 0000000..da60964
--- /dev/null
+++ b/example-apps/dashnote/src/lib/rememberedIdentity.ts
@@ -0,0 +1,41 @@
+// Persists ONLY the identity ID and (optionally) its DPNS username for the
+// most recently logged-in user, so a returning visitor can browse their
+// notes read-only and see their name without re-querying. Never store the
+// mnemonic, derived keys, or identity index here.
+const STORAGE_KEY = "dashnote.lastIdentity";
+
+export interface RememberedIdentity {
+ id: string;
+ name?: string | null;
+}
+
+export function loadRememberedIdentity(): RememberedIdentity | null {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) return null;
+ const parsed = JSON.parse(raw) as Partial;
+ if (typeof parsed?.id !== "string" || !parsed.id) return null;
+ return {
+ id: parsed.id,
+ name: typeof parsed.name === "string" ? parsed.name : null,
+ };
+ } catch {
+ return null;
+ }
+}
+
+export function saveRememberedIdentity(value: RememberedIdentity): void {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(value));
+ } catch {
+ // ignore — quota or disabled storage
+ }
+}
+
+export function clearRememberedIdentity(): void {
+ try {
+ localStorage.removeItem(STORAGE_KEY);
+ } catch {
+ // ignore
+ }
+}
diff --git a/example-apps/dashnote/src/main.tsx b/example-apps/dashnote/src/main.tsx
new file mode 100644
index 0000000..9d8bf4d
--- /dev/null
+++ b/example-apps/dashnote/src/main.tsx
@@ -0,0 +1,16 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import "./styles/globals.css";
+import App from "./App.tsx";
+import { applyTheme, getInitialTheme } from "./hooks/useTheme";
+import { SessionProvider } from "./session/SessionContext";
+
+applyTheme(getInitialTheme());
+
+createRoot(document.getElementById("root")!).render(
+
+
+
+
+ ,
+);
diff --git a/example-apps/dashnote/src/session/SessionContext.tsx b/example-apps/dashnote/src/session/SessionContext.tsx
new file mode 100644
index 0000000..1ed4b59
--- /dev/null
+++ b/example-apps/dashnote/src/session/SessionContext.tsx
@@ -0,0 +1,326 @@
+import { toast } from "sonner";
+import {
+ createContext,
+ useCallback,
+ useMemo,
+ useState,
+ type ReactNode,
+} from "react";
+
+import {
+ clearStoredContractId,
+ loadStoredContractId,
+ refreshContractCache,
+ saveContractId,
+} from "../dash/contract";
+import { resolveDpnsName } from "../dash/resolveDpnsName";
+import type { DashKeyManager, DashSdk } from "../dash/types";
+import { errorMessage, type Logger } from "../lib/logger";
+import { clearCachedNotes } from "../lib/notesCache";
+import {
+ clearRememberedIdentity,
+ loadRememberedIdentity,
+ saveRememberedIdentity,
+} from "../lib/rememberedIdentity";
+
+// The SDK + IdentityKeyManager pull in @dashevo/evo-sdk (and its ~8MB WASM
+// bundle), so we load them lazily on first use to keep the app shell off
+// the critical path. Cached after first call.
+type SdkModule = typeof import("../../../../setupDashClient-core.mjs");
+let sdkModulePromise: Promise | null = null;
+function loadSdkModule(): Promise {
+ if (!sdkModulePromise) {
+ sdkModulePromise = import("../../../../setupDashClient-core.mjs").catch(
+ (err) => {
+ // Clear the cache on failure so a subsequent connect/login retry
+ // can re-attempt the import (e.g., after a transient chunk fetch
+ // failure). Without this, every retry would await the same
+ // rejected promise and fail immediately.
+ sdkModulePromise = null;
+ throw err;
+ },
+ );
+ }
+ return sdkModulePromise;
+}
+
+export type SessionStatus =
+ | "idle"
+ | "connecting"
+ | "readonly"
+ | "browsing"
+ | "authenticated"
+ | "error";
+
+export interface LoginOptions {
+ identityIndex?: number;
+ rememberMe?: boolean;
+}
+
+export interface SessionValue {
+ status: SessionStatus;
+ error: string | null;
+ sdk: DashSdk | null;
+ keyManager: DashKeyManager | null;
+ identityId: string | null;
+ contractId: string | null;
+ rememberedIdentityId: string | null;
+ dpnsName: string | null;
+ setContractId: (id: string | null) => void;
+ log: Logger;
+ login: (mnemonic: string, options?: LoginOptions) => Promise;
+ enterReadOnly: () => Promise;
+ viewAsRemembered: () => Promise;
+ forgetIdentity: () => void;
+ logout: () => void;
+}
+
+const SessionContext = createContext(null);
+export { SessionContext };
+
+export function SessionProvider({ children }: { children: ReactNode }) {
+ const initialRemembered = loadRememberedIdentity();
+ const [status, setStatus] = useState(
+ initialRemembered ? "browsing" : "idle",
+ );
+ const [error, setError] = useState(null);
+ const [sdk, setSdk] = useState(null);
+ const [keyManager, setKeyManager] = useState(null);
+ const [identityId, setIdentityId] = useState(
+ initialRemembered?.id ?? null,
+ );
+ const [contractId, setContractIdState] = useState(() =>
+ loadStoredContractId(),
+ );
+ const [rememberedIdentityId, setRememberedIdentityId] = useState<
+ string | null
+ >(initialRemembered?.id ?? null);
+ const [dpnsName, setDpnsName] = useState(
+ initialRemembered?.name ?? null,
+ );
+
+ const log = useCallback((message, level = "info") => {
+ const method =
+ level === "error" ? "error" : level === "success" ? "info" : "log";
+ console[method](`[${level}] ${message}`);
+ if (level === "success") toast.success(message);
+ if (level === "error") toast.error(message);
+ }, []);
+
+ const setContractId = useCallback(
+ (id: string | null) => {
+ const trimmed = id?.trim() ?? "";
+ // Evict the SDK's cached schema for the contract we're leaving so the
+ // next query refetches against the new ID. queries.ts trusts the cache
+ // for normal note operations; this is the one place it can become
+ // stale (user pastes a different ID or registers a fresh contract).
+ const previousId = contractId;
+ if (sdk && previousId && previousId !== trimmed) {
+ void refreshContractCache({ sdk, contractId: previousId });
+ }
+ if (trimmed) {
+ saveContractId(trimmed);
+ setContractIdState(trimmed);
+ return;
+ }
+ clearStoredContractId();
+ setContractIdState(loadStoredContractId());
+ },
+ [sdk, contractId],
+ );
+
+ const connect = useCallback(async () => {
+ setStatus("connecting");
+ setError(null);
+ log("Connecting to Dash Platform testnet…");
+ const { createClient } = await loadSdkModule();
+ const connected = (await createClient("testnet")) as unknown as DashSdk;
+ setSdk(connected);
+ log("Connected to Dash Platform testnet.");
+ return connected;
+ }, [log]);
+
+ const login = useCallback(
+ async (mnemonic: string, options: LoginOptions = {}) => {
+ const { identityIndex = 0, rememberMe = false } = options;
+ const trimmed = mnemonic.trim();
+ if (!trimmed) throw new Error("Mnemonic is required.");
+ setError(null);
+ try {
+ const connected = sdk ?? (await connect());
+ const { IdentityKeyManager } = await loadSdkModule();
+ const manager = await IdentityKeyManager.create({
+ sdk: connected as never,
+ mnemonic: trimmed,
+ network: "testnet",
+ identityIndex,
+ });
+ setKeyManager(manager);
+ const resolvedId = manager.identityId ?? null;
+ setIdentityId(resolvedId);
+ setStatus("authenticated");
+ log(`Identity resolved: ${resolvedId ?? "(unknown)"}`, "success");
+
+ // Resolve the DPNS name after auth so we can persist it alongside
+ // the identity ID — DPNS bindings are permanent, so what we save
+ // here is correct for every future load. A naming-service failure
+ // shouldn't kill an otherwise-valid session: caption is optional.
+ let resolvedName: string | null = null;
+ if (resolvedId) {
+ try {
+ resolvedName = await resolveDpnsName(connected, resolvedId);
+ } catch (dpnsErr) {
+ log(`DPNS lookup failed: ${errorMessage(dpnsErr)}`, "info");
+ }
+ setDpnsName(resolvedName);
+ }
+ if (rememberMe && resolvedId) {
+ saveRememberedIdentity({ id: resolvedId, name: resolvedName });
+ setRememberedIdentityId(resolvedId);
+ } else {
+ clearRememberedIdentity();
+ setRememberedIdentityId(null);
+ }
+ } catch (err) {
+ const message = errorMessage(err);
+ setError(message);
+ setStatus("error");
+ log(`Login failed: ${message}`, "error");
+ throw err;
+ }
+ },
+ [sdk, connect, log],
+ );
+
+ const enterReadOnly = useCallback(async () => {
+ setError(null);
+ try {
+ if (!sdk) await connect();
+ setKeyManager(null);
+ setIdentityId(null);
+ setDpnsName(null);
+ setStatus("readonly");
+ log("Read-only mode enabled.");
+ } catch (err) {
+ const message = errorMessage(err);
+ setError(message);
+ setStatus("error");
+ log(`Connection failed: ${message}`, "error");
+ }
+ }, [sdk, connect, log]);
+
+ const viewAsRemembered = useCallback(async () => {
+ const remembered = loadRememberedIdentity();
+ if (!remembered) {
+ await enterReadOnly();
+ return;
+ }
+ setError(null);
+ setKeyManager(null);
+ setIdentityId(remembered.id);
+ setRememberedIdentityId(remembered.id);
+ setDpnsName(remembered.name ?? null);
+ setStatus("browsing");
+ try {
+ let connected = sdk;
+ if (!connected) {
+ log("Connecting to Dash Platform testnet…");
+ const { createClient } = await loadSdkModule();
+ connected = (await createClient("testnet")) as unknown as DashSdk;
+ setSdk(connected);
+ log("Connected to Dash Platform testnet.");
+ }
+ log(`Browsing notes for ${remembered.id} (read-only).`);
+
+ // DPNS (name, identityId) bindings are permanent, so a cached name
+ // never needs revalidation. Only resolve when we don't have one yet
+ // (e.g. the identity registered a name after we last saved, or this
+ // record predates the dpnsName field).
+ if (!remembered.name) {
+ try {
+ const fresh = await resolveDpnsName(connected, remembered.id);
+ if (fresh) {
+ setDpnsName(fresh);
+ saveRememberedIdentity({ id: remembered.id, name: fresh });
+ }
+ } catch (dpnsErr) {
+ log(`DPNS lookup failed: ${errorMessage(dpnsErr)}`, "info");
+ }
+ }
+ } catch (err) {
+ const message = errorMessage(err);
+ setError(message);
+ setStatus("error");
+ log(`Connection failed: ${message}`, "error");
+ }
+ }, [sdk, enterReadOnly, log]);
+
+ const forgetIdentity = useCallback(() => {
+ if (rememberedIdentityId) clearCachedNotes(rememberedIdentityId);
+ clearRememberedIdentity();
+ setRememberedIdentityId(null);
+ setDpnsName(null);
+ if (status === "browsing") {
+ setKeyManager(null);
+ setIdentityId(null);
+ setStatus(sdk ? "readonly" : "idle");
+ }
+ log("Forgot remembered identity on this device.");
+ }, [status, sdk, rememberedIdentityId, log]);
+
+ const logout = useCallback(() => {
+ setKeyManager(null);
+ setError(null);
+ if (rememberedIdentityId) {
+ setIdentityId(rememberedIdentityId);
+ setStatus(sdk ? "browsing" : "idle");
+ log("Logged out. Browsing remembered identity (read-only).");
+ return;
+ }
+ setIdentityId(null);
+ setDpnsName(null);
+ setStatus(sdk ? "readonly" : "idle");
+ log("Logged out.");
+ }, [sdk, rememberedIdentityId, log]);
+
+ const value = useMemo(
+ () => ({
+ status,
+ error,
+ sdk,
+ keyManager,
+ identityId,
+ contractId,
+ rememberedIdentityId,
+ dpnsName,
+ setContractId,
+ log,
+ login,
+ enterReadOnly,
+ viewAsRemembered,
+ forgetIdentity,
+ logout,
+ }),
+ [
+ status,
+ error,
+ sdk,
+ keyManager,
+ identityId,
+ contractId,
+ rememberedIdentityId,
+ dpnsName,
+ setContractId,
+ log,
+ login,
+ enterReadOnly,
+ viewAsRemembered,
+ forgetIdentity,
+ logout,
+ ],
+ );
+
+ return (
+ {children}
+ );
+}
diff --git a/example-apps/dashnote/src/session/useSession.ts b/example-apps/dashnote/src/session/useSession.ts
new file mode 100644
index 0000000..a20838d
--- /dev/null
+++ b/example-apps/dashnote/src/session/useSession.ts
@@ -0,0 +1,11 @@
+import { useContext } from "react";
+
+import { SessionContext, type SessionValue } from "./SessionContext";
+
+export function useSession(): SessionValue {
+ const ctx = useContext(SessionContext);
+ if (!ctx) {
+ throw new Error("useSession must be used inside .");
+ }
+ return ctx;
+}
diff --git a/example-apps/dashnote/src/styles/globals.css b/example-apps/dashnote/src/styles/globals.css
new file mode 100644
index 0000000..9b2f764
--- /dev/null
+++ b/example-apps/dashnote/src/styles/globals.css
@@ -0,0 +1,126 @@
+@import url("https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap");
+@import "tailwindcss";
+
+@theme {
+ --color-bg: oklch(22% 0.015 72);
+ --color-surface: oklch(27% 0.018 72);
+ --color-surface-2: oklch(32% 0.018 72);
+ --color-line: oklch(38% 0.018 72);
+ --color-line-2: oklch(45% 0.018 72);
+
+ --color-ink: oklch(95% 0.01 95);
+ --color-ink-2: oklch(83% 0.012 95);
+ --color-ink-3: oklch(66% 0.012 95);
+ --color-ink-4: oklch(55% 0.011 95);
+
+ --color-accent: oklch(71% 0.15 72);
+ --color-accent-dim: oklch(62% 0.13 72);
+ --color-danger: oklch(67% 0.19 26);
+ --color-warning: oklch(78% 0.13 75);
+
+ --font-sans: "Manrope", system-ui, -apple-system, "Segoe UI", sans-serif;
+ --font-mono:
+ "IBM Plex Mono", ui-monospace, "Cascadia Code", "Fira Code", monospace;
+}
+
+:root,
+:root[data-theme="dark"] {
+ color-scheme: dark;
+}
+
+:root[data-theme="light"] {
+ color-scheme: light;
+
+ --color-bg: oklch(98% 0.014 92);
+ --color-surface: oklch(96% 0.02 92);
+ --color-surface-2: oklch(93% 0.022 92);
+ --color-line: oklch(86% 0.022 86);
+ --color-line-2: oklch(80% 0.022 82);
+
+ --color-ink: oklch(25% 0.03 65);
+ --color-ink-2: oklch(38% 0.026 65);
+ --color-ink-3: oklch(51% 0.02 65);
+ --color-ink-4: oklch(61% 0.016 65);
+
+ --color-accent: oklch(60% 0.14 72);
+ --color-accent-dim: oklch(54% 0.12 72);
+ --color-danger: oklch(58% 0.2 26);
+ --color-warning: oklch(68% 0.16 70);
+}
+
+html,
+body,
+#root {
+ min-height: 100vh;
+ margin: 0;
+ background:
+ radial-gradient(
+ circle at top left,
+ color-mix(in oklab, var(--color-surface-2) 35%, transparent),
+ transparent 30%
+ ),
+ linear-gradient(
+ 180deg,
+ color-mix(in oklab, var(--color-bg) 90%, black),
+ var(--color-bg)
+ );
+ color: var(--color-ink);
+ font-family: var(--font-sans);
+}
+
+body {
+ background-attachment: fixed;
+}
+
+@layer base {
+ button,
+ input,
+ textarea {
+ font: inherit;
+ }
+
+ textarea {
+ field-sizing: content;
+ }
+}
+
+code {
+ font-family: var(--font-mono);
+}
+
+:focus-visible {
+ outline: 2px solid var(--color-accent-dim);
+ outline-offset: 2px;
+}
+
+@keyframes conn-pulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.4;
+ }
+}
+
+.conn-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 9999px;
+ background: var(--color-ink-4);
+}
+
+.conn-dot.connecting {
+ background: var(--color-accent);
+ animation: conn-pulse 1s infinite;
+}
+
+.conn-dot.connected,
+.conn-dot.authenticated,
+.conn-dot.readonly {
+ background: oklch(70% 0.16 150);
+}
+
+.conn-dot.error {
+ background: var(--color-danger);
+}
diff --git a/example-apps/dashnote/test/App.test.tsx b/example-apps/dashnote/test/App.test.tsx
new file mode 100644
index 0000000..7f8ff8c
--- /dev/null
+++ b/example-apps/dashnote/test/App.test.tsx
@@ -0,0 +1,140 @@
+// @vitest-environment jsdom
+
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
+import type { ReactNode } from "react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import App from "../src/App";
+
+const { mockUseSession } = vi.hoisted(() => ({
+ mockUseSession: vi.fn(),
+}));
+
+vi.mock("../src/session/useSession", () => ({
+ useSession: mockUseSession,
+}));
+
+vi.mock("sonner", () => ({
+ Toaster: () =>
,
+}));
+
+vi.mock("../src/components/NotesWorkspace", () => ({
+ NotesWorkspace: () => Notes Workspace
,
+}));
+
+vi.mock("../src/components/HowItWorks", () => ({
+ HowItWorks: () => How It Works
,
+}));
+
+vi.mock("../src/components/AppShell", () => ({
+ AppShell: ({
+ children,
+ onLoginOpen,
+ onTabChange,
+ }: {
+ children: ReactNode;
+ onLoginOpen: () => void;
+ onTabChange: (tab: "notes" | "how-it-works") => void;
+ }) => (
+
+
+ Open settings
+
+ onTabChange("how-it-works")}>
+ How it works tab
+
+ {children}
+
+ ),
+}));
+
+vi.mock("../src/components/LoginModal", () => ({
+ LoginModal: ({ open }: { open: boolean }) => login:{String(open)}
,
+}));
+
+function makeSession(overrides: Record = {}) {
+ return {
+ status: "idle",
+ error: null,
+ sdk: null,
+ identityId: null,
+ contractId: null,
+ rememberedIdentityId: null,
+ dpnsName: null,
+ enterReadOnly: vi.fn().mockResolvedValue(undefined),
+ viewAsRemembered: vi.fn().mockResolvedValue(undefined),
+ ...overrides,
+ };
+}
+
+beforeEach(() => {
+ mockUseSession.mockReset();
+});
+
+afterEach(() => {
+ cleanup();
+});
+
+describe("App", () => {
+ it("auto-connects in read-only mode from idle when no identity is remembered", async () => {
+ const session = makeSession();
+ mockUseSession.mockReturnValue(session);
+
+ render( );
+
+ await waitFor(() => {
+ expect(session.enterReadOnly).toHaveBeenCalled();
+ });
+ expect(session.viewAsRemembered).not.toHaveBeenCalled();
+ });
+
+ it("finishes wiring up the SDK when boot starts in browsing mode without an SDK", async () => {
+ const session = makeSession({
+ status: "browsing",
+ sdk: null,
+ rememberedIdentityId: "remembered-identity-id",
+ });
+ mockUseSession.mockReturnValue(session);
+
+ render( );
+
+ await waitFor(() => {
+ expect(session.viewAsRemembered).toHaveBeenCalled();
+ });
+ expect(session.enterReadOnly).not.toHaveBeenCalled();
+ });
+
+ it("does not re-trigger viewAsRemembered once the SDK is initialized", async () => {
+ const session = makeSession({
+ status: "browsing",
+ sdk: {},
+ rememberedIdentityId: "remembered-identity-id",
+ });
+ mockUseSession.mockReturnValue(session);
+
+ render( );
+
+ await Promise.resolve();
+ expect(session.viewAsRemembered).not.toHaveBeenCalled();
+ expect(session.enterReadOnly).not.toHaveBeenCalled();
+ });
+
+ it("switches tabs and opens the settings modal", () => {
+ mockUseSession.mockReturnValue(makeSession({ status: "readonly" }));
+
+ render( );
+ expect(screen.getAllByText("Notes Workspace")[0]).toBeTruthy();
+
+ fireEvent.click(screen.getByRole("button", { name: /how it works tab/i }));
+ expect(screen.getByText("How It Works")).toBeTruthy();
+
+ fireEvent.click(screen.getByRole("button", { name: /open settings/i }));
+ expect(screen.getByText("login:true")).toBeTruthy();
+ });
+});
diff --git a/example-apps/dashnote/test/IdentityCard.test.tsx b/example-apps/dashnote/test/IdentityCard.test.tsx
new file mode 100644
index 0000000..4644ad7
--- /dev/null
+++ b/example-apps/dashnote/test/IdentityCard.test.tsx
@@ -0,0 +1,90 @@
+// @vitest-environment jsdom
+
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { IdentityCard } from "../src/components/IdentityCard";
+import type { SessionStatus } from "../src/session/SessionContext";
+
+afterEach(() => {
+ cleanup();
+});
+
+const IDENTITY_ID = "GgZekwh38XcWQTyWWWvmw6CEYFnLU7yiZFPWZEjqKHit";
+// IdentityCard calls truncateId(id, 6); head=6 + ellipsis + tail=8.
+const TRUNCATED_ID = "GgZekw…ZEjqKHit";
+const CONTRACT_ID = "8d6heK6CoskLBi6Rs7cChRG9RuckcZqZst28BdviBe8y";
+const TRUNCATED_CONTRACT_LINE = "contract 8d6heK…BdviBe8y";
+
+function renderCard(props: {
+ status: SessionStatus;
+ identityId: string | null;
+ dpnsName: string | null;
+ contractId?: string | null;
+}) {
+ return render(
+ ,
+ );
+}
+
+describe("IdentityCard", () => {
+ it("shows @-prefixed DPNS name as the primary line when authenticated", () => {
+ renderCard({
+ status: "authenticated",
+ identityId: IDENTITY_ID,
+ dpnsName: "alice",
+ });
+ expect(screen.getByText("@alice")).toBeTruthy();
+ // Identity ID becomes the secondary line.
+ expect(screen.getByText(TRUNCATED_ID)).toBeTruthy();
+ });
+
+ it("falls back to the truncated identity ID as primary when there is no DPNS name", () => {
+ renderCard({
+ status: "authenticated",
+ identityId: IDENTITY_ID,
+ dpnsName: null,
+ });
+ expect(screen.getByText(TRUNCATED_ID)).toBeTruthy();
+ expect(screen.queryByText(/^@/)).toBeNull();
+ });
+
+ it("shows the contract line as 'contract ' when no DPNS name is present", () => {
+ renderCard({
+ status: "authenticated",
+ identityId: IDENTITY_ID,
+ dpnsName: null,
+ contractId: CONTRACT_ID,
+ });
+ expect(screen.getByText(TRUNCATED_CONTRACT_LINE)).toBeTruthy();
+ });
+
+ it("renders the same primary/secondary layout in browsing (read-only) mode", () => {
+ renderCard({
+ status: "browsing",
+ identityId: IDENTITY_ID,
+ dpnsName: "alice",
+ });
+ expect(screen.getByText("@alice")).toBeTruthy();
+ expect(screen.getByText(TRUNCATED_ID)).toBeTruthy();
+ });
+
+ it("hides the identity layout and shows a Login button when not connected", () => {
+ renderCard({
+ status: "idle",
+ identityId: IDENTITY_ID,
+ dpnsName: "alice",
+ });
+ expect(screen.getByRole("button", { name: /login/i })).toBeTruthy();
+ // The identity layout (name + truncated id) must not render in the
+ // disconnected state, even when an id and name are passed in.
+ expect(screen.queryByText("@alice")).toBeNull();
+ expect(screen.queryByText(TRUNCATED_ID)).toBeNull();
+ });
+});
diff --git a/example-apps/dashnote/test/LoginModal.test.tsx b/example-apps/dashnote/test/LoginModal.test.tsx
new file mode 100644
index 0000000..bd6f765
--- /dev/null
+++ b/example-apps/dashnote/test/LoginModal.test.tsx
@@ -0,0 +1,659 @@
+// @vitest-environment jsdom
+
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+ within,
+} from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { LoginModal } from "../src/components/LoginModal";
+
+const { mockUseSession, mockRegisterContract } = vi.hoisted(() => ({
+ mockUseSession: vi.fn(),
+ mockRegisterContract: vi.fn(),
+}));
+
+vi.mock("../src/session/useSession", () => ({
+ useSession: mockUseSession,
+}));
+
+vi.mock("../src/dash/contract", () => ({
+ registerContract: mockRegisterContract,
+}));
+
+interface SessionOverrides {
+ status?: string;
+ dpnsName?: string | null;
+ contractId?: string | null;
+ identityId?: string | null;
+ sdk?: unknown;
+ keyManager?: unknown;
+ login?: ReturnType;
+ logout?: ReturnType;
+ setContractId?: ReturnType;
+ log?: ReturnType;
+ rememberedIdentityId?: string | null;
+ forgetIdentity?: ReturnType;
+}
+
+function makeSession(overrides: SessionOverrides = {}) {
+ return {
+ status: "readonly",
+ error: null,
+ sdk: { documents: {} },
+ keyManager: null,
+ identityId: null,
+ contractId: null,
+ rememberedIdentityId: null,
+ dpnsName: null,
+ log: vi.fn(),
+ login: vi.fn().mockResolvedValue(undefined),
+ logout: vi.fn(),
+ setContractId: vi.fn(),
+ enterReadOnly: vi.fn().mockResolvedValue(undefined),
+ viewAsRemembered: vi.fn().mockResolvedValue(undefined),
+ forgetIdentity: vi.fn(),
+ ...overrides,
+ };
+}
+
+beforeEach(() => {
+ mockUseSession.mockReset();
+ mockRegisterContract.mockReset();
+});
+
+afterEach(() => {
+ cleanup();
+});
+
+describe("LoginModal", () => {
+ it("renders nothing when closed", () => {
+ mockUseSession.mockReturnValue(makeSession());
+ const { container } = render( );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("submits the mnemonic via session.login and closes on success", async () => {
+ const login = vi.fn().mockResolvedValue(undefined);
+ const onClose = vi.fn();
+ mockUseSession.mockReturnValue(makeSession({ login }));
+
+ render( );
+
+ fireEvent.change(screen.getByPlaceholderText(/mnemonic phrase/i), {
+ target: { value: "test mnemonic phrase" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: /^login$/i }));
+
+ await waitFor(() => {
+ expect(login).toHaveBeenCalledWith("test mnemonic phrase", {
+ identityIndex: 0,
+ rememberMe: true,
+ });
+ });
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ it("displays an error when login throws", async () => {
+ const login = vi.fn().mockRejectedValue(new Error("Bad mnemonic"));
+ mockUseSession.mockReturnValue(makeSession({ login }));
+
+ render( );
+
+ fireEvent.change(screen.getByPlaceholderText(/mnemonic phrase/i), {
+ target: { value: "garbage" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: /^login$/i }));
+
+ expect(await screen.findByText("Bad mnemonic")).toBeTruthy();
+ });
+
+ it("disables the login button while the mnemonic field is empty", () => {
+ mockUseSession.mockReturnValue(makeSession());
+
+ render( );
+
+ const button = screen.getByRole("button", {
+ name: /^login$/i,
+ }) as HTMLButtonElement;
+ expect(button.disabled).toBe(true);
+ });
+
+ it("uses identity index from advanced settings", async () => {
+ const login = vi.fn().mockResolvedValue(undefined);
+ mockUseSession.mockReturnValue(makeSession({ login }));
+
+ render( );
+
+ fireEvent.click(screen.getByText(/advanced settings/i));
+ fireEvent.change(screen.getByRole("spinbutton"), {
+ target: { value: "3" },
+ });
+ fireEvent.change(screen.getByPlaceholderText(/mnemonic phrase/i), {
+ target: { value: "phrase" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: /^login$/i }));
+
+ await waitFor(() => {
+ expect(login).toHaveBeenCalledWith("phrase", {
+ identityIndex: 3,
+ rememberMe: true,
+ });
+ });
+ });
+
+ it("shows the settings view with logout when authenticated", () => {
+ const logout = vi.fn();
+ mockUseSession.mockReturnValue(
+ makeSession({
+ status: "authenticated",
+ identityId: "id-123456789012345678",
+ contractId: "contract-abc",
+ keyManager: { getAuth: vi.fn() },
+ logout,
+ }),
+ );
+
+ render( );
+
+ expect(screen.getByText("id-123456789012345678")).toBeTruthy();
+
+ fireEvent.click(screen.getByRole("button", { name: /^logout$/i }));
+ expect(logout).toHaveBeenCalled();
+ });
+
+ it("applies a pasted contract ID immediately without validation", () => {
+ const setContractId = vi.fn();
+ mockUseSession.mockReturnValue(
+ makeSession({
+ status: "authenticated",
+ identityId: "id-1",
+ contractId: null,
+ keyManager: { getAuth: vi.fn() },
+ setContractId,
+ }),
+ );
+
+ render( );
+
+ fireEvent.click(screen.getByText(/advanced settings/i));
+ fireEvent.change(
+ screen.getByPlaceholderText(
+ /paste a note contract id or register a new one/i,
+ ),
+ {
+ target: { value: " contract-123 " },
+ },
+ );
+ fireEvent.click(screen.getByRole("button", { name: /use this id/i }));
+
+ expect(setContractId).toHaveBeenCalledWith("contract-123");
+ });
+
+ it("defaults the Remember-me checkbox on when no identity is remembered", () => {
+ mockUseSession.mockReturnValue(makeSession());
+
+ render( );
+
+ const checkbox = screen.getByRole("checkbox", {
+ name: /remember this identity on this device/i,
+ }) as HTMLInputElement;
+ expect(checkbox.checked).toBe(true);
+ });
+
+ it("defaults the Remember-me checkbox on when an identity is already remembered", () => {
+ mockUseSession.mockReturnValue(
+ makeSession({ rememberedIdentityId: "remembered-identity-id" }),
+ );
+
+ render( );
+
+ const checkbox = screen.getByRole("checkbox", {
+ name: /remember this identity on this device/i,
+ }) as HTMLInputElement;
+ expect(checkbox.checked).toBe(true);
+ });
+
+ it("renders the Remember-me checkbox above Advanced settings", () => {
+ mockUseSession.mockReturnValue(makeSession());
+
+ render( );
+
+ const checkbox = screen.getByRole("checkbox", {
+ name: /remember this identity on this device/i,
+ });
+ const advanced = screen.getByRole("button", { name: /advanced settings/i });
+ expect(
+ checkbox.compareDocumentPosition(advanced) &
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ });
+
+ it("forwards rememberMe=true by default to session.login", async () => {
+ const login = vi.fn().mockResolvedValue(undefined);
+ mockUseSession.mockReturnValue(makeSession({ login }));
+
+ render( );
+
+ fireEvent.change(screen.getByPlaceholderText(/mnemonic phrase/i), {
+ target: { value: "phrase" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: /^login$/i }));
+
+ await waitFor(() => {
+ expect(login).toHaveBeenCalledWith("phrase", {
+ identityIndex: 0,
+ rememberMe: true,
+ });
+ });
+ });
+
+ it("forwards rememberMe=false when the user opts out", async () => {
+ const login = vi.fn().mockResolvedValue(undefined);
+ mockUseSession.mockReturnValue(makeSession({ login }));
+
+ render( );
+
+ fireEvent.click(
+ screen.getByRole("checkbox", {
+ name: /remember this identity on this device/i,
+ }),
+ );
+ fireEvent.change(screen.getByPlaceholderText(/mnemonic phrase/i), {
+ target: { value: "phrase" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: /^login$/i }));
+
+ await waitFor(() => {
+ expect(login).toHaveBeenCalledWith("phrase", {
+ identityIndex: 0,
+ rememberMe: false,
+ });
+ });
+ });
+
+ it("shows the remembered identity as a read-only field above the mnemonic", () => {
+ mockUseSession.mockReturnValue(
+ makeSession({
+ status: "browsing",
+ identityId: "remembered-identity-id",
+ rememberedIdentityId: "remembered-identity-id",
+ }),
+ );
+
+ render( );
+
+ const identityField = screen.getByLabelText(
+ /remembered identity/i,
+ ) as HTMLInputElement;
+ expect(identityField.readOnly).toBe(true);
+ expect(identityField.value).toBe("remembered-identity-id");
+
+ const mnemonicField = screen.getByPlaceholderText(
+ /enter the mnemonic for this identity/i,
+ );
+ expect(
+ identityField.compareDocumentPosition(mnemonicField) &
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ });
+
+ it("hides the Dash bridge prompt when an identity is remembered", () => {
+ mockUseSession.mockReturnValue(
+ makeSession({ rememberedIdentityId: "remembered-identity-id" }),
+ );
+
+ render( );
+
+ expect(screen.queryByText(/dash bridge/i)).toBeNull();
+ });
+
+ it("shows the Dash bridge prompt when no identity is remembered", () => {
+ mockUseSession.mockReturnValue(makeSession());
+
+ render( );
+
+ expect(screen.getByText(/dash bridge/i)).toBeTruthy();
+ });
+
+ it("renders the switch/forget actions below the mnemonic field", () => {
+ mockUseSession.mockReturnValue(
+ makeSession({ rememberedIdentityId: "remembered-identity-id" }),
+ );
+
+ render( );
+
+ const mnemonicField = screen.getByPlaceholderText(
+ /enter the mnemonic for this identity/i,
+ );
+ const actions = screen.getByTestId("remembered-identity-actions");
+ expect(
+ mnemonicField.compareDocumentPosition(actions) &
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ });
+
+ it("hides the panel and shows a notice when Use a different identity is clicked", () => {
+ const forgetIdentity = vi.fn();
+ mockUseSession.mockReturnValue(
+ makeSession({
+ rememberedIdentityId: "remembered-identity-id",
+ forgetIdentity,
+ }),
+ );
+
+ render( );
+
+ fireEvent.click(
+ screen.getByRole("button", { name: /use a different identity/i }),
+ );
+
+ expect(screen.queryByTestId("remembered-identity-panel")).toBeNull();
+ expect(screen.getByTestId("different-identity-notice")).toBeTruthy();
+ expect(forgetIdentity).not.toHaveBeenCalled();
+
+ const checkbox = screen.getByRole("checkbox", {
+ name: /remember this identity on this device/i,
+ }) as HTMLInputElement;
+ expect(checkbox.checked).toBe(true);
+ });
+
+ it("calls forgetIdentity when Forget this device is clicked from the login form", () => {
+ const forgetIdentity = vi.fn();
+ mockUseSession.mockReturnValue(
+ makeSession({
+ rememberedIdentityId: "remembered-identity-id",
+ forgetIdentity,
+ }),
+ );
+
+ render( );
+
+ fireEvent.click(
+ screen.getByRole("button", { name: /forget this device/i }),
+ );
+ expect(forgetIdentity).toHaveBeenCalled();
+ });
+
+ it("hides the remembered identity panel when no identity is remembered", () => {
+ mockUseSession.mockReturnValue(makeSession());
+
+ render( );
+
+ expect(screen.queryByTestId("remembered-identity-panel")).toBeNull();
+ });
+
+ it("calls onClose when the Cancel button is clicked from the login form", () => {
+ const onClose = vi.fn();
+ mockUseSession.mockReturnValue(makeSession());
+
+ render( );
+
+ fireEvent.click(screen.getByRole("button", { name: /^cancel$/i }));
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it("submits with rememberMe=true after switching to a different identity", async () => {
+ const login = vi.fn().mockResolvedValue(undefined);
+ const onClose = vi.fn();
+ mockUseSession.mockReturnValue(
+ makeSession({
+ login,
+ rememberedIdentityId: "remembered-identity-id",
+ }),
+ );
+
+ render( );
+
+ fireEvent.click(
+ screen.getByRole("button", { name: /use a different identity/i }),
+ );
+ fireEvent.change(
+ screen.getByPlaceholderText(/mnemonic phrase/i) as HTMLInputElement,
+ { target: { value: "fresh mnemonic" } },
+ );
+ fireEvent.click(screen.getByRole("button", { name: /^login$/i }));
+
+ await waitFor(() => {
+ expect(login).toHaveBeenCalledWith("fresh mnemonic", {
+ identityIndex: 0,
+ rememberMe: true,
+ });
+ });
+ });
+
+ it("resets useDifferentIdentity and rememberMe when the modal reopens", () => {
+ mockUseSession.mockReturnValue(
+ makeSession({ rememberedIdentityId: "remembered-identity-id" }),
+ );
+
+ const { rerender } = render( );
+
+ fireEvent.click(
+ screen.getByRole("button", { name: /use a different identity/i }),
+ );
+ fireEvent.click(
+ screen.getByRole("checkbox", {
+ name: /remember this identity on this device/i,
+ }),
+ );
+
+ expect(screen.queryByTestId("remembered-identity-panel")).toBeNull();
+ expect(
+ (
+ screen.getByRole("checkbox", {
+ name: /remember this identity on this device/i,
+ }) as HTMLInputElement
+ ).checked,
+ ).toBe(false);
+
+ rerender( );
+ rerender( );
+
+ expect(screen.getByTestId("remembered-identity-panel")).toBeTruthy();
+ expect(
+ (
+ screen.getByRole("checkbox", {
+ name: /remember this identity on this device/i,
+ }) as HTMLInputElement
+ ).checked,
+ ).toBe(true);
+ });
+
+ it("remembered panel shows the cached DPNS name as a ✓ name.dash caption", () => {
+ mockUseSession.mockReturnValue(
+ makeSession({
+ rememberedIdentityId: "remembered-identity-id",
+ dpnsName: "alice",
+ }),
+ );
+
+ render( );
+
+ const panel = screen.getByTestId("remembered-identity-panel");
+ expect(within(panel).getByText("✓ alice.dash")).toBeTruthy();
+ });
+
+ it("remembered panel omits the DPNS caption when no name is cached", () => {
+ mockUseSession.mockReturnValue(
+ makeSession({
+ rememberedIdentityId: "remembered-identity-id",
+ dpnsName: null,
+ }),
+ );
+
+ render( );
+
+ const panel = screen.getByTestId("remembered-identity-panel");
+ expect(within(panel).queryByText(/\.dash$/)).toBeNull();
+ });
+
+ it("settings panel shows the DPNS name as a ✓ name.dash caption under the identity", () => {
+ mockUseSession.mockReturnValue(
+ makeSession({
+ status: "authenticated",
+ identityId: "id-1",
+ dpnsName: "alice",
+ keyManager: { getAuth: vi.fn() },
+ }),
+ );
+
+ render( );
+
+ const block = screen.getByTestId("settings-identity-block");
+ expect(within(block).getByText("✓ alice.dash")).toBeTruthy();
+ });
+
+ it("settings panel omits the DPNS caption when no name is set", () => {
+ mockUseSession.mockReturnValue(
+ makeSession({
+ status: "authenticated",
+ identityId: "id-1",
+ dpnsName: null,
+ keyManager: { getAuth: vi.fn() },
+ }),
+ );
+
+ render( );
+
+ const block = screen.getByTestId("settings-identity-block");
+ expect(within(block).queryByText(/\.dash$/)).toBeNull();
+ });
+
+ it("settings: Use a different identity link calls session.logout", () => {
+ const logout = vi.fn();
+ mockUseSession.mockReturnValue(
+ makeSession({
+ status: "authenticated",
+ identityId: "id-1",
+ keyManager: { getAuth: vi.fn() },
+ logout,
+ }),
+ );
+
+ render( );
+
+ const actions = screen.getByTestId("settings-identity-actions");
+ fireEvent.click(
+ within(actions).getByRole("button", {
+ name: /use a different identity/i,
+ }),
+ );
+ expect(logout).toHaveBeenCalled();
+ });
+
+ it("settings: Forget this device link calls session.forgetIdentity when remembered", () => {
+ const forgetIdentity = vi.fn();
+ mockUseSession.mockReturnValue(
+ makeSession({
+ status: "authenticated",
+ identityId: "id-1",
+ rememberedIdentityId: "id-1",
+ keyManager: { getAuth: vi.fn() },
+ forgetIdentity,
+ }),
+ );
+
+ render( );
+
+ const actions = screen.getByTestId("settings-identity-actions");
+ fireEvent.click(
+ within(actions).getByRole("button", { name: /forget this device/i }),
+ );
+ expect(forgetIdentity).toHaveBeenCalled();
+ });
+
+ it("settings: Forget this device link is hidden when nothing is remembered", () => {
+ mockUseSession.mockReturnValue(
+ makeSession({
+ status: "authenticated",
+ identityId: "id-1",
+ rememberedIdentityId: null,
+ keyManager: { getAuth: vi.fn() },
+ }),
+ );
+
+ render( );
+
+ const actions = screen.getByTestId("settings-identity-actions");
+ expect(
+ within(actions).queryByRole("button", { name: /forget this device/i }),
+ ).toBeNull();
+ });
+
+ it("settings: Close button calls onClose without logging out", () => {
+ const onClose = vi.fn();
+ const logout = vi.fn();
+ mockUseSession.mockReturnValue(
+ makeSession({
+ status: "authenticated",
+ identityId: "id-1",
+ keyManager: { getAuth: vi.fn() },
+ logout,
+ }),
+ );
+
+ render( );
+
+ const closeButtons = screen.getAllByRole("button", {
+ name: /^close$/i,
+ });
+ const inlineClose = closeButtons.find(
+ (button) => button.textContent === "Close",
+ );
+ expect(inlineClose).toBeDefined();
+ fireEvent.click(inlineClose!);
+ expect(onClose).toHaveBeenCalled();
+ expect(logout).not.toHaveBeenCalled();
+ });
+
+ it("settings: Logout button also calls onClose", () => {
+ const onClose = vi.fn();
+ const logout = vi.fn();
+ mockUseSession.mockReturnValue(
+ makeSession({
+ status: "authenticated",
+ identityId: "id-1",
+ keyManager: { getAuth: vi.fn() },
+ logout,
+ }),
+ );
+
+ render( );
+
+ fireEvent.click(screen.getByRole("button", { name: /^logout$/i }));
+ expect(logout).toHaveBeenCalled();
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it("registers a new contract when the Register new button is clicked", async () => {
+ const setContractId = vi.fn();
+ mockRegisterContract.mockResolvedValue("new-contract-id");
+ mockUseSession.mockReturnValue(
+ makeSession({
+ status: "authenticated",
+ identityId: "id-1",
+ keyManager: { getAuth: vi.fn() },
+ setContractId,
+ }),
+ );
+
+ render( );
+
+ fireEvent.click(screen.getByText(/advanced settings/i));
+ fireEvent.click(screen.getByRole("button", { name: /register new/i }));
+
+ await waitFor(() => {
+ expect(mockRegisterContract).toHaveBeenCalled();
+ });
+ await waitFor(() => {
+ expect(setContractId).toHaveBeenCalledWith("new-contract-id");
+ });
+ });
+});
diff --git a/example-apps/dashnote/test/NotesWorkspace.test.tsx b/example-apps/dashnote/test/NotesWorkspace.test.tsx
new file mode 100644
index 0000000..1497f86
--- /dev/null
+++ b/example-apps/dashnote/test/NotesWorkspace.test.tsx
@@ -0,0 +1,1047 @@
+// @vitest-environment jsdom
+
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { NotesWorkspace } from "../src/components/NotesWorkspace";
+
+const {
+ mockUseSession,
+ mockListMyNotes,
+ mockGetNote,
+ mockCreateNote,
+ mockUpdateNote,
+ mockDeleteNote,
+} = vi.hoisted(() => ({
+ mockUseSession: vi.fn(),
+ mockListMyNotes: vi.fn(),
+ mockGetNote: vi.fn(),
+ mockCreateNote: vi.fn(),
+ mockUpdateNote: vi.fn(),
+ mockDeleteNote: vi.fn(),
+}));
+
+vi.mock("../src/session/useSession", () => ({
+ useSession: mockUseSession,
+}));
+
+vi.mock("../src/dash/queries", () => ({
+ listMyNotes: mockListMyNotes,
+ getNote: mockGetNote,
+}));
+
+vi.mock("../src/dash/createNote", () => ({
+ createNote: mockCreateNote,
+}));
+
+vi.mock("../src/dash/updateNote", () => ({
+ updateNote: mockUpdateNote,
+}));
+
+vi.mock("../src/dash/deleteNote", () => ({
+ deleteNote: mockDeleteNote,
+}));
+
+function makeSession(overrides: Record = {}) {
+ return {
+ status: "authenticated",
+ sdk: {},
+ keyManager: {},
+ contractId: "contract-1",
+ identityId: "identity-1",
+ log: vi.fn(),
+ ...overrides,
+ };
+}
+
+// jsdom does not implement matchMedia; stub it so useMediaQuery resolves
+// the desired breakpoint. Defaults to desktop; mobile-flavored tests can
+// override by calling stubMatchMedia(false) before render.
+function stubMatchMedia(isDesktop: boolean) {
+ vi.stubGlobal(
+ "matchMedia",
+ vi.fn().mockImplementation((query: string) => ({
+ matches: isDesktop,
+ media: query,
+ onchange: null,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ );
+}
+
+beforeEach(() => {
+ localStorage.clear();
+ mockUseSession.mockReset();
+ mockListMyNotes.mockReset();
+ mockGetNote.mockReset();
+ mockCreateNote.mockReset();
+ mockUpdateNote.mockReset();
+ mockDeleteNote.mockReset();
+ vi.spyOn(window, "confirm").mockReturnValue(true);
+ stubMatchMedia(true);
+});
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+});
+
+describe("NotesWorkspace", () => {
+ it("shows auth gating when the session is not authenticated", () => {
+ mockUseSession.mockReturnValue(
+ makeSession({
+ status: "readonly",
+ keyManager: null,
+ identityId: null,
+ }),
+ );
+
+ const onOpenSettings = vi.fn();
+ render( );
+
+ expect(screen.getByText(/sign in to see your notes/i)).toBeTruthy();
+ const loginButton = screen.getByRole("button", { name: /^log in$/i });
+ fireEvent.click(loginButton);
+ expect(onOpenSettings).toHaveBeenCalled();
+ expect(screen.queryByRole("button", { name: /new note/i })).toBeNull();
+
+ const bridgeLink = screen.getByRole("link", { name: /dash bridge/i });
+ expect(bridgeLink.getAttribute("href")).toBe(
+ "https://bridge.thepasta.org/",
+ );
+ });
+
+ it("creates a body-only note and reloads the list", async () => {
+ mockUseSession.mockReturnValue(makeSession());
+ mockListMyNotes.mockResolvedValueOnce([]).mockResolvedValueOnce([
+ {
+ id: "note-1",
+ ownerId: "identity-1",
+ title: null,
+ message: "Body only",
+ createdAt: 1000,
+ updatedAt: 2000,
+ revision: 0,
+ },
+ ]);
+ mockGetNote.mockResolvedValue({
+ id: "note-1",
+ ownerId: "identity-1",
+ title: null,
+ message: "Body only",
+ createdAt: 1000,
+ updatedAt: 2000,
+ revision: 0,
+ });
+ mockCreateNote.mockResolvedValue("note-1");
+
+ render( );
+
+ await waitFor(() => {
+ expect(mockListMyNotes).toHaveBeenCalledTimes(1);
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: /new note/i }));
+ fireEvent.change(screen.getByLabelText(/body/i), {
+ target: { value: "Body only" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: /create note/i }));
+
+ await waitFor(() => {
+ expect(mockCreateNote).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: "",
+ message: "Body only",
+ }),
+ );
+ });
+ await waitFor(() => {
+ expect(mockListMyNotes).toHaveBeenCalledTimes(2);
+ });
+ expect(screen.getByDisplayValue("Body only")).toBeTruthy();
+ });
+
+ it("shows the empty notebook state for a valid contract with no notes", async () => {
+ mockUseSession.mockReturnValue(makeSession());
+ mockListMyNotes.mockResolvedValue([]);
+
+ render( );
+
+ await waitFor(() => {
+ expect(mockListMyNotes).toHaveBeenCalledTimes(1);
+ });
+ expect(screen.getAllByText(/no notes yet/i)[0]).toBeTruthy();
+ expect(screen.getByText(/0 notes/i)).toBeTruthy();
+ });
+
+ it("updates an existing note and shows delete flow", async () => {
+ mockUseSession.mockReturnValue(makeSession());
+ const note = {
+ id: "note-2",
+ ownerId: "identity-1",
+ title: "Original",
+ message: "Hello",
+ createdAt: 1000,
+ updatedAt: 2000,
+ revision: 2,
+ };
+ const updated = { ...note, title: "Edited", updatedAt: 3000, revision: 3 };
+ mockListMyNotes.mockResolvedValue([note]);
+ mockGetNote.mockResolvedValueOnce(note).mockResolvedValueOnce(updated);
+ mockUpdateNote.mockResolvedValue(undefined);
+ mockDeleteNote.mockResolvedValue(undefined);
+
+ render( );
+
+ await waitFor(() => {
+ expect(mockGetNote).toHaveBeenCalledWith(
+ expect.objectContaining({ noteId: "note-2" }),
+ );
+ });
+
+ fireEvent.change(screen.getByLabelText(/title/i), {
+ target: { value: "Edited" },
+ });
+ const saveButton = screen.getByRole("button", { name: /^save$/i });
+ fireEvent.click(saveButton);
+
+ await waitFor(() => {
+ expect(mockUpdateNote).toHaveBeenCalledWith(
+ expect.objectContaining({
+ noteId: "note-2",
+ title: "Edited",
+ message: "Hello",
+ }),
+ );
+ });
+
+ // After update, detail should be refetched (mockGetNote called again with same id)
+ await waitFor(() => {
+ expect(mockGetNote).toHaveBeenCalledTimes(2);
+ });
+
+ // Save button should disable once baselines match the freshly-loaded values
+ await waitFor(() => {
+ expect(saveButton.hasAttribute("disabled")).toBe(true);
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: /^delete$/i }));
+ await waitFor(() => {
+ expect(mockDeleteNote).toHaveBeenCalledWith(
+ expect.objectContaining({ noteId: "note-2" }),
+ );
+ });
+ });
+
+ it("merges fresh getNote data into the list and cache when the chain revision is newer than the cached list", async () => {
+ mockUseSession.mockReturnValue(makeSession());
+ const stale = {
+ id: "note-3",
+ ownerId: "identity-1",
+ title: "Old title",
+ message: "Old body preview",
+ createdAt: 1000,
+ updatedAt: 2000,
+ revision: 1,
+ };
+ const fresh = {
+ ...stale,
+ title: "New title",
+ message: "Fresh body preview",
+ updatedAt: 3000,
+ revision: 2,
+ };
+ // List query returns the stale revision (e.g. served from a peer that
+ // hasn't caught up). getNote returns the newer revision.
+ mockListMyNotes.mockResolvedValue([stale]);
+ mockGetNote.mockResolvedValue(fresh);
+
+ render( );
+
+ await waitFor(() => {
+ expect(mockGetNote).toHaveBeenCalledWith(
+ expect.objectContaining({ noteId: "note-3" }),
+ );
+ });
+
+ // The editor body reflects the fresh fetch.
+ await waitFor(() => {
+ expect(
+ (screen.getByLabelText(/^body$/i) as HTMLTextAreaElement).value,
+ ).toBe("Fresh body preview");
+ });
+
+ // The list preview should also reflect the fresh content, not the stale
+ // body returned by listMyNotes.
+ await waitFor(() => {
+ expect(screen.getAllByText(/fresh body preview/i).length).toBeGreaterThan(
+ 0,
+ );
+ });
+ expect(screen.queryByText(/old body preview/i)).toBeNull();
+
+ // The cache should hold the merged (fresh) revision so a cold reload
+ // would paint the up-to-date content immediately.
+ const cacheRaw = localStorage.getItem(
+ "dashnote.notes.identity-1.contract-1.testnet",
+ );
+ expect(cacheRaw).toBeTruthy();
+ const cached = JSON.parse(cacheRaw as string);
+ expect(cached.notes).toHaveLength(1);
+ expect(cached.notes[0].revision).toBe(2);
+ expect(cached.notes[0].message).toBe("Fresh body preview");
+ });
+
+ describe("concurrent save handling", () => {
+ const initial = {
+ id: "note-conflict",
+ ownerId: "identity-1",
+ title: "Local title",
+ message: "Local body",
+ createdAt: 1000,
+ updatedAt: 2000,
+ revision: 1,
+ };
+ const remote = {
+ ...initial,
+ title: "Remote title",
+ message: "Remote body",
+ updatedAt: 3000,
+ revision: 2,
+ };
+
+ it("surfaces a conflict warning when a failed save reveals the chain has moved", async () => {
+ mockUseSession.mockReturnValue(makeSession());
+ mockListMyNotes.mockResolvedValue([initial]);
+ mockGetNote
+ .mockResolvedValueOnce(initial) // initial detail load
+ .mockResolvedValue(remote); // post-failure refresh — chain has moved
+ mockUpdateNote.mockRejectedValue(new Error("Identity nonce is stale"));
+
+ render( );
+
+ await waitFor(() => {
+ expect(
+ (screen.getByLabelText(/^body$/i) as HTMLTextAreaElement).value,
+ ).toBe("Local body");
+ });
+
+ fireEvent.change(screen.getByLabelText(/^body$/i), {
+ target: { value: "User edited body" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
+
+ // Conflict warning supersedes the raw nonce error — it's the actionable
+ // signal that a retry would overwrite.
+ await waitFor(() => {
+ expect(
+ screen.getByText(/this note changed on the network/i),
+ ).toBeTruthy();
+ });
+ expect(screen.queryByText(/identity nonce is stale/i)).toBeNull();
+ // User's typed input is preserved.
+ expect(
+ (screen.getByLabelText(/^body$/i) as HTMLTextAreaElement).value,
+ ).toBe("User edited body");
+ });
+
+ it("does NOT show the conflict warning when a save fails but the chain revision is unchanged", async () => {
+ mockUseSession.mockReturnValue(makeSession());
+ mockListMyNotes.mockResolvedValue([initial]);
+ // Both the initial detail load and the post-failure refresh return the
+ // same revision — failure is unrelated to a concurrent edit.
+ mockGetNote.mockResolvedValue(initial);
+ mockUpdateNote.mockRejectedValue(new Error("Network unreachable"));
+
+ render( );
+
+ await waitFor(() => {
+ expect(
+ (screen.getByLabelText(/^body$/i) as HTMLTextAreaElement).value,
+ ).toBe("Local body");
+ });
+
+ fireEvent.change(screen.getByLabelText(/^body$/i), {
+ target: { value: "User edited body" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText(/network unreachable/i)).toBeTruthy();
+ });
+ expect(
+ screen.queryByText(/this note changed on the network/i),
+ ).toBeNull();
+ });
+
+ it("retrying after a conflict overwrites the chain's newer revision", async () => {
+ mockUseSession.mockReturnValue(makeSession());
+ mockListMyNotes.mockResolvedValue([remote]);
+ mockGetNote
+ .mockResolvedValueOnce(initial) // initial detail load (rev=1)
+ .mockResolvedValueOnce(remote) // post-failure refresh (rev=2)
+ .mockResolvedValue(remote); // post-success reload
+ mockUpdateNote
+ .mockRejectedValueOnce(new Error("Identity nonce is stale"))
+ .mockResolvedValue(undefined);
+
+ render( );
+
+ await waitFor(() => {
+ expect(
+ (screen.getByLabelText(/^body$/i) as HTMLTextAreaElement).value,
+ ).toBe("Local body");
+ });
+
+ fireEvent.change(screen.getByLabelText(/^body$/i), {
+ target: { value: "User wins" },
+ });
+ const saveButton = screen.getByRole("button", { name: /^save$/i });
+ fireEvent.click(saveButton);
+
+ // First click: warning surfaces.
+ await waitFor(() => {
+ expect(
+ screen.getByText(/this note changed on the network/i),
+ ).toBeTruthy();
+ });
+
+ // Retry — should overwrite.
+ fireEvent.click(saveButton);
+
+ await waitFor(() => {
+ expect(mockUpdateNote).toHaveBeenCalledTimes(2);
+ });
+ expect(mockUpdateNote).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ noteId: "note-conflict",
+ message: "User wins",
+ }),
+ );
+ });
+
+ it("does not flash the conflict warning during a normal successful save", async () => {
+ mockUseSession.mockReturnValue(makeSession());
+ const saved = {
+ ...initial,
+ message: "User edited body",
+ revision: 2,
+ updatedAt: 3000,
+ };
+ mockListMyNotes
+ .mockResolvedValueOnce([initial])
+ .mockResolvedValue([saved]);
+ mockGetNote
+ .mockResolvedValueOnce(initial) // initial detail load
+ .mockResolvedValue(saved); // post-save reload
+ mockUpdateNote.mockResolvedValue(undefined);
+
+ render( );
+
+ await waitFor(() => {
+ expect(
+ (screen.getByLabelText(/^body$/i) as HTMLTextAreaElement).value,
+ ).toBe("Local body");
+ });
+
+ fireEvent.change(screen.getByLabelText(/^body$/i), {
+ target: { value: "User edited body" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
+
+ // Wait for save to complete.
+ await waitFor(() => {
+ expect(mockUpdateNote).toHaveBeenCalledTimes(1);
+ });
+ // Wait for the post-save reload to settle (list query called twice:
+ // once on mount, once after save).
+ await waitFor(() => {
+ expect(mockListMyNotes).toHaveBeenCalledTimes(2);
+ });
+
+ // Conflict warning must never have appeared.
+ expect(
+ screen.queryByText(/this note changed on the network/i),
+ ).toBeNull();
+ });
+ });
+
+ it("surfaces query failures as a regular editor error", async () => {
+ mockUseSession.mockReturnValue(makeSession());
+ mockListMyNotes.mockRejectedValue(new Error("Data contract not found"));
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText(/editor error/i)).toBeTruthy();
+ });
+ expect(screen.getByText("Data contract not found")).toBeTruthy();
+ });
+
+ it("shows the no-contract empty state when authed without a contract", () => {
+ mockUseSession.mockReturnValue(makeSession({ contractId: null }));
+ const onOpenSettings = vi.fn();
+
+ render( );
+
+ expect(screen.getByText(/register or select a contract/i)).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: /open settings/i }));
+ expect(onOpenSettings).toHaveBeenCalled();
+ // List/editor chrome should not render in this branch.
+ expect(screen.queryByRole("searchbox")).toBeNull();
+ expect(screen.queryByLabelText(/^body$/i)).toBeNull();
+ });
+
+ it("filters the list with the search input", async () => {
+ // Use mobile layout so only the list pane renders; otherwise the
+ // auto-selected note's title would render in the editor pane too and
+ // confuse text-presence assertions.
+ stubMatchMedia(false);
+ mockUseSession.mockReturnValue(makeSession());
+ const notes = [
+ {
+ id: "n1",
+ ownerId: "identity-1",
+ title: "Grocery list",
+ message: "milk, eggs, bread",
+ createdAt: 1,
+ updatedAt: 10,
+ revision: 0,
+ },
+ {
+ id: "n2",
+ ownerId: "identity-1",
+ title: "Travel ideas",
+ message: "Lisbon, Tokyo",
+ createdAt: 2,
+ updatedAt: 20,
+ revision: 0,
+ },
+ {
+ id: "n3",
+ ownerId: "identity-1",
+ title: null,
+ message: "Random shower thought",
+ createdAt: 3,
+ updatedAt: 30,
+ revision: 0,
+ },
+ ];
+ mockListMyNotes.mockResolvedValue(notes);
+ mockGetNote.mockResolvedValue(notes[0]);
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText("Grocery list")).toBeTruthy();
+ });
+
+ const search = screen.getByPlaceholderText(/search/i);
+
+ fireEvent.change(search, { target: { value: "tokyo" } });
+ expect(screen.queryByText("Grocery list")).toBeNull();
+ expect(screen.getByText("Travel ideas")).toBeTruthy();
+ expect(screen.queryByText("Random shower thought")).toBeNull();
+
+ fireEvent.change(search, { target: { value: "zzz-no-match" } });
+ expect(screen.getByText(/no notes match that search/i)).toBeTruthy();
+
+ fireEvent.change(search, { target: { value: "" } });
+ expect(screen.getByText("Grocery list")).toBeTruthy();
+ expect(screen.getByText("Travel ideas")).toBeTruthy();
+ });
+
+ describe("mobile", () => {
+ beforeEach(() => {
+ stubMatchMedia(false);
+ });
+
+ const noteFixture = {
+ id: "note-mobile",
+ ownerId: "identity-1",
+ title: "Phone note",
+ message: "Tap target",
+ createdAt: 1000,
+ updatedAt: 2000,
+ revision: 1,
+ };
+
+ it("does not auto-select the first note on load", async () => {
+ mockUseSession.mockReturnValue(makeSession());
+ mockListMyNotes.mockResolvedValue([noteFixture]);
+ mockGetNote.mockResolvedValue(noteFixture);
+
+ render( );
+
+ await waitFor(() => {
+ expect(mockListMyNotes).toHaveBeenCalledTimes(1);
+ });
+ // List rendered (note title visible) but editor body field not, since
+ // no note has been selected yet on mobile.
+ expect(screen.getByText(/phone note/i)).toBeTruthy();
+ expect(mockGetNote).not.toHaveBeenCalled();
+ expect(screen.queryByLabelText(/^body$/i)).toBeNull();
+ });
+
+ it("composes a new draft via the floating compose button", async () => {
+ mockUseSession.mockReturnValue(makeSession());
+ mockListMyNotes.mockResolvedValue([]);
+
+ render( );
+
+ await waitFor(() => {
+ expect(mockListMyNotes).toHaveBeenCalledTimes(1);
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: /compose note/i }));
+ expect(screen.getByRole("button", { name: /create note/i })).toBeTruthy();
+ expect(
+ (screen.getByLabelText(/^title$/i) as HTMLInputElement).value,
+ ).toBe("");
+ expect(
+ (screen.getByLabelText(/^body$/i) as HTMLTextAreaElement).value,
+ ).toBe("");
+ });
+
+ it("returns to the list when the back button is tapped", async () => {
+ mockUseSession.mockReturnValue(makeSession());
+ mockListMyNotes.mockResolvedValue([noteFixture]);
+ mockGetNote.mockResolvedValue(noteFixture);
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText(/phone note/i)).toBeTruthy();
+ });
+
+ fireEvent.click(screen.getByText(/phone note/i));
+ await waitFor(() => {
+ expect(screen.getByLabelText(/^body$/i)).toBeTruthy();
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: /back to notes/i }));
+ // Editor body should no longer be rendered; list header stays.
+ expect(screen.queryByLabelText(/^body$/i)).toBeNull();
+ expect(screen.getByText(/my notes/i)).toBeTruthy();
+ });
+
+ it("blocks back navigation when discard is declined", async () => {
+ mockUseSession.mockReturnValue(makeSession());
+ mockListMyNotes.mockResolvedValue([noteFixture]);
+ mockGetNote.mockResolvedValue(noteFixture);
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText(/phone note/i)).toBeTruthy();
+ });
+
+ fireEvent.click(screen.getByText(/phone note/i));
+ const titleInput = await screen.findByLabelText(/^title$/i);
+ fireEvent.change(titleInput, { target: { value: "Edited offline" } });
+
+ // Decline the discard prompt -> selection persists, editor stays open.
+ vi.spyOn(window, "confirm").mockReturnValueOnce(false);
+ fireEvent.click(screen.getByRole("button", { name: /back to notes/i }));
+ expect(screen.getByLabelText(/^body$/i)).toBeTruthy();
+ expect(
+ (screen.getByLabelText(/^title$/i) as HTMLInputElement).value,
+ ).toBe("Edited offline");
+ });
+
+ it("deletes a note via the bottom 'Delete note' button", async () => {
+ mockUseSession.mockReturnValue(makeSession());
+ mockListMyNotes.mockResolvedValue([noteFixture]);
+ mockGetNote.mockResolvedValue(noteFixture);
+ mockDeleteNote.mockResolvedValue(undefined);
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText(/phone note/i)).toBeTruthy();
+ });
+ fireEvent.click(screen.getByText(/phone note/i));
+ await waitFor(() => {
+ expect(screen.getByLabelText(/^body$/i)).toBeTruthy();
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: /delete note/i }));
+
+ await waitFor(() => {
+ expect(mockDeleteNote).toHaveBeenCalledWith(
+ expect.objectContaining({ noteId: "note-mobile" }),
+ );
+ });
+ });
+ });
+
+ describe("cache hydration & revalidation", () => {
+ function seedCache(notes: unknown[], identityId = "identity-1") {
+ localStorage.setItem(
+ `dashnote.notes.${identityId}.contract-1.testnet`,
+ JSON.stringify({
+ version: 1,
+ identityId,
+ contractId: "contract-1",
+ network: "testnet",
+ cachedAt: Date.now(),
+ notes,
+ }),
+ );
+ }
+
+ it("paints cached notes immediately, before the network revalidation resolves", async () => {
+ mockUseSession.mockReturnValue(makeSession());
+ seedCache([
+ {
+ id: "cached-1",
+ ownerId: "identity-1",
+ title: "Cached title",
+ message: "Cached body",
+ createdAt: 1000,
+ updatedAt: 2000,
+ revision: 1,
+ },
+ ]);
+ // listMyNotes never resolves during this test — we only assert that the
+ // cached content is visible synchronously.
+ let resolveList: (value: unknown[]) => void = () => {};
+ mockListMyNotes.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveList = resolve;
+ }),
+ );
+ mockGetNote.mockImplementation(() => new Promise(() => {}));
+
+ render( );
+
+ // Synchronously visible from cache (no waitFor needed).
+ expect(screen.getAllByText(/cached title/i).length).toBeGreaterThan(0);
+ expect(screen.getByText(/1 note/i)).toBeTruthy();
+ // Refreshing indicator should also be visible while revalidating.
+ expect(screen.getByLabelText(/refreshing notes/i)).toBeTruthy();
+
+ // Clean up: let the pending promise resolve so cleanup() doesn't hang.
+ resolveList([]);
+ });
+
+ it("disables save while revalidating cached data, then enables it after the chain confirms", async () => {
+ mockUseSession.mockReturnValue(makeSession());
+ const cached = {
+ id: "cached-2",
+ ownerId: "identity-1",
+ title: "Cached",
+ message: "Cached body",
+ createdAt: 1000,
+ updatedAt: 2000,
+ revision: 1,
+ };
+ seedCache([cached]);
+
+ let resolveList: (value: unknown[]) => void = () => {};
+ mockListMyNotes.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveList = resolve;
+ }),
+ );
+ mockGetNote.mockResolvedValue(cached);
+
+ render( );
+
+ // Cached note is auto-selected on desktop and the editor is shown, but
+ // the save button must be disabled because editsReady=false.
+ await waitFor(() => {
+ expect(screen.getByLabelText(/^body$/i)).toBeTruthy();
+ });
+
+ // Make the editor dirty so dirty-gating doesn't mask the editsReady gate.
+ fireEvent.change(screen.getByLabelText(/^body$/i), {
+ target: { value: "edited body" },
+ });
+ const saveButton = screen.getByRole("button", { name: /^save$/i });
+ expect(saveButton.hasAttribute("disabled")).toBe(true);
+
+ // Resolve the chain query — editsReady flips true and save becomes
+ // enabled (dirty + canMutate + editsReady all satisfied).
+ resolveList([cached]);
+ await waitFor(() => {
+ expect(saveButton.hasAttribute("disabled")).toBe(false);
+ });
+ });
+
+ it("warns the user instead of clobbering when the chain revision moves while editing", async () => {
+ mockUseSession.mockReturnValue(makeSession());
+ const initial = {
+ id: "note-conflict",
+ ownerId: "identity-1",
+ title: "Original",
+ message: "Original body",
+ createdAt: 1000,
+ updatedAt: 2000,
+ revision: 1,
+ };
+ const newerFromChain = {
+ ...initial,
+ title: "Network edit",
+ message: "Network body",
+ updatedAt: 3000,
+ revision: 2,
+ };
+ // First listMyNotes returns rev 1; second (triggered by post-edit
+ // background revalidation) returns rev 2.
+ mockListMyNotes
+ .mockResolvedValueOnce([initial])
+ .mockResolvedValueOnce([newerFromChain]);
+ mockGetNote.mockResolvedValue(initial);
+
+ render( );
+
+ // Wait for initial load to settle so the editor reflects the cached/
+ // chain content with baselines set.
+ await waitFor(() => {
+ expect(
+ (screen.getByLabelText(/^body$/i) as HTMLTextAreaElement).value,
+ ).toBe("Original body");
+ });
+
+ // User starts editing — dirty=true, baseline still tracks "Original".
+ fireEvent.change(screen.getByLabelText(/^body$/i), {
+ target: { value: "User local edit" },
+ });
+
+ // Trigger background revalidation: ensure the document is "visible",
+ // step Date.now past the focus throttle, and dispatch visibilitychange.
+ Object.defineProperty(document, "visibilityState", {
+ configurable: true,
+ get: () => "visible",
+ });
+ Object.defineProperty(document, "hidden", {
+ configurable: true,
+ get: () => false,
+ });
+ vi.spyOn(Date, "now").mockReturnValue(Date.now() + 60_000);
+ document.dispatchEvent(new Event("visibilitychange"));
+
+ await waitFor(() => {
+ expect(mockListMyNotes).toHaveBeenCalledTimes(2);
+ });
+
+ // Conflict warning should surface; user's edit should remain.
+ await waitFor(() => {
+ expect(
+ screen.getByText(/this note changed on the network/i),
+ ).toBeTruthy();
+ });
+ expect(
+ (screen.getByLabelText(/^body$/i) as HTMLTextAreaElement).value,
+ ).toBe("User local edit");
+ });
+
+ it("keeps cached notes visible during a remembered-identity rehydrate when the SDK is still connecting", async () => {
+ // Simulates the page-load path where SessionContext has identityId +
+ // browsing status from synchronously-loaded localStorage but the SDK
+ // hasn't connected yet. reloadNotes must NOT wipe the cached list while
+ // we wait for sdk to land.
+ mockUseSession.mockReturnValue(
+ makeSession({
+ status: "browsing",
+ sdk: null,
+ keyManager: null,
+ }),
+ );
+ seedCache([
+ {
+ id: "cached-rehydrate",
+ ownerId: "identity-1",
+ title: "Survives rehydrate",
+ message: "Body",
+ createdAt: 1000,
+ updatedAt: 2000,
+ revision: 1,
+ },
+ ]);
+
+ render( );
+
+ // Cached list paints synchronously, even though sdk is null. Without the
+ // reloadNotes-skip-when-sdk-null fix, the list would be wiped to [].
+ expect(screen.getByText("Survives rehydrate")).toBeTruthy();
+ expect(screen.getByText(/1 note/i)).toBeTruthy();
+ // listMyNotes must not have been invoked yet — sdk is still connecting.
+ expect(mockListMyNotes).not.toHaveBeenCalled();
+ });
+
+ it("seeds the editor pane from the first cached note on desktop so 'No note selected' never paints", () => {
+ mockUseSession.mockReturnValue(makeSession());
+ seedCache([
+ {
+ id: "cached-seed",
+ ownerId: "identity-1",
+ title: "Seeded title",
+ message: "Seeded body",
+ createdAt: 1000,
+ updatedAt: 2000,
+ revision: 1,
+ },
+ ]);
+ mockListMyNotes.mockImplementation(() => new Promise(() => {}));
+ mockGetNote.mockImplementation(() => new Promise(() => {}));
+
+ render( );
+
+ // Editor pane shows the seeded note's content from the very first paint
+ // (no "No note selected" empty state in between).
+ expect(screen.queryByText(/no note selected/i)).toBeNull();
+ // Both title (input) and body (textarea) reflect the cached values from
+ // frame 1 — proves the synchronous editor seed.
+ expect(screen.getByDisplayValue("Seeded title")).toBeTruthy();
+ expect(screen.getByDisplayValue("Seeded body")).toBeTruthy();
+ });
+
+ it("does not auto-select the editor on mobile (mobile lands on the list view)", () => {
+ stubMatchMedia(false);
+ mockUseSession.mockReturnValue(makeSession());
+ seedCache([
+ {
+ id: "cached-mobile",
+ ownerId: "identity-1",
+ title: "Mobile cached",
+ message: "Mobile body",
+ createdAt: 1000,
+ updatedAt: 2000,
+ revision: 1,
+ },
+ ]);
+ mockListMyNotes.mockImplementation(() => new Promise(() => {}));
+
+ render( );
+
+ // Cached list paints synchronously on mobile too.
+ expect(screen.getByText("Mobile cached")).toBeTruthy();
+ // Editor pane is in the DOM but in its "No note selected" state — the
+ // mobile gate is selectedId === null, so seeding must NOT have run.
+ expect(screen.getByText(/no note selected/i)).toBeTruthy();
+ // And neither editor input is populated, since selectedNote is null.
+ expect(screen.queryByDisplayValue("Mobile cached")).toBeNull();
+ expect(screen.queryByDisplayValue("Mobile body")).toBeNull();
+ });
+
+ it("ignores a late listMyNotes response from a previous identity/contract", async () => {
+ // Scenario: workspace is mounted for identity-1 + contract-1, and
+ // listMyNotes is in flight. Before it resolves, the user switches to
+ // identity-2 + contract-2 (re-render with a new session). The first
+ // request finally resolves with notes for the *previous* session — it
+ // must be ignored, both for state (no stale notes painted) and for
+ // cache writes (no notes saved under the previous identity/contract).
+ mockUseSession.mockReturnValue(makeSession());
+
+ // First call: hold open so we can resolve it after the switch.
+ let resolveFirst: (value: unknown[]) => void = () => {};
+ // Second call: also hold open so we can assert against the *old*
+ // response specifically without the new one painting first.
+ let resolveSecond: (value: unknown[]) => void = () => {};
+ mockListMyNotes
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveFirst = resolve;
+ }),
+ )
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveSecond = resolve;
+ }),
+ );
+ mockGetNote.mockImplementation(() => new Promise(() => {}));
+
+ const { rerender } = render( );
+
+ // Switch to a new identity+contract while the first listMyNotes is
+ // still pending. Re-rendering with a fresh session value triggers the
+ // hydrate effect, which kicks off a new reloadNotes (and bumps the
+ // reload token).
+ mockUseSession.mockReturnValue(
+ makeSession({ identityId: "identity-2", contractId: "contract-2" }),
+ );
+ rerender( );
+
+ // Wait until the post-rerender hydrate effect has actually issued its
+ // own listMyNotes call. This proves the new reload token is in place
+ // before we resolve the stale request — otherwise the test could pass
+ // by microtask-timing accident rather than by the guard's logic.
+ await waitFor(() => {
+ expect(mockListMyNotes).toHaveBeenCalledTimes(2);
+ });
+
+ // Now resolve the *stale* first request with notes that belong to the
+ // previous session. The guard inside reloadNotes should drop them.
+ const staleNote = {
+ id: "stale-1",
+ ownerId: "identity-1",
+ title: "Stale title from old session",
+ message: "should not appear",
+ createdAt: 1000,
+ updatedAt: 2000,
+ revision: 1,
+ };
+ resolveFirst([staleNote]);
+
+ // Poll the invariant directly — `waitFor` retries until microtasks
+ // queued by resolveFirst() have settled. If the guard is missing,
+ // this fails (cache write happens synchronously in the await
+ // continuation); if it's working, the cache stays null.
+ await waitFor(() => {
+ expect(
+ localStorage.getItem("dashnote.notes.identity-1.contract-1.testnet"),
+ ).toBeNull();
+ });
+ expect(screen.queryByText(/stale title from old session/i)).toBeNull();
+ expect(
+ localStorage.getItem("dashnote.notes.identity-2.contract-2.testnet"),
+ ).toBeNull();
+
+ // Clean up: let the second pending promise resolve so cleanup() doesn't hang.
+ resolveSecond([]);
+ });
+
+ it("keeps cached notes visible when the network revalidation fails", async () => {
+ mockUseSession.mockReturnValue(makeSession());
+ seedCache([
+ {
+ id: "cached-3",
+ ownerId: "identity-1",
+ title: "Cached only",
+ message: "Cached body",
+ createdAt: 1000,
+ updatedAt: 2000,
+ revision: 1,
+ },
+ ]);
+ mockListMyNotes.mockRejectedValue(new Error("Network unreachable"));
+ mockGetNote.mockRejectedValue(new Error("Network unreachable"));
+
+ render( );
+
+ // Error surfaces but cached data stays visible.
+ await waitFor(() => {
+ expect(screen.getByText(/network unreachable/i)).toBeTruthy();
+ });
+ expect(screen.getAllByText(/cached only/i).length).toBeGreaterThan(0);
+ expect(screen.getByText(/1 note/i)).toBeTruthy();
+ });
+ });
+});
diff --git a/example-apps/dashnote/test/SessionContext.test.tsx b/example-apps/dashnote/test/SessionContext.test.tsx
new file mode 100644
index 0000000..48ba06a
--- /dev/null
+++ b/example-apps/dashnote/test/SessionContext.test.tsx
@@ -0,0 +1,487 @@
+// @vitest-environment jsdom
+
+import { act, cleanup, render } from "@testing-library/react";
+import { useContext, type ReactNode } from "react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const {
+ mockCreateClient,
+ mockKeyManagerCreate,
+ mockRefreshContractCache,
+ mockDpnsUsername,
+ mockResolveDpnsName,
+ mockToastError,
+ mockToastSuccess,
+} = vi.hoisted(() => ({
+ mockCreateClient: vi.fn(),
+ mockKeyManagerCreate: vi.fn(),
+ mockRefreshContractCache: vi.fn(),
+ mockDpnsUsername: vi.fn(),
+ mockResolveDpnsName: vi.fn(),
+ mockToastError: vi.fn(),
+ mockToastSuccess: vi.fn(),
+}));
+
+vi.mock("../../../setupDashClient-core.mjs", () => ({
+ createClient: mockCreateClient,
+ IdentityKeyManager: {
+ create: mockKeyManagerCreate,
+ },
+}));
+
+// Default resolver delegates to the real implementation (which calls
+// sdk.dpns.username — already mocked via mockDpnsUsername). Specific tests
+// override this to make resolveDpnsName itself throw, exercising the outer
+// try/catch in SessionContext.
+vi.mock("../src/dash/resolveDpnsName", () => ({
+ resolveDpnsName: mockResolveDpnsName,
+}));
+
+vi.mock("../src/dash/contract", async () => {
+ const actual = await vi.importActual(
+ "../src/dash/contract",
+ );
+ return {
+ ...actual,
+ refreshContractCache: mockRefreshContractCache,
+ };
+});
+
+vi.mock("sonner", () => ({
+ toast: {
+ success: mockToastSuccess,
+ error: mockToastError,
+ },
+}));
+
+import {
+ SessionContext,
+ SessionProvider,
+ type SessionValue,
+} from "../src/session/SessionContext";
+
+const REMEMBERED_KEY = "dashnote.lastIdentity";
+
+function Harness({ onValue }: { onValue: (value: SessionValue) => void }) {
+ const value = useContext(SessionContext);
+ if (value) onValue(value);
+ return null;
+}
+
+function mountSession(): { current: SessionValue } {
+ const ref = { current: null as unknown as SessionValue };
+ const handler = (value: SessionValue) => {
+ ref.current = value;
+ };
+ const ui: ReactNode = (
+
+
+
+ );
+ render(ui);
+ return ref;
+}
+
+beforeEach(() => {
+ localStorage.clear();
+ mockCreateClient.mockReset();
+ mockKeyManagerCreate.mockReset();
+ mockRefreshContractCache.mockReset();
+ mockDpnsUsername.mockReset();
+ mockDpnsUsername.mockResolvedValue(null);
+ mockCreateClient.mockResolvedValue({
+ documents: {},
+ dpns: { username: mockDpnsUsername },
+ });
+ // Default behavior mirrors the real resolveDpnsName: forward to
+ // sdk.dpns.username, strip the .dash suffix, and return null on null/empty.
+ // Tests that need the resolver to throw outright override this.
+ mockToastError.mockReset();
+ mockToastSuccess.mockReset();
+ mockResolveDpnsName.mockReset();
+ mockResolveDpnsName.mockImplementation(async (sdk, identityId) => {
+ 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;
+ });
+});
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+describe("SessionProvider", () => {
+ it("loads the remembered identity ID from localStorage on mount and starts in browsing mode", () => {
+ localStorage.setItem(
+ REMEMBERED_KEY,
+ JSON.stringify({ id: "stored-identity-id" }),
+ );
+ const ref = mountSession();
+ expect(ref.current.rememberedIdentityId).toBe("stored-identity-id");
+ expect(ref.current.status).toBe("browsing");
+ expect(ref.current.identityId).toBe("stored-identity-id");
+ });
+
+ it("starts in idle mode when no identity is remembered", () => {
+ const ref = mountSession();
+ expect(ref.current.rememberedIdentityId).toBeNull();
+ expect(ref.current.status).toBe("idle");
+ });
+
+ it("persists the identity ID on login when rememberMe is true", async () => {
+ mockKeyManagerCreate.mockResolvedValue({
+ identityId: "logged-in-identity-id",
+ });
+ const ref = mountSession();
+
+ await act(async () => {
+ await ref.current.login("test mnemonic", { rememberMe: true });
+ });
+
+ expect(JSON.parse(localStorage.getItem(REMEMBERED_KEY) ?? "null")).toEqual({
+ id: "logged-in-identity-id",
+ name: null,
+ });
+ expect(ref.current.rememberedIdentityId).toBe("logged-in-identity-id");
+ expect(ref.current.status).toBe("authenticated");
+ expect(ref.current.identityId).toBe("logged-in-identity-id");
+ expect(ref.current.dpnsName).toBeNull();
+ });
+
+ it("persists the resolved DPNS name alongside the identity on login", async () => {
+ mockKeyManagerCreate.mockResolvedValue({
+ identityId: "logged-in-identity-id",
+ });
+ mockDpnsUsername.mockResolvedValue("alice.dash");
+ const ref = mountSession();
+
+ await act(async () => {
+ await ref.current.login("test mnemonic", { rememberMe: true });
+ });
+
+ expect(JSON.parse(localStorage.getItem(REMEMBERED_KEY) ?? "null")).toEqual({
+ id: "logged-in-identity-id",
+ name: "alice",
+ });
+ expect(ref.current.dpnsName).toBe("alice");
+ });
+
+ it("does not persist on login when rememberMe is false", async () => {
+ mockKeyManagerCreate.mockResolvedValue({
+ identityId: "logged-in-identity-id",
+ });
+ const ref = mountSession();
+
+ await act(async () => {
+ await ref.current.login("test mnemonic");
+ });
+
+ expect(localStorage.getItem(REMEMBERED_KEY)).toBeNull();
+ expect(ref.current.rememberedIdentityId).toBeNull();
+ expect(ref.current.status).toBe("authenticated");
+ });
+
+ it("clears a previously remembered identity when login uses rememberMe: false", async () => {
+ // Pre-seed a remembered identity from an earlier session, then log in
+ // with the box unchecked. The new identity must NOT be persisted, and
+ // the previously-remembered identity must be wiped — otherwise logout
+ // falls back to the wrong identity.
+ localStorage.setItem(
+ REMEMBERED_KEY,
+ JSON.stringify({ id: "previously-remembered-id", name: "alice" }),
+ );
+ mockKeyManagerCreate.mockResolvedValue({
+ identityId: "newly-logged-in-id",
+ });
+ const ref = mountSession();
+ expect(ref.current.rememberedIdentityId).toBe("previously-remembered-id");
+
+ await act(async () => {
+ await ref.current.login("test mnemonic", { rememberMe: false });
+ });
+
+ expect(localStorage.getItem(REMEMBERED_KEY)).toBeNull();
+ expect(ref.current.rememberedIdentityId).toBeNull();
+ expect(ref.current.status).toBe("authenticated");
+ expect(ref.current.identityId).toBe("newly-logged-in-id");
+ });
+
+ it("completes login when DPNS resolution rejects (caption is optional)", async () => {
+ mockKeyManagerCreate.mockResolvedValue({
+ identityId: "logged-in-identity-id",
+ });
+ // Make resolveDpnsName itself throw, escaping its internal try/catch
+ // (e.g., a future refactor removes the swallow, or sdk.dpns is missing).
+ // The session must still reach authenticated state with dpnsName=null.
+ mockResolveDpnsName.mockRejectedValue(new Error("DPNS service down"));
+ const ref = mountSession();
+
+ await act(async () => {
+ await ref.current.login("test mnemonic", { rememberMe: true });
+ });
+
+ expect(ref.current.status).toBe("authenticated");
+ expect(ref.current.error).toBeNull();
+ expect(ref.current.dpnsName).toBeNull();
+ // Identity is still persisted — just without the optional name caption.
+ expect(JSON.parse(localStorage.getItem(REMEMBERED_KEY) ?? "null")).toEqual({
+ id: "logged-in-identity-id",
+ name: null,
+ });
+ // No error toast: a missing name is not a session failure.
+ expect(mockToastError).not.toHaveBeenCalled();
+ });
+
+ it("hydrates dpnsName from the remembered identity record on mount", () => {
+ localStorage.setItem(
+ REMEMBERED_KEY,
+ JSON.stringify({ id: "stored-identity-id", name: "alice" }),
+ );
+ const ref = mountSession();
+ expect(ref.current.dpnsName).toBe("alice");
+ });
+
+ it("viewAsRemembered skips the DPNS lookup when a name is already cached", async () => {
+ localStorage.setItem(
+ REMEMBERED_KEY,
+ JSON.stringify({ id: "stored-identity-id", name: "alice" }),
+ );
+ const ref = mountSession();
+
+ await act(async () => {
+ await ref.current.viewAsRemembered();
+ });
+
+ expect(mockDpnsUsername).not.toHaveBeenCalled();
+ expect(ref.current.dpnsName).toBe("alice");
+ });
+
+ it("viewAsRemembered resolves and persists the DPNS name when none is cached", async () => {
+ localStorage.setItem(
+ REMEMBERED_KEY,
+ JSON.stringify({ id: "stored-identity-id" }),
+ );
+ mockDpnsUsername.mockResolvedValue("alice.dash");
+ const ref = mountSession();
+
+ await act(async () => {
+ await ref.current.viewAsRemembered();
+ });
+
+ expect(mockDpnsUsername).toHaveBeenCalledWith("stored-identity-id");
+ expect(ref.current.dpnsName).toBe("alice");
+ expect(JSON.parse(localStorage.getItem(REMEMBERED_KEY) ?? "null")).toEqual({
+ id: "stored-identity-id",
+ name: "alice",
+ });
+ });
+
+ it("viewAsRemembered enters browsing mode using the stored identity", async () => {
+ localStorage.setItem(
+ REMEMBERED_KEY,
+ JSON.stringify({ id: "stored-identity-id" }),
+ );
+ const ref = mountSession();
+
+ await act(async () => {
+ await ref.current.viewAsRemembered();
+ });
+
+ expect(ref.current.status).toBe("browsing");
+ expect(ref.current.identityId).toBe("stored-identity-id");
+ expect(ref.current.keyManager).toBeNull();
+ });
+
+ it("viewAsRemembered falls back to read-only when nothing is stored", async () => {
+ const ref = mountSession();
+
+ await act(async () => {
+ await ref.current.viewAsRemembered();
+ });
+
+ expect(ref.current.status).toBe("readonly");
+ expect(ref.current.identityId).toBeNull();
+ });
+
+ it("forgetIdentity clears storage and drops browsing back to readonly", async () => {
+ localStorage.setItem(
+ REMEMBERED_KEY,
+ JSON.stringify({ id: "stored-identity-id" }),
+ );
+ const ref = mountSession();
+
+ await act(async () => {
+ await ref.current.viewAsRemembered();
+ });
+ expect(ref.current.status).toBe("browsing");
+
+ act(() => {
+ ref.current.forgetIdentity();
+ });
+
+ expect(localStorage.getItem(REMEMBERED_KEY)).toBeNull();
+ expect(ref.current.rememberedIdentityId).toBeNull();
+ expect(ref.current.status).toBe("readonly");
+ expect(ref.current.identityId).toBeNull();
+ });
+
+ it("forgetIdentity also evicts the remembered identity's note cache", async () => {
+ const REMEMBERED_ID = "stored-identity-id";
+ const NOTES_KEY = `dashnote.notes.${REMEMBERED_ID}.contract-1.testnet`;
+ localStorage.setItem(REMEMBERED_KEY, JSON.stringify({ id: REMEMBERED_ID }));
+ localStorage.setItem(
+ NOTES_KEY,
+ JSON.stringify({
+ version: 1,
+ identityId: REMEMBERED_ID,
+ contractId: "contract-1",
+ network: "testnet",
+ cachedAt: Date.now(),
+ notes: [],
+ }),
+ );
+
+ const ref = mountSession();
+ await act(async () => {
+ await ref.current.viewAsRemembered();
+ });
+
+ act(() => {
+ ref.current.forgetIdentity();
+ });
+
+ expect(localStorage.getItem(REMEMBERED_KEY)).toBeNull();
+ expect(localStorage.getItem(NOTES_KEY)).toBeNull();
+ });
+
+ it("logout retains the remembered identity and transitions to browsing", async () => {
+ mockKeyManagerCreate.mockResolvedValue({
+ identityId: "logged-in-identity-id",
+ });
+ const ref = mountSession();
+
+ await act(async () => {
+ await ref.current.login("mnemonic", { rememberMe: true });
+ });
+ expect(ref.current.status).toBe("authenticated");
+
+ act(() => {
+ ref.current.logout();
+ });
+
+ expect(ref.current.status).toBe("browsing");
+ expect(ref.current.identityId).toBe("logged-in-identity-id");
+ expect(ref.current.keyManager).toBeNull();
+ expect(
+ JSON.parse(localStorage.getItem(REMEMBERED_KEY) ?? "null"),
+ ).toMatchObject({ id: "logged-in-identity-id" });
+ });
+
+ it("setContractId evicts the previous contract from the SDK cache when the ID changes", async () => {
+ localStorage.setItem("dashnote.contractId", "old-contract-id");
+ const ref = mountSession();
+ await act(async () => {
+ await ref.current.viewAsRemembered();
+ });
+ mockRefreshContractCache.mockClear();
+
+ act(() => {
+ ref.current.setContractId("new-contract-id");
+ });
+
+ expect(mockRefreshContractCache).toHaveBeenCalledTimes(1);
+ expect(mockRefreshContractCache).toHaveBeenCalledWith({
+ sdk: expect.anything(),
+ contractId: "old-contract-id",
+ });
+ });
+
+ it("setContractId does not evict when the new ID equals the current ID", async () => {
+ localStorage.setItem("dashnote.contractId", "same-contract-id");
+ const ref = mountSession();
+ await act(async () => {
+ await ref.current.viewAsRemembered();
+ });
+ mockRefreshContractCache.mockClear();
+
+ act(() => {
+ ref.current.setContractId("same-contract-id");
+ });
+
+ expect(mockRefreshContractCache).not.toHaveBeenCalled();
+ });
+
+ it("logout falls back to readonly when no identity is remembered", async () => {
+ mockKeyManagerCreate.mockResolvedValue({
+ identityId: "logged-in-identity-id",
+ });
+ const ref = mountSession();
+
+ await act(async () => {
+ await ref.current.login("mnemonic");
+ });
+
+ act(() => {
+ ref.current.logout();
+ });
+
+ expect(ref.current.status).toBe("readonly");
+ expect(ref.current.identityId).toBeNull();
+ });
+
+ it("logout clears dpnsName when no identity is remembered", async () => {
+ mockKeyManagerCreate.mockResolvedValue({
+ identityId: "logged-in-identity-id",
+ });
+ mockDpnsUsername.mockResolvedValue("alice.dash");
+ const ref = mountSession();
+
+ await act(async () => {
+ await ref.current.login("mnemonic");
+ });
+ expect(ref.current.dpnsName).toBe("alice");
+
+ act(() => {
+ ref.current.logout();
+ });
+
+ expect(ref.current.dpnsName).toBeNull();
+ });
+
+ it("forgetIdentity clears the cached dpnsName", async () => {
+ localStorage.setItem(
+ REMEMBERED_KEY,
+ JSON.stringify({ id: "stored-identity-id", name: "alice" }),
+ );
+ const ref = mountSession();
+ expect(ref.current.dpnsName).toBe("alice");
+
+ act(() => {
+ ref.current.forgetIdentity();
+ });
+
+ expect(ref.current.dpnsName).toBeNull();
+ });
+
+ it("enterReadOnly clears any prior dpnsName because readonly has no identity", async () => {
+ mockKeyManagerCreate.mockResolvedValue({
+ identityId: "logged-in-identity-id",
+ });
+ mockDpnsUsername.mockResolvedValue("alice.dash");
+ const ref = mountSession();
+
+ await act(async () => {
+ await ref.current.login("mnemonic");
+ });
+ expect(ref.current.dpnsName).toBe("alice");
+
+ await act(async () => {
+ await ref.current.enterReadOnly();
+ });
+
+ expect(ref.current.dpnsName).toBeNull();
+ expect(ref.current.identityId).toBeNull();
+ expect(ref.current.status).toBe("readonly");
+ });
+});
diff --git a/example-apps/dashnote/test/contract.test.ts b/example-apps/dashnote/test/contract.test.ts
new file mode 100644
index 0000000..091991c
--- /dev/null
+++ b/example-apps/dashnote/test/contract.test.ts
@@ -0,0 +1,168 @@
+// @vitest-environment jsdom
+
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const {
+ mockDataContractConstructor,
+ mockSetConfig,
+ MockIdentifier,
+ identifierFreeMock,
+} = vi.hoisted(() => {
+ const identifierFreeMock = vi.fn();
+ const mockSetConfig = vi.fn();
+ class MockIdentifier {
+ static last: MockIdentifier | null = null;
+ constructor(public readonly id: string) {
+ MockIdentifier.last = this;
+ }
+ free = identifierFreeMock;
+ }
+ return {
+ mockDataContractConstructor: vi.fn(),
+ mockSetConfig,
+ MockIdentifier,
+ identifierFreeMock,
+ };
+});
+
+vi.mock("@dashevo/evo-sdk", () => ({
+ DataContract: function MockDataContract(args: unknown) {
+ mockDataContractConstructor(args);
+ return { args, setConfig: mockSetConfig };
+ },
+ Identifier: MockIdentifier,
+}));
+
+import {
+ DEFAULT_CONTRACT_ID,
+ NOTE_SCHEMAS,
+ loadStoredContractId,
+ refreshContractCache,
+ registerContract,
+} from "../src/dash/contract";
+
+beforeEach(() => {
+ localStorage.clear();
+ mockDataContractConstructor.mockReset();
+ mockSetConfig.mockReset();
+ identifierFreeMock.mockReset();
+ MockIdentifier.last = null;
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("NOTE_SCHEMAS", () => {
+ it("defines the note contract with timestamps, mutability, and owner/timestamp indices", () => {
+ expect(NOTE_SCHEMAS.note.documentsMutable).toBe(true);
+ expect(NOTE_SCHEMAS.note.canBeDeleted).toBe(true);
+ expect(NOTE_SCHEMAS.note.required).toEqual([
+ "$createdAt",
+ "$updatedAt",
+ "message",
+ ]);
+ expect(NOTE_SCHEMAS.note.properties.title).toMatchObject({
+ type: "string",
+ maxLength: 120,
+ position: 0,
+ });
+ expect(NOTE_SCHEMAS.note.properties.message).toMatchObject({
+ type: "string",
+ maxLength: 10000,
+ position: 1,
+ });
+ expect(NOTE_SCHEMAS.note.indices).toEqual([
+ {
+ name: "byOwnerUpdated",
+ properties: [{ $ownerId: "asc" }, { $updatedAt: "asc" }],
+ },
+ {
+ name: "byOwnerCreated",
+ properties: [{ $ownerId: "asc" }, { $createdAt: "asc" }],
+ },
+ ]);
+ });
+});
+
+describe("loadStoredContractId", () => {
+ it("falls back to the default contract when nothing is stored", () => {
+ expect(loadStoredContractId()).toBe(DEFAULT_CONTRACT_ID);
+ });
+
+ it("falls back to the default contract when localStorage access throws", () => {
+ vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
+ throw new Error("SecurityError");
+ });
+
+ expect(loadStoredContractId()).toBe(DEFAULT_CONTRACT_ID);
+ });
+
+ it("returns the stored value when present", () => {
+ localStorage.setItem("dashnote.contractId", "stored-contract");
+ expect(loadStoredContractId()).toBe("stored-contract");
+ });
+});
+
+describe("refreshContractCache", () => {
+ it("evicts the cached contract and frees the identifier", async () => {
+ const removeCachedContract = vi.fn();
+ const sdk = {
+ getWasmSdkConnected: vi.fn().mockResolvedValue({ removeCachedContract }),
+ };
+
+ await refreshContractCache({ sdk: sdk as never, contractId: "contract-1" });
+
+ expect(MockIdentifier.last?.id).toBe("contract-1");
+ expect(removeCachedContract).toHaveBeenCalledWith(MockIdentifier.last);
+ expect(identifierFreeMock).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe("registerContract", () => {
+ it("builds the note contract, applies config, publishes it, and persists the ID", async () => {
+ const sdk = {
+ identities: {
+ nonce: vi.fn().mockResolvedValue(4n),
+ },
+ contracts: {
+ publish: vi.fn().mockResolvedValue({
+ id: { toString: () => "new-contract-id" },
+ }),
+ },
+ };
+ const keyManager = {
+ identityId: "identity-1",
+ getAuth: vi.fn().mockResolvedValue({
+ identity: { id: "identity-1", toString: () => "identity-1" },
+ identityKey: "identity-key",
+ signer: "signer",
+ }),
+ };
+ const log = vi.fn();
+
+ const id = await registerContract({
+ sdk: sdk as never,
+ keyManager: keyManager as never,
+ log,
+ });
+
+ expect(id).toBe("new-contract-id");
+ expect(mockDataContractConstructor).toHaveBeenCalledWith({
+ ownerId: "identity-1",
+ identityNonce: 5n,
+ schemas: NOTE_SCHEMAS,
+ fullValidation: true,
+ });
+ expect(mockSetConfig).toHaveBeenCalledWith({
+ canBeDeleted: false,
+ readonly: false,
+ keepsHistory: false,
+ documentsKeepHistoryContractDefault: false,
+ documentsMutableContractDefault: true,
+ documentsCanBeDeletedContractDefault: true,
+ });
+ expect(localStorage.getItem("dashnote.contractId")).toBe("new-contract-id");
+ expect(log).toHaveBeenCalledWith("Registering Dashnote note contract…");
+ });
+});
diff --git a/example-apps/dashnote/test/dash.test.ts b/example-apps/dashnote/test/dash.test.ts
new file mode 100644
index 0000000..0a3c1b9
--- /dev/null
+++ b/example-apps/dashnote/test/dash.test.ts
@@ -0,0 +1,151 @@
+// @vitest-environment jsdom
+
+import { describe, expect, it, vi } from "vitest";
+
+const { mockDocumentConstructor } = vi.hoisted(() => ({
+ mockDocumentConstructor: vi.fn(),
+}));
+
+vi.mock("@dashevo/evo-sdk", () => ({
+ Document: function MockDocument(args: unknown) {
+ mockDocumentConstructor(args);
+ return {
+ args,
+ toJSON: () => ({ $id: "note-1" }),
+ };
+ },
+}));
+
+import { createNote } from "../src/dash/createNote";
+import { deleteNote } from "../src/dash/deleteNote";
+import { updateNote } from "../src/dash/updateNote";
+
+function makeKeyManager() {
+ return {
+ getAuth: vi.fn().mockResolvedValue({
+ identity: { id: "identity-1" },
+ identityKey: "identity-key",
+ signer: "signer",
+ }),
+ };
+}
+
+describe("createNote", () => {
+ it("creates a note with a trimmed title", async () => {
+ const sdk = {
+ documents: {
+ create: vi.fn().mockResolvedValue(undefined),
+ },
+ };
+
+ const noteId = await createNote({
+ sdk: sdk as never,
+ keyManager: makeKeyManager() as never,
+ contractId: "contract-1",
+ title: " Hello ",
+ message: "Body",
+ });
+
+ expect(noteId).toBe("note-1");
+ expect(mockDocumentConstructor).toHaveBeenCalledWith({
+ properties: {
+ title: "Hello",
+ message: "Body",
+ },
+ documentTypeName: "note",
+ dataContractId: "contract-1",
+ ownerId: "identity-1",
+ });
+ });
+
+ it("omits a blank title for body-only notes", async () => {
+ mockDocumentConstructor.mockReset();
+ const sdk = {
+ documents: {
+ create: vi.fn().mockResolvedValue(undefined),
+ },
+ };
+
+ await createNote({
+ sdk: sdk as never,
+ keyManager: makeKeyManager() as never,
+ contractId: "contract-1",
+ title: " ",
+ message: "Body only",
+ });
+
+ expect(mockDocumentConstructor).toHaveBeenCalledWith({
+ properties: {
+ message: "Body only",
+ },
+ documentTypeName: "note",
+ dataContractId: "contract-1",
+ ownerId: "identity-1",
+ });
+ });
+});
+
+describe("updateNote", () => {
+ it("fetches the current note and increments revision before replace", async () => {
+ mockDocumentConstructor.mockReset();
+ const sdk = {
+ documents: {
+ get: vi.fn().mockResolvedValue({ revision: 4n }),
+ replace: vi.fn().mockResolvedValue(undefined),
+ },
+ };
+
+ await updateNote({
+ sdk: sdk as never,
+ keyManager: makeKeyManager() as never,
+ contractId: "contract-1",
+ noteId: "note-9",
+ title: "",
+ message: "Updated body",
+ });
+
+ expect(sdk.documents.get).toHaveBeenCalledWith(
+ "contract-1",
+ "note",
+ "note-9",
+ );
+ expect(mockDocumentConstructor).toHaveBeenCalledWith({
+ properties: {
+ message: "Updated body",
+ },
+ documentTypeName: "note",
+ dataContractId: "contract-1",
+ ownerId: "identity-1",
+ revision: 5n,
+ id: "note-9",
+ });
+ });
+});
+
+describe("deleteNote", () => {
+ it("passes the note identity fields to sdk.documents.delete", async () => {
+ const sdk = {
+ documents: {
+ delete: vi.fn().mockResolvedValue(undefined),
+ },
+ };
+
+ await deleteNote({
+ sdk: sdk as never,
+ keyManager: makeKeyManager() as never,
+ contractId: "contract-1",
+ noteId: "note-3",
+ });
+
+ expect(sdk.documents.delete).toHaveBeenCalledWith({
+ document: {
+ id: "note-3",
+ ownerId: "identity-1",
+ dataContractId: "contract-1",
+ documentTypeName: "note",
+ },
+ identityKey: "identity-key",
+ signer: "signer",
+ });
+ });
+});
diff --git a/example-apps/dashnote/test/format.test.ts b/example-apps/dashnote/test/format.test.ts
new file mode 100644
index 0000000..bd46e10
--- /dev/null
+++ b/example-apps/dashnote/test/format.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from "vitest";
+
+import { noteDisplayTitle, notePreview } from "../src/lib/format";
+
+describe("noteDisplayTitle", () => {
+ it("prefers the explicit title", () => {
+ expect(
+ noteDisplayTitle({ title: "Explicit", message: "First line\nSecond" }),
+ ).toBe("Explicit");
+ });
+
+ it("falls back to the first non-empty body line, then Untitled", () => {
+ expect(
+ noteDisplayTitle({ title: " ", message: "\n Body title \nNext" }),
+ ).toBe("Body title");
+ expect(noteDisplayTitle({ title: "", message: " " })).toBe("Untitled");
+ });
+});
+
+describe("notePreview", () => {
+ it("returns a compact preview and handles blank notes", () => {
+ expect(notePreview("hello world")).toBe("hello world");
+ expect(notePreview(" ")).toBe("Empty note");
+ });
+});
diff --git a/example-apps/dashnote/test/notesCache.test.ts b/example-apps/dashnote/test/notesCache.test.ts
new file mode 100644
index 0000000..2857155
--- /dev/null
+++ b/example-apps/dashnote/test/notesCache.test.ts
@@ -0,0 +1,220 @@
+// @vitest-environment jsdom
+
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import {
+ clearCachedNotes,
+ loadCachedNotes,
+ notesEqualByRevision,
+ saveCachedNotes,
+} from "../src/lib/notesCache";
+import type { NoteRecord } from "../src/dash/queries";
+
+const KEY = (
+ id: string,
+ contractId: string = CONTRACT,
+ network: string = "testnet",
+) => `dashnote.notes.${id}.${contractId}.${network}`;
+
+const ID = "identity-abc";
+const CONTRACT = "contract-1";
+
+function note(
+ overrides: Partial & Pick,
+): NoteRecord {
+ return {
+ ownerId: ID,
+ title: null,
+ message: "",
+ createdAt: 1000,
+ updatedAt: 2000,
+ ...overrides,
+ };
+}
+
+beforeEach(() => {
+ localStorage.clear();
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("notesCache load/save/clear", () => {
+ it("returns null on a cold cache", () => {
+ expect(loadCachedNotes(ID, CONTRACT, "testnet")).toBeNull();
+ });
+
+ it("round-trips a saved list", () => {
+ const notes = [note({ id: "a", revision: 1, message: "alpha" })];
+ saveCachedNotes(ID, CONTRACT, "testnet", notes);
+ const loaded = loadCachedNotes(ID, CONTRACT, "testnet");
+ expect(loaded).toEqual(notes);
+ });
+
+ it("clears every contract+network slot for an identity in one call", () => {
+ // Seed three slots under the same identity across different contract/
+ // network combinations, plus an unrelated identity that must survive.
+ saveCachedNotes(ID, CONTRACT, "testnet", [note({ id: "a", revision: 1 })]);
+ saveCachedNotes(ID, "contract-2", "testnet", [
+ note({ id: "b", revision: 1 }),
+ ]);
+ saveCachedNotes(ID, CONTRACT, "mainnet", [note({ id: "c", revision: 1 })]);
+ saveCachedNotes("other-identity", CONTRACT, "testnet", [
+ note({ id: "d", revision: 1 }),
+ ]);
+
+ clearCachedNotes(ID);
+
+ expect(loadCachedNotes(ID, CONTRACT, "testnet")).toBeNull();
+ expect(loadCachedNotes(ID, "contract-2", "testnet")).toBeNull();
+ expect(loadCachedNotes(ID, CONTRACT, "mainnet")).toBeNull();
+ expect(localStorage.getItem(KEY(ID, CONTRACT))).toBeNull();
+ expect(localStorage.getItem(KEY(ID, "contract-2"))).toBeNull();
+ expect(localStorage.getItem(KEY(ID, CONTRACT, "mainnet"))).toBeNull();
+ // Unrelated identity must not be swept.
+ expect(
+ loadCachedNotes("other-identity", CONTRACT, "testnet"),
+ ).not.toBeNull();
+ });
+
+ it("isolates entries per contract (different contract gets its own slot)", () => {
+ saveCachedNotes(ID, CONTRACT, "testnet", [note({ id: "a", revision: 1 })]);
+ // A different contract has its own entry; the original contract's
+ // cache stays intact.
+ expect(loadCachedNotes(ID, "different-contract", "testnet")).toBeNull();
+ expect(localStorage.getItem(KEY(ID, CONTRACT))).not.toBeNull();
+ });
+
+ it("isolates entries per network (different network gets its own slot)", () => {
+ saveCachedNotes(ID, CONTRACT, "testnet", [note({ id: "a", revision: 1 })]);
+ expect(loadCachedNotes(ID, CONTRACT, "mainnet")).toBeNull();
+ // Original network's slot is untouched.
+ expect(loadCachedNotes(ID, CONTRACT, "testnet")).not.toBeNull();
+ });
+
+ it("rejects hand-corrupted payloads where the embedded identityId disagrees with the key", () => {
+ // Defense-in-depth: the storage key already encodes identityId, so the
+ // only way to hit this branch is by hand-planting a payload whose
+ // internal `identityId` field disagrees with the one in the key. We
+ // still want to reject it rather than serve mismatched notes.
+ localStorage.setItem(
+ KEY(ID),
+ JSON.stringify({
+ version: 1,
+ identityId: "other-identity",
+ contractId: CONTRACT,
+ network: "testnet",
+ cachedAt: Date.now(),
+ notes: [],
+ }),
+ );
+ expect(loadCachedNotes(ID, CONTRACT, "testnet")).toBeNull();
+ });
+
+ it("invalidates and removes the entry when the schema version is unknown", () => {
+ localStorage.setItem(
+ KEY(ID),
+ JSON.stringify({
+ version: 999,
+ identityId: ID,
+ contractId: CONTRACT,
+ network: "testnet",
+ cachedAt: Date.now(),
+ notes: [],
+ }),
+ );
+ expect(loadCachedNotes(ID, CONTRACT, "testnet")).toBeNull();
+ expect(localStorage.getItem(KEY(ID))).toBeNull();
+ });
+
+ it("invalidates and removes the entry when the JSON payload is malformed", () => {
+ localStorage.setItem(KEY(ID), "{not valid json");
+ expect(loadCachedNotes(ID, CONTRACT, "testnet")).toBeNull();
+ expect(localStorage.getItem(KEY(ID))).toBeNull();
+ });
+
+ it("returns null without throwing when localStorage.getItem throws", () => {
+ vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
+ throw new Error("SecurityError");
+ });
+ expect(loadCachedNotes(ID, CONTRACT, "testnet")).toBeNull();
+ });
+
+ it("swallows errors when saving fails (e.g. quota exceeded)", () => {
+ vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
+ throw new Error("QuotaExceeded");
+ });
+ expect(() =>
+ saveCachedNotes(ID, CONTRACT, "testnet", [
+ note({ id: "a", revision: 1 }),
+ ]),
+ ).not.toThrow();
+ });
+
+ it("swallows errors when clearing fails", () => {
+ vi.spyOn(Storage.prototype, "removeItem").mockImplementation(() => {
+ throw new Error("SecurityError");
+ });
+ expect(() => clearCachedNotes(ID)).not.toThrow();
+ });
+
+ it("ignores empty identity or contract IDs on save/load/clear", () => {
+ expect(() =>
+ saveCachedNotes("", CONTRACT, "testnet", [
+ note({ id: "a", revision: 1 }),
+ ]),
+ ).not.toThrow();
+ expect(() =>
+ saveCachedNotes(ID, "", "testnet", [note({ id: "a", revision: 1 })]),
+ ).not.toThrow();
+ expect(loadCachedNotes("", CONTRACT, "testnet")).toBeNull();
+ expect(loadCachedNotes(ID, "", "testnet")).toBeNull();
+ expect(() => clearCachedNotes("")).not.toThrow();
+ });
+
+ it("isolates entries per identity", () => {
+ const notesA = [note({ id: "a", revision: 1, message: "for-A" })];
+ const notesB = [note({ id: "b", revision: 1, message: "for-B" })];
+ saveCachedNotes("identity-A", CONTRACT, "testnet", notesA);
+ saveCachedNotes("identity-B", CONTRACT, "testnet", notesB);
+ expect(loadCachedNotes("identity-A", CONTRACT, "testnet")).toEqual(notesA);
+ expect(loadCachedNotes("identity-B", CONTRACT, "testnet")).toEqual(notesB);
+ clearCachedNotes("identity-A");
+ expect(loadCachedNotes("identity-A", CONTRACT, "testnet")).toBeNull();
+ expect(loadCachedNotes("identity-B", CONTRACT, "testnet")).toEqual(notesB);
+ });
+});
+
+describe("notesEqualByRevision", () => {
+ it("treats two empty arrays as equal", () => {
+ expect(notesEqualByRevision([], [])).toBe(true);
+ });
+
+ it("returns false when lengths differ", () => {
+ expect(notesEqualByRevision([note({ id: "a", revision: 1 })], [])).toBe(
+ false,
+ );
+ });
+
+ it("returns true when every (id, revision) pair matches in order", () => {
+ const a = [note({ id: "1", revision: 5 }), note({ id: "2", revision: 3 })];
+ const b = [
+ note({ id: "1", revision: 5, message: "different body but same rev" }),
+ note({ id: "2", revision: 3 }),
+ ];
+ expect(notesEqualByRevision(a, b)).toBe(true);
+ });
+
+ it("returns false when any revision changes", () => {
+ const a = [note({ id: "1", revision: 5 })];
+ const b = [note({ id: "1", revision: 6 })];
+ expect(notesEqualByRevision(a, b)).toBe(false);
+ });
+
+ it("returns false when ordering or IDs change", () => {
+ const a = [note({ id: "1", revision: 1 }), note({ id: "2", revision: 1 })];
+ const b = [note({ id: "2", revision: 1 }), note({ id: "1", revision: 1 })];
+ expect(notesEqualByRevision(a, b)).toBe(false);
+ });
+});
diff --git a/example-apps/dashnote/test/queries.test.ts b/example-apps/dashnote/test/queries.test.ts
new file mode 100644
index 0000000..5590a39
--- /dev/null
+++ b/example-apps/dashnote/test/queries.test.ts
@@ -0,0 +1,127 @@
+// @vitest-environment jsdom
+
+import { describe, expect, it, vi } from "vitest";
+
+import { getNote, listMyNotes, normalizeNotes } from "../src/dash/queries";
+
+function makeSdk(result: unknown) {
+ return {
+ documents: {
+ query: vi.fn().mockResolvedValue(result),
+ get: vi.fn().mockResolvedValue(result),
+ },
+ };
+}
+
+describe("normalizeNotes", () => {
+ it("normalizes arrays, maps, and revision values", () => {
+ const notes = normalizeNotes(
+ new Map([
+ [
+ "note-1",
+ {
+ $ownerId: "owner-1",
+ $createdAt: "1000",
+ $updatedAt: 2000n,
+ $revision: "4",
+ title: "Title",
+ message: "Body",
+ },
+ ],
+ ]),
+ );
+
+ expect(notes).toEqual([
+ {
+ id: "note-1",
+ ownerId: "owner-1",
+ title: "Title",
+ message: "Body",
+ createdAt: 1000,
+ updatedAt: 2000,
+ revision: 4,
+ },
+ ]);
+ });
+});
+
+describe("listMyNotes", () => {
+ it("queries by owner and returns notes sorted newest-first by updatedAt", async () => {
+ const sdk = makeSdk([
+ {
+ $id: "note-old",
+ $ownerId: "owner-1",
+ $createdAt: 1000,
+ $updatedAt: 1000,
+ title: "Old",
+ message: "First",
+ $revision: 0,
+ },
+ {
+ $id: "note-new",
+ $ownerId: "owner-1",
+ $createdAt: 2000,
+ $updatedAt: 5000,
+ title: "New",
+ message: "Second",
+ $revision: 2,
+ },
+ ]);
+
+ const notes = await listMyNotes({
+ sdk: sdk as never,
+ contractId: "contract-1",
+ ownerId: "owner-1",
+ });
+
+ expect(sdk.documents.query).toHaveBeenCalledWith({
+ dataContractId: "contract-1",
+ documentTypeName: "note",
+ where: [["$ownerId", "==", "owner-1"]],
+ orderBy: [
+ ["$ownerId", "asc"],
+ ["$updatedAt", "asc"],
+ ],
+ limit: 100,
+ });
+ expect(notes.map((note) => note.id)).toEqual(["note-new", "note-old"]);
+ });
+});
+
+describe("getNote", () => {
+ it("normalizes a single note document", async () => {
+ const sdk = makeSdk({
+ toJSON() {
+ return {
+ $ownerId: "owner-1",
+ $createdAt: 1000,
+ $updatedAt: 3000,
+ title: null,
+ message: "Hello",
+ };
+ },
+ revision: 7n,
+ });
+
+ const note = await getNote({
+ sdk: sdk as never,
+ contractId: "contract-1",
+ noteId: "note-7",
+ });
+
+ expect(sdk.documents.get).toHaveBeenCalledWith(
+ "contract-1",
+ "note",
+ "note-7",
+ );
+ expect(note).toEqual({
+ id: "note-7",
+ ownerId: "owner-1",
+ title: null,
+ message: "Hello",
+ createdAt: 1000,
+ updatedAt: 3000,
+ revision: 7,
+ });
+ });
+});
diff --git a/example-apps/dashnote/test/rememberedIdentity.test.ts b/example-apps/dashnote/test/rememberedIdentity.test.ts
new file mode 100644
index 0000000..1e7bb99
--- /dev/null
+++ b/example-apps/dashnote/test/rememberedIdentity.test.ts
@@ -0,0 +1,82 @@
+// @vitest-environment jsdom
+
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import {
+ clearRememberedIdentity,
+ loadRememberedIdentity,
+ saveRememberedIdentity,
+} from "../src/lib/rememberedIdentity";
+
+const KEY = "dashnote.lastIdentity";
+
+beforeEach(() => {
+ localStorage.clear();
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("rememberedIdentity storage", () => {
+ it("returns null when nothing is stored", () => {
+ expect(loadRememberedIdentity()).toBeNull();
+ });
+
+ it("persists an identity ID and reads it back", () => {
+ saveRememberedIdentity({ id: "identity-abc" });
+ expect(JSON.parse(localStorage.getItem(KEY) ?? "null")).toEqual({
+ id: "identity-abc",
+ });
+ expect(loadRememberedIdentity()).toEqual({
+ id: "identity-abc",
+ name: null,
+ });
+ });
+
+ it("persists an identity ID with its DPNS name", () => {
+ saveRememberedIdentity({ id: "identity-abc", name: "alice" });
+ expect(loadRememberedIdentity()).toEqual({
+ id: "identity-abc",
+ name: "alice",
+ });
+ });
+
+ it("clears the stored identity", () => {
+ localStorage.setItem(KEY, JSON.stringify({ id: "identity-abc" }));
+ clearRememberedIdentity();
+ expect(localStorage.getItem(KEY)).toBeNull();
+ expect(loadRememberedIdentity()).toBeNull();
+ });
+
+ it("returns null when the stored value is not valid JSON", () => {
+ localStorage.setItem(KEY, "not-json");
+ expect(loadRememberedIdentity()).toBeNull();
+ });
+
+ it("returns null when the stored record is missing an id", () => {
+ localStorage.setItem(KEY, JSON.stringify({ name: "alice" }));
+ expect(loadRememberedIdentity()).toBeNull();
+ });
+
+ it("returns null when localStorage access throws", () => {
+ vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
+ throw new Error("SecurityError");
+ });
+ expect(loadRememberedIdentity()).toBeNull();
+ });
+
+ it("swallows errors when saving fails", () => {
+ vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
+ throw new Error("QuotaExceeded");
+ });
+ expect(() => saveRememberedIdentity({ id: "anything" })).not.toThrow();
+ });
+
+ it("swallows errors when clearing fails", () => {
+ vi.spyOn(Storage.prototype, "removeItem").mockImplementation(() => {
+ throw new Error("SecurityError");
+ });
+ expect(() => clearRememberedIdentity()).not.toThrow();
+ });
+});
diff --git a/example-apps/dashnote/test/resolveDpnsName.test.ts b/example-apps/dashnote/test/resolveDpnsName.test.ts
new file mode 100644
index 0000000..6b4541d
--- /dev/null
+++ b/example-apps/dashnote/test/resolveDpnsName.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { resolveDpnsName } from "../src/dash/resolveDpnsName";
+import type { DashSdk } from "../src/dash/types";
+
+function makeSdk(username: ReturnType): DashSdk {
+ return { dpns: { username } } as unknown as DashSdk;
+}
+
+describe("resolveDpnsName", () => {
+ it("strips the .dash TLD suffix", async () => {
+ const sdk = makeSdk(vi.fn().mockResolvedValue("alice.dash"));
+ expect(await resolveDpnsName(sdk, "id-1")).toBe("alice");
+ });
+
+ it("returns the value unchanged when there is no .dash suffix", async () => {
+ const sdk = makeSdk(vi.fn().mockResolvedValue("alice"));
+ expect(await resolveDpnsName(sdk, "id-1")).toBe("alice");
+ });
+
+ it.each([
+ ["undefined", undefined],
+ ["null", null],
+ ["a number", 0],
+ ["an object", {}],
+ ])("returns null when the SDK resolves to %s", async (_label, value) => {
+ const sdk = makeSdk(vi.fn().mockResolvedValue(value));
+ expect(await resolveDpnsName(sdk, "id-1")).toBeNull();
+ });
+
+ it("returns null when the SDK resolves to an empty string", async () => {
+ const sdk = makeSdk(vi.fn().mockResolvedValue(""));
+ expect(await resolveDpnsName(sdk, "id-1")).toBeNull();
+ });
+
+ it("returns null when the SDK call throws", async () => {
+ const sdk = makeSdk(vi.fn().mockRejectedValue(new Error("network")));
+ expect(await resolveDpnsName(sdk, "id-1")).toBeNull();
+ });
+});
diff --git a/example-apps/dashnote/tsconfig.app.json b/example-apps/dashnote/tsconfig.app.json
new file mode 100644
index 0000000..afa2754
--- /dev/null
+++ b/example-apps/dashnote/tsconfig.app.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "es2023",
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
+ "module": "esnext",
+ "types": ["vite/client"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "allowJs": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"]
+}
diff --git a/example-apps/dashnote/tsconfig.json b/example-apps/dashnote/tsconfig.json
new file mode 100644
index 0000000..1ffef60
--- /dev/null
+++ b/example-apps/dashnote/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/example-apps/dashnote/tsconfig.node.json b/example-apps/dashnote/tsconfig.node.json
new file mode 100644
index 0000000..d3c52ea
--- /dev/null
+++ b/example-apps/dashnote/tsconfig.node.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "es2023",
+ "lib": ["ES2023"],
+ "module": "esnext",
+ "types": ["node"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/example-apps/dashnote/vite.config.ts b/example-apps/dashnote/vite.config.ts
new file mode 100644
index 0000000..e54fb30
--- /dev/null
+++ b/example-apps/dashnote/vite.config.ts
@@ -0,0 +1,45 @@
+import { defineConfig } from "vitest/config";
+import { fileURLToPath, URL } from "node:url";
+import react from "@vitejs/plugin-react";
+import tailwindcss from "@tailwindcss/vite";
+
+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: {
+ // The shared setupDashClient core lives at the repo root, so bind
+ // "@dashevo/evo-sdk" to this app's installed browser bundle.
+ "@dashevo/evo-sdk": evoSdkModulePath,
+ },
+ },
+ plugins: [react(), tailwindcss()],
+ build: {
+ // Vite auto-injects for every dynamic-import
+ // chunk it discovers at build time. For the ~8MB Evo SDK + WASM chunk
+ // this defeats the whole point of dynamic-importing it: the browser
+ // races to fetch the SDK in parallel with the entry chunk, blocking
+ // FCP. Strip the SDK preload so it only fetches when SessionContext
+ // actually triggers the dynamic import.
+ 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"],
+ },
+ },
+});