diff --git a/examples/showcase/README.md b/examples/showcase/README.md
new file mode 100644
index 0000000..fffec6e
--- /dev/null
+++ b/examples/showcase/README.md
@@ -0,0 +1,110 @@
+# React Auth Showcase
+
+A comprehensive example demonstrating **all key features** of [`@forward-software/react-auth`](../../lib/README.md).
+
+## Features Demonstrated
+
+| Feature | Where |
+|---|---|
+| `createAuth` setup | `src/auth.ts` |
+| `AuthClient` implementation (full lifecycle) | `src/mock-auth-client.ts` |
+| `AuthProvider` with `LoadingComponent` / `ErrorComponent` | `src/App.tsx` |
+| `useAuthClient` hook | All components |
+| `useSyncExternalStore` for reactive state | `Dashboard`, `AuthStatus`, `AuthenticatedView` |
+| Token init / restore from `localStorage` | `MockAuthClient.onInit` |
+| Login with credential validation | `LoginForm` |
+| Token refresh | `AuthenticatedView` |
+| Logout with storage cleanup | `AuthenticatedView` |
+| Event system (`on` / `off`) | `EventLog` |
+| All lifecycle hooks (`onPre*` / `onPost*`) | `MockAuthClient` |
+| Error handling for failed login | `LoginForm` |
+| `useAsyncCallback` with error tracking | `src/hooks/useAsyncCallback.ts` |
+| `createMultiAuth` (multiple providers) | Not demonstrated — see [core library docs](../../lib/README.md) |
+
+## Prerequisites
+
+- [Node.js](https://nodejs.org/) ≥ 18
+- [pnpm](https://pnpm.io/) ≥ 10
+
+## Setup
+
+From the **repository root**:
+
+```bash
+pnpm install
+```
+
+## Local Development
+
+When developing inside the monorepo, `@forward-software/react-auth` imports resolve to the **local lib source** via build-tool aliases configured in `vite.config.ts`, `vitest.config.ts`, and `tsconfig.json`. No workspace linking is required.
+
+Users installing from npm get the published version specified in `package.json` (`^2.0.0`).
+
+## Running
+
+```bash
+cd examples/showcase
+pnpm dev
+```
+
+The app runs at [http://localhost:3003](http://localhost:3003).
+
+## Running Tests
+
+```bash
+cd examples/showcase
+pnpm test
+```
+
+## Valid Credentials
+
+| Field | Value |
+|---|---|
+| Username | `user` |
+| Password | `password` |
+
+## Project Structure
+
+```
+src/
+├── mock-auth-client.ts # AuthClient implementation with full lifecycle
+├── auth.ts # createAuth setup (exports AuthProvider, authClient, useAuthClient)
+├── App.tsx # Root component with AuthProvider
+├── main.tsx # Entry point
+├── styles.css # Minimal styling
+├── components/
+│ ├── Dashboard.tsx # Main layout with conditional rendering
+│ ├── AuthStatus.tsx # Status badges + token display
+│ ├── LoginForm.tsx # Login form with error handling
+│ ├── AuthenticatedView.tsx # Refresh + logout actions
+│ └── EventLog.tsx # Real-time auth event log
+└── hooks/
+ └── useAsyncCallback.ts # Reusable async action hook with error tracking
+
+test/
+├── test-utils.tsx # Test helpers and mock factory
+├── mock-auth-client.spec.ts # Unit tests for MockAuthClient
+└── components/
+ ├── AuthStatus.spec.tsx
+ ├── LoginForm.spec.tsx
+ └── EventLog.spec.tsx
+```
+
+## UI Sections
+
+### Auth Status
+Displays `isInitialized` and `isAuthenticated` as badges, and the current tokens as formatted JSON. Updates reactively via `useSyncExternalStore`.
+
+### Login Form
+Username/password form with a **Login** button and a **Try Invalid Credentials** button that intentionally fails to demonstrate error handling.
+
+### Authenticated View
+Shown after login. Displays token expiry time, a **Refresh Tokens** button, and a **Logout** button, each with loading states.
+
+### Event Log
+Subscribes to all auth events (`initSuccess`, `loginStarted`, `loginFailed`, `refreshSuccess`, `logoutSuccess`, etc.) and displays them in a scrollable, timestamped list. Includes a **Clear** button.
+
+## Links
+
+- [Core Library README](../../lib/README.md)
+- [Repository Root](../../README.md)
diff --git a/examples/showcase/index.html b/examples/showcase/index.html
new file mode 100644
index 0000000..6e9131b
--- /dev/null
+++ b/examples/showcase/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+ React Auth Showcase
+
+
+
+
+
+
+
diff --git a/examples/showcase/package.json b/examples/showcase/package.json
new file mode 100644
index 0000000..beeb5c8
--- /dev/null
+++ b/examples/showcase/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "showcase-example",
+ "version": "1.0.0",
+ "license": "MIT",
+ "private": true,
+ "scripts": {
+ "dev": "vite --host",
+ "build": "tsc && vite build",
+ "preview": "vite preview --host",
+ "test": "vitest run",
+ "test:watch": "vitest watch"
+ },
+ "dependencies": {
+ "@forward-software/react-auth": "^2.0.0",
+ "react": "^19.2.4",
+ "react-dom": "^19.2.4"
+ },
+ "devDependencies": {
+ "@testing-library/dom": "^10.4.1",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.1",
+ "jsdom": "^29.0.1",
+ "typescript": "^6.0.2",
+ "vite": "^8.0.3",
+ "vitest": "^4.1.2"
+ }
+}
diff --git a/examples/showcase/pnpm-lock.yaml b/examples/showcase/pnpm-lock.yaml
new file mode 100644
index 0000000..844bfad
--- /dev/null
+++ b/examples/showcase/pnpm-lock.yaml
@@ -0,0 +1,1377 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ '@forward-software/react-auth':
+ specifier: ^2.0.0
+ version: 2.1.0(react@19.2.4)
+ react:
+ specifier: ^19.2.4
+ version: 19.2.4
+ react-dom:
+ specifier: ^19.2.4
+ version: 19.2.4(react@19.2.4)
+ devDependencies:
+ '@testing-library/dom':
+ specifier: ^10.4.1
+ version: 10.4.1
+ '@testing-library/jest-dom':
+ specifier: ^6.9.1
+ version: 6.9.1
+ '@testing-library/react':
+ specifier: ^16.3.2
+ version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@types/react':
+ specifier: ^19.2.14
+ version: 19.2.14
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.3(@types/react@19.2.14)
+ '@vitejs/plugin-react':
+ specifier: ^6.0.1
+ version: 6.0.1(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2))
+ jsdom:
+ specifier: ^29.0.1
+ version: 29.0.1
+ typescript:
+ specifier: ^6.0.2
+ version: 6.0.2
+ vite:
+ specifier: ^8.0.3
+ version: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
+ vitest:
+ specifier: ^4.1.2
+ version: 4.1.2(jsdom@29.0.1)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2))
+
+packages:
+
+ '@adobe/css-tools@4.4.4':
+ resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==}
+
+ '@asamuzakjp/css-color@5.1.4':
+ resolution: {integrity: sha512-503MoTEmPSyEJ7zQ+5vlkwPtkyxDhbDwR9ajk/jpPGrCLiUFHzgEG4iViUPKdGlZPRT1mWSPSbDL2qkOoLU4vg==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ '@asamuzakjp/dom-selector@7.0.4':
+ resolution: {integrity: sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ '@asamuzakjp/nwsapi@2.3.9':
+ resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
+
+ '@babel/code-frame@7.29.0':
+ resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-identifier@7.28.5':
+ resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/runtime@7.29.2':
+ resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
+ engines: {node: '>=6.9.0'}
+
+ '@bramus/specificity@2.4.2':
+ resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
+ hasBin: true
+
+ '@csstools/color-helpers@6.0.2':
+ resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==}
+ engines: {node: '>=20.19.0'}
+
+ '@csstools/css-calc@3.1.1':
+ resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-parser-algorithms': ^4.0.0
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-color-parser@4.0.2':
+ resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-parser-algorithms': ^4.0.0
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-parser-algorithms@4.0.0':
+ resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-syntax-patches-for-csstree@1.1.2':
+ resolution: {integrity: sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==}
+ peerDependencies:
+ css-tree: ^3.2.1
+ peerDependenciesMeta:
+ css-tree:
+ optional: true
+
+ '@csstools/css-tokenizer@4.0.0':
+ resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
+ engines: {node: '>=20.19.0'}
+
+ '@emnapi/core@1.9.2':
+ resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==}
+
+ '@emnapi/runtime@1.9.2':
+ resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==}
+
+ '@emnapi/wasi-threads@1.2.1':
+ resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
+
+ '@exodus/bytes@1.15.0':
+ resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+ peerDependencies:
+ '@noble/hashes': ^1.8.0 || ^2.0.0
+ peerDependenciesMeta:
+ '@noble/hashes':
+ optional: true
+
+ '@forward-software/react-auth@2.1.0':
+ resolution: {integrity: sha512-58y5an4LR+IDrZtjbujPBVngNKhEL3H4nXApLXzcojrad7YYWh3Jv+wxOj+JpkT5lmg3cjdh0jrX5GqhMG30EQ==}
+ peerDependencies:
+ react: '>=16.8'
+
+ '@jridgewell/sourcemap-codec@1.5.5':
+ resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+
+ '@napi-rs/wasm-runtime@1.1.2':
+ resolution: {integrity: sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==}
+ peerDependencies:
+ '@emnapi/core': ^1.7.1
+ '@emnapi/runtime': ^1.7.1
+
+ '@oxc-project/types@0.122.0':
+ resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==}
+
+ '@rolldown/binding-android-arm64@1.0.0-rc.12':
+ resolution: {integrity: sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [android]
+
+ '@rolldown/binding-darwin-arm64@1.0.0-rc.12':
+ resolution: {integrity: sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rolldown/binding-darwin-x64@1.0.0-rc.12':
+ resolution: {integrity: sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rolldown/binding-freebsd-x64@1.0.0-rc.12':
+ resolution: {integrity: sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12':
+ resolution: {integrity: sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm]
+ os: [linux]
+
+ '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12':
+ resolution: {integrity: sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12':
+ resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12':
+ resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12':
+ resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12':
+ resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rolldown/binding-linux-x64-musl@1.0.0-rc.12':
+ resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@rolldown/binding-openharmony-arm64@1.0.0-rc.12':
+ resolution: {integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rolldown/binding-wasm32-wasi@1.0.0-rc.12':
+ resolution: {integrity: sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==}
+ engines: {node: '>=14.0.0'}
+ cpu: [wasm32]
+
+ '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12':
+ resolution: {integrity: sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12':
+ resolution: {integrity: sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [win32]
+
+ '@rolldown/pluginutils@1.0.0-rc.12':
+ resolution: {integrity: sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==}
+
+ '@rolldown/pluginutils@1.0.0-rc.7':
+ resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==}
+
+ '@standard-schema/spec@1.1.0':
+ resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
+
+ '@testing-library/dom@10.4.1':
+ resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
+ engines: {node: '>=18'}
+
+ '@testing-library/jest-dom@6.9.1':
+ resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==}
+ engines: {node: '>=14', npm: '>=6', yarn: '>=1'}
+
+ '@testing-library/react@16.3.2':
+ resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==}
+ 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
+
+ '@tybys/wasm-util@0.10.1':
+ resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
+
+ '@types/aria-query@5.0.4':
+ resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
+
+ '@types/chai@5.2.3':
+ resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
+
+ '@types/deep-eql@4.0.2':
+ resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
+
+ '@types/estree@1.0.8':
+ resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+
+ '@types/react-dom@19.2.3':
+ resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
+ peerDependencies:
+ '@types/react': ^19.2.0
+
+ '@types/react@19.2.14':
+ resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
+
+ '@vitejs/plugin-react@6.0.1':
+ resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==}
+ 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
+
+ '@vitest/expect@4.1.2':
+ resolution: {integrity: sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==}
+
+ '@vitest/mocker@4.1.2':
+ resolution: {integrity: sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==}
+ peerDependencies:
+ msw: ^2.4.9
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ msw:
+ optional: true
+ vite:
+ optional: true
+
+ '@vitest/pretty-format@4.1.2':
+ resolution: {integrity: sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==}
+
+ '@vitest/runner@4.1.2':
+ resolution: {integrity: sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==}
+
+ '@vitest/snapshot@4.1.2':
+ resolution: {integrity: sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==}
+
+ '@vitest/spy@4.1.2':
+ resolution: {integrity: sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==}
+
+ '@vitest/utils@4.1.2':
+ resolution: {integrity: sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==}
+
+ ansi-regex@5.0.1:
+ resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
+ engines: {node: '>=8'}
+
+ ansi-styles@5.2.0:
+ resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
+ engines: {node: '>=10'}
+
+ aria-query@5.3.0:
+ resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
+
+ aria-query@5.3.2:
+ resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
+ engines: {node: '>= 0.4'}
+
+ assertion-error@2.0.1:
+ resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
+ engines: {node: '>=12'}
+
+ bidi-js@1.0.3:
+ resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
+
+ chai@6.2.2:
+ resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
+ engines: {node: '>=18'}
+
+ convert-source-map@2.0.0:
+ resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+
+ css-tree@3.2.1:
+ resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
+ engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
+
+ css.escape@1.5.1:
+ resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
+
+ csstype@3.2.3:
+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
+ data-urls@7.0.0:
+ resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ decimal.js@10.6.0:
+ resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
+
+ dequal@2.0.3:
+ resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
+ engines: {node: '>=6'}
+
+ detect-libc@2.1.2:
+ resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
+ engines: {node: '>=8'}
+
+ dom-accessibility-api@0.5.16:
+ resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
+
+ dom-accessibility-api@0.6.3:
+ resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
+
+ entities@6.0.1:
+ resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
+ engines: {node: '>=0.12'}
+
+ es-module-lexer@2.0.0:
+ resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==}
+
+ estree-walker@3.0.3:
+ resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
+
+ expect-type@1.3.0:
+ resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
+ engines: {node: '>=12.0.0'}
+
+ fdir@6.5.0:
+ resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+ engines: {node: '>=12.0.0'}
+ peerDependencies:
+ picomatch: ^3 || ^4
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ html-encoding-sniffer@6.0.0:
+ resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ indent-string@4.0.0:
+ resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
+ engines: {node: '>=8'}
+
+ is-potential-custom-element-name@1.0.1:
+ resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
+
+ js-tokens@4.0.0:
+ resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+
+ jsdom@29.0.1:
+ resolution: {integrity: sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0}
+ peerDependencies:
+ canvas: ^3.0.0
+ peerDependenciesMeta:
+ canvas:
+ optional: true
+
+ lightningcss-android-arm64@1.32.0:
+ resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [android]
+
+ lightningcss-darwin-arm64@1.32.0:
+ resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [darwin]
+
+ lightningcss-darwin-x64@1.32.0:
+ resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ lightningcss-freebsd-x64@1.32.0:
+ resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [freebsd]
+
+ lightningcss-linux-arm-gnueabihf@1.32.0:
+ resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm]
+ os: [linux]
+
+ lightningcss-linux-arm64-gnu@1.32.0:
+ resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ lightningcss-linux-arm64-musl@1.32.0:
+ resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ lightningcss-linux-x64-gnu@1.32.0:
+ resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ lightningcss-linux-x64-musl@1.32.0:
+ resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ lightningcss-win32-arm64-msvc@1.32.0:
+ resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [win32]
+
+ lightningcss-win32-x64-msvc@1.32.0:
+ resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [win32]
+
+ lightningcss@1.32.0:
+ resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
+ engines: {node: '>= 12.0.0'}
+
+ lru-cache@11.2.7:
+ resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==}
+ engines: {node: 20 || >=22}
+
+ lz-string@1.5.0:
+ resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
+ hasBin: true
+
+ magic-string@0.30.21:
+ resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+
+ mdn-data@2.27.1:
+ resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
+
+ min-indent@1.0.1:
+ resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
+ engines: {node: '>=4'}
+
+ nanoid@3.3.11:
+ resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ obug@2.1.1:
+ resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
+
+ parse5@8.0.0:
+ resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==}
+
+ pathe@2.0.3:
+ resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ picomatch@4.0.4:
+ resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
+ engines: {node: '>=12'}
+
+ postcss@8.5.8:
+ resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ pretty-format@27.5.1:
+ resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
+ engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
+
+ punycode@2.3.1:
+ resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
+ engines: {node: '>=6'}
+
+ react-dom@19.2.4:
+ resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
+ peerDependencies:
+ react: ^19.2.4
+
+ react-is@17.0.2:
+ resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
+
+ react@19.2.4:
+ resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
+ engines: {node: '>=0.10.0'}
+
+ redent@3.0.0:
+ resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
+ engines: {node: '>=8'}
+
+ require-from-string@2.0.2:
+ resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
+ engines: {node: '>=0.10.0'}
+
+ rolldown@1.0.0-rc.12:
+ resolution: {integrity: sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+
+ saxes@6.0.0:
+ resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
+ engines: {node: '>=v12.22.7'}
+
+ scheduler@0.27.0:
+ resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
+
+ siginfo@2.0.0:
+ resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
+
+ source-map-js@1.2.1:
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+ engines: {node: '>=0.10.0'}
+
+ stackback@0.0.2:
+ resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
+
+ std-env@4.0.0:
+ resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==}
+
+ strip-indent@3.0.0:
+ resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
+ engines: {node: '>=8'}
+
+ symbol-tree@3.2.4:
+ resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
+
+ tinybench@2.9.0:
+ resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
+
+ tinyexec@1.0.4:
+ resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==}
+ engines: {node: '>=18'}
+
+ tinyglobby@0.2.15:
+ resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
+ engines: {node: '>=12.0.0'}
+
+ tinyrainbow@3.1.0:
+ resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
+ engines: {node: '>=14.0.0'}
+
+ tldts-core@7.0.27:
+ resolution: {integrity: sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==}
+
+ tldts@7.0.27:
+ resolution: {integrity: sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==}
+ hasBin: true
+
+ tough-cookie@6.0.1:
+ resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
+ engines: {node: '>=16'}
+
+ tr46@6.0.0:
+ resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
+ engines: {node: '>=20'}
+
+ tslib@2.8.1:
+ resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
+ typescript@6.0.2:
+ resolution: {integrity: sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ undici@7.24.7:
+ resolution: {integrity: sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==}
+ engines: {node: '>=20.18.1'}
+
+ vite@8.0.3:
+ resolution: {integrity: sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^20.19.0 || >=22.12.0
+ '@vitejs/devtools': ^0.1.0
+ esbuild: ^0.27.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
+
+ vitest@4.1.2:
+ resolution: {integrity: sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==}
+ engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
+ hasBin: true
+ 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.2
+ '@vitest/browser-preview': 4.1.2
+ '@vitest/browser-webdriverio': 4.1.2
+ '@vitest/ui': 4.1.2
+ 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/ui':
+ optional: true
+ happy-dom:
+ optional: true
+ jsdom:
+ optional: true
+
+ w3c-xmlserializer@5.0.0:
+ resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
+ engines: {node: '>=18'}
+
+ webidl-conversions@8.0.1:
+ resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
+ engines: {node: '>=20'}
+
+ whatwg-mimetype@5.0.0:
+ resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
+ engines: {node: '>=20'}
+
+ whatwg-url@16.0.1:
+ resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ why-is-node-running@2.3.0:
+ resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
+ engines: {node: '>=8'}
+ hasBin: true
+
+ xml-name-validator@5.0.0:
+ resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
+ engines: {node: '>=18'}
+
+ xmlchars@2.2.0:
+ resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
+
+snapshots:
+
+ '@adobe/css-tools@4.4.4': {}
+
+ '@asamuzakjp/css-color@5.1.4':
+ dependencies:
+ '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+ lru-cache: 11.2.7
+
+ '@asamuzakjp/dom-selector@7.0.4':
+ dependencies:
+ '@asamuzakjp/nwsapi': 2.3.9
+ bidi-js: 1.0.3
+ css-tree: 3.2.1
+ is-potential-custom-element-name: 1.0.1
+ lru-cache: 11.2.7
+
+ '@asamuzakjp/nwsapi@2.3.9': {}
+
+ '@babel/code-frame@7.29.0':
+ dependencies:
+ '@babel/helper-validator-identifier': 7.28.5
+ js-tokens: 4.0.0
+ picocolors: 1.1.1
+
+ '@babel/helper-validator-identifier@7.28.5': {}
+
+ '@babel/runtime@7.29.2': {}
+
+ '@bramus/specificity@2.4.2':
+ dependencies:
+ css-tree: 3.2.1
+
+ '@csstools/color-helpers@6.0.2': {}
+
+ '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/color-helpers': 6.0.2
+ '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-syntax-patches-for-csstree@1.1.2(css-tree@3.2.1)':
+ optionalDependencies:
+ css-tree: 3.2.1
+
+ '@csstools/css-tokenizer@4.0.0': {}
+
+ '@emnapi/core@1.9.2':
+ dependencies:
+ '@emnapi/wasi-threads': 1.2.1
+ tslib: 2.8.1
+ optional: true
+
+ '@emnapi/runtime@1.9.2':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@emnapi/wasi-threads@1.2.1':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@exodus/bytes@1.15.0': {}
+
+ '@forward-software/react-auth@2.1.0(react@19.2.4)':
+ dependencies:
+ react: 19.2.4
+
+ '@jridgewell/sourcemap-codec@1.5.5': {}
+
+ '@napi-rs/wasm-runtime@1.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)':
+ dependencies:
+ '@emnapi/core': 1.9.2
+ '@emnapi/runtime': 1.9.2
+ '@tybys/wasm-util': 0.10.1
+ optional: true
+
+ '@oxc-project/types@0.122.0': {}
+
+ '@rolldown/binding-android-arm64@1.0.0-rc.12':
+ optional: true
+
+ '@rolldown/binding-darwin-arm64@1.0.0-rc.12':
+ optional: true
+
+ '@rolldown/binding-darwin-x64@1.0.0-rc.12':
+ optional: true
+
+ '@rolldown/binding-freebsd-x64@1.0.0-rc.12':
+ optional: true
+
+ '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12':
+ optional: true
+
+ '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12':
+ optional: true
+
+ '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12':
+ optional: true
+
+ '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12':
+ optional: true
+
+ '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12':
+ optional: true
+
+ '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12':
+ optional: true
+
+ '@rolldown/binding-linux-x64-musl@1.0.0-rc.12':
+ optional: true
+
+ '@rolldown/binding-openharmony-arm64@1.0.0-rc.12':
+ optional: true
+
+ '@rolldown/binding-wasm32-wasi@1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)':
+ dependencies:
+ '@napi-rs/wasm-runtime': 1.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
+ transitivePeerDependencies:
+ - '@emnapi/core'
+ - '@emnapi/runtime'
+ optional: true
+
+ '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12':
+ optional: true
+
+ '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12':
+ optional: true
+
+ '@rolldown/pluginutils@1.0.0-rc.12': {}
+
+ '@rolldown/pluginutils@1.0.0-rc.7': {}
+
+ '@standard-schema/spec@1.1.0': {}
+
+ '@testing-library/dom@10.4.1':
+ dependencies:
+ '@babel/code-frame': 7.29.0
+ '@babel/runtime': 7.29.2
+ '@types/aria-query': 5.0.4
+ aria-query: 5.3.0
+ dom-accessibility-api: 0.5.16
+ lz-string: 1.5.0
+ picocolors: 1.1.1
+ pretty-format: 27.5.1
+
+ '@testing-library/jest-dom@6.9.1':
+ dependencies:
+ '@adobe/css-tools': 4.4.4
+ aria-query: 5.3.2
+ css.escape: 1.5.1
+ dom-accessibility-api: 0.6.3
+ picocolors: 1.1.1
+ redent: 3.0.0
+
+ '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ dependencies:
+ '@babel/runtime': 7.29.2
+ '@testing-library/dom': 10.4.1
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+ optionalDependencies:
+ '@types/react': 19.2.14
+ '@types/react-dom': 19.2.3(@types/react@19.2.14)
+
+ '@tybys/wasm-util@0.10.1':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@types/aria-query@5.0.4': {}
+
+ '@types/chai@5.2.3':
+ dependencies:
+ '@types/deep-eql': 4.0.2
+ assertion-error: 2.0.1
+
+ '@types/deep-eql@4.0.2': {}
+
+ '@types/estree@1.0.8': {}
+
+ '@types/react-dom@19.2.3(@types/react@19.2.14)':
+ dependencies:
+ '@types/react': 19.2.14
+
+ '@types/react@19.2.14':
+ dependencies:
+ csstype: 3.2.3
+
+ '@vitejs/plugin-react@6.0.1(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2))':
+ dependencies:
+ '@rolldown/pluginutils': 1.0.0-rc.7
+ vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
+
+ '@vitest/expect@4.1.2':
+ dependencies:
+ '@standard-schema/spec': 1.1.0
+ '@types/chai': 5.2.3
+ '@vitest/spy': 4.1.2
+ '@vitest/utils': 4.1.2
+ chai: 6.2.2
+ tinyrainbow: 3.1.0
+
+ '@vitest/mocker@4.1.2(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2))':
+ dependencies:
+ '@vitest/spy': 4.1.2
+ estree-walker: 3.0.3
+ magic-string: 0.30.21
+ optionalDependencies:
+ vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
+
+ '@vitest/pretty-format@4.1.2':
+ dependencies:
+ tinyrainbow: 3.1.0
+
+ '@vitest/runner@4.1.2':
+ dependencies:
+ '@vitest/utils': 4.1.2
+ pathe: 2.0.3
+
+ '@vitest/snapshot@4.1.2':
+ dependencies:
+ '@vitest/pretty-format': 4.1.2
+ '@vitest/utils': 4.1.2
+ magic-string: 0.30.21
+ pathe: 2.0.3
+
+ '@vitest/spy@4.1.2': {}
+
+ '@vitest/utils@4.1.2':
+ dependencies:
+ '@vitest/pretty-format': 4.1.2
+ convert-source-map: 2.0.0
+ tinyrainbow: 3.1.0
+
+ ansi-regex@5.0.1: {}
+
+ ansi-styles@5.2.0: {}
+
+ aria-query@5.3.0:
+ dependencies:
+ dequal: 2.0.3
+
+ aria-query@5.3.2: {}
+
+ assertion-error@2.0.1: {}
+
+ bidi-js@1.0.3:
+ dependencies:
+ require-from-string: 2.0.2
+
+ chai@6.2.2: {}
+
+ convert-source-map@2.0.0: {}
+
+ css-tree@3.2.1:
+ dependencies:
+ mdn-data: 2.27.1
+ source-map-js: 1.2.1
+
+ css.escape@1.5.1: {}
+
+ csstype@3.2.3: {}
+
+ data-urls@7.0.0:
+ dependencies:
+ whatwg-mimetype: 5.0.0
+ whatwg-url: 16.0.1
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
+ decimal.js@10.6.0: {}
+
+ dequal@2.0.3: {}
+
+ detect-libc@2.1.2: {}
+
+ dom-accessibility-api@0.5.16: {}
+
+ dom-accessibility-api@0.6.3: {}
+
+ entities@6.0.1: {}
+
+ es-module-lexer@2.0.0: {}
+
+ estree-walker@3.0.3:
+ dependencies:
+ '@types/estree': 1.0.8
+
+ expect-type@1.3.0: {}
+
+ fdir@6.5.0(picomatch@4.0.4):
+ optionalDependencies:
+ picomatch: 4.0.4
+
+ fsevents@2.3.3:
+ optional: true
+
+ html-encoding-sniffer@6.0.0:
+ dependencies:
+ '@exodus/bytes': 1.15.0
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
+ indent-string@4.0.0: {}
+
+ is-potential-custom-element-name@1.0.1: {}
+
+ js-tokens@4.0.0: {}
+
+ jsdom@29.0.1:
+ dependencies:
+ '@asamuzakjp/css-color': 5.1.4
+ '@asamuzakjp/dom-selector': 7.0.4
+ '@bramus/specificity': 2.4.2
+ '@csstools/css-syntax-patches-for-csstree': 1.1.2(css-tree@3.2.1)
+ '@exodus/bytes': 1.15.0
+ css-tree: 3.2.1
+ data-urls: 7.0.0
+ decimal.js: 10.6.0
+ html-encoding-sniffer: 6.0.0
+ is-potential-custom-element-name: 1.0.1
+ lru-cache: 11.2.7
+ parse5: 8.0.0
+ saxes: 6.0.0
+ symbol-tree: 3.2.4
+ tough-cookie: 6.0.1
+ undici: 7.24.7
+ w3c-xmlserializer: 5.0.0
+ webidl-conversions: 8.0.1
+ whatwg-mimetype: 5.0.0
+ whatwg-url: 16.0.1
+ xml-name-validator: 5.0.0
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
+ lightningcss-android-arm64@1.32.0:
+ optional: true
+
+ lightningcss-darwin-arm64@1.32.0:
+ optional: true
+
+ lightningcss-darwin-x64@1.32.0:
+ optional: true
+
+ lightningcss-freebsd-x64@1.32.0:
+ optional: true
+
+ lightningcss-linux-arm-gnueabihf@1.32.0:
+ optional: true
+
+ lightningcss-linux-arm64-gnu@1.32.0:
+ optional: true
+
+ lightningcss-linux-arm64-musl@1.32.0:
+ optional: true
+
+ lightningcss-linux-x64-gnu@1.32.0:
+ optional: true
+
+ lightningcss-linux-x64-musl@1.32.0:
+ optional: true
+
+ lightningcss-win32-arm64-msvc@1.32.0:
+ optional: true
+
+ lightningcss-win32-x64-msvc@1.32.0:
+ optional: true
+
+ lightningcss@1.32.0:
+ dependencies:
+ detect-libc: 2.1.2
+ 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
+
+ lru-cache@11.2.7: {}
+
+ lz-string@1.5.0: {}
+
+ magic-string@0.30.21:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ mdn-data@2.27.1: {}
+
+ min-indent@1.0.1: {}
+
+ nanoid@3.3.11: {}
+
+ obug@2.1.1: {}
+
+ parse5@8.0.0:
+ dependencies:
+ entities: 6.0.1
+
+ pathe@2.0.3: {}
+
+ picocolors@1.1.1: {}
+
+ picomatch@4.0.4: {}
+
+ postcss@8.5.8:
+ dependencies:
+ nanoid: 3.3.11
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ pretty-format@27.5.1:
+ dependencies:
+ ansi-regex: 5.0.1
+ ansi-styles: 5.2.0
+ react-is: 17.0.2
+
+ punycode@2.3.1: {}
+
+ react-dom@19.2.4(react@19.2.4):
+ dependencies:
+ react: 19.2.4
+ scheduler: 0.27.0
+
+ react-is@17.0.2: {}
+
+ react@19.2.4: {}
+
+ redent@3.0.0:
+ dependencies:
+ indent-string: 4.0.0
+ strip-indent: 3.0.0
+
+ require-from-string@2.0.2: {}
+
+ rolldown@1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2):
+ dependencies:
+ '@oxc-project/types': 0.122.0
+ '@rolldown/pluginutils': 1.0.0-rc.12
+ optionalDependencies:
+ '@rolldown/binding-android-arm64': 1.0.0-rc.12
+ '@rolldown/binding-darwin-arm64': 1.0.0-rc.12
+ '@rolldown/binding-darwin-x64': 1.0.0-rc.12
+ '@rolldown/binding-freebsd-x64': 1.0.0-rc.12
+ '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.12
+ '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.12
+ '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.12
+ '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.12
+ '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.12
+ '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.12
+ '@rolldown/binding-linux-x64-musl': 1.0.0-rc.12
+ '@rolldown/binding-openharmony-arm64': 1.0.0-rc.12
+ '@rolldown/binding-wasm32-wasi': 1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
+ '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.12
+ '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.12
+ transitivePeerDependencies:
+ - '@emnapi/core'
+ - '@emnapi/runtime'
+
+ saxes@6.0.0:
+ dependencies:
+ xmlchars: 2.2.0
+
+ scheduler@0.27.0: {}
+
+ siginfo@2.0.0: {}
+
+ source-map-js@1.2.1: {}
+
+ stackback@0.0.2: {}
+
+ std-env@4.0.0: {}
+
+ strip-indent@3.0.0:
+ dependencies:
+ min-indent: 1.0.1
+
+ symbol-tree@3.2.4: {}
+
+ tinybench@2.9.0: {}
+
+ tinyexec@1.0.4: {}
+
+ tinyglobby@0.2.15:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.4)
+ picomatch: 4.0.4
+
+ tinyrainbow@3.1.0: {}
+
+ tldts-core@7.0.27: {}
+
+ tldts@7.0.27:
+ dependencies:
+ tldts-core: 7.0.27
+
+ tough-cookie@6.0.1:
+ dependencies:
+ tldts: 7.0.27
+
+ tr46@6.0.0:
+ dependencies:
+ punycode: 2.3.1
+
+ tslib@2.8.1:
+ optional: true
+
+ typescript@6.0.2: {}
+
+ undici@7.24.7: {}
+
+ vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2):
+ dependencies:
+ lightningcss: 1.32.0
+ picomatch: 4.0.4
+ postcss: 8.5.8
+ rolldown: 1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
+ tinyglobby: 0.2.15
+ optionalDependencies:
+ fsevents: 2.3.3
+ transitivePeerDependencies:
+ - '@emnapi/core'
+ - '@emnapi/runtime'
+
+ vitest@4.1.2(jsdom@29.0.1)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)):
+ dependencies:
+ '@vitest/expect': 4.1.2
+ '@vitest/mocker': 4.1.2(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2))
+ '@vitest/pretty-format': 4.1.2
+ '@vitest/runner': 4.1.2
+ '@vitest/snapshot': 4.1.2
+ '@vitest/spy': 4.1.2
+ '@vitest/utils': 4.1.2
+ 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.4
+ std-env: 4.0.0
+ tinybench: 2.9.0
+ tinyexec: 1.0.4
+ tinyglobby: 0.2.15
+ tinyrainbow: 3.1.0
+ vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
+ why-is-node-running: 2.3.0
+ optionalDependencies:
+ jsdom: 29.0.1
+ transitivePeerDependencies:
+ - msw
+
+ w3c-xmlserializer@5.0.0:
+ dependencies:
+ xml-name-validator: 5.0.0
+
+ webidl-conversions@8.0.1: {}
+
+ whatwg-mimetype@5.0.0: {}
+
+ whatwg-url@16.0.1:
+ dependencies:
+ '@exodus/bytes': 1.15.0
+ tr46: 6.0.0
+ webidl-conversions: 8.0.1
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
+ why-is-node-running@2.3.0:
+ dependencies:
+ siginfo: 2.0.0
+ stackback: 0.0.2
+
+ xml-name-validator@5.0.0: {}
+
+ xmlchars@2.2.0: {}
diff --git a/examples/showcase/src/App.tsx b/examples/showcase/src/App.tsx
new file mode 100644
index 0000000..b8febef
--- /dev/null
+++ b/examples/showcase/src/App.tsx
@@ -0,0 +1,21 @@
+import { AuthProvider } from './auth';
+import { Dashboard } from './components/Dashboard';
+
+const LoadingComponent = (
+
+
+
Initializing authentication…
+
+);
+
+const ErrorComponent = (
+
+
⚠️ Authentication failed to initialize. Please reload the page.
+
+);
+
+export const App = () => (
+
+
+
+);
diff --git a/examples/showcase/src/auth.ts b/examples/showcase/src/auth.ts
new file mode 100644
index 0000000..7cb5a9c
--- /dev/null
+++ b/examples/showcase/src/auth.ts
@@ -0,0 +1,4 @@
+import { createAuth } from '@forward-software/react-auth';
+import { MockAuthClient } from './mock-auth-client';
+
+export const { AuthProvider, authClient, useAuthClient } = createAuth(new MockAuthClient());
diff --git a/examples/showcase/src/components/AuthStatus.tsx b/examples/showcase/src/components/AuthStatus.tsx
new file mode 100644
index 0000000..7309d19
--- /dev/null
+++ b/examples/showcase/src/components/AuthStatus.tsx
@@ -0,0 +1,35 @@
+import { useSyncExternalStore } from 'react';
+
+import { useAuthClient } from '../auth';
+
+export const AuthStatus = () => {
+ const authClient = useAuthClient();
+ const { isInitialized, isAuthenticated, tokens } = useSyncExternalStore(
+ authClient.subscribe,
+ authClient.getSnapshot,
+ );
+
+ return (
+
+
Auth Status
+
+
+ Initialized: {String(isInitialized)}
+
+
+ Authenticated: {String(isAuthenticated)}
+
+
+
+
Current Tokens
+
{JSON.stringify(tokens, null, 2)}
+
+
+ );
+};
diff --git a/examples/showcase/src/components/AuthenticatedView.tsx b/examples/showcase/src/components/AuthenticatedView.tsx
new file mode 100644
index 0000000..fd83431
--- /dev/null
+++ b/examples/showcase/src/components/AuthenticatedView.tsx
@@ -0,0 +1,46 @@
+import { useSyncExternalStore } from 'react';
+
+import { useAuthClient } from '../auth';
+import { useAsyncCallback } from '../hooks/useAsyncCallback';
+import type { MockTokens } from '../mock-auth-client';
+
+export const AuthenticatedView = () => {
+ const authClient = useAuthClient();
+ const { tokens } = useSyncExternalStore(
+ authClient.subscribe,
+ authClient.getSnapshot,
+ );
+
+ const [doRefresh, isRefreshing] = useAsyncCallback(
+ () => authClient.refresh(),
+ [authClient],
+ );
+
+ const [doLogout, isLoggingOut] = useAsyncCallback(
+ () => authClient.logout(),
+ [authClient],
+ );
+
+ const typedTokens = tokens as Partial;
+ const expiryDate = typedTokens.expiresAt
+ ? new Date(typedTokens.expiresAt).toLocaleTimeString()
+ : 'N/A';
+
+ return (
+
+
Welcome!
+
You are authenticated.
+
+ Tokens expire at: {expiryDate}
+
+
+
+
+
+
+ );
+};
diff --git a/examples/showcase/src/components/Dashboard.tsx b/examples/showcase/src/components/Dashboard.tsx
new file mode 100644
index 0000000..515ada5
--- /dev/null
+++ b/examples/showcase/src/components/Dashboard.tsx
@@ -0,0 +1,26 @@
+import { useSyncExternalStore } from 'react';
+
+import { useAuthClient } from '../auth';
+import { AuthenticatedView } from './AuthenticatedView';
+import { AuthStatus } from './AuthStatus';
+import { EventLog } from './EventLog';
+import { LoginForm } from './LoginForm';
+
+export const Dashboard = () => {
+ const authClient = useAuthClient();
+ const { isAuthenticated } = useSyncExternalStore(
+ authClient.subscribe,
+ authClient.getSnapshot,
+ );
+
+ return (
+
+
React Auth Showcase
+
+
+ {isAuthenticated ?
:
}
+
+
+
+ );
+};
diff --git a/examples/showcase/src/components/EventLog.tsx b/examples/showcase/src/components/EventLog.tsx
new file mode 100644
index 0000000..654d406
--- /dev/null
+++ b/examples/showcase/src/components/EventLog.tsx
@@ -0,0 +1,70 @@
+import { useEffect, useState } from 'react';
+
+import { authClient } from '../auth';
+
+type AuthEvent = {
+ name: string;
+ timestamp: string;
+};
+
+const EVENT_NAMES = [
+ 'initSuccess',
+ 'initFailed',
+ 'loginStarted',
+ 'loginSuccess',
+ 'loginFailed',
+ 'refreshStarted',
+ 'refreshSuccess',
+ 'refreshFailed',
+ 'logoutStarted',
+ 'logoutSuccess',
+ 'logoutFailed',
+] as const;
+
+export const EventLog = () => {
+ const [events, setEvents] = useState([]);
+
+ useEffect(() => {
+ const handlers = EVENT_NAMES.map((name) => {
+ const handler = () => {
+ setEvents((prev) => [
+ ...prev,
+ { name, timestamp: new Date().toLocaleTimeString() },
+ ]);
+ };
+ authClient.on(name, handler);
+ return { name, handler };
+ });
+
+ return () => {
+ handlers.forEach(({ name, handler }) => {
+ authClient.off(name, handler);
+ });
+ };
+ }, []);
+
+ const clearEvents = () => setEvents([]);
+
+ return (
+
+
+
Event Log
+
+
+
+ {events.length === 0 ? (
+
No events yet
+ ) : (
+ events.map((event, index) => (
+
+ {event.timestamp}
+ {event.name}
+
+ ))
+ )}
+
+
+ );
+};
diff --git a/examples/showcase/src/components/LoginForm.tsx b/examples/showcase/src/components/LoginForm.tsx
new file mode 100644
index 0000000..1755dda
--- /dev/null
+++ b/examples/showcase/src/components/LoginForm.tsx
@@ -0,0 +1,82 @@
+import { useState } from 'react';
+
+import { useAuthClient } from '../auth';
+import { useAsyncCallback } from '../hooks/useAsyncCallback';
+import { VALID_CREDENTIALS } from '../mock-auth-client';
+
+export const LoginForm = () => {
+ const authClient = useAuthClient();
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+
+ const [doLogin, isLoading, loginError] = useAsyncCallback(
+ async () => {
+ const success = await authClient.login({ username, password });
+ if (!success) {
+ throw new Error('Login failed. Please check your credentials.');
+ }
+ },
+ [authClient, username, password],
+ );
+
+ const [doInvalidLogin, isInvalidLoading, invalidError] = useAsyncCallback(
+ async () => {
+ const success = await authClient.login({ username: 'wrong', password: 'wrong' });
+ if (!success) {
+ throw new Error('Login failed. Please check your credentials.');
+ }
+ },
+ [authClient],
+ );
+
+ const displayError = loginError ?? invalidError;
+
+ return (
+
+
Login
+
+
+ setUsername(e.target.value)}
+ placeholder="Enter username"
+ data-testid="username-input"
+ />
+
+
+
+ setPassword(e.target.value)}
+ placeholder="Enter password"
+ data-testid="password-input"
+ />
+
+
+
+
+
+ {displayError && (
+
+ {displayError.message}
+
+ )}
+
+ Valid credentials: {VALID_CREDENTIALS.username} /{' '}
+ {VALID_CREDENTIALS.password}
+
+
+ );
+};
diff --git a/examples/showcase/src/hooks/useAsyncCallback.ts b/examples/showcase/src/hooks/useAsyncCallback.ts
new file mode 100644
index 0000000..5a36b81
--- /dev/null
+++ b/examples/showcase/src/hooks/useAsyncCallback.ts
@@ -0,0 +1,26 @@
+import { useCallback, useState } from 'react';
+import type { DependencyList } from 'react';
+
+export function useAsyncCallback Promise>(
+ callback: T,
+ deps: DependencyList,
+): [T, boolean, Error | null] {
+ const [isLoading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ const wrappedCallback = useCallback(async (...args: never[]) => {
+ setLoading(true);
+ setError(null);
+ try {
+ const result = await callback(...args);
+ return result;
+ } catch (err) {
+ setError(err instanceof Error ? err : new Error(String(err)));
+ } finally {
+ setLoading(false);
+ }
+ }, deps) as T;
+
+ return [wrappedCallback, isLoading, error];
+}
diff --git a/examples/showcase/src/main.tsx b/examples/showcase/src/main.tsx
new file mode 100644
index 0000000..9aab210
--- /dev/null
+++ b/examples/showcase/src/main.tsx
@@ -0,0 +1,11 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+
+import { App } from './App';
+import './styles.css';
+
+createRoot(document.getElementById('root')!).render(
+
+
+ ,
+);
diff --git a/examples/showcase/src/mock-auth-client.ts b/examples/showcase/src/mock-auth-client.ts
new file mode 100644
index 0000000..4dba5e2
--- /dev/null
+++ b/examples/showcase/src/mock-auth-client.ts
@@ -0,0 +1,110 @@
+import type { AuthClient } from '@forward-software/react-auth';
+
+export type MockTokens = {
+ accessToken: string;
+ refreshToken: string;
+ expiresAt: number;
+};
+
+export type MockCredentials = {
+ username: string;
+ password: string;
+};
+
+export const VALID_CREDENTIALS: MockCredentials = {
+ username: 'user',
+ password: 'password',
+};
+
+const STORAGE_KEY = 'react-auth-showcase-tokens';
+const DEFAULT_DELAY_MS = 800;
+
+export class MockAuthClient implements AuthClient {
+ private delayMs: number;
+
+ constructor(delayMs = DEFAULT_DELAY_MS) {
+ this.delayMs = delayMs;
+ }
+
+ private wait(): Promise {
+ return new Promise((resolve) => setTimeout(resolve, this.delayMs));
+ }
+
+ private generateTokens(): MockTokens {
+ return {
+ accessToken: `mock-access-${Date.now()}`,
+ refreshToken: `mock-refresh-${Date.now()}`,
+ expiresAt: Date.now() + 5 * 60 * 1000,
+ };
+ }
+
+ async onInit(): Promise {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (stored) {
+ try {
+ const parsed = JSON.parse(stored);
+ if (
+ typeof parsed?.accessToken === 'string' &&
+ typeof parsed?.refreshToken === 'string' &&
+ typeof parsed?.expiresAt === 'number' &&
+ parsed.expiresAt > Date.now()
+ ) {
+ return parsed as MockTokens;
+ }
+ } catch {
+ localStorage.removeItem(STORAGE_KEY);
+ }
+ }
+ return null;
+ }
+
+ async onPostInit(): Promise {
+ // Post-initialization hook — no-op in this demo
+ }
+
+ async onPreLogin(): Promise {
+ await this.wait();
+ }
+
+ async onLogin(credentials?: MockCredentials): Promise {
+ if (
+ credentials?.username !== VALID_CREDENTIALS.username ||
+ credentials?.password !== VALID_CREDENTIALS.password
+ ) {
+ throw new Error('Invalid credentials');
+ }
+ const tokens = this.generateTokens();
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(tokens));
+ return tokens;
+ }
+
+ async onPostLogin(_isSuccess: boolean): Promise {
+ // Post-login hook — no-op in this demo
+ }
+
+ async onPreRefresh(): Promise {
+ await this.wait();
+ }
+
+ async onRefresh(_currentTokens: MockTokens, _minValidity?: number): Promise {
+ const tokens = this.generateTokens();
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(tokens));
+ return tokens;
+ }
+
+ async onPostRefresh(_isSuccess: boolean): Promise {
+ // Post-refresh hook — no-op in this demo
+ }
+
+ async onPreLogout(): Promise {
+ await this.wait();
+ }
+
+ async onLogout(): Promise {
+ localStorage.removeItem(STORAGE_KEY);
+ }
+
+ async onPostLogout(_isSuccess: boolean): Promise {
+ // Post-logout hook — no-op in this demo
+ }
+}
diff --git a/examples/showcase/src/styles.css b/examples/showcase/src/styles.css
new file mode 100644
index 0000000..3860be2
--- /dev/null
+++ b/examples/showcase/src/styles.css
@@ -0,0 +1,298 @@
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ font-family:
+ -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
+ Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
+ background: #f5f7fa;
+ color: #1a1a2e;
+ line-height: 1.6;
+}
+
+h1 {
+ text-align: center;
+ margin-bottom: 1.5rem;
+ color: #16213e;
+}
+
+h2 {
+ margin-top: 0;
+ margin-bottom: 0.75rem;
+ font-size: 1.2rem;
+ color: #16213e;
+}
+
+h3 {
+ margin-top: 0.75rem;
+ margin-bottom: 0.5rem;
+ font-size: 1rem;
+ color: #333;
+}
+
+/* Layout */
+
+.dashboard {
+ max-width: 720px;
+ margin: 2rem auto;
+ padding: 0 1rem;
+}
+
+.main-content {
+ margin-bottom: 1rem;
+}
+
+/* Card */
+
+.card {
+ background: #fff;
+ border-radius: 8px;
+ padding: 1.25rem;
+ margin-bottom: 1rem;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
+}
+
+/* Badges */
+
+.badges {
+ display: flex;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+ margin-bottom: 0.5rem;
+}
+
+.badge {
+ display: inline-block;
+ padding: 0.25rem 0.75rem;
+ border-radius: 12px;
+ font-size: 0.85rem;
+ font-weight: 500;
+}
+
+.badge-success {
+ background: #d4edda;
+ color: #155724;
+}
+
+.badge-pending {
+ background: #fff3cd;
+ color: #856404;
+}
+
+.badge-inactive {
+ background: #e2e3e5;
+ color: #383d41;
+}
+
+/* Token display */
+
+.token-display pre {
+ background: #f0f2f5;
+ padding: 0.75rem;
+ border-radius: 6px;
+ overflow-x: auto;
+ font-size: 0.8rem;
+ line-height: 1.4;
+}
+
+/* Form */
+
+.form-group {
+ margin-bottom: 0.75rem;
+}
+
+.form-group label {
+ display: block;
+ margin-bottom: 0.25rem;
+ font-weight: 500;
+ font-size: 0.9rem;
+}
+
+.form-group input {
+ width: 100%;
+ padding: 0.5rem 0.75rem;
+ border: 1px solid #ccc;
+ border-radius: 6px;
+ font-size: 0.95rem;
+}
+
+.form-group input:focus {
+ outline: none;
+ border-color: #4a90d9;
+ box-shadow: 0 0 0 2px rgba(74, 144, 217, 0.2);
+}
+
+/* Buttons */
+
+.button-group {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+ margin-top: 0.75rem;
+}
+
+button {
+ padding: 0.5rem 1rem;
+ border: none;
+ border-radius: 6px;
+ font-size: 0.9rem;
+ font-weight: 500;
+ cursor: pointer;
+ background: #4a90d9;
+ color: #fff;
+ transition: background 0.15s ease;
+}
+
+button:hover:not(:disabled) {
+ background: #357abd;
+}
+
+button:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+/* Error */
+
+.error-message {
+ color: #c0392b;
+ background: #fde8e8;
+ padding: 0.5rem 0.75rem;
+ border-radius: 6px;
+ margin-top: 0.75rem;
+ font-size: 0.9rem;
+}
+
+/* Hint */
+
+.hint {
+ margin-top: 0.75rem;
+ font-size: 0.85rem;
+ color: #666;
+}
+
+.hint code {
+ background: #f0f2f5;
+ padding: 0.15rem 0.35rem;
+ border-radius: 4px;
+ font-size: 0.85rem;
+}
+
+/* Expiry */
+
+.expiry-info {
+ font-size: 0.9rem;
+ color: #555;
+}
+
+/* Event Log */
+
+.event-log-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.5rem;
+}
+
+.event-log-header h2 {
+ margin-bottom: 0;
+}
+
+.event-log-header button {
+ padding: 0.3rem 0.75rem;
+ font-size: 0.8rem;
+ background: #6c757d;
+}
+
+.event-log-header button:hover:not(:disabled) {
+ background: #5a6268;
+}
+
+.event-list {
+ max-height: 220px;
+ overflow-y: auto;
+ background: #f8f9fa;
+ border-radius: 6px;
+ padding: 0.5rem;
+ font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
+ font-size: 0.8rem;
+}
+
+.event-item {
+ display: flex;
+ gap: 0.75rem;
+ padding: 0.25rem 0.5rem;
+ border-bottom: 1px solid #e9ecef;
+}
+
+.event-item:last-child {
+ border-bottom: none;
+}
+
+.event-time {
+ color: #6c757d;
+ white-space: nowrap;
+}
+
+.event-name {
+ font-weight: 500;
+ color: #333;
+}
+
+.empty-message {
+ text-align: center;
+ color: #999;
+ padding: 1rem;
+ margin: 0;
+}
+
+/* Loading / Error screens */
+
+.loading-screen,
+.error-screen {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 50vh;
+ gap: 1rem;
+}
+
+.spinner {
+ width: 32px;
+ height: 32px;
+ border: 3px solid #e2e3e5;
+ border-top-color: #4a90d9;
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+/* Responsive */
+
+@media (max-width: 480px) {
+ .dashboard {
+ padding: 0 0.5rem;
+ }
+
+ .badges {
+ flex-direction: column;
+ }
+
+ .button-group {
+ flex-direction: column;
+ }
+
+ button {
+ width: 100%;
+ }
+}
diff --git a/examples/showcase/src/vite-env.d.ts b/examples/showcase/src/vite-env.d.ts
new file mode 100644
index 0000000..b81ec62
--- /dev/null
+++ b/examples/showcase/src/vite-env.d.ts
@@ -0,0 +1,2 @@
+///
+declare module '*.css';
diff --git a/examples/showcase/test/components/AuthStatus.spec.tsx b/examples/showcase/test/components/AuthStatus.spec.tsx
new file mode 100644
index 0000000..462e9a0
--- /dev/null
+++ b/examples/showcase/test/components/AuthStatus.spec.tsx
@@ -0,0 +1,106 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import * as rtl from '@testing-library/react';
+import '@testing-library/jest-dom';
+
+import { TestAuthClient, flushPromises } from '../test-utils';
+import type { TestTokens } from '../test-utils';
+
+// Create a shared test client accessible by both the mock factory and test bodies.
+const testState = vi.hoisted(() => ({
+ client: null as InstanceType | null,
+}));
+
+vi.mock('../../src/auth', async () => {
+ const { createAuth } = await import('@forward-software/react-auth');
+ const { TestAuthClient: Cls } = await import('../test-utils');
+ testState.client = new Cls();
+ return createAuth(testState.client);
+});
+
+// Imports resolved AFTER the mock is installed.
+import { AuthStatus } from '../../src/components/AuthStatus';
+import { AuthProvider } from '../../src/auth';
+
+afterEach(rtl.cleanup);
+
+describe('AuthStatus', () => {
+ it('should render initialization status', async () => {
+ // Arrange — default onInit returns null → initialized = true
+
+ // Act
+ rtl.render(
+
+
+ ,
+ );
+ await rtl.act(() => flushPromises());
+
+ // Assert
+ expect(rtl.screen.getByTestId('initialized-badge')).toHaveTextContent(
+ 'Initialized: true',
+ );
+ });
+
+ it('should render authentication status when not authenticated', async () => {
+ // Arrange — onInit returns null → not authenticated
+
+ // Act
+ rtl.render(
+
+
+ ,
+ );
+ await rtl.act(() => flushPromises());
+
+ // Assert
+ expect(rtl.screen.getByTestId('authenticated-badge')).toHaveTextContent(
+ 'Authenticated: false',
+ );
+ });
+
+ it('should render authentication status when authenticated', async () => {
+ // Arrange — onInit returns tokens → authenticated
+ const tokens: TestTokens = {
+ accessToken: 'init-access',
+ refreshToken: 'init-refresh',
+ expiresAt: Date.now() + 300_000,
+ };
+ vi.spyOn(testState.client!, 'onInit').mockResolvedValueOnce(tokens);
+
+ // Act
+ rtl.render(
+
+
+ ,
+ );
+ await rtl.act(() => flushPromises());
+
+ // Assert
+ expect(rtl.screen.getByTestId('authenticated-badge')).toHaveTextContent(
+ 'Authenticated: true',
+ );
+ });
+
+ it('should render token display', async () => {
+ // Arrange — onInit returns tokens
+ const tokens: TestTokens = {
+ accessToken: 'display-access',
+ refreshToken: 'display-refresh',
+ expiresAt: 1700000000000,
+ };
+ vi.spyOn(testState.client!, 'onInit').mockResolvedValueOnce(tokens);
+
+ // Act
+ rtl.render(
+
+
+ ,
+ );
+ await rtl.act(() => flushPromises());
+
+ // Assert
+ const tokenDisplay = rtl.screen.getByTestId('token-display');
+ expect(tokenDisplay).toHaveTextContent('display-access');
+ expect(tokenDisplay).toHaveTextContent('display-refresh');
+ });
+});
diff --git a/examples/showcase/test/components/AuthenticatedView.spec.tsx b/examples/showcase/test/components/AuthenticatedView.spec.tsx
new file mode 100644
index 0000000..83ed5c5
--- /dev/null
+++ b/examples/showcase/test/components/AuthenticatedView.spec.tsx
@@ -0,0 +1,115 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import * as rtl from '@testing-library/react';
+import '@testing-library/jest-dom';
+
+import { TestAuthClient, flushPromises } from '../test-utils';
+import type { TestTokens } from '../test-utils';
+
+const testState = vi.hoisted(() => ({
+ client: null as InstanceType | null,
+}));
+
+vi.mock('../../src/auth', async () => {
+ const { createAuth } = await import('@forward-software/react-auth');
+ const { TestAuthClient: Cls } = await import('../test-utils');
+ testState.client = new Cls();
+ return createAuth(testState.client);
+});
+
+import { AuthenticatedView } from '../../src/components/AuthenticatedView';
+import { AuthProvider } from '../../src/auth';
+
+afterEach(rtl.cleanup);
+
+const MOCK_TOKENS: TestTokens = {
+ accessToken: 'test-access-token',
+ refreshToken: 'test-refresh-token',
+ expiresAt: Date.now() + 5 * 60 * 1000,
+};
+
+describe('AuthenticatedView', () => {
+ it('should render the authenticated view when logged in', async () => {
+ // Arrange — onInit returns tokens → authenticated
+ vi.spyOn(testState.client!, 'onInit').mockResolvedValueOnce(MOCK_TOKENS);
+
+ // Act
+ rtl.render(
+
+
+ ,
+ );
+ await rtl.act(() => flushPromises());
+
+ // Assert
+ expect(rtl.screen.getByTestId('authenticated-view')).toBeInTheDocument();
+ expect(rtl.screen.getByText('You are authenticated.')).toBeInTheDocument();
+ });
+
+ it('should show token expiry time', async () => {
+ // Arrange
+ const tokens: TestTokens = {
+ ...MOCK_TOKENS,
+ expiresAt: 1700000000000,
+ };
+ vi.spyOn(testState.client!, 'onInit').mockResolvedValueOnce(tokens);
+
+ // Act
+ rtl.render(
+
+
+ ,
+ );
+ await rtl.act(() => flushPromises());
+
+ // Assert
+ const expiryEl = rtl.screen.getByTestId('token-expiry');
+ expect(expiryEl).toBeInTheDocument();
+ expect(expiryEl.textContent).toContain('Tokens expire at:');
+ // The formatted time should not be N/A since we provided a valid expiresAt
+ expect(expiryEl.textContent).not.toContain('N/A');
+ });
+
+ it('should trigger token refresh on refresh button click', async () => {
+ // Arrange
+ vi.spyOn(testState.client!, 'onInit').mockResolvedValueOnce(MOCK_TOKENS);
+ const refreshSpy = vi.spyOn(testState.client!, 'onRefresh');
+
+ rtl.render(
+
+
+ ,
+ );
+ await rtl.act(() => flushPromises());
+
+ // Act
+ await rtl.act(async () => {
+ rtl.fireEvent.click(rtl.screen.getByTestId('refresh-button'));
+ await flushPromises();
+ });
+
+ // Assert
+ expect(refreshSpy).toHaveBeenCalled();
+ });
+
+ it('should trigger logout on logout button click', async () => {
+ // Arrange
+ vi.spyOn(testState.client!, 'onInit').mockResolvedValueOnce(MOCK_TOKENS);
+ const logoutSpy = vi.spyOn(testState.client!, 'onLogout');
+
+ rtl.render(
+
+
+ ,
+ );
+ await rtl.act(() => flushPromises());
+
+ // Act
+ await rtl.act(async () => {
+ rtl.fireEvent.click(rtl.screen.getByTestId('logout-button'));
+ await flushPromises();
+ });
+
+ // Assert
+ expect(logoutSpy).toHaveBeenCalled();
+ });
+});
diff --git a/examples/showcase/test/components/EventLog.spec.tsx b/examples/showcase/test/components/EventLog.spec.tsx
new file mode 100644
index 0000000..cad2be6
--- /dev/null
+++ b/examples/showcase/test/components/EventLog.spec.tsx
@@ -0,0 +1,76 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import * as rtl from '@testing-library/react';
+import '@testing-library/jest-dom';
+
+import { TestAuthClient, flushPromises } from '../test-utils';
+
+const testState = vi.hoisted(() => ({
+ client: null as InstanceType | null,
+}));
+
+vi.mock('../../src/auth', async () => {
+ const { createAuth } = await import('@forward-software/react-auth');
+ const { TestAuthClient: Cls } = await import('../test-utils');
+ testState.client = new Cls();
+ return createAuth(testState.client);
+});
+
+import { EventLog } from '../../src/components/EventLog';
+import { AuthProvider } from '../../src/auth';
+
+afterEach(rtl.cleanup);
+
+describe('EventLog', () => {
+ it('should render event log container', async () => {
+ // Arrange & Act
+ rtl.render(
+
+
+ ,
+ );
+ await rtl.act(() => flushPromises());
+
+ // Assert
+ expect(rtl.screen.getByTestId('event-log')).toBeInTheDocument();
+ expect(rtl.screen.getByTestId('event-list')).toBeInTheDocument();
+ });
+
+ it('should display events when auth events fire', async () => {
+ // Arrange & Act — AuthProvider calls init(), which emits initSuccess
+ rtl.render(
+
+
+ ,
+ );
+ await rtl.act(() => flushPromises());
+
+ // Assert — initSuccess should appear from the provider's init call
+ const eventItems = rtl.screen.getAllByTestId('event-item');
+ expect(eventItems.length).toBeGreaterThanOrEqual(1);
+
+ const eventNames = eventItems.map((el) => el.textContent);
+ expect(eventNames.some((text) => text?.includes('initSuccess'))).toBe(true);
+ });
+
+ it('should clear events when clear button is clicked', async () => {
+ // Arrange — render and wait for init events
+ rtl.render(
+
+
+ ,
+ );
+ await rtl.act(() => flushPromises());
+
+ // Verify events exist first
+ expect(rtl.screen.getAllByTestId('event-item').length).toBeGreaterThanOrEqual(1);
+
+ // Act — click the clear button
+ await rtl.act(async () => {
+ rtl.fireEvent.click(rtl.screen.getByTestId('clear-events-button'));
+ });
+
+ // Assert — no event items remain
+ expect(rtl.screen.queryAllByTestId('event-item')).toHaveLength(0);
+ expect(rtl.screen.getByText('No events yet')).toBeInTheDocument();
+ });
+});
diff --git a/examples/showcase/test/components/LoginForm.spec.tsx b/examples/showcase/test/components/LoginForm.spec.tsx
new file mode 100644
index 0000000..c6f22b5
--- /dev/null
+++ b/examples/showcase/test/components/LoginForm.spec.tsx
@@ -0,0 +1,91 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import * as rtl from '@testing-library/react';
+import '@testing-library/jest-dom';
+
+import { TestAuthClient, flushPromises } from '../test-utils';
+
+const testState = vi.hoisted(() => ({
+ client: null as InstanceType | null,
+}));
+
+vi.mock('../../src/auth', async () => {
+ const { createAuth } = await import('@forward-software/react-auth');
+ const { TestAuthClient: Cls } = await import('../test-utils');
+ testState.client = new Cls();
+ return createAuth(testState.client);
+});
+
+import { LoginForm } from '../../src/components/LoginForm';
+import { AuthProvider } from '../../src/auth';
+
+afterEach(rtl.cleanup);
+
+describe('LoginForm', () => {
+ it('should render login form inputs', async () => {
+ // Arrange & Act
+ rtl.render(
+
+
+ ,
+ );
+ await rtl.act(() => flushPromises());
+
+ // Assert
+ expect(rtl.screen.getByTestId('username-input')).toBeInTheDocument();
+ expect(rtl.screen.getByTestId('password-input')).toBeInTheDocument();
+ expect(rtl.screen.getByTestId('login-button')).toBeInTheDocument();
+ });
+
+ it('should trigger auth login on button click', async () => {
+ // Arrange
+ const loginSpy = vi.spyOn(testState.client!, 'onLogin');
+
+ rtl.render(
+
+
+ ,
+ );
+ await rtl.act(() => flushPromises());
+
+ // Act — fill in credentials and click login
+ await rtl.act(async () => {
+ rtl.fireEvent.change(rtl.screen.getByTestId('username-input'), {
+ target: { value: 'user' },
+ });
+ rtl.fireEvent.change(rtl.screen.getByTestId('password-input'), {
+ target: { value: 'password' },
+ });
+ });
+
+ await rtl.act(async () => {
+ rtl.fireEvent.click(rtl.screen.getByTestId('login-button'));
+ await flushPromises();
+ });
+
+ // Assert
+ expect(loginSpy).toHaveBeenCalled();
+ });
+
+ it('should display error on failed login', async () => {
+ // Arrange — make onLogin reject
+ vi.spyOn(testState.client!, 'onLogin').mockRejectedValueOnce(
+ new Error('Bad credentials'),
+ );
+
+ rtl.render(
+
+
+ ,
+ );
+ await rtl.act(() => flushPromises());
+
+ // Act — click the invalid-login button (uses wrong credentials)
+ await rtl.act(async () => {
+ rtl.fireEvent.click(rtl.screen.getByTestId('invalid-login-button'));
+ await flushPromises();
+ });
+
+ // Assert
+ expect(rtl.screen.getByTestId('login-error')).toBeInTheDocument();
+ });
+});
diff --git a/examples/showcase/test/mock-auth-client.spec.ts b/examples/showcase/test/mock-auth-client.spec.ts
new file mode 100644
index 0000000..9e5e9b8
--- /dev/null
+++ b/examples/showcase/test/mock-auth-client.spec.ts
@@ -0,0 +1,191 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+
+import { MockAuthClient, VALID_CREDENTIALS } from '../src/mock-auth-client';
+import type { MockTokens } from '../src/mock-auth-client';
+
+const STORAGE_KEY = 'react-auth-showcase-tokens';
+
+describe('MockAuthClient', () => {
+ let client: MockAuthClient;
+
+ beforeEach(() => {
+ localStorage.clear();
+ client = new MockAuthClient(0);
+ });
+
+ afterEach(() => {
+ localStorage.clear();
+ });
+
+ describe('onInit', () => {
+ it('should return null when no tokens are stored', async () => {
+ // Arrange — empty localStorage
+
+ // Act
+ const result = await client.onInit();
+
+ // Assert
+ expect(result).toBeNull();
+ });
+
+ it('should restore valid tokens from localStorage', async () => {
+ // Arrange
+ const storedTokens: MockTokens = {
+ accessToken: 'stored-access',
+ refreshToken: 'stored-refresh',
+ expiresAt: Date.now() + 60_000,
+ };
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(storedTokens));
+
+ // Act
+ const result = await client.onInit();
+
+ // Assert
+ expect(result).toEqual(storedTokens);
+ });
+
+ it('should return null for expired tokens', async () => {
+ // Arrange
+ const expiredTokens: MockTokens = {
+ accessToken: 'expired-access',
+ refreshToken: 'expired-refresh',
+ expiresAt: Date.now() - 1000,
+ };
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(expiredTokens));
+
+ // Act
+ const result = await client.onInit();
+
+ // Assert
+ expect(result).toBeNull();
+ });
+ });
+
+ describe('onLogin', () => {
+ it('should return tokens for valid credentials', async () => {
+ // Arrange — use VALID_CREDENTIALS
+
+ // Act
+ const tokens = await client.onLogin(VALID_CREDENTIALS);
+
+ // Assert
+ expect(tokens).toHaveProperty('accessToken');
+ expect(tokens).toHaveProperty('refreshToken');
+ expect(tokens).toHaveProperty('expiresAt');
+ expect(tokens.expiresAt).toBeGreaterThan(Date.now());
+ });
+
+ it('should persist tokens to localStorage on success', async () => {
+ // Arrange — empty localStorage
+
+ // Act
+ await client.onLogin(VALID_CREDENTIALS);
+
+ // Assert
+ const stored = localStorage.getItem(STORAGE_KEY);
+ expect(stored).not.toBeNull();
+ expect(JSON.parse(stored!)).toHaveProperty('accessToken');
+ });
+
+ it('should throw for invalid credentials', async () => {
+ // Arrange
+ const badCredentials = { username: 'wrong', password: 'wrong' };
+
+ // Act & Assert
+ await expect(client.onLogin(badCredentials)).rejects.toThrow('Invalid credentials');
+ });
+
+ it('should throw when no credentials are provided', async () => {
+ // Act & Assert
+ await expect(client.onLogin()).rejects.toThrow('Invalid credentials');
+ });
+ });
+
+ describe('onRefresh', () => {
+ it('should generate new tokens', async () => {
+ // Arrange
+ const currentTokens: MockTokens = {
+ accessToken: 'old-access',
+ refreshToken: 'old-refresh',
+ expiresAt: Date.now() + 10_000,
+ };
+
+ // Act
+ const newTokens = await client.onRefresh(currentTokens);
+
+ // Assert
+ expect(newTokens.accessToken).not.toBe(currentTokens.accessToken);
+ expect(newTokens.refreshToken).not.toBe(currentTokens.refreshToken);
+ expect(newTokens.expiresAt).toBeGreaterThan(Date.now());
+ });
+
+ it('should persist refreshed tokens to localStorage', async () => {
+ // Arrange
+ const currentTokens: MockTokens = {
+ accessToken: 'old-access',
+ refreshToken: 'old-refresh',
+ expiresAt: Date.now() + 10_000,
+ };
+
+ // Act
+ const newTokens = await client.onRefresh(currentTokens);
+
+ // Assert
+ const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!);
+ expect(stored.accessToken).toBe(newTokens.accessToken);
+ });
+ });
+
+ describe('onLogout', () => {
+ it('should clear localStorage', async () => {
+ // Arrange
+ localStorage.setItem(STORAGE_KEY, '{"accessToken":"x"}');
+
+ // Act
+ await client.onLogout();
+
+ // Assert
+ expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
+ });
+ });
+
+ describe('lifecycle hooks', () => {
+ it('should have callable onPostInit', async () => {
+ // Act & Assert
+ await expect(client.onPostInit()).resolves.toBeUndefined();
+ });
+
+ it('should have callable onPreLogin', async () => {
+ // Act & Assert
+ await expect(client.onPreLogin()).resolves.toBeUndefined();
+ });
+
+ it('should have callable onPostLogin', async () => {
+ // Act & Assert
+ await expect(client.onPostLogin(true)).resolves.toBeUndefined();
+ await expect(client.onPostLogin(false)).resolves.toBeUndefined();
+ });
+
+ it('should have callable onPreRefresh', async () => {
+ // Act & Assert
+ await expect(client.onPreRefresh()).resolves.toBeUndefined();
+ });
+
+ it('should have callable onPostRefresh', async () => {
+ // Act & Assert
+ await expect(client.onPostRefresh(true)).resolves.toBeUndefined();
+ await expect(client.onPostRefresh(false)).resolves.toBeUndefined();
+ });
+
+ it('should have callable onPreLogout', async () => {
+ // Act & Assert
+ await expect(client.onPreLogout()).resolves.toBeUndefined();
+ });
+
+ it('should have callable onPostLogout', async () => {
+ // Act & Assert
+ await expect(client.onPostLogout(true)).resolves.toBeUndefined();
+ await expect(client.onPostLogout(false)).resolves.toBeUndefined();
+ });
+ });
+});
diff --git a/examples/showcase/test/test-utils.tsx b/examples/showcase/test/test-utils.tsx
new file mode 100644
index 0000000..77e9df6
--- /dev/null
+++ b/examples/showcase/test/test-utils.tsx
@@ -0,0 +1,55 @@
+/* istanbul ignore file */
+
+import type { FC, PropsWithChildren } from 'react';
+
+import { createAuth } from '@forward-software/react-auth';
+import type { AuthClient } from '@forward-software/react-auth';
+
+export type TestTokens = {
+ accessToken: string;
+ refreshToken: string;
+ expiresAt: number;
+};
+
+export type TestCredentials = {
+ username: string;
+ password: string;
+};
+
+export class TestAuthClient implements AuthClient {
+ async onInit(): Promise {
+ return null;
+ }
+
+ async onLogin(_credentials?: TestCredentials): Promise {
+ return {
+ accessToken: 'test-access-token',
+ refreshToken: 'test-refresh-token',
+ expiresAt: Date.now() + 5 * 60 * 1000,
+ };
+ }
+
+ async onRefresh(_currentTokens: TestTokens): Promise {
+ return {
+ accessToken: 'refreshed-access-token',
+ refreshToken: 'refreshed-refresh-token',
+ expiresAt: Date.now() + 5 * 60 * 1000,
+ };
+ }
+
+ async onLogout(): Promise {
+ // no-op
+ }
+}
+
+export function createTestAuth(client?: AuthClient) {
+ return createAuth(client ?? new TestAuthClient());
+}
+
+export function createTestWrapper(AuthProvider: FC) {
+ return function Wrapper({ children }: PropsWithChildren) {
+ return {children};
+ };
+}
+
+export const flushPromises = () => new Promise(process.nextTick);
diff --git a/examples/showcase/tsconfig.json b/examples/showcase/tsconfig.json
new file mode 100644
index 0000000..60ec96e
--- /dev/null
+++ b/examples/showcase/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "paths": {
+ "@forward-software/react-auth": ["./../../lib/src/index.ts"]
+ }
+ },
+ "include": ["src"]
+}
diff --git a/examples/showcase/vite.config.ts b/examples/showcase/vite.config.ts
new file mode 100644
index 0000000..5081a90
--- /dev/null
+++ b/examples/showcase/vite.config.ts
@@ -0,0 +1,16 @@
+import path from 'node:path';
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: {
+ '@forward-software/react-auth': path.resolve(__dirname, '../../lib/src/index.ts'),
+ },
+ },
+ server: {
+ port: 3003,
+ open: true,
+ },
+});
diff --git a/examples/showcase/vitest.config.ts b/examples/showcase/vitest.config.ts
new file mode 100644
index 0000000..69e146e
--- /dev/null
+++ b/examples/showcase/vitest.config.ts
@@ -0,0 +1,24 @@
+import path from 'node:path';
+import { defineConfig } from 'vitest/config';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: {
+ '@forward-software/react-auth': path.resolve(__dirname, '../../lib/src/index.ts'),
+ react: path.resolve(__dirname, 'node_modules/react'),
+ 'react-dom': path.resolve(__dirname, 'node_modules/react-dom'),
+ },
+ },
+ test: {
+ environment: 'jsdom',
+ globals: true,
+ include: ['**/*.{test,spec}.{js,jsx,ts,tsx}'],
+ coverage: {
+ reporter: ['clover', 'lcov', 'html'],
+ include: ['src/**/*.{js,jsx,ts,tsx}'],
+ exclude: ['**/*.d.ts'],
+ },
+ },
+});