From dc00e4d9f489a85283ef0a4402c32a1f2b8a3bd6 Mon Sep 17 00:00:00 2001
From: Brion
Date: Mon, 19 Oct 2026 22:44:58 +0530
Subject: [PATCH] Move the quickstarts to the samples repository and add
playgrounds
The framework quickstarts now live in thunder-id/samples and use the published packages. The E2E suite fetches them through tests/e2e/scripts/prepare-samples.ts and installs them against tarballs of this workspace's builds. SDK releases no longer attach a quickstart archive.
playgrounds/react and playgrounds/express take their place for exercising the SDKs from source: capability checks, a component explorer, hooks and token inspection for React, and an API explorer with an SDK trace for Express.
Refs thunder-id/thunderid#5904
Refs thunder-id/thunderid#5919
Signed-off-by: Brion
---
.github/actions/pack-sample/action.yml | 12 +-
.github/actions/run-e2e-suite/action.yml | 51 +-
.github/workflows/release.yml | 70 +-
AGENTS.md | 11 +-
README.md | 10 +
eslint.config.js | 2 +-
playgrounds/express/.env.example | 32 +
playgrounds/express/.gitignore | 4 +
playgrounds/express/.prettierignore | 4 +
playgrounds/express/README.md | 71 +
playgrounds/express/eslint.config.js | 30 +
playgrounds/express/package.json | 32 +
playgrounds/express/prettier.config.js | 6 +
playgrounds/express/public/app.js | 404 ++++
playgrounds/express/public/favicon.svg | 1 +
playgrounds/express/public/index.html | 179 ++
playgrounds/express/public/styles.css | 897 ++++++++
playgrounds/express/src/catalog.ts | 200 ++
playgrounds/express/src/config.ts | 39 +
playgrounds/express/src/explorer.ts | 243 +++
playgrounds/express/src/helpers.ts | 17 +
playgrounds/express/src/server.ts | 194 ++
playgrounds/express/src/trace.ts | 314 +++
playgrounds/express/tsconfig.eslint.json | 8 +
playgrounds/express/tsconfig.json | 22 +
playgrounds/express/turbo.json | 9 +
playgrounds/react/.env.example | 22 +
playgrounds/react/.gitignore | 5 +
playgrounds/react/README.md | 70 +
playgrounds/react/eslint.config.js | 11 +
playgrounds/react/index.html | 18 +
playgrounds/react/package.json | 35 +
playgrounds/react/prettier.config.js | 6 +
playgrounds/react/public/favicon.svg | 1 +
playgrounds/react/src/App.tsx | 29 +
.../react/src/components/ErrorBoundary.tsx | 45 +
playgrounds/react/src/components/EventLog.tsx | 36 +
playgrounds/react/src/components/Icons.tsx | 152 ++
.../react/src/components/SdkObserver.tsx | 67 +
.../react/src/components/SessionCard.tsx | 155 ++
playgrounds/react/src/components/TopBar.tsx | 95 +
playgrounds/react/src/config.ts | 39 +
playgrounds/react/src/lib/events.ts | 81 +
playgrounds/react/src/lib/health.ts | 64 +
playgrounds/react/src/lib/inspect.ts | 31 +
playgrounds/react/src/lib/jwt.ts | 47 +
playgrounds/react/src/lib/router.ts | 25 +
playgrounds/react/src/lib/sdk.ts | 39 +
playgrounds/react/src/lib/session.ts | 99 +
playgrounds/react/src/lib/user.ts | 38 +
playgrounds/react/src/main.tsx | 25 +
playgrounds/react/src/pages/Hooks.tsx | 217 ++
playgrounds/react/src/pages/Overview.tsx | 338 +++
playgrounds/react/src/pages/Tokens.tsx | 204 ++
playgrounds/react/src/pages/capabilities.ts | 489 +++++
.../react/src/pages/explorer/Explorer.tsx | 199 ++
.../react/src/pages/explorer/StoryWidgets.tsx | 83 +
.../react/src/pages/explorer/Workbench.tsx | 295 +++
playgrounds/react/src/pages/explorer/a11y.ts | 235 +++
.../react/src/pages/explorer/stories.tsx | 1030 +++++++++
playgrounds/react/src/pages/explorer/types.ts | 41 +
playgrounds/react/src/styles.css | 1280 ++++++++++++
playgrounds/react/src/vite-env.d.ts | 4 +
playgrounds/react/tsconfig.json | 21 +
playgrounds/react/vite.config.ts | 17 +
pnpm-lock.yaml | 1844 +----------------
pnpm-workspace.yaml | 26 +-
samples/browser/quickstart/.env.example | 5 -
samples/browser/quickstart/.stackblitzrc | 4 -
samples/browser/quickstart/README.md | 37 -
samples/browser/quickstart/index.html | 12 -
samples/browser/quickstart/package.json | 18 -
.../quickstart/scripts/prepare-dev.cjs | 34 -
samples/browser/quickstart/src/auth.js | 18 -
.../browser/quickstart/src/components/nav.js | 171 --
.../src/components/profileFields.js | 305 ---
samples/browser/quickstart/src/main.js | 101 -
.../browser/quickstart/src/pages/account.js | 307 ---
samples/browser/quickstart/src/pages/home.js | 321 ---
samples/browser/quickstart/src/pages/token.js | 118 --
samples/browser/quickstart/src/style.css | 1595 --------------
samples/browser/quickstart/vite.config.js | 7 -
samples/express/quickstart/.env.example | 11 -
samples/express/quickstart/.stackblitzrc | 4 -
samples/express/quickstart/README.md | 99 -
samples/express/quickstart/index.mjs | 406 ----
samples/express/quickstart/lib/auth.mjs | 47 -
.../express/quickstart/lib/expressLogo.mjs | 10 -
samples/express/quickstart/lib/layout.mjs | 96 -
.../express/quickstart/lib/thunderMark.mjs | 8 -
samples/express/quickstart/package.json | 16 -
...Express-Quickstart.postman_collection.json | 62 -
samples/express/quickstart/public/styles.css | 991 ---------
.../quickstart/scripts/prepare-dev.cjs | 34 -
samples/nextjs/quickstart/.env.example | 20 -
samples/nextjs/quickstart/.stackblitzrc | 4 -
samples/nextjs/quickstart/README.md | 117 --
.../nextjs/quickstart/app/account/page.tsx | 212 --
samples/nextjs/quickstart/app/actions.ts | 19 -
.../app/api/auth/backchannel-logout/route.ts | 7 -
.../app/components/ConfigNotice.tsx | 121 --
.../quickstart/app/components/HeroCtas.tsx | 16 -
.../nextjs/quickstart/app/components/Nav.tsx | 102 -
.../quickstart/app/components/ThunderMark.tsx | 36 -
.../app/components/icons/NextLogo.tsx | 17 -
samples/nextjs/quickstart/app/globals.css | 1521 --------------
samples/nextjs/quickstart/app/layout.tsx | 47 -
samples/nextjs/quickstart/app/not-found.tsx | 13 -
samples/nextjs/quickstart/app/page.tsx | 264 ---
samples/nextjs/quickstart/app/signin/page.tsx | 30 -
samples/nextjs/quickstart/app/signup/page.tsx | 27 -
samples/nextjs/quickstart/app/token/page.tsx | 143 --
samples/nextjs/quickstart/next-env.d.ts | 6 -
samples/nextjs/quickstart/next.config.ts | 5 -
samples/nextjs/quickstart/package.json | 24 -
samples/nextjs/quickstart/proxy.ts | 13 -
.../nextjs/quickstart/scripts/prepare-dev.cjs | 93 -
samples/nextjs/quickstart/tsconfig.json | 21 -
samples/node/quickstart/.env.example | 14 -
samples/node/quickstart/.stackblitzrc | 4 -
samples/node/quickstart/README.md | 79 -
samples/node/quickstart/index.mjs | 42 -
samples/node/quickstart/lib/AuthService.mjs | 33 -
.../node/quickstart/lib/InventoryService.mjs | 29 -
samples/node/quickstart/lib/ui.mjs | 89 -
samples/node/quickstart/package.json | 16 -
.../node/quickstart/scripts/prepare-dev.cjs | 34 -
samples/nuxt/quickstart/.env.example | 20 -
samples/nuxt/quickstart/.stackblitzrc | 4 -
samples/nuxt/quickstart/README.md | 119 --
samples/nuxt/quickstart/app/app.vue | 28 -
samples/nuxt/quickstart/app/assets/styles.css | 1397 -------------
.../nuxt/quickstart/app/components/AppNav.vue | 82 -
.../app/components/ConfigNotice.vue | 112 -
samples/nuxt/quickstart/app/pages/account.vue | 162 --
samples/nuxt/quickstart/app/pages/index.vue | 234 ---
samples/nuxt/quickstart/app/pages/signin.vue | 24 -
samples/nuxt/quickstart/app/pages/signup.vue | 24 -
samples/nuxt/quickstart/app/pages/token.vue | 125 --
samples/nuxt/quickstart/nuxt.config.ts | 11 -
samples/nuxt/quickstart/package.json | 18 -
.../nuxt/quickstart/scripts/prepare-dev.cjs | 88 -
samples/react/quickstart/.env.example | 5 -
samples/react/quickstart/.stackblitzrc | 4 -
samples/react/quickstart/README.md | 36 -
samples/react/quickstart/index.html | 13 -
samples/react/quickstart/package.json | 23 -
.../react/quickstart/scripts/prepare-dev.cjs | 34 -
samples/react/quickstart/src/App.css | 1069 ----------
samples/react/quickstart/src/App.jsx | 22 -
.../src/components/ConfigNotice.jsx | 164 --
.../quickstart/src/components/HeroCtas.jsx | 15 -
.../react/quickstart/src/components/Nav.jsx | 111 -
.../quickstart/src/components/ThunderMark.jsx | 10 -
.../src/components/icons/ReactLogo.jsx | 16 -
samples/react/quickstart/src/index.css | 9 -
samples/react/quickstart/src/main.jsx | 24 -
.../quickstart/src/pages/AccountPage.jsx | 187 --
.../react/quickstart/src/pages/HomePage.jsx | 254 ---
.../quickstart/src/pages/TokenDebugPage.jsx | 139 --
samples/react/quickstart/vite.config.js | 6 -
samples/vue/quickstart/.env.example | 5 -
samples/vue/quickstart/.stackblitzrc | 4 -
samples/vue/quickstart/README.md | 36 -
samples/vue/quickstart/index.html | 13 -
samples/vue/quickstart/package.json | 20 -
.../vue/quickstart/scripts/prepare-dev.cjs | 34 -
samples/vue/quickstart/src/App.vue | 40 -
.../src/components/ConfigNotice.vue | 157 --
samples/vue/quickstart/src/components/Nav.vue | 85 -
samples/vue/quickstart/src/main.js | 8 -
.../vue/quickstart/src/pages/AccountPage.vue | 146 --
samples/vue/quickstart/src/pages/HomePage.vue | 249 ---
.../quickstart/src/pages/TokenDebugPage.vue | 118 --
samples/vue/quickstart/src/style.css | 1103 ----------
samples/vue/quickstart/vite.config.js | 6 -
tests/e2e/.gitignore | 3 +
tests/e2e/.prettierignore | 3 +
tests/e2e/constants/sample-apps.ts | 36 +-
tests/e2e/pages/browser-quickstart.page.ts | 6 +-
tests/e2e/pages/express-quickstart.page.ts | 6 +-
tests/e2e/run-e2e.sh | 114 +-
tests/e2e/scripts/prepare-samples.ts | 119 ++
.../client-credentials.spec.ts | 8 +-
tests/e2e/tsconfig.json | 2 +-
tests/e2e/utils/jwt/index.ts | 2 +-
186 files changed, 8746 insertions(+), 16641 deletions(-)
create mode 100644 playgrounds/express/.env.example
create mode 100644 playgrounds/express/.gitignore
create mode 100644 playgrounds/express/.prettierignore
create mode 100644 playgrounds/express/README.md
create mode 100644 playgrounds/express/eslint.config.js
create mode 100644 playgrounds/express/package.json
create mode 100644 playgrounds/express/prettier.config.js
create mode 100644 playgrounds/express/public/app.js
create mode 100644 playgrounds/express/public/favicon.svg
create mode 100644 playgrounds/express/public/index.html
create mode 100644 playgrounds/express/public/styles.css
create mode 100644 playgrounds/express/src/catalog.ts
create mode 100644 playgrounds/express/src/config.ts
create mode 100644 playgrounds/express/src/explorer.ts
create mode 100644 playgrounds/express/src/helpers.ts
create mode 100644 playgrounds/express/src/server.ts
create mode 100644 playgrounds/express/src/trace.ts
create mode 100644 playgrounds/express/tsconfig.eslint.json
create mode 100644 playgrounds/express/tsconfig.json
create mode 100644 playgrounds/express/turbo.json
create mode 100644 playgrounds/react/.env.example
create mode 100644 playgrounds/react/.gitignore
create mode 100644 playgrounds/react/README.md
create mode 100644 playgrounds/react/eslint.config.js
create mode 100644 playgrounds/react/index.html
create mode 100644 playgrounds/react/package.json
create mode 100644 playgrounds/react/prettier.config.js
create mode 100644 playgrounds/react/public/favicon.svg
create mode 100644 playgrounds/react/src/App.tsx
create mode 100644 playgrounds/react/src/components/ErrorBoundary.tsx
create mode 100644 playgrounds/react/src/components/EventLog.tsx
create mode 100644 playgrounds/react/src/components/Icons.tsx
create mode 100644 playgrounds/react/src/components/SdkObserver.tsx
create mode 100644 playgrounds/react/src/components/SessionCard.tsx
create mode 100644 playgrounds/react/src/components/TopBar.tsx
create mode 100644 playgrounds/react/src/config.ts
create mode 100644 playgrounds/react/src/lib/events.ts
create mode 100644 playgrounds/react/src/lib/health.ts
create mode 100644 playgrounds/react/src/lib/inspect.ts
create mode 100644 playgrounds/react/src/lib/jwt.ts
create mode 100644 playgrounds/react/src/lib/router.ts
create mode 100644 playgrounds/react/src/lib/sdk.ts
create mode 100644 playgrounds/react/src/lib/session.ts
create mode 100644 playgrounds/react/src/lib/user.ts
create mode 100644 playgrounds/react/src/main.tsx
create mode 100644 playgrounds/react/src/pages/Hooks.tsx
create mode 100644 playgrounds/react/src/pages/Overview.tsx
create mode 100644 playgrounds/react/src/pages/Tokens.tsx
create mode 100644 playgrounds/react/src/pages/capabilities.ts
create mode 100644 playgrounds/react/src/pages/explorer/Explorer.tsx
create mode 100644 playgrounds/react/src/pages/explorer/StoryWidgets.tsx
create mode 100644 playgrounds/react/src/pages/explorer/Workbench.tsx
create mode 100644 playgrounds/react/src/pages/explorer/a11y.ts
create mode 100644 playgrounds/react/src/pages/explorer/stories.tsx
create mode 100644 playgrounds/react/src/pages/explorer/types.ts
create mode 100644 playgrounds/react/src/styles.css
create mode 100644 playgrounds/react/src/vite-env.d.ts
create mode 100644 playgrounds/react/tsconfig.json
create mode 100644 playgrounds/react/vite.config.ts
delete mode 100644 samples/browser/quickstart/.env.example
delete mode 100644 samples/browser/quickstart/.stackblitzrc
delete mode 100644 samples/browser/quickstart/README.md
delete mode 100644 samples/browser/quickstart/index.html
delete mode 100644 samples/browser/quickstart/package.json
delete mode 100644 samples/browser/quickstart/scripts/prepare-dev.cjs
delete mode 100644 samples/browser/quickstart/src/auth.js
delete mode 100644 samples/browser/quickstart/src/components/nav.js
delete mode 100644 samples/browser/quickstart/src/components/profileFields.js
delete mode 100644 samples/browser/quickstart/src/main.js
delete mode 100644 samples/browser/quickstart/src/pages/account.js
delete mode 100644 samples/browser/quickstart/src/pages/home.js
delete mode 100644 samples/browser/quickstart/src/pages/token.js
delete mode 100644 samples/browser/quickstart/src/style.css
delete mode 100644 samples/browser/quickstart/vite.config.js
delete mode 100644 samples/express/quickstart/.env.example
delete mode 100644 samples/express/quickstart/.stackblitzrc
delete mode 100644 samples/express/quickstart/README.md
delete mode 100644 samples/express/quickstart/index.mjs
delete mode 100644 samples/express/quickstart/lib/auth.mjs
delete mode 100644 samples/express/quickstart/lib/expressLogo.mjs
delete mode 100644 samples/express/quickstart/lib/layout.mjs
delete mode 100644 samples/express/quickstart/lib/thunderMark.mjs
delete mode 100644 samples/express/quickstart/package.json
delete mode 100644 samples/express/quickstart/postman/ThunderID-Express-Quickstart.postman_collection.json
delete mode 100644 samples/express/quickstart/public/styles.css
delete mode 100644 samples/express/quickstart/scripts/prepare-dev.cjs
delete mode 100644 samples/nextjs/quickstart/.env.example
delete mode 100644 samples/nextjs/quickstart/.stackblitzrc
delete mode 100644 samples/nextjs/quickstart/README.md
delete mode 100644 samples/nextjs/quickstart/app/account/page.tsx
delete mode 100644 samples/nextjs/quickstart/app/actions.ts
delete mode 100644 samples/nextjs/quickstart/app/api/auth/backchannel-logout/route.ts
delete mode 100644 samples/nextjs/quickstart/app/components/ConfigNotice.tsx
delete mode 100644 samples/nextjs/quickstart/app/components/HeroCtas.tsx
delete mode 100644 samples/nextjs/quickstart/app/components/Nav.tsx
delete mode 100644 samples/nextjs/quickstart/app/components/ThunderMark.tsx
delete mode 100644 samples/nextjs/quickstart/app/components/icons/NextLogo.tsx
delete mode 100644 samples/nextjs/quickstart/app/globals.css
delete mode 100644 samples/nextjs/quickstart/app/layout.tsx
delete mode 100644 samples/nextjs/quickstart/app/not-found.tsx
delete mode 100644 samples/nextjs/quickstart/app/page.tsx
delete mode 100644 samples/nextjs/quickstart/app/signin/page.tsx
delete mode 100644 samples/nextjs/quickstart/app/signup/page.tsx
delete mode 100644 samples/nextjs/quickstart/app/token/page.tsx
delete mode 100644 samples/nextjs/quickstart/next-env.d.ts
delete mode 100644 samples/nextjs/quickstart/next.config.ts
delete mode 100644 samples/nextjs/quickstart/package.json
delete mode 100644 samples/nextjs/quickstart/proxy.ts
delete mode 100644 samples/nextjs/quickstart/scripts/prepare-dev.cjs
delete mode 100644 samples/nextjs/quickstart/tsconfig.json
delete mode 100644 samples/node/quickstart/.env.example
delete mode 100644 samples/node/quickstart/.stackblitzrc
delete mode 100644 samples/node/quickstart/README.md
delete mode 100644 samples/node/quickstart/index.mjs
delete mode 100644 samples/node/quickstart/lib/AuthService.mjs
delete mode 100644 samples/node/quickstart/lib/InventoryService.mjs
delete mode 100644 samples/node/quickstart/lib/ui.mjs
delete mode 100644 samples/node/quickstart/package.json
delete mode 100644 samples/node/quickstart/scripts/prepare-dev.cjs
delete mode 100644 samples/nuxt/quickstart/.env.example
delete mode 100644 samples/nuxt/quickstart/.stackblitzrc
delete mode 100644 samples/nuxt/quickstart/README.md
delete mode 100644 samples/nuxt/quickstart/app/app.vue
delete mode 100644 samples/nuxt/quickstart/app/assets/styles.css
delete mode 100644 samples/nuxt/quickstart/app/components/AppNav.vue
delete mode 100644 samples/nuxt/quickstart/app/components/ConfigNotice.vue
delete mode 100644 samples/nuxt/quickstart/app/pages/account.vue
delete mode 100644 samples/nuxt/quickstart/app/pages/index.vue
delete mode 100644 samples/nuxt/quickstart/app/pages/signin.vue
delete mode 100644 samples/nuxt/quickstart/app/pages/signup.vue
delete mode 100644 samples/nuxt/quickstart/app/pages/token.vue
delete mode 100644 samples/nuxt/quickstart/nuxt.config.ts
delete mode 100644 samples/nuxt/quickstart/package.json
delete mode 100644 samples/nuxt/quickstart/scripts/prepare-dev.cjs
delete mode 100644 samples/react/quickstart/.env.example
delete mode 100644 samples/react/quickstart/.stackblitzrc
delete mode 100644 samples/react/quickstart/README.md
delete mode 100644 samples/react/quickstart/index.html
delete mode 100644 samples/react/quickstart/package.json
delete mode 100644 samples/react/quickstart/scripts/prepare-dev.cjs
delete mode 100644 samples/react/quickstart/src/App.css
delete mode 100644 samples/react/quickstart/src/App.jsx
delete mode 100644 samples/react/quickstart/src/components/ConfigNotice.jsx
delete mode 100644 samples/react/quickstart/src/components/HeroCtas.jsx
delete mode 100644 samples/react/quickstart/src/components/Nav.jsx
delete mode 100644 samples/react/quickstart/src/components/ThunderMark.jsx
delete mode 100644 samples/react/quickstart/src/components/icons/ReactLogo.jsx
delete mode 100644 samples/react/quickstart/src/index.css
delete mode 100644 samples/react/quickstart/src/main.jsx
delete mode 100644 samples/react/quickstart/src/pages/AccountPage.jsx
delete mode 100644 samples/react/quickstart/src/pages/HomePage.jsx
delete mode 100644 samples/react/quickstart/src/pages/TokenDebugPage.jsx
delete mode 100644 samples/react/quickstart/vite.config.js
delete mode 100644 samples/vue/quickstart/.env.example
delete mode 100644 samples/vue/quickstart/.stackblitzrc
delete mode 100644 samples/vue/quickstart/README.md
delete mode 100644 samples/vue/quickstart/index.html
delete mode 100644 samples/vue/quickstart/package.json
delete mode 100644 samples/vue/quickstart/scripts/prepare-dev.cjs
delete mode 100644 samples/vue/quickstart/src/App.vue
delete mode 100644 samples/vue/quickstart/src/components/ConfigNotice.vue
delete mode 100644 samples/vue/quickstart/src/components/Nav.vue
delete mode 100644 samples/vue/quickstart/src/main.js
delete mode 100644 samples/vue/quickstart/src/pages/AccountPage.vue
delete mode 100644 samples/vue/quickstart/src/pages/HomePage.vue
delete mode 100644 samples/vue/quickstart/src/pages/TokenDebugPage.vue
delete mode 100644 samples/vue/quickstart/src/style.css
delete mode 100644 samples/vue/quickstart/vite.config.js
create mode 100644 tests/e2e/scripts/prepare-samples.ts
diff --git a/.github/actions/pack-sample/action.yml b/.github/actions/pack-sample/action.yml
index b995f706..0a573d62 100644
--- a/.github/actions/pack-sample/action.yml
+++ b/.github/actions/pack-sample/action.yml
@@ -6,18 +6,16 @@ description: >
inputs:
sdk:
- description: SDK slug matching the samples/ subdirectory (e.g. react, browser, node)
+ description: SDK slug used to name the zip file (e.g. better-auth)
required: true
version:
description: Version being released (e.g. 1.2.3), used to name the zip file
required: true
path:
description: >
- Sample app directory, relative to the repo root. Defaults to samples//quickstart;
- pass this for a sample that doesn't follow that convention (e.g. an integration sample
- under samples/integrations//).
- required: false
- default: ''
+ Sample app directory, relative to the repo root (e.g. samples/integrations//).
+ The quickstarts live in thunder-id/samples, which releases them itself.
+ required: true
outputs:
archive:
@@ -41,7 +39,7 @@ runs:
PACK_DIR="$(mktemp -d)"
trap 'rm -rf "$PACK_DIR"' EXIT
- cd "${SAMPLE_PATH:-samples/${SDK}/quickstart}"
+ cd "${SAMPLE_PATH}"
pnpm pack --pack-destination "${PACK_DIR}"
cd "${DEST_DIR}"
diff --git a/.github/actions/run-e2e-suite/action.yml b/.github/actions/run-e2e-suite/action.yml
index 69461d92..cc5e8b59 100644
--- a/.github/actions/run-e2e-suite/action.yml
+++ b/.github/actions/run-e2e-suite/action.yml
@@ -1,7 +1,8 @@
name: Run E2E Suite
description: >
- Installs and starts a local ThunderID release, imports the sample apps' OAuth2 clients, starts
- all six sample apps against the workspace's own SDK packages, and runs the Playwright E2E suite
+ Installs and starts a local ThunderID release, imports the sample apps' OAuth2 clients, fetches
+ the quickstarts from thunder-id/samples, starts all six against the workspace's own SDK packages,
+ and runs the Playwright E2E suite
against them. Shared by pr-builder.yml's label-gated `e2e` job and nightly.yml's scheduled
run against the latest ThunderID release β the two differ only in trigger, not in what they run.
Requires the repo to already be checked out (a local composite action can't check itself out).
@@ -44,10 +45,16 @@ runs:
- name: π¨ Build SDK Packages
shell: bash
- # Sample apps consume workspace:* SDK packages β this is what actually puts the PR's
- # changes under test.
+ # prepare-samples.ts packs these builds into the sample apps β this is what actually puts
+ # the PR's changes under test.
run: pnpm build
+ - name: π₯ Fetch Sample Apps
+ shell: bash
+ # Copies each quickstart from thunder-id/samples into tests/e2e/.samples and pins its
+ # @thunderid/* dependencies to tarballs of the packages built above.
+ run: node tests/e2e/scripts/prepare-samples.ts
+
- name: π Install, Set up, and Start ThunderID (Latest, via npx)
id: install
shell: bash
@@ -211,12 +218,10 @@ runs:
shell: bash
# Each app needs its own OAuth2 client wired in before it starts (see the OAuth2 clients
# imported above). The apps' own `prepare-dev.cjs --flow=redirect` helper looks like the
- # natural fit for nextjs/nuxt but isn't: it rewrites the sample's checked-in package.json
- # (workspace:* -> latest, defeating the point of testing this PR's own SDK changes) and
- # blanks any value matching its own placeholders β including the literal string
- # `https://localhost:8090`, which is also our real server URL. Writing each .env directly
- # sidesteps both; variable names come from each app's own scripts/prepare-dev.cjs
- # (native-flow-only vars for nextjs/nuxt are omitted).
+ # natural fit for nextjs/nuxt but isn't: it blanks any value matching its own placeholders,
+ # including the literal string `https://localhost:8090`, which is also our real server URL.
+ # Writing each .env directly sidesteps that; variable names come from each app's own
+ # scripts/prepare-dev.cjs (native-flow-only vars for nextjs/nuxt are omitted).
run: |
NEXTJS_SECRET=$(openssl rand -base64 32)
NUXT_SECRET=$(openssl rand -base64 32)
@@ -226,32 +231,32 @@ runs:
printf '%s\n' "$@" > "$file"
}
- write_env samples/browser/quickstart/.env \
+ write_env tests/e2e/.samples/quickstarts/browser/.env \
"VITE_THUNDERID_BASE_URL=https://localhost:8090" \
"VITE_THUNDERID_CLIENT_ID=JS_SDK_E2E_BROWSER"
- write_env samples/react/quickstart/.env \
+ write_env tests/e2e/.samples/quickstarts/react/.env \
"VITE_THUNDERID_BASE_URL=https://localhost:8090" \
"VITE_THUNDERID_CLIENT_ID=JS_SDK_E2E_REACT"
- write_env samples/vue/quickstart/.env \
+ write_env tests/e2e/.samples/quickstarts/vue/.env \
"VITE_THUNDERID_BASE_URL=https://localhost:8090" \
"VITE_THUNDERID_CLIENT_ID=JS_SDK_E2E_VUE"
- write_env samples/express/quickstart/.env \
+ write_env tests/e2e/.samples/quickstarts/express/.env \
"THUNDERID_BASE_URL=https://localhost:8090" \
"THUNDERID_CLIENT_ID=JS_SDK_E2E_EXPRESS" \
"THUNDERID_CLIENT_SECRET=e2e-express-secret" \
"NODE_TLS_REJECT_UNAUTHORIZED=0"
- write_env samples/nextjs/quickstart/.env \
+ write_env tests/e2e/.samples/quickstarts/nextjs/.env \
"NEXT_PUBLIC_THUNDERID_BASE_URL=https://localhost:8090" \
"NEXT_PUBLIC_THUNDERID_CLIENT_ID=JS_SDK_E2E_NEXTJS" \
"THUNDERID_CLIENT_SECRET=e2e-nextjs-secret" \
"THUNDERID_SECRET=${NEXTJS_SECRET}" \
"NODE_TLS_REJECT_UNAUTHORIZED=0"
- write_env samples/nuxt/quickstart/.env \
+ write_env tests/e2e/.samples/quickstarts/nuxt/.env \
"NUXT_PUBLIC_THUNDERID_BASE_URL=https://localhost:8090" \
"NUXT_PUBLIC_THUNDERID_CLIENT_ID=JS_SDK_E2E_NUXT" \
"THUNDERID_CLIENT_SECRET=e2e-nuxt-secret" \
@@ -261,12 +266,12 @@ runs:
- name: π Start Sample Apps
shell: bash
run: |
- ( cd samples/browser/quickstart && pnpm exec vite --port 5173 & )
- ( cd samples/react/quickstart && pnpm exec vite --port 5174 & )
- ( cd samples/vue/quickstart && pnpm exec vite --port 5175 & )
- ( cd samples/nextjs/quickstart && pnpm exec next dev -p 3001 & )
- ( cd samples/nuxt/quickstart && pnpm exec nuxt dev --port 3002 & )
- ( cd samples/express/quickstart && pnpm exec node --env-file-if-exists=.env index.mjs & )
+ ( cd tests/e2e/.samples/quickstarts/browser && pnpm exec vite --port 5173 & )
+ ( cd tests/e2e/.samples/quickstarts/react && pnpm exec vite --port 5174 & )
+ ( cd tests/e2e/.samples/quickstarts/vue && pnpm exec vite --port 5175 & )
+ ( cd tests/e2e/.samples/quickstarts/nextjs && pnpm exec next dev -p 3001 & )
+ ( cd tests/e2e/.samples/quickstarts/nuxt && pnpm exec nuxt dev --port 3002 & )
+ ( cd tests/e2e/.samples/quickstarts/express && pnpm exec node --env-file-if-exists=.env index.mjs & )
for url in http://localhost:5173 http://localhost:5174 http://localhost:5175 http://localhost:3000 http://localhost:3001 http://localhost:3002; do
ready=0
for i in $(seq 1 60); do
@@ -299,7 +304,7 @@ runs:
NEXTJS_APP_URL: http://localhost:3001
NUXT_APP_URL: http://localhost:3002
EXPRESS_APP_URL: http://localhost:3000
- # node/quickstart has no page to drive β client-credentials.spec.ts spawns it directly
+ # quickstarts/node has no page to drive β client-credentials.spec.ts spawns it directly
# with `env: process.env`, so its credentials just need to be here, not a .env file.
THUNDERID_BASE_URL: https://localhost:8090
THUNDERID_CLIENT_ID: JS_SDK_E2E_NODE
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 0ccf2092..4a223a79 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -330,21 +330,13 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- - name: π¦ Pack Sample
- id: pack
- uses: ./.github/actions/pack-sample
- with:
- sdk: browser
- version: ${{ steps.bump.outputs.version }}
-
- name: π Create GitHub Release
env:
GH_TOKEN: ${{ secrets.THUNDERID_AUTOMATION_BOT }}
run: |
gh release create "sdk/browser/v${{ steps.bump.outputs.version }}" \
--title "ThunderID Browser SDK v${{ steps.bump.outputs.version }}" \
- --generate-notes \
- "${{ steps.pack.outputs.archive }}"
+ --generate-notes
release-node:
name: π’ Release @thunderid/node
@@ -403,21 +395,13 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- - name: π¦ Pack Sample
- id: pack
- uses: ./.github/actions/pack-sample
- with:
- sdk: node
- version: ${{ steps.bump.outputs.version }}
-
- name: π Create GitHub Release
env:
GH_TOKEN: ${{ secrets.THUNDERID_AUTOMATION_BOT }}
run: |
gh release create "sdk/node/v${{ steps.bump.outputs.version }}" \
--title "ThunderID Node SDK v${{ steps.bump.outputs.version }}" \
- --generate-notes \
- "${{ steps.pack.outputs.archive }}"
+ --generate-notes
# ββ Level 2: depends on browser / node βββββββββββββββββββββββββββββββββββββ
release-express:
@@ -477,21 +461,13 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- - name: π¦ Pack Sample
- id: pack
- uses: ./.github/actions/pack-sample
- with:
- sdk: express
- version: ${{ steps.bump.outputs.version }}
-
- name: π Create GitHub Release
env:
GH_TOKEN: ${{ secrets.THUNDERID_AUTOMATION_BOT }}
run: |
gh release create "sdk/express/v${{ steps.bump.outputs.version }}" \
--title "ThunderID Express SDK v${{ steps.bump.outputs.version }}" \
- --generate-notes \
- "${{ steps.pack.outputs.archive }}"
+ --generate-notes
release-react:
name: βοΈ Release @thunderid/react
@@ -550,21 +526,13 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- - name: π¦ Pack Sample
- id: pack
- uses: ./.github/actions/pack-sample
- with:
- sdk: react
- version: ${{ steps.bump.outputs.version }}
-
- name: π Create GitHub Release
env:
GH_TOKEN: ${{ secrets.THUNDERID_AUTOMATION_BOT }}
run: |
gh release create "sdk/react/v${{ steps.bump.outputs.version }}" \
--title "ThunderID React SDK v${{ steps.bump.outputs.version }}" \
- --generate-notes \
- "${{ steps.pack.outputs.archive }}"
+ --generate-notes
release-vue:
name: π© Release @thunderid/vue
@@ -623,21 +591,13 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- - name: π¦ Pack Sample
- id: pack
- uses: ./.github/actions/pack-sample
- with:
- sdk: vue
- version: ${{ steps.bump.outputs.version }}
-
- name: π Create GitHub Release
env:
GH_TOKEN: ${{ secrets.THUNDERID_AUTOMATION_BOT }}
run: |
gh release create "sdk/vue/v${{ steps.bump.outputs.version }}" \
--title "ThunderID Vue SDK v${{ steps.bump.outputs.version }}" \
- --generate-notes \
- "${{ steps.pack.outputs.archive }}"
+ --generate-notes
# ββ Level 3: depends on react / node+react / browser+node+vue ββββββββββββββ
release-react-router:
@@ -827,21 +787,13 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- - name: π¦ Pack Sample
- id: pack
- uses: ./.github/actions/pack-sample
- with:
- sdk: nextjs
- version: ${{ steps.bump.outputs.version }}
-
- name: π Create GitHub Release
env:
GH_TOKEN: ${{ secrets.THUNDERID_AUTOMATION_BOT }}
run: |
gh release create "sdk/nextjs/v${{ steps.bump.outputs.version }}" \
--title "ThunderID Next.js SDK v${{ steps.bump.outputs.version }}" \
- --generate-notes \
- "${{ steps.pack.outputs.archive }}"
+ --generate-notes
release-nuxt:
name: π Release @thunderid/nuxt
@@ -900,18 +852,10 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- - name: π¦ Pack Sample
- id: pack
- uses: ./.github/actions/pack-sample
- with:
- sdk: nuxt
- version: ${{ steps.bump.outputs.version }}
-
- name: π Create GitHub Release
env:
GH_TOKEN: ${{ secrets.THUNDERID_AUTOMATION_BOT }}
run: |
gh release create "sdk/nuxt/v${{ steps.bump.outputs.version }}" \
--title "ThunderID Nuxt SDK v${{ steps.bump.outputs.version }}" \
- --generate-notes \
- "${{ steps.pack.outputs.archive }}"
+ --generate-notes
diff --git a/AGENTS.md b/AGENTS.md
index 796dcbf2..17713347 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -5,8 +5,11 @@
pnpm + Turborepo monorepo of TypeScript SDKs under `packages/`: a framework-agnostic core
(`@thunderid/javascript`), a browser layer (`@thunderid/browser`), a server layer (`@thunderid/node`), and
framework SDKs built on top of those (`react`, `vue`, `nuxt`, `nextjs`, `express`, `react-router`,
-`tanstack-router`). `samples//quickstart` directories contain standalone demo apps β not part of
-any published package.
+`tanstack-router`). The per-framework quickstarts live in
+[thunder-id/samples](https://github.com/thunder-id/samples) and depend on the published packages; the E2E
+suite fetches them into `tests/e2e/.samples` and wires them to this workspace's builds
+(`tests/e2e/scripts/prepare-samples.ts`). `samples/integrations/` holds integration samples that are not
+part of any published package.
## SDK development specification
@@ -112,5 +115,7 @@ packages/
express/ Express middleware
react-router/ React Router integration
tanstack-router/ TanStack Router integration
-samples/ Standalone quickstart demo apps, one per framework
+playgrounds/ Playground apps that exercise the SDKs from source (not published)
+samples/ Integration samples (quickstarts live in thunder-id/samples)
+tests/e2e/ Playwright suite driving the quickstarts against a real ThunderID server
```
diff --git a/README.md b/README.md
index ee766c17..b9bd49f4 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,16 @@ JavaScript SDKs for ThunderID. Provides authentication and user management for b
| [`@thunderid/tanstack-router`](packages/tanstack-router) |  | TanStack Router integration |
| [`@thunderid/better-auth`](packages/better-auth) |  | Better Auth provider helper |
+## Playgrounds
+
+Apps under [`playgrounds/`](playgrounds) exercise the SDKs from the source in this workspace. Build the SDKs with
+`pnpm build`, then start one:
+
+| Playground | Run | What it shows |
+|---|---|---|
+| [Express](playgrounds/express) | `pnpm --filter @thunderid/playground-express dev` | API explorer for the `@thunderid/express` handlers and protected routes, with an SDK trace |
+| [React](playgrounds/react) | `pnpm --filter @thunderid/playground-react dev` | Capability checks, a component explorer for the `@thunderid/react` components, and live hooks and tokens |
+
## Contributing
Please refer to the [Contributing Guide](https://thunderid.dev/community/overview) for the different ways to contribute to this project and the relevant guidelines.
diff --git a/eslint.config.js b/eslint.config.js
index 4eddf2d9..c10c2852 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -5,7 +5,7 @@ import thunderIdPlugin from '@thunderid/eslint-plugin';
export default [
{
- ignores: ['dist/**', 'build/**', 'node_modules/**', 'coverage/**'],
+ ignores: ['dist/**', 'build/**', 'node_modules/**', 'coverage/**', 'tests/e2e/.samples/**'],
},
...thunderIdPlugin.configs.typescript,
{
diff --git a/playgrounds/express/.env.example b/playgrounds/express/.env.example
new file mode 100644
index 00000000..0d7c7a8e
--- /dev/null
+++ b/playgrounds/express/.env.example
@@ -0,0 +1,32 @@
+# Copy to .env and fill in the values of the application you registered in ThunderID.
+
+# ThunderID server.
+THUNDERID_BASE_URL=https://localhost:8090
+
+# OAuth2 client of the application. The client secret is required for a confidential client.
+THUNDERID_CLIENT_ID=
+THUNDERID_CLIENT_SECRET=
+
+# Must match the redirect URIs registered for the application. They default to APP_URL/login and
+# APP_URL/logout, which are the routes handleSignIn() and handleSignOut() are mounted on.
+THUNDERID_AFTER_SIGN_IN_URL=http://localhost:3000/login
+THUNDERID_AFTER_SIGN_OUT_URL=http://localhost:3000/logout
+
+# Scopes to request, separated by spaces.
+THUNDERID_SCOPES=openid profile email
+
+# Only for POST /flow/sign-in (handleFlow()): the application ID, and the flow secret if the
+# application requires one.
+THUNDERID_APPLICATION_ID=
+THUNDERID_FLOW_SECRET=
+
+# Scope that GET /api/admin/users requires in the access token.
+PLAYGROUND_ADMIN_SCOPE=system
+
+# Where the playground listens.
+PORT=3000
+APP_URL=http://localhost:3000
+
+# A local ThunderID server uses a self-signed certificate, which Node rejects. For local
+# development only, turn certificate checks off:
+# NODE_TLS_REJECT_UNAUTHORIZED=0
diff --git a/playgrounds/express/.gitignore b/playgrounds/express/.gitignore
new file mode 100644
index 00000000..327f74d5
--- /dev/null
+++ b/playgrounds/express/.gitignore
@@ -0,0 +1,4 @@
+node_modules
+.env
+.env.*
+!.env.example
diff --git a/playgrounds/express/.prettierignore b/playgrounds/express/.prettierignore
new file mode 100644
index 00000000..99b0b518
--- /dev/null
+++ b/playgrounds/express/.prettierignore
@@ -0,0 +1,4 @@
+/dist
+/build
+/node_modules
+/coverage
diff --git a/playgrounds/express/README.md b/playgrounds/express/README.md
new file mode 100644
index 00000000..320015fa
--- /dev/null
+++ b/playgrounds/express/README.md
@@ -0,0 +1,71 @@
+# Express playground
+
+A playground for `@thunderid/express`, built against the SDK source in this workspace. It runs an Express server that
+mounts the SDK's handlers and a set of example protected routes, and serves an API explorer for calling them.
+
+## What it covers
+
+| Route | Uses |
+| -------------------------- | ----------------------------------------------- |
+| `GET /login` | `handleSignIn()` |
+| `GET /logout` | `handleSignOut()` |
+| `POST /flow/sign-in` | `handleFlow()` (embedded sign-in flow) |
+| `POST /backchannel-logout` | `handleBackchannelLogout()` |
+| `GET /api/session` | `client.isSignedIn()` |
+| `GET /api/me` | `protect()`, `client.getUser()` |
+| `GET /api/id-token` | `protect()`, `client.getDecodedIdToken()` |
+| `POST /api/token/refresh` | `client.refreshAccessToken()` |
+| `GET /api/public` | nothing (baseline) |
+| `GET /api/orders`, `/:id` | `protect()` |
+| `GET /api/admin/users` | `protect()`, a scope check, `client.getUsers()` |
+
+Every route sits behind `thunderID()`, which attaches the client to `req.thunderIDAuth`.
+
+The API explorer at the playground's root lets you:
+
+- pick a route and **send it as** an anonymous caller (no cookie), your signed-in session, or a copy of your session
+ whose access token expired a minute ago;
+- read the response status, body and headers;
+- follow the **SDK trace**: the middleware that ran, the client methods they called, the requests the SDK made to
+ ThunderID and the SDK's own log lines;
+- copy the request as cURL and read the handler's source.
+
+## Run it against a local ThunderID server
+
+1. Start ThunderID (by default at `https://localhost:8090`).
+2. In the ThunderID Console, create an application with an OAuth2 client:
+ - redirect URI `http://localhost:3000/login`, post-logout redirect URI `http://localhost:3000/logout`;
+ - grant types `authorization_code` and, for `POST /api/token/refresh`, `refresh_token`;
+ - a confidential client (note the client secret).
+3. Build the SDKs and configure the playground, from the repository root:
+
+ ```bash
+ pnpm install
+ pnpm build
+ cp playgrounds/express/.env.example playgrounds/express/.env
+ ```
+
+ Fill in `THUNDERID_CLIENT_ID` and `THUNDERID_CLIENT_SECRET` in `.env`. A local ThunderID server uses a self-signed
+ certificate, so for local development also uncomment `NODE_TLS_REJECT_UNAUTHORIZED=0`.
+
+4. Start the playground:
+
+ ```bash
+ pnpm --filter @thunderid/playground-express dev
+ ```
+
+5. Open http://localhost:3000, select **Sign in**, and come back to the explorer.
+
+Node runs `src/server.ts` directly, so the playground needs Node.js 22.18 or later and has no build output. `pnpm build`
+type-checks it against the workspace SDK, and `pnpm --filter @thunderid/express build` picks up SDK changes on the next
+restart.
+
+Without a ThunderID server, the explorer still runs: the top bar shows ThunderID as unreachable and the routes that call
+it answer with the SDK's errors, which the trace shows.
+
+## How the trace works
+
+`@thunderid/express` emits no debug events, so the playground assembles the trace itself (`src/trace.ts`): it routes the
+SDK logger through its `formatter` hook, wraps each route's middleware and handlers, wraps the client methods they call,
+and wraps `fetch`. Requests sent from the explorer carry an `x-playground-trace` header; the steps recorded while
+serving them go back with the response.
diff --git a/playgrounds/express/eslint.config.js b/playgrounds/express/eslint.config.js
new file mode 100644
index 00000000..f329cc06
--- /dev/null
+++ b/playgrounds/express/eslint.config.js
@@ -0,0 +1,30 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+import thunderIdPlugin from '@thunderid/eslint-plugin';
+
+export default [
+ {
+ ignores: ['node_modules/**'],
+ },
+ ...thunderIdPlugin.configs.typescript,
+ {
+ // Node runs src/server.ts directly (type stripping), which needs the `.ts` extension on
+ // relative imports.
+ files: ['src/**/*.ts'],
+ rules: {
+ 'import-x/extensions': 'off',
+ },
+ },
+ {
+ // The explorer UI is plain browser JavaScript, served as is.
+ files: ['public/**/*.js'],
+ languageOptions: {
+ globals: Object.fromEntries(
+ ['URL', 'URLSearchParams', 'document', 'fetch', 'history', 'location', 'navigator', 'setTimeout', 'window'].map(
+ (name) => [name, 'readonly'],
+ ),
+ ),
+ },
+ },
+];
diff --git a/playgrounds/express/package.json b/playgrounds/express/package.json
new file mode 100644
index 00000000..932fed5d
--- /dev/null
+++ b/playgrounds/express/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "@thunderid/playground-express",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "description": "Playground for exercising every @thunderid/express capability, with an API explorer UI.",
+ "scripts": {
+ "dev": "node --env-file-if-exists=.env --watch src/server.ts",
+ "start": "node --env-file-if-exists=.env src/server.ts",
+ "build": "tsc --noEmit",
+ "typecheck": "tsc --noEmit",
+ "lint": "eslint . --ext .js,.ts,.cjs,.mjs",
+ "lint:fix": "eslint . --fix --ext .js,.ts,.cjs,.mjs",
+ "format:check": "prettier --check --cache .",
+ "format:fix": "prettier --write --cache ."
+ },
+ "dependencies": {
+ "@thunderid/express": "workspace:*",
+ "cookie-parser": "^1.4.7",
+ "express": "^5.2.1"
+ },
+ "devDependencies": {
+ "@thunderid/eslint-plugin": "catalog:",
+ "@thunderid/prettier-config": "catalog:",
+ "@types/cookie-parser": "^1.4.8",
+ "@types/express": "^5.0.3",
+ "@types/node": "catalog:",
+ "eslint": "catalog:",
+ "prettier": "catalog:",
+ "typescript": "catalog:"
+ }
+}
diff --git a/playgrounds/express/prettier.config.js b/playgrounds/express/prettier.config.js
new file mode 100644
index 00000000..f054cd50
--- /dev/null
+++ b/playgrounds/express/prettier.config.js
@@ -0,0 +1,6 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+import config from '@thunderid/prettier-config';
+
+export default config;
diff --git a/playgrounds/express/public/app.js b/playgrounds/express/public/app.js
new file mode 100644
index 00000000..9d739e6b
--- /dev/null
+++ b/playgrounds/express/public/app.js
@@ -0,0 +1,404 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+const GROUPS = ['Authentication', 'Session', 'Tokens', 'Protected routes'];
+
+const state = {
+ curlCopied: false,
+ identity: 'anonymous',
+ meta: null,
+ results: {},
+ routeId: null,
+ sending: false,
+ showCurl: false,
+ tab: 'body',
+};
+
+const $ = (id) => document.getElementById(id);
+
+const el = (tag, props = {}, children = []) => {
+ const node = document.createElement(tag);
+ Object.entries(props).forEach(([key, value]) => {
+ if (value === undefined || value === null || value === false) return;
+ if (key === 'text') node.textContent = value;
+ else if (key === 'onClick') node.addEventListener('click', value);
+ else if (key === 'className') node.className = value;
+ else node.setAttribute(key, value === true ? '' : value);
+ });
+ children.forEach((child) => node.append(child));
+ return node;
+};
+
+const LOCK_ICON =
+ ' ';
+
+const currentRoute = () => state.meta?.routes.find((route) => route.id === state.routeId);
+
+const initials = (name) =>
+ name
+ .split(/[\s@._-]+/)
+ .filter(Boolean)
+ .slice(0, 2)
+ .map((part) => part[0].toUpperCase())
+ .join('');
+
+const formatSize = (bytes) => (bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`);
+
+const maskCookie = (text) => text.replace(/(Cookie: [^=]+=)([0-9a-f-]{8})[0-9a-f-]+/gi, '$1$2...');
+
+const identities = () => {
+ const session = state.meta?.session ?? {present: false};
+ const name = session.name ?? 'Signed in';
+ const usable = session.present && session.hasTokens;
+ const reason = 'Sign in first: this identity uses your playground session.';
+ return [
+ {color: 'rgba(255,255,255,0.4)', id: 'anonymous', label: 'Anonymous', sub: 'no session cookie'},
+ {
+ color: '#34d399',
+ disabled: !usable,
+ id: 'session',
+ label: name,
+ reason,
+ sub: usable ? (session.signedIn ? 'your session' : 'your session, token expired') : 'sign in first',
+ },
+ {
+ color: '#f59e0b',
+ disabled: !usable,
+ id: 'expired',
+ label: usable ? name : 'Expired token',
+ reason,
+ sub: 'copy of your session, access token expired',
+ },
+ ];
+};
+
+const renderTopbar = () => {
+ const {meta} = state;
+ $('sdk-label').textContent = `Express Β· ${meta.sdk.name} ${meta.sdk.version}`;
+
+ const appStatus = $('app-status');
+ appStatus.dataset.state = 'ok';
+ appStatus.lastChild.textContent = new URL(meta.app).host;
+
+ const idpStatus = $('idp-status');
+ idpStatus.dataset.state = meta.idp.reachable ? 'ok' : 'down';
+ idpStatus.lastChild.textContent = `idp ${new URL(meta.idp.baseUrl).host}`;
+ idpStatus.title = meta.idp.reachable ? 'ThunderID is reachable' : `ThunderID is unreachable: ${meta.idp.error}`;
+
+ const chip = $('session-chip');
+ chip.replaceChildren();
+ if (meta.session.present && meta.session.hasTokens) {
+ const name = meta.session.name ?? 'Signed in';
+ chip.append(
+ el('div', {className: 'avatar', 'aria-hidden': 'true', text: initials(name)}),
+ el('span', {className: 'session-name'}, [
+ el('span', {text: name}),
+ el('small', {text: meta.session.signedIn ? 'signed in' : 'access token expired'}),
+ ]),
+ el('a', {className: 'btn-link', href: '/logout', text: 'Sign out'}),
+ );
+ } else {
+ chip.append(el('a', {className: 'btn-primary small', href: '/login', text: 'Sign in'}));
+ }
+
+ const notices = $('notices');
+ notices.replaceChildren();
+ const notice = (parts, title) =>
+ notices.append(el('div', {className: 'notice', role: 'status'}, [el('strong', {text: title}), ...parts]));
+ if (!meta.configured) {
+ notice(
+ [
+ el('span', {text: 'Set '}),
+ el('code', {text: 'THUNDERID_CLIENT_ID'}),
+ el('span', {text: ' in .env (copy .env.example) and restart.'}),
+ ],
+ 'Not configured.',
+ );
+ }
+ if (!meta.idp.reachable) {
+ notice(
+ [
+ el('span', {
+ text: `${meta.idp.baseUrl} did not answer: ${meta.idp.error}. See the README for local TLS setup.`,
+ }),
+ ],
+ 'ThunderID unreachable.',
+ );
+ }
+};
+
+const renderRoutes = () => {
+ const container = $('route-groups');
+ container.replaceChildren(
+ ...GROUPS.map((group) => {
+ const items = state.meta.routes.filter((route) => route.group === group);
+ return el('div', {className: 'group'}, [
+ el('div', {className: 'group-name', text: group}),
+ el(
+ 'ul',
+ {},
+ items.map((route) => {
+ const button = el(
+ 'button',
+ {
+ 'aria-current': route.id === state.routeId ? 'true' : 'false',
+ className: 'route-btn',
+ onClick: () => selectRoute(route.id),
+ type: 'button',
+ },
+ [
+ el('span', {className: 'method', 'data-method': route.method, text: route.method}),
+ el('span', {className: 'route-path', text: route.path}),
+ ],
+ );
+ if (route.locked) {
+ const lock = el('span', {className: 'lock', role: 'img', 'aria-label': 'Requires a session'});
+ lock.innerHTML = LOCK_ICON;
+ button.append(lock);
+ }
+ return el('li', {}, [button]);
+ }),
+ ),
+ ]);
+ }),
+ );
+};
+
+const renderRequest = () => {
+ const route = currentRoute();
+ $('ep-method').textContent = route.method;
+ $('ep-method').dataset.method = route.method;
+ $('ep-path').textContent = route.path;
+ $('ep-tags').replaceChildren(
+ ...route.chain.filter((c) => c !== 'handler').map((c) => el('span', {className: 'tag', text: c})),
+ );
+ $('ep-summary').textContent = route.summary;
+ $('ep-desc').textContent = route.description;
+ $('req-content-type').textContent = route.contentType ?? 'no body';
+ $('handler').textContent = route.source;
+
+ $('identities').replaceChildren(
+ ...identities().map((identity) =>
+ el(
+ 'button',
+ {
+ 'aria-checked': state.identity === identity.id ? 'true' : 'false',
+ className: 'identity',
+ disabled: identity.disabled,
+ onClick: () => {
+ state.identity = identity.id;
+ state.showCurl = false;
+ renderRequest();
+ },
+ role: 'radio',
+ title: identity.disabled ? identity.reason : undefined,
+ type: 'button',
+ },
+ [
+ el('span', {className: 'id-dot', style: `background: ${identity.color}`}),
+ el('span', {className: 'id-text'}, [
+ el('span', {className: 'id-label', text: identity.label}),
+ el('span', {className: 'id-sub', text: identity.sub}),
+ ]),
+ ],
+ ),
+ ),
+ );
+
+ $('params-block').hidden = route.params.length === 0;
+ $('params').replaceChildren(
+ ...route.params.map((param) =>
+ el('label', {className: 'param'}, [
+ el('span', {className: 'param-name'}, [
+ el('span', {}, [
+ document.createTextNode(param.name),
+ param.required ? el('span', {className: 'required', text: ' *'}) : '',
+ ]),
+ el('small', {text: `${param.where} Β· ${param.type}`}),
+ ]),
+ el('input', {'data-param': param.name, type: 'text', value: param.value}),
+ ]),
+ ),
+ );
+
+ $('body-block').hidden = !route.body;
+ $('req-body').value = route.body ?? '';
+
+ renderCurl();
+};
+
+const readParams = () =>
+ Object.fromEntries([...document.querySelectorAll('[data-param]')].map((input) => [input.dataset.param, input.value]));
+
+const draftCurl = () => {
+ const route = currentRoute();
+ const params = readParams();
+ let path = route.path;
+ const query = new URLSearchParams();
+ route.params.forEach((param) => {
+ const value = params[param.name] ?? '';
+ if (param.where === 'path') path = path.replace(`:${param.name}`, encodeURIComponent(value));
+ else if (value) query.set(param.name, value);
+ });
+ const lines = [`curl -i -X ${route.method} '${state.meta.app}${path}${query.size ? `?${query}` : ''}'`];
+ if (state.identity !== 'anonymous') lines.push(` -H 'Cookie: ${state.meta.cookieName}='`);
+ if (route.method === 'POST') {
+ if (route.contentType) lines.push(` -H 'Content-Type: ${route.contentType}'`);
+ lines.push(` --data '${$('req-body').value.replace(/\s*\n\s*/g, ' ')}'`);
+ }
+ return lines.join(' \\\n');
+};
+
+const curlText = () => state.results[state.routeId]?.curl ?? draftCurl();
+
+const renderCurl = () => {
+ const button = $('curl-toggle');
+ button.textContent = state.curlCopied ? 'Copied' : state.showCurl ? 'Hide cURL' : 'Copy as cURL';
+ $('curl').hidden = !state.showCurl;
+ $('curl').textContent = state.showCurl ? maskCookie(curlText()) : '';
+};
+
+const renderResponse = () => {
+ const result = state.results[state.routeId];
+ $('send').classList.toggle('sending', state.sending);
+ $('send').disabled = state.sending;
+ $('res-empty').hidden = Boolean(result);
+ $('res-content').hidden = !result;
+ $('res-status').hidden = !result || result.error;
+ $('res-meta').textContent = '';
+
+ document.querySelectorAll('.tabs button').forEach((tab) => {
+ tab.setAttribute('aria-selected', String(tab.dataset.tab === state.tab));
+ });
+ document.querySelectorAll('[data-panel]').forEach((panel) => {
+ panel.hidden = panel.dataset.panel !== state.tab;
+ });
+
+ if (!result) return;
+
+ if (result.error) {
+ $('res-body').textContent = result.error;
+ $('res-headers').replaceChildren();
+ $('res-trace').replaceChildren();
+ return;
+ }
+
+ const badge = $('res-status');
+ badge.textContent = `${result.status} ${result.statusText}`;
+ badge.dataset.class = String(result.status).charAt(0);
+ $('res-meta').textContent = `${result.ms} ms Β· ${formatSize(result.size)}`;
+ $('res-body').textContent = result.body || '(no body)';
+ $('res-headers').replaceChildren(
+ ...result.headers.flatMap(([name, value]) => [el('dt', {text: name}), el('dd', {text: value})]),
+ );
+ $('res-trace').replaceChildren(
+ ...(result.trace.length
+ ? result.trace.map((step) =>
+ el('li', {style: `padding-left: ${step.depth * 20}px`}, [
+ el('div', {className: 'trace-rail'}, [
+ el('span', {className: 'trace-dot', 'data-kind': step.kind}),
+ el('span', {className: 'trace-line'}),
+ ]),
+ el('div', {className: 'trace-main'}, [
+ el('div', {className: 'trace-row'}, [
+ el('span', {className: 'trace-source', text: step.source}),
+ el('span', {className: `trace-step${step.source === 'log' ? '' : ' code'}`, text: step.step}),
+ el('span', {
+ className: 'trace-ms',
+ text: `+${step.at} ms${step.durationMs ? ` Β· ${step.durationMs} ms` : ''}`,
+ }),
+ ]),
+ el('div', {className: 'trace-detail', text: step.detail}),
+ ]),
+ ]),
+ )
+ : [el('li', {className: 'trace-detail', text: 'No SDK steps were recorded for this request.'})]),
+ );
+};
+
+const selectRoute = (id) => {
+ state.routeId = id;
+ state.showCurl = false;
+ state.curlCopied = false;
+ history.replaceState(null, '', `#${id}`);
+ renderRoutes();
+ renderRequest();
+ renderResponse();
+};
+
+const loadMeta = async () => {
+ const response = await fetch('/_playground/meta');
+ state.meta = await response.json();
+ const session = state.meta.session;
+ if (state.identity !== 'anonymous' && !(session.present && session.hasTokens)) state.identity = 'anonymous';
+ renderTopbar();
+};
+
+const send = async () => {
+ if (state.sending) return;
+ state.sending = true;
+ renderResponse();
+ try {
+ const response = await fetch('/_playground/send', {
+ body: JSON.stringify({
+ body: $('req-body').value,
+ identity: state.identity,
+ params: readParams(),
+ routeId: state.routeId,
+ }),
+ headers: {'Content-Type': 'application/json'},
+ method: 'POST',
+ });
+ const payload = await response.json();
+ state.results[state.routeId] = response.ok ? payload : {error: payload.message};
+ } catch (e) {
+ state.results[state.routeId] = {error: `The explorer could not reach the playground server: ${e.message}`};
+ }
+ state.sending = false;
+ await loadMeta();
+ renderRequest();
+ renderResponse();
+};
+
+const copyCurl = async () => {
+ if (state.showCurl) {
+ state.showCurl = false;
+ renderCurl();
+ return;
+ }
+ state.showCurl = true;
+ try {
+ await navigator.clipboard.writeText(curlText());
+ state.curlCopied = true;
+ setTimeout(() => {
+ state.curlCopied = false;
+ renderCurl();
+ }, 1500);
+ } catch {
+ state.curlCopied = false;
+ }
+ renderCurl();
+};
+
+const start = async () => {
+ $('send').addEventListener('click', send);
+ $('curl-toggle').addEventListener('click', copyCurl);
+ window.addEventListener('hashchange', () => {
+ const id = location.hash.slice(1);
+ if (id !== state.routeId && state.meta?.routes.some((route) => route.id === id)) selectRoute(id);
+ });
+ document.querySelectorAll('.tabs button').forEach((tab) => {
+ tab.addEventListener('click', () => {
+ state.tab = tab.dataset.tab;
+ renderResponse();
+ });
+ });
+
+ state.identity = new URLSearchParams(location.search).get('as') ?? 'anonymous';
+ await loadMeta();
+ const fromHash = location.hash.slice(1);
+ const ids = state.meta.routes.map((route) => route.id);
+ selectRoute(ids.includes(fromHash) ? fromHash : (ids.find((id) => id === 'me') ?? ids[0]));
+};
+
+start();
diff --git a/playgrounds/express/public/favicon.svg b/playgrounds/express/public/favicon.svg
new file mode 100644
index 00000000..3fa7c648
--- /dev/null
+++ b/playgrounds/express/public/favicon.svg
@@ -0,0 +1 @@
+
diff --git a/playgrounds/express/public/index.html b/playgrounds/express/public/index.html
new file mode 100644
index 00000000..9b38d30a
--- /dev/null
+++ b/playgrounds/express/public/index.html
@@ -0,0 +1,179 @@
+
+
+
+
+
+
+ Express playground Β· API explorer
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
ThunderID
+
Playground
+
+
+ ex
+ Express
+
+
+ API explorer
+
+
+ app
+ idp
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Request
+
+
+
+
+
+
+ Body
+
+
+
+
+
+
+
+
+
+
+ Send request
+
+
Copy as cURL
+
+
+
+
+
+
+
+
Handler
+ src/server.ts
+
+
+
+
+
+
+
+
Response
+
+
+
+
+ Body
+ Headers
+ SDK trace
+
+
+
+
+
+
+
Send the request to see the response.
+
+ The SDK trace shows each step the middleware took: session lookup, client calls and requests to
+ ThunderID.
+
+
+
+
+
+
+
+
+
diff --git a/playgrounds/express/public/styles.css b/playgrounds/express/public/styles.css
new file mode 100644
index 00000000..8f5243be
--- /dev/null
+++ b/playgrounds/express/public/styles.css
@@ -0,0 +1,897 @@
+/*
+ * Copyright 2026 The ThunderID Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+:root {
+ --bg: #050b18;
+ --surface: rgba(255, 255, 255, 0.02);
+ --surface-2: rgba(255, 255, 255, 0.05);
+ --line: rgba(255, 255, 255, 0.08);
+ --line-strong: rgba(255, 255, 255, 0.1);
+ --text: #fff;
+ --text-2: rgba(255, 255, 255, 0.75);
+ --muted: rgba(255, 255, 255, 0.5);
+ --code: #cfe3ff;
+ --primary: #3688ff;
+ --primary-2: #2060c8;
+ --accent: #8bf9fa;
+ --ok: #34d399;
+ --warn: #f59e0b;
+ --error: #ef4444;
+ --info: #9cc4ff;
+ --mono: 'JetBrains Mono', ui-monospace, monospace;
+ --sans: 'Inter', system-ui, sans-serif;
+ color-scheme: dark;
+}
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+[hidden] {
+ display: none !important;
+}
+
+/* Show code as typed: JetBrains Mono would otherwise render => and === as ligatures. */
+* {
+ font-variant-ligatures: none !important;
+}
+
+body {
+ background: var(--bg);
+ color: var(--text);
+ font-family: var(--sans);
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+button,
+input,
+textarea {
+ font: inherit;
+ color: inherit;
+}
+
+button {
+ cursor: pointer;
+ border: none;
+ background: none;
+}
+
+button:disabled {
+ cursor: not-allowed;
+}
+
+a {
+ color: #6aa8ff;
+ text-decoration: none;
+}
+
+code,
+.mono {
+ font-family: var(--mono);
+}
+
+.muted {
+ color: var(--muted);
+ font-size: 11.5px;
+ font-weight: 500;
+}
+
+.spacer {
+ flex: 1;
+}
+
+/* Top bar */
+
+.topbar {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ flex-wrap: wrap;
+ padding: 10px 20px;
+ border-bottom: 1px solid var(--line);
+ background: rgba(255, 255, 255, 0.015);
+}
+
+.brand {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 15px;
+}
+
+.brand-name {
+ font-weight: 600;
+ letter-spacing: -0.2px;
+}
+
+.brand-sub {
+ color: var(--muted);
+}
+
+.sdk-pill {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 10px;
+ border-radius: 8px;
+ background: var(--surface-2);
+ border: 1px solid var(--line-strong);
+ font-size: 13px;
+ font-weight: 500;
+}
+
+.sdk-badge {
+ width: 18px;
+ height: 18px;
+ border-radius: 5px;
+ background: rgba(255, 255, 255, 0.12);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font: 600 10px/1 var(--mono);
+}
+
+.sections {
+ display: flex;
+ gap: 2px;
+ padding: 3px;
+ border-radius: 9px;
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.sections a {
+ padding: 6px 12px;
+ border-radius: 7px;
+ font-size: 13px;
+ font-weight: 500;
+ color: rgba(255, 255, 255, 0.6);
+}
+
+.sections a[aria-current='page'] {
+ color: var(--text);
+ background: rgba(54, 136, 255, 0.18);
+}
+
+.status {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ font: 500 12px/1 var(--mono);
+ color: rgba(255, 255, 255, 0.6);
+}
+
+.dot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: var(--muted);
+}
+
+.status[data-state='ok'] .dot {
+ background: var(--ok);
+ box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.15);
+}
+
+.status[data-state='down'] .dot {
+ background: var(--error);
+ box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15);
+}
+
+.session-chip {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.avatar {
+ width: 30px;
+ height: 30px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 11px;
+ font-weight: 600;
+ background: linear-gradient(135deg, #22d3ee, #0e7490);
+}
+
+.session-name {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ font-size: 12.5px;
+ font-weight: 500;
+}
+
+.session-name small {
+ font-size: 11px;
+ font-weight: 400;
+ color: var(--muted);
+}
+
+.btn-primary,
+.btn-secondary,
+.btn-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ border-radius: 8px;
+ font-size: 13px;
+ font-weight: 500;
+}
+
+.btn-primary {
+ background: linear-gradient(90deg, var(--primary) 0%, var(--primary-2) 100%);
+ color: #fff;
+ padding: 10px 18px;
+ font-size: 13.5px;
+}
+
+.btn-primary.small {
+ padding: 7px 14px;
+ font-size: 13px;
+}
+
+.btn-secondary {
+ padding: 10px 14px;
+ background: var(--surface-2);
+ border: 1px solid var(--line-strong);
+ color: rgba(255, 255, 255, 0.8);
+}
+
+.btn-link {
+ padding: 7px 10px;
+ color: rgba(255, 255, 255, 0.7);
+ border: 1px solid var(--line-strong);
+}
+
+/* Notices */
+
+.notice {
+ display: flex;
+ gap: 10px;
+ align-items: baseline;
+ padding: 10px 20px;
+ font-size: 13px;
+ line-height: 1.5;
+ border-bottom: 1px solid rgba(245, 158, 11, 0.2);
+ background: rgba(245, 158, 11, 0.07);
+ color: #fcd34d;
+}
+
+.notice code {
+ color: #fde68a;
+ font-size: 12px;
+}
+
+/* Layout */
+
+.layout {
+ flex: 1;
+ display: flex;
+ flex-wrap: wrap;
+ min-height: 0;
+}
+
+.routes {
+ flex: 1 1 280px;
+ max-width: 100%;
+ border-right: 1px solid var(--line);
+ padding: 14px 10px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ background: rgba(255, 255, 255, 0.01);
+}
+
+.routes-intro {
+ padding: 0 6px;
+}
+
+.routes-title {
+ font-size: 14px;
+ font-weight: 600;
+ margin-bottom: 4px;
+}
+
+.routes-intro p {
+ font-size: 12px;
+ line-height: 1.5;
+ color: var(--muted);
+}
+
+.routes-intro code {
+ color: var(--accent);
+}
+
+#route-groups {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.group-name {
+ padding: 0 6px 6px;
+ font-size: 10.5px;
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: rgba(255, 255, 255, 0.45);
+}
+
+.group ul {
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+}
+
+.route-btn {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ padding: 7px 8px;
+ border-radius: 7px;
+ text-align: left;
+ color: rgba(255, 255, 255, 0.72);
+}
+
+.route-btn:hover {
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.route-btn[aria-current='true'] {
+ background: rgba(54, 136, 255, 0.16);
+ color: var(--text);
+}
+
+.route-path {
+ font: 500 12.5px/1.3 var(--mono);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.lock {
+ margin-left: auto;
+ flex: none;
+ opacity: 0.55;
+}
+
+.method {
+ flex: none;
+ display: inline-flex;
+ justify-content: center;
+ width: 42px;
+ padding: 3px 0;
+ font: 600 10.5px/1.2 var(--mono);
+ border-radius: 5px;
+}
+
+.method-big {
+ width: auto;
+ min-width: 58px;
+ padding: 5px 10px;
+ font-size: 12px;
+ border-radius: 6px;
+}
+
+.method[data-method='GET'] {
+ color: #6ee7b7;
+ background: rgba(52, 211, 153, 0.12);
+}
+
+.method[data-method='POST'] {
+ color: #9cc4ff;
+ background: rgba(54, 136, 255, 0.12);
+}
+
+.main {
+ flex: 999 1 640px;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+.endpoint-head {
+ padding: 22px 28px 18px;
+ border-bottom: 1px solid var(--line);
+}
+
+.endpoint-title {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex-wrap: wrap;
+ margin-bottom: 10px;
+}
+
+.endpoint-title h1 {
+ font: 500 20px/1.3 var(--mono);
+ letter-spacing: -0.3px;
+}
+
+.tags {
+ display: inline-flex;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+
+.tag {
+ display: inline-flex;
+ align-items: center;
+ font: 500 11.5px/1 var(--mono);
+ color: var(--accent);
+ background: rgba(139, 249, 250, 0.07);
+ border: 1px solid rgba(139, 249, 250, 0.16);
+ border-radius: 6px;
+ padding: 5px 8px;
+}
+
+.summary {
+ font-size: 14px;
+ color: rgba(255, 255, 255, 0.85);
+ margin-bottom: 4px;
+}
+
+.desc {
+ font-size: 13px;
+ line-height: 1.55;
+ color: rgba(255, 255, 255, 0.55);
+ max-width: 820px;
+}
+
+.columns {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 20px;
+ padding: 20px 28px;
+}
+
+.stack {
+ flex: 1 1 380px;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.card {
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ background: var(--surface);
+}
+
+.card-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--line);
+}
+
+.card-head h2 {
+ font-size: 13px;
+ font-weight: 600;
+}
+
+.card-body {
+ padding: 14px 16px;
+}
+
+.request-body {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.field-label {
+ display: block;
+ font-size: 12px;
+ font-weight: 500;
+ color: rgba(255, 255, 255, 0.65);
+ margin-bottom: 8px;
+}
+
+.identities {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.identity {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ padding: 8px 12px;
+ border-radius: 9px;
+ border: 1px solid var(--line-strong);
+ text-align: left;
+}
+
+.identity[aria-checked='true'] {
+ background: rgba(54, 136, 255, 0.12);
+ border-color: var(--primary);
+}
+
+.identity:disabled {
+ opacity: 0.45;
+}
+
+.identity .id-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex: none;
+}
+
+.identity .id-text {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.identity .id-label {
+ font-size: 12.5px;
+ font-weight: 500;
+}
+
+.identity .id-sub {
+ font-size: 11px;
+ color: var(--muted);
+}
+
+.params {
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ overflow: hidden;
+}
+
+.param {
+ display: grid;
+ grid-template-columns: 140px minmax(0, 1fr);
+ gap: 12px;
+ align-items: center;
+ padding: 9px 12px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.param:last-child {
+ border-bottom: none;
+}
+
+.param-name {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ font: 500 12.5px/1 var(--mono);
+}
+
+.param-name small {
+ font: 400 11px/1 var(--mono);
+ color: rgba(255, 255, 255, 0.45);
+}
+
+.required {
+ color: #f87171;
+}
+
+.param input,
+.body-field textarea {
+ width: 100%;
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid var(--line-strong);
+ border-radius: 8px;
+ padding: 7px 9px;
+ font: 400 12px/1.3 var(--mono);
+ color: var(--code);
+}
+
+.body-field {
+ display: flex;
+ flex-direction: column;
+}
+
+.body-field textarea {
+ resize: vertical;
+ background: rgba(0, 0, 0, 0.3);
+ padding: 10px 12px;
+ font-size: 12.5px;
+ line-height: 1.6;
+}
+
+.actions {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.icon-spin {
+ display: none;
+ animation: pg-spin 0.8s linear infinite;
+}
+
+.sending .icon-spin {
+ display: block;
+}
+
+.sending .icon-play {
+ display: none;
+}
+
+@keyframes pg-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.code-block {
+ padding: 12px 14px;
+ border-radius: 8px;
+ background: rgba(0, 0, 0, 0.35);
+ border: 1px solid var(--line);
+ font: 400 12px/1.6 var(--mono);
+ color: var(--code);
+ white-space: pre-wrap;
+ word-break: break-all;
+}
+
+.source {
+ padding: 14px 16px;
+ font: 400 12.5px/1.65 var(--mono);
+ color: var(--code);
+ white-space: pre;
+ overflow-x: auto;
+}
+
+/* Response */
+
+.response {
+ flex: 1 1 380px;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ min-height: 520px;
+}
+
+.response-head {
+ justify-content: flex-start;
+ flex-wrap: wrap;
+ padding: 10px 16px;
+}
+
+.status-badge {
+ display: inline-flex;
+ padding: 4px 9px;
+ border-radius: 6px;
+ font: 600 12px/1 var(--mono);
+}
+
+.status-badge[data-class='2'] {
+ color: #6ee7b7;
+ background: rgba(52, 211, 153, 0.12);
+}
+
+.status-badge[data-class='3'] {
+ color: #9cc4ff;
+ background: rgba(54, 136, 255, 0.12);
+}
+
+.status-badge[data-class='4'] {
+ color: #fcd34d;
+ background: rgba(245, 158, 11, 0.12);
+}
+
+.status-badge[data-class='5'] {
+ color: #fca5a5;
+ background: rgba(239, 68, 68, 0.12);
+}
+
+.tabs {
+ display: flex;
+ gap: 2px;
+ padding: 3px;
+ border-radius: 8px;
+ background: var(--surface-2);
+}
+
+.tabs button {
+ padding: 5px 10px;
+ border-radius: 6px;
+ font-size: 12px;
+ font-weight: 500;
+ color: rgba(255, 255, 255, 0.6);
+}
+
+.tabs button[aria-selected='true'] {
+ background: rgba(54, 136, 255, 0.2);
+ color: var(--info);
+}
+
+.empty {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ padding: 40px;
+ text-align: center;
+ color: var(--muted);
+ font-size: 12.5px;
+ line-height: 1.5;
+}
+
+.empty p {
+ max-width: 320px;
+}
+
+.empty .empty-title {
+ font-size: 13.5px;
+ color: var(--text-2);
+}
+
+.res-content {
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
+}
+
+.headers {
+ display: grid;
+ grid-template-columns: minmax(140px, auto) minmax(0, 1fr);
+}
+
+.headers dt,
+.headers dd {
+ padding: 9px 16px;
+ font: 400 12px/1.4 var(--mono);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+}
+
+.headers dt {
+ font-weight: 500;
+ color: rgba(255, 255, 255, 0.55);
+}
+
+.headers dd {
+ color: var(--code);
+ word-break: break-all;
+}
+
+.trace {
+ list-style: none;
+ padding: 16px;
+}
+
+.trace li {
+ display: flex;
+ gap: 12px;
+}
+
+.trace-rail {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.trace-dot {
+ width: 10px;
+ height: 10px;
+ margin-top: 4px;
+ flex: none;
+ border-radius: 50%;
+}
+
+.trace-line {
+ width: 1px;
+ flex: 1;
+ background: rgba(255, 255, 255, 0.1);
+}
+
+.trace-dot[data-kind='ok'] {
+ background: var(--ok);
+ box-shadow: 0 0 0 4px rgba(52, 211, 153, 0.13);
+}
+
+.trace-dot[data-kind='warn'] {
+ background: var(--warn);
+ box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.13);
+}
+
+.trace-dot[data-kind='error'] {
+ background: var(--error);
+ box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.13);
+}
+
+.trace-dot[data-kind='info'] {
+ background: var(--info);
+ box-shadow: 0 0 0 4px rgba(156, 196, 255, 0.13);
+}
+
+.trace-main {
+ padding-bottom: 16px;
+ flex: 1;
+ min-width: 0;
+}
+
+.trace-row {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+}
+
+.trace-step {
+ font-size: 13px;
+ color: rgba(255, 255, 255, 0.9);
+ word-break: break-word;
+}
+
+.trace-step.code {
+ font: 500 12.5px/1.4 var(--mono);
+}
+
+.trace-source {
+ flex: none;
+ font: 500 10px/1 var(--mono);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ padding: 3px 5px;
+ border-radius: 4px;
+ color: rgba(255, 255, 255, 0.55);
+ background: rgba(255, 255, 255, 0.06);
+}
+
+.trace-ms {
+ margin-left: auto;
+ font: 500 11.5px/1 var(--mono);
+ color: rgba(255, 255, 255, 0.45);
+ white-space: nowrap;
+}
+
+.trace-detail {
+ font: 400 11.5px/1.5 var(--mono);
+ color: var(--muted);
+ margin-top: 3px;
+ word-break: break-word;
+}
+
+.error-text {
+ padding: 16px;
+ font-size: 13px;
+ color: #fca5a5;
+}
+
+@media (max-width: 720px) {
+ .topbar,
+ .notice {
+ padding-left: 16px;
+ padding-right: 16px;
+ }
+
+ .endpoint-head,
+ .columns {
+ padding-left: 16px;
+ padding-right: 16px;
+ }
+
+ .routes {
+ border-right: none;
+ border-bottom: 1px solid var(--line);
+ }
+
+ .param {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/playgrounds/express/src/catalog.ts b/playgrounds/express/src/catalog.ts
new file mode 100644
index 00000000..742eb532
--- /dev/null
+++ b/playgrounds/express/src/catalog.ts
@@ -0,0 +1,200 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+import config from './config.ts';
+
+export interface RouteParam {
+ name: string;
+ required: boolean;
+ type: string;
+ value: string;
+ where: 'path' | 'query';
+}
+
+export interface RouteSpec {
+ /** Example request body, sent with `contentType`. */
+ body?: string;
+ /** Labels for the route's handlers, in mount order, as they appear in the SDK trace. */
+ chain: string[];
+ contentType?: string;
+ description: string;
+ group: 'Authentication' | 'Session' | 'Tokens' | 'Protected routes';
+ id: string;
+ /** Whether the route needs a session. */
+ locked: boolean;
+ method: 'GET' | 'POST';
+ params: RouteParam[];
+ path: string;
+ /** `#region` names in `src/server.ts` that make up the route's source. */
+ regions: string[];
+ summary: string;
+}
+
+const routes: RouteSpec[] = [
+ {
+ chain: ['handleSignIn()'],
+ description:
+ 'Without a code, creates the session cookie and redirects to the ThunderID authorize endpoint. With a code (the redirect back from ThunderID), exchanges it for tokens and calls onSignIn.',
+ group: 'Authentication',
+ id: 'login',
+ locked: false,
+ method: 'GET',
+ params: [
+ {name: 'code', required: false, type: 'string', value: '', where: 'query'},
+ {name: 'state', required: false, type: 'string', value: '', where: 'query'},
+ ],
+ path: '/login',
+ regions: ['setup', 'login'],
+ summary: 'Start or finish sign-in.',
+ },
+ {
+ chain: ['handleSignOut()'],
+ description:
+ 'Ends the local session, clears the cookie and redirects to the ThunderID end-session endpoint. Sending this as your session signs you out of the playground.',
+ group: 'Authentication',
+ id: 'logout',
+ locked: true,
+ method: 'GET',
+ params: [],
+ path: '/logout',
+ regions: ['logout'],
+ summary: 'Sign out.',
+ },
+ {
+ body: JSON.stringify({applicationId: config.applicationId ?? '', flowType: 'SIGN_IN'}, null, 2),
+ chain: ['handleFlow()'],
+ contentType: 'application/json',
+ description:
+ 'Drives the embedded (app-native) sign-in flow one step at a time. The first call starts the flow; later calls send executionId, challengeToken, authId and inputs.',
+ group: 'Authentication',
+ id: 'flow',
+ locked: false,
+ method: 'POST',
+ params: [],
+ path: '/flow/sign-in',
+ regions: ['flow'],
+ summary: 'Embedded sign-in flow.',
+ },
+ {
+ body: 'logout_token=not-a-real-token',
+ chain: ['handleBackchannelLogout()'],
+ contentType: 'application/x-www-form-urlencoded',
+ description:
+ 'ThunderID posts a signed logout_token here when a session ends elsewhere. The handler verifies it and ends the matching local sessions. An invalid token gets 400.',
+ group: 'Authentication',
+ id: 'backchannel',
+ locked: false,
+ method: 'POST',
+ params: [],
+ path: '/backchannel-logout',
+ regions: ['backchannel'],
+ summary: 'OpenID Connect back-channel logout.',
+ },
+ {
+ chain: ['handler'],
+ description: 'Reports whether the request carries a session cookie and whether the SDK considers it signed in.',
+ group: 'Session',
+ id: 'session',
+ locked: false,
+ method: 'GET',
+ params: [],
+ path: '/api/session',
+ regions: ['session'],
+ summary: 'Session status.',
+ },
+ {
+ chain: ['protect()', 'handler'],
+ description: 'Returns the signed-in user from the session with client.getUser().',
+ group: 'Session',
+ id: 'me',
+ locked: true,
+ method: 'GET',
+ params: [],
+ path: '/api/me',
+ regions: ['unauthenticated', 'me'],
+ summary: 'Current user.',
+ },
+ {
+ chain: ['protect()', 'handler'],
+ description: 'Returns the claims of the session ID token with client.getDecodedIdToken().',
+ group: 'Session',
+ id: 'idToken',
+ locked: true,
+ method: 'GET',
+ params: [],
+ path: '/api/id-token',
+ regions: ['unauthenticated', 'idToken'],
+ summary: 'Decoded ID token.',
+ },
+ {
+ body: '{}',
+ chain: ['handler'],
+ contentType: 'application/json',
+ description:
+ 'Uses the refresh token in the session to get a new access token with client.refreshAccessToken(). Needs a refresh token, so the application must allow the refresh_token grant.',
+ group: 'Tokens',
+ id: 'refresh',
+ locked: true,
+ method: 'POST',
+ params: [],
+ path: '/api/token/refresh',
+ regions: ['unauthenticated', 'refresh'],
+ summary: 'Refresh tokens.',
+ },
+ {
+ chain: ['handler'],
+ description: 'Open to everyone. A baseline that runs no SDK checks.',
+ group: 'Protected routes',
+ id: 'public',
+ locked: false,
+ method: 'GET',
+ params: [],
+ path: '/api/public',
+ regions: ['public'],
+ summary: 'Example public route.',
+ },
+ {
+ chain: ['protect()', 'handler'],
+ description: 'Any signed-in user. protect() answers 401 when the session cookie is missing or not signed in.',
+ group: 'Protected routes',
+ id: 'orders',
+ locked: true,
+ method: 'GET',
+ params: [],
+ path: '/api/orders',
+ regions: ['unauthenticated', 'orders'],
+ summary: 'Example protected route.',
+ },
+ {
+ chain: ['protect()', 'handler'],
+ description: 'A protected route with a path parameter.',
+ group: 'Protected routes',
+ id: 'order',
+ locked: true,
+ method: 'GET',
+ params: [{name: 'id', required: true, type: 'string', value: 'ord_1001', where: 'path'}],
+ path: '/api/orders/:id',
+ regions: ['order'],
+ summary: 'Example protected route with a parameter.',
+ },
+ {
+ chain: ['protect()', `requireScope('${config.adminScope}')`, 'handler'],
+ description: `Requires the ${config.adminScope} scope in the access token, then lists users through the ThunderID management API with client.getUsers().`,
+ group: 'Protected routes',
+ id: 'admin',
+ locked: true,
+ method: 'GET',
+ params: [{name: 'limit', required: false, type: 'number', value: '10', where: 'query'}],
+ path: '/api/admin/users',
+ regions: ['requireScope', 'admin'],
+ summary: 'Example scoped route.',
+ },
+];
+
+const catalog: {chains: () => Map; routes: RouteSpec[]} = {
+ chains: (): Map =>
+ new Map(routes.map((route: RouteSpec): [string, string[]] => [`${route.method} ${route.path}`, route.chain])),
+ routes,
+};
+
+export default catalog;
diff --git a/playgrounds/express/src/config.ts b/playgrounds/express/src/config.ts
new file mode 100644
index 00000000..6319098d
--- /dev/null
+++ b/playgrounds/express/src/config.ts
@@ -0,0 +1,39 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+const env = (name: string): string | undefined => {
+ const value: string | undefined = process.env[name]?.trim();
+ return value === '' ? undefined : value;
+};
+
+const port = Number(env('PORT') ?? 3000);
+const appUrl: string = env('APP_URL') ?? `http://localhost:${port}`;
+
+/** Playground settings, read from the environment (see `.env.example`). */
+const config: {
+ adminScope: string;
+ afterSignInUrl: string;
+ afterSignOutUrl: string;
+ appUrl: string;
+ applicationId: string | undefined;
+ baseUrl: string;
+ clientId: string | undefined;
+ clientSecret: string | undefined;
+ flowSecret: string | undefined;
+ port: number;
+ scopes: string[];
+} = {
+ adminScope: env('PLAYGROUND_ADMIN_SCOPE') ?? 'system',
+ afterSignInUrl: env('THUNDERID_AFTER_SIGN_IN_URL') ?? `${appUrl}/login`,
+ afterSignOutUrl: env('THUNDERID_AFTER_SIGN_OUT_URL') ?? `${appUrl}/logout`,
+ appUrl,
+ applicationId: env('THUNDERID_APPLICATION_ID'),
+ baseUrl: env('THUNDERID_BASE_URL') ?? 'https://localhost:8090',
+ clientId: env('THUNDERID_CLIENT_ID'),
+ clientSecret: env('THUNDERID_CLIENT_SECRET'),
+ flowSecret: env('THUNDERID_FLOW_SECRET'),
+ port,
+ scopes: (env('THUNDERID_SCOPES') ?? 'openid profile email').split(/\s+/),
+};
+
+export default config;
diff --git a/playgrounds/express/src/explorer.ts b/playgrounds/express/src/explorer.ts
new file mode 100644
index 00000000..99aa425e
--- /dev/null
+++ b/playgrounds/express/src/explorer.ts
@@ -0,0 +1,243 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+import {randomUUID} from 'node:crypto';
+import {readFileSync} from 'node:fs';
+import {fileURLToPath} from 'node:url';
+import type {SessionData, ThunderIDExpressClient, User} from '@thunderid/express';
+import express, {type Request, type Response, type Router} from 'express';
+import catalog, {type RouteSpec} from './catalog.ts';
+import config from './config.ts';
+import {clientOf, sessionOf} from './helpers.ts';
+import {newTraceId, takeTrace, TRACE_HEADER, type TraceStep} from './trace.ts';
+
+type Identity = 'anonymous' | 'session' | 'expired';
+
+const SERVER_FILE: URL = new URL('./server.ts', import.meta.url);
+
+const readRegions = (): Map => {
+ const source: string = readFileSync(SERVER_FILE, 'utf8');
+ const regions: Map = new Map();
+ const pattern = /\/\/ #region (\S+)\n([\s\S]*?)\/\/ #endregion/g;
+ let match: RegExpExecArray | null = pattern.exec(source);
+ while (match) {
+ regions.set(match[1], match[2].trimEnd());
+ match = pattern.exec(source);
+ }
+ return regions;
+};
+
+const readSdkVersion = (): string => {
+ const entry: string = fileURLToPath(import.meta.resolve('@thunderid/express'));
+ const manifest: {version: string} = JSON.parse(
+ readFileSync(new URL('../package.json', `file://${entry}`), 'utf8'),
+ ) as {version: string};
+ return manifest.version;
+};
+
+/**
+ * The session record behind a session id. `@thunderid/node` exposes no public read of it (expiry,
+ * refresh token presence), so the playground reaches the client's storage manager directly.
+ */
+const sessionRecord = (client: ThunderIDExpressClient, sessionId: string): Promise =>
+ (client as unknown as {getStorageManager(): {getSessionData(id: string): Promise}})
+ .getStorageManager()
+ .getSessionData(sessionId);
+
+const expiresAt = (data: SessionData | undefined): number | undefined =>
+ data?.created_at && data.expires_in ? data.created_at + parseInt(data.expires_in, 10) * 1000 : undefined;
+
+const displayName = (user: User): string => {
+ const u: Record = user as Record;
+ const given: unknown = u['given_name'] ?? u['givenName'];
+ const family: unknown = u['family_name'] ?? u['familyName'];
+ const named: string = [given, family].filter((part: unknown): part is string => typeof part === 'string').join(' ');
+ const candidates: unknown[] = [u['name'], named, u['username'], u['email'], u['sub']];
+ const found: unknown = candidates.find((value: unknown): boolean => typeof value === 'string' && value !== '');
+ return typeof found === 'string' ? found : 'Signed-in user';
+};
+
+const idpStatus = async (): Promise<{error?: string; reachable: boolean}> => {
+ try {
+ await fetch(`${config.baseUrl}/.well-known/openid-configuration`, {signal: AbortSignal.timeout(1500)});
+ return {reachable: true};
+ } catch (e: unknown) {
+ const cause: unknown = e instanceof Error ? e.cause : undefined;
+ return {error: cause instanceof Error ? cause.message : String(e), reachable: false};
+ }
+};
+
+// One expired copy per real session, rebuilt before every send that uses it.
+const expiredCopies: Map = new Map();
+
+/**
+ * Builds the "expired access token" identity: a copy of the caller's session whose access token
+ * expired a minute ago. The SDK then sees a real expired session, and the caller's own session is
+ * left alone.
+ */
+const expiredCopyOf = async (client: ThunderIDExpressClient, sessionId: string): Promise => {
+ const data: SessionData | undefined = await sessionRecord(client, sessionId);
+ if (!data?.access_token) return undefined;
+
+ const copyId: string = expiredCopies.get(sessionId) ?? randomUUID();
+ expiredCopies.set(sessionId, copyId);
+ const lifetimeSeconds: number = parseInt(data.expires_in, 10) || 3600;
+ await client.setSession({...data, created_at: Date.now() - (lifetimeSeconds + 60) * 1000}, copyId);
+ return copyId;
+};
+
+const shellQuote = (value: string): string => `'${value.replace(/'/g, "'\\''")}'`;
+
+const buildCurl = (method: string, url: string, headers: Record, body?: string): string => {
+ const lines: string[] = [`curl -i -X ${method} ${shellQuote(url)}`];
+ Object.entries(headers).forEach(([name, value]: [string, string]): void => {
+ lines.push(` -H ${shellQuote(`${name}: ${value}`)}`);
+ });
+ if (body) lines.push(` --data ${shellQuote(body)}`);
+ return lines.join(' \\\n');
+};
+
+const prettyBody = (text: string, contentType: string | null): string => {
+ if (!text) return '';
+ if (contentType?.includes('json')) {
+ try {
+ return JSON.stringify(JSON.parse(text), null, 2);
+ } catch {
+ return text;
+ }
+ }
+ return text;
+};
+
+const waitForTrace = async (id: string): Promise => {
+ let steps: TraceStep[] = takeTrace(id);
+ for (let attempt = 0; steps.length === 0 && attempt < 10; attempt += 1) {
+ await new Promise((resolve: (value: unknown) => void): void => {
+ setTimeout(resolve, 10);
+ });
+ steps = takeTrace(id);
+ }
+ return steps;
+};
+
+/** The API explorer's backend: route catalogue, session state and the request runner. */
+const explorer = (): Router => {
+ const router: Router = express.Router();
+ const regions: Map = readRegions();
+ const sdkVersion: string = readSdkVersion();
+
+ router.get('/meta', async (req: Request, res: Response) => {
+ const client: ThunderIDExpressClient = clientOf(req);
+ const sessionId: string | undefined = sessionOf(req);
+ let session: Record = {present: false};
+
+ if (sessionId) {
+ const data: SessionData | undefined = await sessionRecord(client, sessionId);
+ const signedIn: boolean = await client.isSignedIn(sessionId);
+ let name: string | undefined;
+ try {
+ name = data?.id_token ? displayName(await client.getUser(sessionId)) : undefined;
+ } catch {
+ name = undefined;
+ }
+ session = {
+ expiresAt: expiresAt(data),
+ hasRefreshToken: Boolean(data?.refresh_token),
+ hasTokens: Boolean(data?.access_token),
+ name,
+ present: true,
+ scopes: data?.scope?.split(' ') ?? [],
+ signedIn,
+ };
+ }
+
+ res.json({
+ app: config.appUrl,
+ configured: Boolean(config.clientId),
+ cookieName: client.getSessionCookieName(),
+ idp: {baseUrl: config.baseUrl, ...(await idpStatus())},
+ routes: catalog.routes.map((route: RouteSpec) => ({
+ ...route,
+ source: route.regions.map((name: string): string => regions.get(name) ?? '').join('\n\n'),
+ })),
+ sdk: {name: '@thunderid/express', version: sdkVersion},
+ session,
+ });
+ });
+
+ router.post('/send', async (req: Request, res: Response) => {
+ const {body, identity, params, routeId} = (req.body ?? {}) as {
+ body?: string;
+ identity?: Identity;
+ params?: Record;
+ routeId?: string;
+ };
+ const route: RouteSpec | undefined = catalog.routes.find((r: RouteSpec): boolean => r.id === routeId);
+
+ if (!route) {
+ res.status(404).json({message: 'Unknown route.'});
+ return;
+ }
+
+ const client: ThunderIDExpressClient = clientOf(req);
+ const ownSession: string | undefined = sessionOf(req);
+ let sessionId: string | undefined;
+
+ if (identity === 'session' || identity === 'expired') {
+ if (!ownSession) {
+ res.status(409).json({message: 'Sign in first: this identity uses your playground session.'});
+ return;
+ }
+ sessionId = identity === 'session' ? ownSession : await expiredCopyOf(client, ownSession);
+ if (!sessionId) {
+ res.status(409).json({message: 'Your session holds no tokens to expire. Sign in again.'});
+ return;
+ }
+ }
+
+ let path: string = route.path;
+ const query: URLSearchParams = new URLSearchParams();
+ route.params.forEach((param: {name: string; where: string}): void => {
+ const value: string = params?.[param.name] ?? '';
+ if (param.where === 'path') path = path.replace(`:${param.name}`, encodeURIComponent(value));
+ else if (value) query.set(param.name, value);
+ });
+ const url = `${config.appUrl}${path}${query.size ? `?${query.toString()}` : ''}`;
+
+ const headers: Record = {};
+ if (sessionId) headers['Cookie'] = `${client.getSessionCookieName()}=${sessionId}`;
+ const sendBody: string | undefined = route.method === 'POST' ? (body ?? route.body ?? '') : undefined;
+ if (sendBody !== undefined && route.contentType) headers['Content-Type'] = route.contentType;
+
+ const traceId: string = newTraceId();
+ const started: number = performance.now();
+
+ try {
+ const response: globalThis.Response = await fetch(url, {
+ body: sendBody,
+ headers: {...headers, [TRACE_HEADER]: traceId},
+ method: route.method,
+ redirect: 'manual',
+ });
+ const text: string = await response.text();
+ const ms: number = Math.round(performance.now() - started);
+
+ res.json({
+ body: prettyBody(text, response.headers.get('content-type')),
+ curl: buildCurl(route.method, url, headers, sendBody),
+ headers: [...response.headers.entries()],
+ ms,
+ size: Buffer.byteLength(text),
+ status: response.status,
+ statusText: response.statusText,
+ trace: await waitForTrace(traceId),
+ });
+ } catch (e: unknown) {
+ res.status(502).json({message: `The request did not complete: ${e instanceof Error ? e.message : String(e)}`});
+ }
+ });
+
+ return router;
+};
+
+export default explorer;
diff --git a/playgrounds/express/src/helpers.ts b/playgrounds/express/src/helpers.ts
new file mode 100644
index 00000000..e521cc68
--- /dev/null
+++ b/playgrounds/express/src/helpers.ts
@@ -0,0 +1,17 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+import type {ThunderIDExpressClient} from '@thunderid/express';
+import type {Request} from 'express';
+
+/** The client that `thunderID()` attaches to every request. */
+export const clientOf = (req: Request): ThunderIDExpressClient =>
+ (req as Request & {thunderIDAuth: ThunderIDExpressClient}).thunderIDAuth;
+
+/** The session id from the SDK's session cookie, if the request carries one. */
+export const sessionOf = (req: Request): string | undefined => {
+ const cookies: Record | undefined = req.cookies as
+ | Record
+ | undefined;
+ return cookies?.[clientOf(req).getSessionCookieName()];
+};
diff --git a/playgrounds/express/src/server.ts b/playgrounds/express/src/server.ts
new file mode 100644
index 00000000..4f3c755e
--- /dev/null
+++ b/playgrounds/express/src/server.ts
@@ -0,0 +1,194 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+// The API explorer shows each route's source by reading the `#region` blocks of this file, so keep
+// a route's code inside its region.
+
+import {fileURLToPath} from 'node:url';
+import {
+ handleBackchannelLogout,
+ handleFlow,
+ handleSignIn,
+ handleSignOut,
+ protect,
+ thunderID,
+ type ThunderIDRuntimeError,
+ type TokenResponse,
+} from '@thunderid/express';
+import cookieParser from 'cookie-parser';
+import express, {type NextFunction, type Request, type RequestHandler, type Response} from 'express';
+import catalog from './catalog.ts';
+import config from './config.ts';
+import explorer from './explorer.ts';
+import {clientOf, sessionOf} from './helpers.ts';
+import {traceFetch, traceRequests, traceRoutes, traceSdkClient, traceSdkLogs} from './trace.ts';
+
+traceSdkLogs();
+traceFetch();
+
+const app: express.Express = express();
+
+app.use(traceRequests());
+app.use(cookieParser());
+app.use(express.json());
+
+// #region setup
+app.use(
+ thunderID({
+ afterSignInUrl: config.afterSignInUrl,
+ afterSignOutUrl: config.afterSignOutUrl,
+ applicationId: config.applicationId,
+ baseUrl: config.baseUrl,
+ clientId: config.clientId,
+ clientSecret: config.clientSecret,
+ flowSecret: config.flowSecret,
+ onError: (res: Response, error: ThunderIDRuntimeError) =>
+ res.status(500).json({error: error.code, message: error.message}),
+ onSignIn: (res: Response) => res.redirect('/'),
+ onSignOut: (res: Response) => res.redirect('/'),
+ scopes: config.scopes,
+ }),
+);
+// #endregion
+
+app.use(traceSdkClient());
+
+// #region unauthenticated
+const unauthenticated = (res: Response): void => {
+ res
+ .status(401)
+ .set('WWW-Authenticate', 'Bearer realm="playground"')
+ .json({error: 'unauthenticated', message: 'Sign in to access this resource.'});
+};
+// #endregion
+
+// #region requireScope
+// protect() checks for a session only. Scope checks are up to the application: this one reads the
+// `scope` claim of the session's access token.
+const requireScope =
+ (scope: string): RequestHandler =>
+ async (req: Request, res: Response, next: NextFunction): Promise => {
+ const client = clientOf(req);
+ const accessToken: string = await client.getAccessToken(sessionOf(req));
+ let granted: string[] = [];
+ try {
+ const claims = await client.decodeJwtToken<{scope?: string}>(accessToken);
+ granted = claims.scope?.split(' ') ?? [];
+ } catch {
+ // An opaque access token has no claims to read, so it grants nothing here.
+ }
+ if (granted.includes(scope)) {
+ next();
+ return;
+ }
+ res.status(403).json({error: 'insufficient_scope', granted, required: [scope]});
+ };
+// #endregion
+
+// #region login
+app.get('/login', handleSignIn());
+// #endregion
+
+// #region logout
+app.get('/logout', handleSignOut());
+// #endregion
+
+// #region flow
+app.post('/flow/sign-in', handleFlow());
+// #endregion
+
+// #region backchannel
+app.post('/backchannel-logout', handleBackchannelLogout());
+// #endregion
+
+// #region public
+app.get('/api/public', (_req: Request, res: Response) => {
+ res.json({message: 'Anyone can call this route.', time: new Date().toISOString()});
+});
+// #endregion
+
+// #region session
+app.get('/api/session', async (req: Request, res: Response) => {
+ const sessionId = sessionOf(req);
+ const signedIn = sessionId ? await clientOf(req).isSignedIn(sessionId) : false;
+ res.json({sessionCookie: sessionId ? 'present' : 'absent', signedIn});
+});
+// #endregion
+
+// #region me
+app.get('/api/me', protect(unauthenticated), async (req: Request, res: Response) => {
+ res.json(await clientOf(req).getUser(sessionOf(req)));
+});
+// #endregion
+
+// #region idToken
+app.get('/api/id-token', protect(unauthenticated), async (req: Request, res: Response) => {
+ res.json(await clientOf(req).getDecodedIdToken(sessionOf(req)));
+});
+// #endregion
+
+// #region refresh
+// Not behind protect(): a session whose access token expired is exactly the one to refresh.
+app.post('/api/token/refresh', async (req: Request, res: Response) => {
+ const sessionId = sessionOf(req);
+ if (!sessionId) {
+ unauthenticated(res);
+ return;
+ }
+ const tokens = (await clientOf(req).refreshAccessToken(sessionId)) as TokenResponse;
+ res.json({expiresIn: tokens.expiresIn, refreshed: true, scope: tokens.scope, tokenType: tokens.tokenType});
+});
+// #endregion
+
+// #region orders
+const orders = [
+ {id: 'ord_1001', item: 'Noise-cancelling headphones', status: 'shipped', total: 249},
+ {id: 'ord_1002', item: 'Mechanical keyboard', status: 'processing', total: 129},
+];
+
+app.get('/api/orders', protect(unauthenticated), (_req: Request, res: Response) => {
+ res.json({orders});
+});
+// #endregion
+
+// #region order
+app.get('/api/orders/:id', protect(unauthenticated), (req: Request, res: Response) => {
+ const order = orders.find((o) => o.id === req.params['id']);
+ if (!order) {
+ res.status(404).json({error: 'not_found'});
+ return;
+ }
+ res.json(order);
+});
+// #endregion
+
+// #region admin
+app.get(
+ '/api/admin/users',
+ protect(unauthenticated),
+ requireScope(config.adminScope),
+ async (req: Request, res: Response) => {
+ const limit = Number(req.query['limit'] ?? 10);
+ res.json(await clientOf(req).getUsers({limit}, sessionOf(req)));
+ },
+);
+// #endregion
+
+traceRoutes(app.router as unknown as Parameters[0], catalog.chains());
+
+app.use('/_playground', explorer());
+app.use(express.static(fileURLToPath(new URL('../public', import.meta.url))));
+
+// #region errors
+app.use((err: Error & {code?: string; statusCode?: number}, _req: Request, res: Response, next: NextFunction) => {
+ if (res.headersSent) {
+ next(err);
+ return;
+ }
+ res.status(err.statusCode ?? 500).json({error: err.code ?? err.name, message: err.message});
+});
+// #endregion
+
+app.listen(config.port, () => {
+ process.stdout.write(`ThunderID Express playground on ${config.appUrl} (ThunderID at ${config.baseUrl})\n`);
+});
diff --git a/playgrounds/express/src/trace.ts b/playgrounds/express/src/trace.ts
new file mode 100644
index 00000000..ee5a1428
--- /dev/null
+++ b/playgrounds/express/src/trace.ts
@@ -0,0 +1,314 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+/**
+ * Playground-only request tracing. `@thunderid/express` has no debug event stream, so the
+ * playground builds the SDK trace from three sources:
+ *
+ * - the SDK's own logger, through its public `formatter` hook (`logger.configure()`);
+ * - wrappers around the middleware and handlers mounted on each route;
+ * - wrappers around the client methods those handlers call, and around `fetch`, which the SDK
+ * uses for every request to ThunderID.
+ *
+ * Each traced request carries an `x-playground-trace` header set by the explorer. Its steps are
+ * collected in an `AsyncLocalStorage` context and kept briefly so the explorer can read them.
+ */
+
+import {AsyncLocalStorage} from 'node:async_hooks';
+import {randomUUID} from 'node:crypto';
+import {logger, type LogLevel, type ThunderIDExpressClient} from '@thunderid/express';
+import type {NextFunction, Request, RequestHandler, Response} from 'express';
+
+export type TraceKind = 'ok' | 'info' | 'warn' | 'error';
+
+export type TraceSource = 'middleware' | 'client' | 'http' | 'log';
+
+export interface TraceStep {
+ at: number;
+ depth: number;
+ detail: string;
+ durationMs?: number;
+ kind: TraceKind;
+ source: TraceSource;
+ step: string;
+}
+
+interface TraceContext {
+ depth: number;
+ start: number;
+ steps: TraceStep[];
+}
+
+export const TRACE_HEADER = 'x-playground-trace';
+
+const KEEP_TRACES = 50;
+
+const storage: AsyncLocalStorage = new AsyncLocalStorage();
+
+const finished: Map = new Map();
+
+const begin = (step: string, source: TraceSource): ((detail: string, kind: TraceKind) => void) => {
+ const ctx: TraceContext | undefined = storage.getStore();
+
+ if (!ctx) {
+ return (): void => undefined;
+ }
+
+ const startedAt: number = performance.now();
+ const entry: TraceStep = {
+ at: Math.round(startedAt - ctx.start),
+ depth: ctx.depth,
+ detail: '',
+ kind: 'info',
+ source,
+ step,
+ };
+
+ ctx.steps.push(entry);
+ ctx.depth += 1;
+
+ return (detail: string, kind: TraceKind): void => {
+ ctx.depth -= 1;
+ entry.detail = detail;
+ entry.kind = kind;
+ entry.durationMs = Math.round(performance.now() - startedAt);
+ };
+};
+
+const record = (step: string, detail: string, kind: TraceKind, source: TraceSource): void => {
+ begin(step, source)(detail, kind);
+};
+
+const kindForStatus = (status: number): TraceKind => {
+ if (status >= 500) return 'error';
+ if (status >= 400) return 'warn';
+ return 'ok';
+};
+
+/** Starts a trace for requests sent by the explorer. Mount it before everything else. */
+export const traceRequests =
+ (): RequestHandler =>
+ (req: Request, res: Response, next: NextFunction): void => {
+ const id: string | undefined = req.get(TRACE_HEADER);
+
+ if (!id) {
+ next();
+ return;
+ }
+
+ const ctx: TraceContext = {depth: 0, start: performance.now(), steps: []};
+
+ res.on('finish', (): void => {
+ finished.set(id, ctx.steps);
+ if (finished.size > KEEP_TRACES) {
+ finished.delete(finished.keys().next().value!);
+ }
+ });
+
+ storage.run(ctx, next);
+ };
+
+export const newTraceId = (): string => randomUUID();
+
+/** Returns and forgets the steps recorded for a finished request. */
+export const takeTrace = (id: string): TraceStep[] => {
+ const steps: TraceStep[] = finished.get(id) ?? [];
+ finished.delete(id);
+ return steps;
+};
+
+/**
+ * Wraps a middleware or handler so the trace shows whether it passed the request on or answered
+ * it, and with what status.
+ */
+export const traced =
+ (label: string, handler: RequestHandler): RequestHandler =>
+ async (req: Request, res: Response, next: NextFunction): Promise => {
+ const end: (detail: string, kind: TraceKind) => void = begin(label, 'middleware');
+ let passed = false;
+
+ try {
+ await handler(req, res, (err?: unknown): void => {
+ passed = true;
+ if (res.headersSent && !err) {
+ end(`responded ${res.statusCode}, then called next()`, kindForStatus(res.statusCode));
+ next();
+ return;
+ }
+ end(
+ err ? `next(error): ${err instanceof Error ? err.message : 'error'}` : 'passed the request on',
+ err ? 'error' : 'ok',
+ );
+ next(err);
+ });
+ } catch (e: unknown) {
+ end(`threw: ${e instanceof Error ? e.message : String(e)}`, 'error');
+ throw e;
+ }
+
+ if (!passed) {
+ // Some handlers (handleBackchannelLogout()) answer from a callback after they return.
+ if (!res.writableEnded) {
+ await new Promise((resolve: () => void): void => {
+ res.once('finish', resolve);
+ res.once('close', resolve);
+ });
+ }
+ const location: string | undefined = res.get('location');
+ const status: number = res.statusCode;
+ end(location ? `responded ${status}, location ${location}` : `responded ${status}`, kindForStatus(status));
+ }
+ };
+
+/** Route-layer shape of Express 5's router, enough to wrap each handler in place. */
+interface RouterLayer {
+ handle: RequestHandler;
+ route?: {methods: Record; path: string; stack: RouterLayer[]};
+}
+
+/**
+ * Wraps the handlers of every route registered on `app` with {@link traced}, labelling them from
+ * `chains` (keyed by `METHOD path`), so route definitions stay free of tracing code.
+ */
+export const traceRoutes = (router: {stack: RouterLayer[]}, chains: Map): void => {
+ router.stack.forEach((layer: RouterLayer): void => {
+ if (!layer.route) return;
+ const method: string = Object.keys(layer.route.methods)[0]?.toUpperCase() ?? 'GET';
+ const labels: string[] = chains.get(`${method} ${layer.route.path}`) ?? [];
+ layer.route.stack.forEach((routeLayer: RouterLayer, index: number): void => {
+ routeLayer.handle = traced(labels[index] ?? 'handler', routeLayer.handle);
+ });
+ });
+};
+
+const summarize = (value: unknown): string => {
+ if (typeof value === 'boolean') return String(value);
+ if (value === undefined || value === null || value === '') return 'nothing';
+ if (typeof value === 'string') {
+ if (value.startsWith('http')) {
+ const url: URL = new URL(value);
+ return `${url.host}${url.pathname}`;
+ }
+ return `string (${value.length} chars)`;
+ }
+ if (typeof value === 'object') {
+ const keys: string[] = Object.keys(value);
+ return `{${keys.slice(0, 4).join(', ')}${keys.length > 4 ? ', ...' : ''}}`;
+ }
+ return typeof value;
+};
+
+const shortId = (value: unknown): string => (typeof value === 'string' ? `sid=${value.slice(0, 8)}...` : '');
+
+// Client methods to trace, with the position of their session id argument (-1: none).
+const CLIENT_METHODS: Record = {
+ decodeJwtToken: -1,
+ getAccessToken: 0,
+ getDecodedIdToken: 0,
+ getIdToken: 0,
+ getSignInUrl: 1,
+ getUser: 0,
+ getUsers: 1,
+ handleBackchannelLogout: -1,
+ isSignedIn: 0,
+ refreshAccessToken: 0,
+ signOut: 0,
+};
+
+const instrumented: WeakSet = new WeakSet();
+
+/** Wraps the client's public methods on the instance so nested SDK calls show up in the trace. */
+const traceClient = (client: ThunderIDExpressClient): void => {
+ if (instrumented.has(client)) return;
+ instrumented.add(client);
+
+ const target: Record = client as unknown as Record;
+
+ Object.entries(CLIENT_METHODS).forEach(([name, sessionIndex]: [string, number]): void => {
+ const original: unknown = target[name];
+ if (typeof original !== 'function') return;
+
+ target[name] = async (...args: unknown[]): Promise => {
+ const sessionArg: unknown = sessionIndex >= 0 ? args[sessionIndex] : undefined;
+ const end: (detail: string, kind: TraceKind) => void = begin(`client.${name}(${shortId(sessionArg)})`, 'client');
+ try {
+ const result: unknown = await (original as (...a: unknown[]) => Promise).apply(client, args);
+ end(`returned ${summarize(result)}`, result === false ? 'warn' : 'ok');
+ return result;
+ } catch (e: unknown) {
+ end(`threw ${e instanceof Error ? e.message : String(e)}`, 'error');
+ throw e;
+ }
+ };
+ });
+};
+
+/**
+ * Mount right after `thunderID()`: instruments the client it attached and notes the step in the
+ * trace (an app-level middleware is not wrapped by {@link traceRoutes}).
+ */
+export const traceSdkClient =
+ (): RequestHandler =>
+ (req: Request, _res: Response, next: NextFunction): void => {
+ const client: ThunderIDExpressClient | undefined = (req as Request & {thunderIDAuth?: ThunderIDExpressClient})
+ .thunderIDAuth;
+ if (client) traceClient(client);
+ record(
+ 'thunderID()',
+ client ? 'initialised the client and attached it to req.thunderIDAuth' : 'no client attached',
+ client ? 'ok' : 'error',
+ 'middleware',
+ );
+ next();
+ };
+
+const describeBody = (body: unknown): string => {
+ if (typeof body !== 'string') return '';
+ const grant: string | null = new URLSearchParams(body).get('grant_type');
+ return grant ? ` grant_type=${grant}` : '';
+};
+
+/** Wraps `fetch` so the SDK's requests to ThunderID appear in the trace. */
+export const traceFetch = (): void => {
+ const original: typeof fetch = globalThis.fetch;
+
+ globalThis.fetch = async (
+ input: string | URL | globalThis.Request,
+ init?: RequestInit,
+ ): Promise => {
+ if (!storage.getStore()) return original(input, init);
+
+ const url: URL = new URL(typeof input === 'string' || input instanceof URL ? input : input.url);
+ const method: string = init?.method ?? (input instanceof globalThis.Request ? input.method : 'GET');
+ const end: (detail: string, kind: TraceKind) => void = begin(
+ `${method} ${url.host}${url.pathname}${describeBody(init?.body)}`,
+ 'http',
+ );
+
+ try {
+ const response: globalThis.Response = await original(input, init);
+ end(`${response.status} ${response.statusText}`, kindForStatus(response.status));
+ return response;
+ } catch (e: unknown) {
+ const cause: unknown = e instanceof Error ? e.cause : undefined;
+ end(`failed: ${cause instanceof Error ? cause.message : String(e)}`, 'error');
+ throw e;
+ }
+ };
+};
+
+const LOG_KIND: Record = {debug: 'info', error: 'error', info: 'info', warn: 'warn'};
+
+/**
+ * Routes the SDK logger through its `formatter` hook: every line goes to stdout and, for a traced
+ * request, into its trace.
+ */
+export const traceSdkLogs = (): void => {
+ logger.configure({
+ formatter: (level: LogLevel, message: string): void => {
+ process.stdout.write(`[ThunderID SDK] ${level.toUpperCase()} ${message}\n`);
+ record('SDK log', message, LOG_KIND[level], 'log');
+ },
+ level: 'debug',
+ });
+};
diff --git a/playgrounds/express/tsconfig.eslint.json b/playgrounds/express/tsconfig.eslint.json
new file mode 100644
index 00000000..b03733cd
--- /dev/null
+++ b/playgrounds/express/tsconfig.eslint.json
@@ -0,0 +1,8 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "allowJs": true
+ },
+ "include": ["**/*.js", "**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/playgrounds/express/tsconfig.json b/playgrounds/express/tsconfig.json
new file mode 100644
index 00000000..6b0a5b83
--- /dev/null
+++ b/playgrounds/express/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "compilerOptions": {
+ "allowImportingTsExtensions": true,
+ "erasableSyntaxOnly": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "lib": ["ESNext"],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "noEmit": true,
+ "noFallthroughCasesInSwitch": true,
+ "noImplicitOverride": true,
+ "noImplicitReturns": true,
+ "noPropertyAccessFromIndexSignature": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "target": "ESNext",
+ "types": ["node"],
+ "verbatimModuleSyntax": true
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/playgrounds/express/turbo.json b/playgrounds/express/turbo.json
new file mode 100644
index 00000000..b743d5d2
--- /dev/null
+++ b/playgrounds/express/turbo.json
@@ -0,0 +1,9 @@
+{
+ "$schema": "https://turbo.build/schema.json",
+ "extends": ["//"],
+ "tasks": {
+ "build": {
+ "outputs": []
+ }
+ }
+}
diff --git a/playgrounds/react/.env.example b/playgrounds/react/.env.example
new file mode 100644
index 00000000..a8604289
--- /dev/null
+++ b/playgrounds/react/.env.example
@@ -0,0 +1,22 @@
+# Base URL of your ThunderID server.
+VITE_THUNDERID_BASE_URL=https://localhost:8090
+
+# Client ID of the application registered in ThunderID (a public SPA client).
+VITE_THUNDERID_CLIENT_ID=
+
+# Optional: ID of the same application. Needed for the app-native (embedded) sign-in, sign-up,
+# and recovery flows rendered by , , and .
+VITE_THUNDERID_APPLICATION_ID=
+
+# Where ThunderID sends the user after sign-in and sign-out. Register both as redirect URIs.
+VITE_THUNDERID_AFTER_SIGN_IN_URL=http://localhost:5173
+VITE_THUNDERID_AFTER_SIGN_OUT_URL=http://localhost:5173
+
+# Space separated scopes requested at sign-in.
+VITE_THUNDERID_SCOPES=openid profile email
+
+# Optional: token storage. One of sessionStorage, localStorage, browserMemory. Unset uses the SDK default.
+VITE_THUNDERID_STORAGE=
+
+# Optional: URL of the Express playground, linked from the SDK switcher.
+VITE_PLAYGROUND_EXPRESS_URL=http://localhost:3000
diff --git a/playgrounds/react/.gitignore b/playgrounds/react/.gitignore
new file mode 100644
index 00000000..3ea646c5
--- /dev/null
+++ b/playgrounds/react/.gitignore
@@ -0,0 +1,5 @@
+node_modules
+dist
+.env
+.env.local
+.env.*.local
diff --git a/playgrounds/react/README.md b/playgrounds/react/README.md
new file mode 100644
index 00000000..8e1c97c4
--- /dev/null
+++ b/playgrounds/react/README.md
@@ -0,0 +1,70 @@
+# ThunderID React Playground
+
+A Vite app that exercises every capability of `@thunderid/react` against a local ThunderID server. It links the SDK
+packages from this workspace (`workspace:*`), so it always runs the code you are changing. It is not published.
+
+| Page | What it does |
+| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Overview (`#/`) | Capability checks driven by the real SDK API (run one or run all, pass/fail with a reason, filters), a session card with an access token countdown, the provider configuration, and a live SDK event log. |
+| Components (`#/components`) | A component explorer: a searchable tree of the SDK components and their stories, a viewport, theme, and grid toolbar, and Controls, Code, Actions, and Accessibility panels. |
+| Hooks (`#/hooks`) | Live values of `useThunderID`, `useUser`, `useI18n`, `useTranslation`, `useTheme`, `useFlow`, and the management query hooks. |
+| Tokens (`#/tokens`) | The access and ID tokens held by the SDK, decoded locally, with expiry and claim notes. |
+
+## Set up a local ThunderID server
+
+1. Start ThunderID on `https://localhost:8090` (see the [ThunderID docs](https://thunderid.dev)) and sign in to the
+ Console.
+2. Create a single-page (public) application and register these URLs:
+ - Redirect URIs: `http://localhost:5173`
+ - Allowed origins (CORS): `http://localhost:5173`
+3. Note the application's client ID and application ID.
+4. Accept the server's self-signed certificate once by opening `https://localhost:8090` in the browser you use for the
+ playground.
+
+## Run
+
+```bash
+# From the repository root: install and build the SDKs once.
+pnpm install
+pnpm build
+
+# Configure the playground.
+cp playgrounds/react/.env.example playgrounds/react/.env
+# Set VITE_THUNDERID_CLIENT_ID, and VITE_THUNDERID_APPLICATION_ID for the app-native flows.
+
+pnpm --filter @thunderid/playground-react dev
+```
+
+Open http://localhost:5173. After changing SDK source, rebuild that package (for example
+`pnpm --filter @thunderid/react build`) and reload.
+
+| Variable | Purpose |
+| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
+| `VITE_THUNDERID_BASE_URL` | ThunderID server. Defaults to `https://localhost:8090`. |
+| `VITE_THUNDERID_CLIENT_ID` | Client ID of the SPA application. Required for sign-in. |
+| `VITE_THUNDERID_APPLICATION_ID` | Application ID. Needed by ` `, ` `, ` ` and their checks. |
+| `VITE_THUNDERID_AFTER_SIGN_IN_URL`, `VITE_THUNDERID_AFTER_SIGN_OUT_URL` | Redirect targets. Default to the app origin. |
+| `VITE_THUNDERID_SCOPES` | Space separated scopes. Defaults to `openid profile email`. |
+| `VITE_THUNDERID_STORAGE` | `sessionStorage`, `localStorage`, or `browserMemory`. Unset uses the SDK default. |
+| `VITE_PLAYGROUND_EXPRESS_URL` | Where the SDK switcher sends you for the Express playground. Defaults to `http://localhost:3000`. |
+
+Without a server or a client ID the app still runs: checks that need one fail with the reason.
+
+## Checks
+
+Checks that would leave the page (redirect sign-in, sign-out) only run from their own **Run** button; **Run all checks**
+marks them as not run. Results are kept in `sessionStorage` so they survive the sign-in redirect.
+
+## Known SDK gaps
+
+The playground reports these instead of faking them:
+
+- No organization API in `@thunderid/react` (`useOrganization`, `switchOrganization`): the Organizations check is shown
+ as unavailable.
+- No public event subscription. The browser client has `on('sign-in' | 'sign-out' | ...)` hooks, but `ThunderIDProvider`
+ keeps them internal. The event log is built from observed `useThunderID()`/`useUser()` state changes plus the SDK
+ calls the playground makes. Exposing `on()` (sign-in, sign-out, token refreshed, refresh failed, HTTP request
+ start/finish/error) would let apps and this log subscribe directly.
+- No manual token refresh or access token expiry on the context. The session card reads expiry from
+ `getStorageManager().getSessionData()`, and "Renew silently" uses `signInSilently()`.
+- `user`, `signIn`, `signOut`, `signUp`, `recover`, and `getStorageManager` are typed as `any`; see `src/lib/sdk.ts`.
diff --git a/playgrounds/react/eslint.config.js b/playgrounds/react/eslint.config.js
new file mode 100644
index 00000000..f5fd8bcd
--- /dev/null
+++ b/playgrounds/react/eslint.config.js
@@ -0,0 +1,11 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+import thunderIdPlugin from '@thunderid/eslint-plugin';
+
+export default [
+ {
+ ignores: ['dist/**', 'build/**', 'node_modules/**', 'coverage/**'],
+ },
+ ...thunderIdPlugin.configs.react,
+];
diff --git a/playgrounds/react/index.html b/playgrounds/react/index.html
new file mode 100644
index 00000000..396e104d
--- /dev/null
+++ b/playgrounds/react/index.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+ ThunderID React Playground
+
+
+
+
+
+
diff --git a/playgrounds/react/package.json b/playgrounds/react/package.json
new file mode 100644
index 00000000..93ef16f2
--- /dev/null
+++ b/playgrounds/react/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "@thunderid/playground-react",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "description": "Playground for exercising every @thunderid/react capability against a local ThunderID server.",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc --noEmit && vite build",
+ "preview": "vite preview",
+ "typecheck": "tsc --noEmit",
+ "lint": "eslint . --ext .js,.jsx,.ts,.tsx,.cjs,.mjs",
+ "lint:fix": "eslint . --fix --ext .js,.jsx,.ts,.tsx,.cjs,.mjs",
+ "format:check": "prettier --check --cache .",
+ "format:fix": "prettier --write --cache ."
+ },
+ "dependencies": {
+ "@thunderid/browser": "workspace:*",
+ "@thunderid/react": "workspace:*",
+ "react": "catalog:",
+ "react-dom": "catalog:"
+ },
+ "devDependencies": {
+ "@thunderid/eslint-plugin": "catalog:",
+ "@thunderid/prettier-config": "catalog:",
+ "@types/node": "catalog:",
+ "@types/react": "catalog:",
+ "@types/react-dom": "catalog:",
+ "@vitejs/plugin-react": "^4.5.2",
+ "eslint": "catalog:",
+ "prettier": "catalog:",
+ "typescript": "catalog:",
+ "vite": "^6.3.5"
+ }
+}
diff --git a/playgrounds/react/prettier.config.js b/playgrounds/react/prettier.config.js
new file mode 100644
index 00000000..f054cd50
--- /dev/null
+++ b/playgrounds/react/prettier.config.js
@@ -0,0 +1,6 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+import config from '@thunderid/prettier-config';
+
+export default config;
diff --git a/playgrounds/react/public/favicon.svg b/playgrounds/react/public/favicon.svg
new file mode 100644
index 00000000..3fa7c648
--- /dev/null
+++ b/playgrounds/react/public/favicon.svg
@@ -0,0 +1 @@
+
diff --git a/playgrounds/react/src/App.tsx b/playgrounds/react/src/App.tsx
new file mode 100644
index 00000000..5790aabe
--- /dev/null
+++ b/playgrounds/react/src/App.tsx
@@ -0,0 +1,29 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+import {ReactElement} from 'react';
+import SdkObserver from './components/SdkObserver';
+import TopBar from './components/TopBar';
+import {Section, useHashPath} from './lib/router';
+import Explorer from './pages/explorer/Explorer';
+import Hooks from './pages/Hooks';
+import Overview from './pages/Overview';
+import Tokens from './pages/Tokens';
+
+const App = (): ReactElement => {
+ const [first, ...rest] = useHashPath();
+ const section: Section = first === 'components' || first === 'hooks' || first === 'tokens' ? first : 'overview';
+
+ return (
+
+
+
+ {section === 'overview' && }
+ {section === 'components' && }
+ {section === 'hooks' && }
+ {section === 'tokens' && }
+
+ );
+};
+
+export default App;
diff --git a/playgrounds/react/src/components/ErrorBoundary.tsx b/playgrounds/react/src/components/ErrorBoundary.tsx
new file mode 100644
index 00000000..6f533872
--- /dev/null
+++ b/playgrounds/react/src/components/ErrorBoundary.tsx
@@ -0,0 +1,45 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+import {Component, ErrorInfo, ReactNode} from 'react';
+import {logEvent} from '../lib/events';
+
+interface ErrorBoundaryProps {
+ children: ReactNode;
+}
+
+interface ErrorBoundaryState {
+ error: Error | null;
+}
+
+/**
+ * Keeps a throwing story from taking down the whole explorer.
+ */
+class ErrorBoundary extends Component {
+ override state: ErrorBoundaryState = {error: null};
+
+ static getDerivedStateFromError(error: Error): ErrorBoundaryState {
+ return {error};
+ }
+
+ override componentDidCatch(error: Error, info: ErrorInfo): void {
+ logEvent('story:error', `${error.message} ${info.componentStack?.split('\n')[1]?.trim() ?? ''}`, 'error');
+ }
+
+ override render(): ReactNode {
+ const {error} = this.state;
+ const {children} = this.props;
+
+ if (error) {
+ return (
+
+ This story threw: {error.message}
+
+ );
+ }
+
+ return children;
+ }
+}
+
+export default ErrorBoundary;
diff --git a/playgrounds/react/src/components/EventLog.tsx b/playgrounds/react/src/components/EventLog.tsx
new file mode 100644
index 00000000..801bebf3
--- /dev/null
+++ b/playgrounds/react/src/components/EventLog.tsx
@@ -0,0 +1,36 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+import {ReactElement} from 'react';
+import {clearEvents, PlaygroundEvent, useEvents} from '../lib/events';
+
+const EventLog = (): ReactElement => {
+ const events: PlaygroundEvent[] = useEvents();
+
+ return (
+
+
+
SDK events
+
+ Clear
+
+
+
+ {events.map((event: PlaygroundEvent) => (
+
+ {event.time}
+
+ {event.name}
+
+
+ {event.detail}
+
+
+ ))}
+ {events.length === 0 && No events yet. }
+
+
+ );
+};
+
+export default EventLog;
diff --git a/playgrounds/react/src/components/Icons.tsx b/playgrounds/react/src/components/Icons.tsx
new file mode 100644
index 00000000..03b9fd0f
--- /dev/null
+++ b/playgrounds/react/src/components/Icons.tsx
@@ -0,0 +1,152 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+import {ReactElement} from 'react';
+
+/** The ThunderID mark, with the top bar lightened for the playground's dark background. */
+export const Logo = ({height = 26}: {height?: number}): ReactElement => (
+
+
+
+
+
+);
+
+export const Spinner = ({size = 14}: {size?: number}): ReactElement => (
+
+
+
+);
+
+export const Play = (): ReactElement => (
+
+
+
+);
+
+const stroke = {
+ fill: 'none',
+ stroke: 'currentColor',
+ strokeLinecap: 'round' as const,
+ strokeLinejoin: 'round' as const,
+ strokeWidth: 2,
+};
+
+export const Check = (): ReactElement => (
+
+
+
+);
+
+export const Cross = (): ReactElement => (
+
+
+
+
+);
+
+export const Dash = (): ReactElement => (
+
+
+
+);
+
+export const Bang = (): ReactElement => (
+
+
+
+
+);
+
+export const Search = (): ReactElement => (
+
+
+
+
+);
+
+export const Chevron = (): ReactElement => (
+
+
+
+);
+
+export const Grid4 = (): ReactElement => (
+
+
+
+
+
+
+);
+
+export const Desktop = (): ReactElement => (
+
+
+
+
+
+);
+
+export const Tablet = (): ReactElement => (
+
+
+
+
+);
+
+export const Mobile = (): ReactElement => (
+
+
+
+
+);
+
+export const Moon = (): ReactElement => (
+
+
+
+);
+
+export const Sun = (): ReactElement => (
+
+
+
+
+);
+
+export const GridBg = (): ReactElement => (
+
+
+
+
+
+
+
+);
+
+export const Copy = (): ReactElement => (
+
+
+
+
+);
diff --git a/playgrounds/react/src/components/SdkObserver.tsx b/playgrounds/react/src/components/SdkObserver.tsx
new file mode 100644
index 00000000..a2296444
--- /dev/null
+++ b/playgrounds/react/src/components/SdkObserver.tsx
@@ -0,0 +1,67 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+import {useThunderID, useUser} from '@thunderid/react';
+import {ReactElement, useEffect, useRef} from 'react';
+import {logEvent} from '../lib/events';
+import {toUser, SdkUser} from '../lib/sdk';
+import {getDisplayName} from '../lib/user';
+
+/**
+ * Turns changes in the SDK's React state into playground events. @thunderid/react does not expose an
+ * event subscription API, so state transitions observed through `useThunderID()` and `useUser()` are
+ * the closest public signal.
+ */
+const SdkObserver = (): ReactElement | null => {
+ const sdk = useThunderID();
+ const {isInitialized, isSignedIn, isLoading, meta, discovery} = sdk;
+ const user: SdkUser | null = toUser(sdk.user);
+ const {profile} = useUser();
+ const previous = useRef<{isSignedIn: boolean | null}>({isSignedIn: null});
+
+ useEffect(() => {
+ if (isInitialized) logEvent('sdk:initialized', 'ThunderIDProvider is ready', 'ok');
+ }, [isInitialized]);
+
+ useEffect(() => {
+ if (!isInitialized) return;
+
+ const before: boolean | null = previous.current.isSignedIn;
+
+ if (before === null) {
+ logEvent(
+ isSignedIn ? 'session:restored' : 'session:none',
+ isSignedIn ? 'signed in from storage' : 'signed out',
+ 'info',
+ );
+ } else if (before !== isSignedIn) {
+ logEvent(isSignedIn ? 'signIn:completed' : 'signOut:completed', '', 'ok');
+ }
+
+ previous.current.isSignedIn = isSignedIn;
+ }, [isInitialized, isSignedIn]);
+
+ useEffect(() => {
+ logEvent('sdk:loading', isLoading ? 'true' : 'false', 'info');
+ }, [isLoading]);
+
+ useEffect(() => {
+ if (user) logEvent('user:loaded', getDisplayName(user), 'ok');
+ }, [user]);
+
+ useEffect(() => {
+ if (profile) logEvent('profile:loaded', `${Object.keys(profile).length} fields`, 'ok');
+ }, [profile]);
+
+ useEffect(() => {
+ if (meta) logEvent('flowMeta:loaded', 'GET /flow/meta', 'ok');
+ }, [meta]);
+
+ useEffect(() => {
+ if (discovery.wellKnown) logEvent('discovery:loaded', String(discovery.wellKnown.issuer ?? ''), 'ok');
+ }, [discovery.wellKnown]);
+
+ return null;
+};
+
+export default SdkObserver;
diff --git a/playgrounds/react/src/components/SessionCard.tsx b/playgrounds/react/src/components/SessionCard.tsx
new file mode 100644
index 00000000..24cfbf3f
--- /dev/null
+++ b/playgrounds/react/src/components/SessionCard.tsx
@@ -0,0 +1,155 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+import {useThunderID} from '@thunderid/react';
+import {ReactElement, useState} from 'react';
+import {describeError, track} from '../lib/events';
+import {formatClaim, formatDuration} from '../lib/jwt';
+import {href} from '../lib/router';
+import {asText, sdkSignIn, sdkSignOut, toUser, SdkUser} from '../lib/sdk';
+import {SessionSnapshot, useNow, useSession} from '../lib/session';
+import {getDisplayName, getEmail, getInitials} from '../lib/user';
+
+const SessionCard = (): ReactElement => {
+ const sdk = useThunderID();
+ const {isSignedIn, isInitialized, signInSilently} = sdk;
+ const user: SdkUser | null = toUser(sdk.user);
+ const [refreshKey, setRefreshKey] = useState(0);
+ const [busy, setBusy] = useState(null);
+ const [error, setError] = useState(null);
+ const session: SessionSnapshot = useSession(refreshKey);
+ const now: number = useNow();
+
+ const act = async (label: string, call: () => Promise): Promise => {
+ setBusy(label);
+ setError(null);
+
+ try {
+ await track(label, call);
+ setRefreshKey((key: number) => key + 1);
+ } catch (caught) {
+ setError(describeError(caught));
+ } finally {
+ setBusy(null);
+ }
+ };
+
+ const remaining: number | null = session.expiresAt ? session.expiresAt - now : null;
+ const lifetime: number | null = session.expiresAt && session.issuedAt ? session.expiresAt - session.issuedAt : null;
+ const percent: number = remaining !== null && lifetime ? Math.max(0, Math.min(100, (remaining / lifetime) * 100)) : 0;
+ const claims: [string, unknown][] = session.idClaims
+ ? (
+ [
+ ['sub', session.idClaims['sub']],
+ ['scope', session.scope ?? session.idClaims['scope']],
+ ['amr', session.idClaims['amr']],
+ ] as [string, unknown][]
+ ).filter(([, value]: [string, unknown]) => value !== undefined)
+ : [];
+
+ return (
+
+
+
Session
+
+ {isSignedIn ? 'Signed in' : isInitialized ? 'Signed out' : 'Initializing'}
+
+
+
+
+
+ {isSignedIn ? getInitials(user) : '?'}
+
+
+
{isSignedIn ? getDisplayName(user) : 'No active session'}
+
+ {isSignedIn ? (getEmail(user) ?? asText(user?.['sub'])) : 'Sign in to inspect tokens and claims.'}
+
+
+
+
+ {isSignedIn && (
+
+
+ Access token
+
+ {remaining === null
+ ? 'expiry unknown'
+ : remaining > 0
+ ? `expires in ${formatDuration(remaining)}`
+ : 'expired'}
+
+
+
+
+ )}
+
+ {claims.length > 0 && (
+
+ {claims.map(([key, value]: [string, unknown]) => (
+
+
{key}
+ {formatClaim(value)}
+
+ ))}
+
+ )}
+
+ {(error ?? session.error) && (
+
+ {error ?? session.error}
+
+ )}
+
+
+ {isSignedIn ? (
+ <>
+
void act('signInSilently', () => signInSilently())}
+ title="@thunderid/react exposes no manual refresh; signInSilently() renews the session instead"
+ >
+ {busy === 'signInSilently' ? 'Renewing' : 'Renew silently'}
+
+
+ Decode tokens
+
+
void act('signOut', () => sdkSignOut(sdk))}
+ >
+ Sign out
+
+ >
+ ) : (
+
void act('signIn', () => sdkSignIn(sdk))}
+ >
+ Sign in
+
+ )}
+
+
+
+ );
+};
+
+export default SessionCard;
diff --git a/playgrounds/react/src/components/TopBar.tsx b/playgrounds/react/src/components/TopBar.tsx
new file mode 100644
index 00000000..37720f7d
--- /dev/null
+++ b/playgrounds/react/src/components/TopBar.tsx
@@ -0,0 +1,95 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+import {useThunderID} from '@thunderid/react';
+import {ChangeEvent, ReactElement, useEffect} from 'react';
+import {Logo} from './Icons';
+import playgroundConfig from '../config';
+import {checkServerHealth, useServerHealth} from '../lib/health';
+import {href, Section} from '../lib/router';
+import {toUser, SdkUser} from '../lib/sdk';
+import {getDisplayName, getInitials} from '../lib/user';
+
+interface SdkOption {
+ href?: string;
+ id: string;
+ label: string;
+}
+
+const SDK_OPTIONS: SdkOption[] = [
+ {id: 'react', label: 'React Β· @thunderid/react'},
+ {href: playgroundConfig.expressPlaygroundUrl, id: 'express', label: 'Express Β· @thunderid/express'},
+ {id: 'vue', label: 'Vue Β· @thunderid/vue (no playground yet)'},
+ {id: 'nextjs', label: 'Next.js Β· @thunderid/nextjs (no playground yet)'},
+ {id: 'browser', label: 'Browser Β· @thunderid/browser (no playground yet)'},
+];
+
+const NAV: [Section, string, string][] = [
+ ['overview', 'Overview', '#/'],
+ ['components', 'Components', href('components')],
+ ['hooks', 'Hooks', href('hooks')],
+ ['tokens', 'Tokens', href('tokens')],
+];
+
+const TopBar = ({section}: {section: Section}): ReactElement => {
+ const sdk = useThunderID();
+ const {isSignedIn} = sdk;
+ const user: SdkUser | null = toUser(sdk.user);
+ const health = useServerHealth();
+
+ useEffect(() => {
+ void checkServerHealth();
+ }, []);
+
+ const onSdkChange = (event: ChangeEvent): void => {
+ const option: SdkOption | undefined = SDK_OPTIONS.find((sdk: SdkOption) => sdk.id === event.target.value);
+
+ if (option?.href) window.location.assign(option.href);
+ };
+
+ const host: string = playgroundConfig.baseUrl.replace(/^https?:\/\//, '');
+
+ return (
+
+ );
+};
+
+export default TopBar;
diff --git a/playgrounds/react/src/config.ts b/playgrounds/react/src/config.ts
new file mode 100644
index 00000000..3f8935a1
--- /dev/null
+++ b/playgrounds/react/src/config.ts
@@ -0,0 +1,39 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+export type StorageMode = 'sessionStorage' | 'localStorage' | 'browserMemory';
+
+export interface PlaygroundConfig {
+ afterSignInUrl: string;
+ afterSignOutUrl: string;
+ applicationId?: string;
+ baseUrl: string;
+ clientId: string;
+ expressPlaygroundUrl: string;
+ scopes: string[];
+ storage?: StorageMode;
+}
+
+const env: Record = import.meta.env as unknown as Record;
+
+const read = (key: string): string | undefined => {
+ const value: string | undefined = env[key]?.trim();
+
+ return value === '' ? undefined : value;
+};
+
+const readStorage = (value: string | undefined): StorageMode | undefined =>
+ value === 'sessionStorage' || value === 'localStorage' || value === 'browserMemory' ? value : undefined;
+
+const playgroundConfig: PlaygroundConfig = {
+ afterSignInUrl: read('VITE_THUNDERID_AFTER_SIGN_IN_URL') ?? window.location.origin,
+ afterSignOutUrl: read('VITE_THUNDERID_AFTER_SIGN_OUT_URL') ?? window.location.origin,
+ applicationId: read('VITE_THUNDERID_APPLICATION_ID'),
+ baseUrl: (read('VITE_THUNDERID_BASE_URL') ?? 'https://localhost:8090').replace(/\/+$/, ''),
+ clientId: read('VITE_THUNDERID_CLIENT_ID') ?? '',
+ expressPlaygroundUrl: read('VITE_PLAYGROUND_EXPRESS_URL') ?? 'http://localhost:3000',
+ scopes: (read('VITE_THUNDERID_SCOPES') ?? 'openid profile email').split(/\s+/).filter(Boolean),
+ storage: readStorage(read('VITE_THUNDERID_STORAGE')),
+};
+
+export default playgroundConfig;
diff --git a/playgrounds/react/src/lib/events.ts b/playgrounds/react/src/lib/events.ts
new file mode 100644
index 00000000..d2adec3b
--- /dev/null
+++ b/playgrounds/react/src/lib/events.ts
@@ -0,0 +1,81 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+import {useSyncExternalStore} from 'react';
+
+export type EventKind = 'ok' | 'error' | 'info';
+
+export interface PlaygroundEvent {
+ detail: string;
+ id: number;
+ kind: EventKind;
+ name: string;
+ time: string;
+}
+
+const MAX_EVENTS = 200;
+
+let events: PlaygroundEvent[] = [];
+let nextId = 1;
+const listeners = new Set<() => void>();
+
+const emit = (): void => listeners.forEach((listener: () => void) => listener());
+
+export const timestamp = (): string => {
+ const now: Date = new Date();
+
+ return `${now.toTimeString().slice(0, 8)}.${String(now.getMilliseconds()).padStart(3, '0')}`;
+};
+
+export const logEvent = (name: string, detail = '', kind: EventKind = 'info'): void => {
+ events = [{detail, id: nextId, kind, name, time: timestamp()}, ...events].slice(0, MAX_EVENTS);
+ nextId += 1;
+ emit();
+};
+
+export const clearEvents = (): void => {
+ events = [];
+ emit();
+};
+
+const subscribe = (listener: () => void): (() => void) => {
+ listeners.add(listener);
+
+ return () => {
+ listeners.delete(listener);
+ };
+};
+
+const getSnapshot = (): PlaygroundEvent[] => events;
+
+export const useEvents = (): PlaygroundEvent[] => useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
+
+export const describeError = (error: unknown): string => {
+ if (error instanceof Error) return error.message;
+ if (typeof error === 'string') return error;
+
+ try {
+ return JSON.stringify(error);
+ } catch {
+ return String(error);
+ }
+};
+
+/**
+ * Runs an SDK call and records started, completed, and failed events for it. The React SDK has no
+ * public event subscription, so the playground instruments the calls it makes instead.
+ */
+export const track = async (name: string, call: () => Promise, describe?: (result: T) => string): Promise => {
+ logEvent(`${name}:started`, '', 'info');
+
+ try {
+ const result: T = await call();
+
+ logEvent(`${name}:completed`, describe ? describe(result) : '', 'ok');
+
+ return result;
+ } catch (error) {
+ logEvent(`${name}:failed`, describeError(error), 'error');
+ throw error;
+ }
+};
diff --git a/playgrounds/react/src/lib/health.ts b/playgrounds/react/src/lib/health.ts
new file mode 100644
index 00000000..371e3f4d
--- /dev/null
+++ b/playgrounds/react/src/lib/health.ts
@@ -0,0 +1,64 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+import {useSyncExternalStore} from 'react';
+import {describeError, logEvent} from './events';
+import playgroundConfig from '../config';
+
+export interface ServerHealth {
+ checkedAt: number | null;
+ detail: string;
+ state: 'unknown' | 'up' | 'down';
+}
+
+let health: ServerHealth = {checkedAt: null, detail: 'not checked', state: 'unknown'};
+const listeners = new Set<() => void>();
+
+const setHealth = (next: ServerHealth): void => {
+ health = next;
+ listeners.forEach((listener: () => void) => listener());
+};
+
+/**
+ * Probes the ThunderID liveness endpoint. This is a plain fetch, not an SDK call.
+ */
+export const checkServerHealth = async (): Promise => {
+ const url = `${playgroundConfig.baseUrl}/health/liveness`;
+
+ try {
+ const response: Response = await fetch(url, {cache: 'no-store'});
+ const next: ServerHealth = {
+ checkedAt: Date.now(),
+ detail: response.ok ? 'healthy' : `HTTP ${response.status}`,
+ state: response.ok ? 'up' : 'down',
+ };
+
+ setHealth(next);
+ logEvent('server:health', `${next.detail} (${url})`, response.ok ? 'ok' : 'error');
+
+ return next;
+ } catch (error) {
+ const next: ServerHealth = {
+ checkedAt: Date.now(),
+ detail: `unreachable: ${describeError(error)}`,
+ state: 'down',
+ };
+
+ setHealth(next);
+ logEvent('server:health', next.detail, 'error');
+
+ return next;
+ }
+};
+
+const subscribe = (listener: () => void): (() => void) => {
+ listeners.add(listener);
+
+ return () => {
+ listeners.delete(listener);
+ };
+};
+
+const getSnapshot = (): ServerHealth => health;
+
+export const useServerHealth = (): ServerHealth => useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
diff --git a/playgrounds/react/src/lib/inspect.ts b/playgrounds/react/src/lib/inspect.ts
new file mode 100644
index 00000000..93f36b87
--- /dev/null
+++ b/playgrounds/react/src/lib/inspect.ts
@@ -0,0 +1,31 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+/**
+ * Serializes hook values for display: functions become signatures, long strings are shortened, and
+ * deep or circular structures are cut off.
+ */
+export const inspect = (value: unknown, maxDepth = 4): string => {
+ const seen = new WeakSet();
+
+ const walk = (current: unknown, depth: number): unknown => {
+ if (typeof current === 'function') return `Ζ ${current.name || 'anonymous'}()`;
+ if (typeof current === 'string') return current.length > 120 ? `${current.slice(0, 117)}β¦` : current;
+ if (current === null || typeof current !== 'object') return current;
+ if (seen.has(current)) return '[circular]';
+ if (depth >= maxDepth) return Array.isArray(current) ? `[${current.length} items]` : '{β¦}';
+
+ seen.add(current);
+
+ if (Array.isArray(current)) return current.slice(0, 20).map((item: unknown) => walk(item, depth + 1));
+
+ return Object.fromEntries(
+ Object.entries(current as Record).map(([key, item]: [string, unknown]) => [
+ key,
+ walk(item, depth + 1),
+ ]),
+ );
+ };
+
+ return JSON.stringify(walk(value, 0), null, 2) ?? 'undefined';
+};
diff --git a/playgrounds/react/src/lib/jwt.ts b/playgrounds/react/src/lib/jwt.ts
new file mode 100644
index 00000000..4276a0f3
--- /dev/null
+++ b/playgrounds/react/src/lib/jwt.ts
@@ -0,0 +1,47 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+export type JwtClaims = Record;
+
+export interface DecodedJwt {
+ header: JwtClaims;
+ payload: JwtClaims;
+}
+
+const decodeSegment = (segment: string): JwtClaims => {
+ const base64: string = segment.replace(/-/g, '+').replace(/_/g, '/');
+ const padded: string = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
+ const binary: string = atob(padded);
+ const bytes: Uint8Array = Uint8Array.from(binary, (char: string) => char.charCodeAt(0));
+
+ return JSON.parse(new TextDecoder().decode(bytes)) as JwtClaims;
+};
+
+/**
+ * Decodes a JWT without verifying it. Returns null for opaque tokens.
+ */
+export const decodeJwt = (token: string | null | undefined): DecodedJwt | null => {
+ if (!token) return null;
+
+ const parts: string[] = token.split('.');
+
+ if (parts.length !== 3) return null;
+
+ try {
+ return {header: decodeSegment(parts[0]), payload: decodeSegment(parts[1])};
+ } catch {
+ return null;
+ }
+};
+
+export const formatClaim = (value: unknown): string => (typeof value === 'string' ? value : JSON.stringify(value));
+
+export const formatDuration = (ms: number): string => {
+ const total: number = Math.max(0, Math.floor(ms / 1000));
+ const hours: number = Math.floor(total / 3600);
+ const minutes: number = Math.floor((total % 3600) / 60);
+ const seconds: number = total % 60;
+ const mmss = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
+
+ return hours > 0 ? `${hours}:${mmss}` : mmss;
+};
diff --git a/playgrounds/react/src/lib/router.ts b/playgrounds/react/src/lib/router.ts
new file mode 100644
index 00000000..85796fa6
--- /dev/null
+++ b/playgrounds/react/src/lib/router.ts
@@ -0,0 +1,25 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+import {useSyncExternalStore} from 'react';
+
+export type Section = 'overview' | 'components' | 'hooks' | 'tokens';
+
+const subscribe = (listener: () => void): (() => void) => {
+ window.addEventListener('hashchange', listener);
+
+ return () => window.removeEventListener('hashchange', listener);
+};
+
+const getSnapshot = (): string => window.location.hash.replace(/^#\/?/, '');
+
+/**
+ * A tiny hash router. Returns the path segments after `#/`.
+ */
+export const useHashPath = (): string[] => {
+ const hash: string = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
+
+ return hash.split('/').filter(Boolean).map(decodeURIComponent);
+};
+
+export const href = (...segments: string[]): string => `#/${segments.map(encodeURIComponent).join('/')}`;
diff --git a/playgrounds/react/src/lib/sdk.ts b/playgrounds/react/src/lib/sdk.ts
new file mode 100644
index 00000000..e375c31a
--- /dev/null
+++ b/playgrounds/react/src/lib/sdk.ts
@@ -0,0 +1,39 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+import type {ThunderIDContextProps} from '@thunderid/react';
+
+/**
+ * Typed views over the parts of `useThunderID()` that the SDK types as `any`. Each one lists a gap
+ * the playground had to paper over; see the README for the follow-ups.
+ */
+export type SdkUser = Record;
+
+export interface SdkStorageManager {
+ getSessionData: () => Promise | null | undefined>;
+}
+
+export const toUser = (value: unknown): SdkUser | null =>
+ value && typeof value === 'object' ? (value as SdkUser) : null;
+
+export const sdkSignIn = (sdk: ThunderIDContextProps, ...args: unknown[]): Promise =>
+ (sdk.signIn as (...params: unknown[]) => Promise)(...args);
+
+export const sdkSignUp = (sdk: ThunderIDContextProps, ...args: unknown[]): Promise =>
+ (sdk.signUp as (...params: unknown[]) => Promise)(...args);
+
+export const sdkRecover = (sdk: ThunderIDContextProps, payload: unknown): Promise =>
+ (sdk.recover as (params: unknown) => Promise)(payload);
+
+export const sdkSignOut = (sdk: ThunderIDContextProps): Promise => (sdk.signOut as () => Promise)();
+
+export const sdkStorage = async (sdk: ThunderIDContextProps): Promise =>
+ ((await sdk.getStorageManager()) as SdkStorageManager | null) ?? null;
+
+export const asText = (value: unknown): string => {
+ if (value === undefined || value === null) return '';
+ if (typeof value === 'string') return value;
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
+
+ return JSON.stringify(value);
+};
diff --git a/playgrounds/react/src/lib/session.ts b/playgrounds/react/src/lib/session.ts
new file mode 100644
index 00000000..5d0af8b0
--- /dev/null
+++ b/playgrounds/react/src/lib/session.ts
@@ -0,0 +1,99 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+import {useThunderID} from '@thunderid/react';
+import {useEffect, useState} from 'react';
+import {describeError} from './events';
+import {decodeJwt, JwtClaims} from './jwt';
+import {SdkStorageManager} from './sdk';
+
+export interface SessionSnapshot {
+ accessToken: string | null;
+ error: string | null;
+ expiresAt: number | null;
+ idClaims: JwtClaims | null;
+ idToken: string | null;
+ issuedAt: number | null;
+ refreshToken: boolean;
+ scope: string | null;
+}
+
+const EMPTY: SessionSnapshot = {
+ accessToken: null,
+ error: null,
+ expiresAt: null,
+ idClaims: null,
+ idToken: null,
+ issuedAt: null,
+ refreshToken: false,
+ scope: null,
+};
+
+/**
+ * Reads the current session through the SDK: tokens via getAccessToken()/getIdToken(), claims via
+ * getDecodedIdToken(), and expiry from the stored session data.
+ */
+export const useSession = (refreshKey = 0): SessionSnapshot => {
+ const {isSignedIn, getAccessToken, getIdToken, getDecodedIdToken, getStorageManager} = useThunderID();
+ const [snapshot, setSnapshot] = useState(EMPTY);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ const load = async (): Promise => {
+ if (!isSignedIn) return EMPTY;
+
+ try {
+ const [accessToken, idToken, idClaims] = await Promise.all([
+ getAccessToken(),
+ getIdToken(),
+ getDecodedIdToken(),
+ ]);
+ const storage: SdkStorageManager | null = (await getStorageManager()) as SdkStorageManager | null;
+ const data: Record = (await storage?.getSessionData()) ?? {};
+ const createdAt: number | null = typeof data['created_at'] === 'number' ? data['created_at'] : null;
+ const expiresIn = Number(data['expires_in'] ?? 0);
+ const accessExp: unknown = decodeJwt(accessToken)?.payload['exp'];
+ let expiresAt: number | null = null;
+
+ if (createdAt && expiresIn) expiresAt = createdAt + expiresIn * 1000;
+ else if (typeof accessExp === 'number') expiresAt = accessExp * 1000;
+
+ return {
+ accessToken,
+ error: null,
+ expiresAt,
+ idClaims: idClaims as JwtClaims,
+ idToken,
+ issuedAt: createdAt,
+ refreshToken: !!data['refresh_token'],
+ scope: typeof data['scope'] === 'string' ? data['scope'] : null,
+ };
+ } catch (error) {
+ return {...EMPTY, error: describeError(error)};
+ }
+ };
+
+ void load().then((next: SessionSnapshot) => {
+ if (!cancelled) setSnapshot(next);
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [isSignedIn, getAccessToken, getIdToken, getDecodedIdToken, getStorageManager, refreshKey]);
+
+ return snapshot;
+};
+
+export const useNow = (intervalMs = 1000): number => {
+ const [now, setNow] = useState(() => Date.now());
+
+ useEffect(() => {
+ const timer: number = window.setInterval(() => setNow(Date.now()), intervalMs);
+
+ return () => window.clearInterval(timer);
+ }, [intervalMs]);
+
+ return now;
+};
diff --git a/playgrounds/react/src/lib/user.ts b/playgrounds/react/src/lib/user.ts
new file mode 100644
index 00000000..70bf9529
--- /dev/null
+++ b/playgrounds/react/src/lib/user.ts
@@ -0,0 +1,38 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+type UserLike = Record | null | undefined;
+
+const pick = (user: UserLike, keys: string[]): string | undefined => {
+ if (!user) return undefined;
+
+ for (const key of keys) {
+ const value: unknown = user[key];
+
+ if (typeof value === 'string' && value.trim()) return value;
+ }
+
+ return undefined;
+};
+
+export const getDisplayName = (user: UserLike): string => {
+ const given: string | undefined = pick(user, ['given_name', 'givenName', 'firstName']);
+ const family: string | undefined = pick(user, ['family_name', 'familyName', 'lastName']);
+
+ if (given || family) return [given, family].filter(Boolean).join(' ');
+
+ return pick(user, ['name', 'displayName', 'username', 'preferred_username', 'email', 'sub']) ?? 'Anonymous';
+};
+
+export const getEmail = (user: UserLike): string | undefined => pick(user, ['email', 'emailaddress', 'username']);
+
+export const getInitials = (user: UserLike): string => {
+ if (!user) return '?';
+
+ const parts: string[] = getDisplayName(user)
+ .replace(/@.*/, '')
+ .split(/[\s._-]+/)
+ .filter(Boolean);
+
+ return (parts.length > 1 ? parts[0][0] + parts[1][0] : (parts[0] ?? '?').slice(0, 2)).toUpperCase();
+};
diff --git a/playgrounds/react/src/main.tsx b/playgrounds/react/src/main.tsx
new file mode 100644
index 00000000..26e6017b
--- /dev/null
+++ b/playgrounds/react/src/main.tsx
@@ -0,0 +1,25 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+import {ThunderIDProvider} from '@thunderid/react';
+import {StrictMode} from 'react';
+import {createRoot} from 'react-dom/client';
+import App from './App';
+import playgroundConfig from './config';
+import './styles.css';
+
+createRoot(document.getElementById('root')!).render(
+
+
+
+
+ ,
+);
diff --git a/playgrounds/react/src/pages/Hooks.tsx b/playgrounds/react/src/pages/Hooks.tsx
new file mode 100644
index 00000000..6d5ddef0
--- /dev/null
+++ b/playgrounds/react/src/pages/Hooks.tsx
@@ -0,0 +1,217 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+import {
+ useFlow,
+ useGetAgents,
+ useGetApplications,
+ useGetUsers,
+ useI18n,
+ useTheme,
+ useThunderID,
+ useTranslation,
+ useUser,
+} from '@thunderid/react';
+import {ReactElement, ReactNode, useState} from 'react';
+import EventLog from '../components/EventLog';
+import {describeError, track} from '../lib/events';
+import {inspect} from '../lib/inspect';
+import {sdkSignIn, sdkSignOut} from '../lib/sdk';
+
+interface HookCardProps {
+ actions?: ReactNode;
+ children?: ReactNode;
+ name: string;
+ signature: string;
+ value: unknown;
+}
+
+const HookCard = ({actions = null, children = null, name, signature, value}: HookCardProps): ReactElement => (
+
+
+
+ {actions &&
{actions}
}
+
+ {children}
+ {inspect(value)}
+
+);
+
+interface QueryLike {
+ data: unknown;
+ error: Error | null;
+ isLoading: boolean;
+ refetch: () => Promise;
+}
+
+const QueryCard = ({name, query}: {name: string; query: QueryLike}): ReactElement => (
+ void track(name, () => query.refetch()).catch(() => undefined)}
+ >
+ Fetch
+
+ }
+ >
+
+ Management hook. Calls the ThunderID management API with the signed-in user's token.
+
+
+);
+
+const Hooks = (): ReactElement => {
+ const sdk = useThunderID();
+ const user = useUser();
+ const i18n = useI18n();
+ const theme = useTheme();
+ const flow = useFlow();
+ const {t} = useTranslation();
+ const users = useGetUsers({limit: 5}, {enabled: false});
+ const applications = useGetApplications({limit: 5}, {enabled: false});
+ const agents = useGetAgents({limit: 5}, {enabled: false});
+ const [error, setError] = useState(null);
+
+ const call = (label: string, run: () => Promise): void => {
+ setError(null);
+ track(label, run).catch((caught: unknown) => setError(describeError(caught)));
+ };
+
+ const languages: string[] = Object.keys(i18n.bundles);
+
+ return (
+
+
+
+
Hooks
+
+ Live values from every hook @thunderid/react exposes. Functions are shown
+ as signatures; use the buttons to call them.
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ call('signIn', () => sdkSignIn(sdk))}
+ >
+ signIn()
+
+ call('signInSilently', () => sdk.signInSilently())}
+ >
+ signInSilently()
+
+ call('signOut', () => sdkSignOut(sdk))}
+ >
+ signOut()
+
+ >
+ }
+ />
+ call('revalidateProfile', () => user.revalidateProfile())}
+ >
+ revalidateProfile()
+
+ }
+ />
+ (
+ i18n.setLanguage(language)}
+ >
+ {language}
+
+ ))}
+ />
+
+
+ toggleTheme()
+
+ }
+ />
+
+
+
+
+
+
+
+ );
+};
+
+export default Hooks;
diff --git a/playgrounds/react/src/pages/Overview.tsx b/playgrounds/react/src/pages/Overview.tsx
new file mode 100644
index 00000000..227d904a
--- /dev/null
+++ b/playgrounds/react/src/pages/Overview.tsx
@@ -0,0 +1,338 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+import {useI18n, useTheme, useThunderID, useUser} from '@thunderid/react';
+import {ReactElement, useEffect, useRef, useState} from 'react';
+import {
+ CAPABILITIES,
+ Capability,
+ CapabilityContext,
+ CapabilityResult,
+ CapabilityStatus,
+ runCapability,
+} from './capabilities';
+import EventLog from '../components/EventLog';
+import {Bang, Check, Cross, Dash, Play, Spinner} from '../components/Icons';
+import SessionCard from '../components/SessionCard';
+import playgroundConfig from '../config';
+import {logEvent} from '../lib/events';
+import {useServerHealth} from '../lib/health';
+
+interface CapabilityState {
+ note: string;
+ status: CapabilityStatus;
+}
+
+type Filter = 'all' | 'pass' | 'fail' | 'idle' | 'unavailable';
+
+interface LastRun {
+ at: number;
+ durationMs: number;
+}
+
+const RESULTS_KEY = 'playground:react:results';
+
+const readStored = (): {lastRun: LastRun | null; results: Record} => {
+ try {
+ const raw: string | null = sessionStorage.getItem(RESULTS_KEY);
+
+ if (raw) return JSON.parse(raw) as {lastRun: LastRun | null; results: Record};
+ } catch {
+ // Storage can be blocked; start fresh.
+ }
+
+ return {lastRun: null, results: {}};
+};
+
+const writeStored = (results: Record, lastRun: LastRun | null): void => {
+ try {
+ sessionStorage.setItem(RESULTS_KEY, JSON.stringify({lastRun, results}));
+ } catch {
+ // Persisting results is a convenience only.
+ }
+};
+
+const orNotSet = (value: string | undefined): string => (value === undefined || value === '' ? '(not set)' : value);
+
+const matchesFilter = (status: CapabilityStatus, filter: Filter): boolean => {
+ if (filter === 'all') return true;
+ if (filter === 'idle') return status === 'idle' || status === 'skip';
+
+ return status === filter;
+};
+
+const StatusIcon = ({status}: {status: CapabilityStatus}): ReactElement => (
+
+ {status === 'pass' && }
+ {status === 'fail' && }
+ {status === 'running' && }
+ {status === 'unavailable' && }
+ {(status === 'idle' || status === 'skip') && }
+
+);
+
+const timeAgo = (at: number, now: number): string => {
+ const seconds: number = Math.max(0, Math.round((now - at) / 1000));
+
+ if (seconds < 60) return `${seconds} s`;
+ if (seconds < 3600) return `${Math.round(seconds / 60)} min`;
+
+ return `${Math.round(seconds / 3600)} h`;
+};
+
+const Overview = (): ReactElement => {
+ const sdk = useThunderID();
+ const user = useUser();
+ const i18n = useI18n();
+ const theme = useTheme();
+ const health = useServerHealth();
+
+ const [stored] = useState(readStored);
+ const [results, setResults] = useState>(stored.results);
+ const [lastRun, setLastRun] = useState(stored.lastRun);
+ const [filter, setFilter] = useState('all');
+ const [runningAll, setRunningAll] = useState(false);
+ const [now, setNow] = useState(() => Date.now());
+
+ const contextRef = useRef({i18n, sdk, theme, user});
+
+ useEffect(() => {
+ contextRef.current = {i18n, sdk, theme, user};
+ });
+
+ useEffect(() => {
+ writeStored(results, lastRun);
+ }, [results, lastRun]);
+
+ useEffect(() => {
+ const timer: number = window.setInterval(() => setNow(Date.now()), 15000);
+
+ return () => window.clearInterval(timer);
+ }, []);
+
+ const getContext = (): CapabilityContext => contextRef.current;
+
+ const statusOf = (id: string): CapabilityStatus => results[id]?.status ?? 'idle';
+
+ const setOne = (id: string, state: CapabilityState): void =>
+ setResults((previous: Record) => ({...previous, [id]: state}));
+
+ const runOne = async (capability: Capability, interactive: boolean): Promise => {
+ setOne(capability.id, {note: 'runningβ¦', status: 'running'});
+
+ const result: CapabilityResult = await runCapability(capability, getContext, {interactive});
+
+ setOne(capability.id, result);
+ logEvent(`check:${capability.id}`, `${result.status}: ${result.note}`, result.status === 'fail' ? 'error' : 'ok');
+
+ return result;
+ };
+
+ const runAll = async (): Promise => {
+ if (runningAll) return;
+
+ setRunningAll(true);
+ logEvent('checks:started', `${CAPABILITIES.length} capabilities`, 'info');
+
+ const started: number = Date.now();
+
+ setResults(
+ Object.fromEntries(
+ CAPABILITIES.map((capability: Capability) => [capability.id, {note: 'queued', status: 'running'}]),
+ ),
+ );
+
+ for (const capability of CAPABILITIES) {
+ // Sequential on purpose: several checks depend on the state earlier ones settle.
+ await runOne(capability, false);
+ }
+
+ const finished: number = Date.now();
+
+ setLastRun({at: finished, durationMs: finished - started});
+ setNow(finished);
+ setRunningAll(false);
+ logEvent('checks:finished', `${((finished - started) / 1000).toFixed(1)} s`, 'ok');
+ };
+
+ const counts: Record = {fail: 0, idle: 0, pass: 0, running: 0, skip: 0, unavailable: 0};
+
+ CAPABILITIES.forEach((capability: Capability) => {
+ counts[statusOf(capability.id)] += 1;
+ });
+
+ const total: number = CAPABILITIES.length;
+ const filters: [Filter, string][] = [
+ ['all', `All ${total}`],
+ ['pass', `Passed ${counts.pass}`],
+ ['fail', `Failed ${counts.fail}`],
+ ['idle', `Not run ${counts.idle + counts.skip}`],
+ ['unavailable', `Unavailable ${counts.unavailable}`],
+ ];
+
+ const sections: string[] = Array.from(new Set(CAPABILITIES.map((capability: Capability) => capability.section)));
+ const host: string = playgroundConfig.baseUrl.replace(/^https?:\/\//, '');
+
+ const configText: string = [
+ ['baseUrl', orNotSet(sdk.baseUrl)],
+ ['clientId', orNotSet(sdk.clientId)],
+ ['applicationId', orNotSet(sdk.applicationId)],
+ ['afterSignIn', sdk.afterSignInUrl ?? playgroundConfig.afterSignInUrl],
+ ['afterSignOut', playgroundConfig.afterSignOutUrl],
+ ['scopes', Array.isArray(sdk.scopes) ? sdk.scopes.join(' ') : String(sdk.scopes ?? '')],
+ ['storage', playgroundConfig.storage ?? 'SDK default'],
+ ]
+ .map(([key, value]: string[]) => `${key.padEnd(14)}${value}`)
+ .join('\n');
+
+ return (
+
+
+
+
React SDK playground
+
+ Exercise every capability of @thunderid/react against your local
+ ThunderID server.
+
+
+
+
+ Server SDKs
+
+
void runAll()}
+ disabled={runningAll}
+ >
+ {runningAll ? : }
+ {runningAll ? 'Running checks' : 'Run all checks'}
+
+
+
+
+ {!playgroundConfig.clientId && (
+
+ No client ID configured. Copy .env.example to .env, set{' '}
+ VITE_THUNDERID_CLIENT_ID, and restart pnpm dev. Checks that need a server session
+ will fail until then.
+
+ )}
+
+
+
+
Capabilities passing
+
+
+ {counts.pass} / {total}
+
+ {Math.round((counts.pass / total) * 100)}%
+
+
+
+
Failing
+
+ {counts.fail}
+ {counts.fail ? 'needs attention' : 'all good'}
+
+
+
+
Server
+
+
+ {health.state === 'up' ? 'Healthy' : health.state === 'down' ? 'Down' : 'Checking'}
+
+ {host}
+
+
+
+
Last full run
+
+ {lastRun ? timeAgo(lastRun.at, now) : 'Never'}
+ {lastRun ? `ago Β· ${(lastRun.durationMs / 1000).toFixed(1)} s` : 'press Run all checks'}
+
+
+
+
+
+
+
+
Capabilities
+
+ {filters.map(([id, label]: [Filter, string]) => (
+ setFilter(id)}>
+ {label}
+
+ ))}
+
+
+ {sections.map((section: string) => {
+ const items: Capability[] = CAPABILITIES.filter(
+ (capability: Capability) =>
+ capability.section === section && matchesFilter(statusOf(capability.id), filter),
+ );
+
+ if (!items.length) return null;
+
+ return (
+
+
{section}
+ {items.map((capability: Capability) => {
+ const status: CapabilityStatus = statusOf(capability.id);
+ const note: string = results[capability.id]?.note ?? (status === 'idle' ? 'not run yet' : '');
+
+ return (
+
+
+
+ {capability.name}
+ {capability.api}
+
+
+ {note}
+
+
void runOne(capability, true)}
+ >
+ {status === 'idle' ? 'Run' : 'Re-run'}
+
+
+ );
+ })}
+
+ );
+ })}
+ {CAPABILITIES.every((capability: Capability) => !matchesFilter(statusOf(capability.id), filter)) && (
+ No capabilities match this filter.
+ )}
+
+
+
+
+
+
+
+
ThunderIDProvider
+ .env
+
+ {configText}
+
+
+
+
+
+
+ );
+};
+
+export default Overview;
diff --git a/playgrounds/react/src/pages/Tokens.tsx b/playgrounds/react/src/pages/Tokens.tsx
new file mode 100644
index 00000000..a9632f18
--- /dev/null
+++ b/playgrounds/react/src/pages/Tokens.tsx
@@ -0,0 +1,204 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+import {useThunderID} from '@thunderid/react';
+import {ReactElement, useState} from 'react';
+import {decodeJwt, DecodedJwt, formatClaim, formatDuration, JwtClaims} from '../lib/jwt';
+import {SessionSnapshot, useNow, useSession} from '../lib/session';
+
+const CLAIM_NOTES: Record = {
+ acr: 'authentication context class',
+ amr: 'authentication methods used',
+ aud: 'audience',
+ auth_time: 'time the user authenticated',
+ azp: 'authorized party',
+ client_id: 'client the token was issued to',
+ email: 'email address',
+ exp: 'expires at',
+ iat: 'issued at',
+ iss: 'issuer',
+ jti: 'token id',
+ nbf: 'not valid before',
+ nonce: 'replay protection value',
+ scope: 'granted scopes',
+ sid: 'session id',
+ sub: 'subject (user id)',
+};
+
+const TIME_CLAIMS: string[] = ['exp', 'iat', 'nbf', 'auth_time'];
+
+const ClaimsTable = ({claims, now}: {claims: JwtClaims; now: number}): ReactElement => (
+
+
+ {Object.entries(claims).map(([key, value]: [string, unknown]) => {
+ const isTime: boolean = TIME_CLAIMS.includes(key) && typeof value === 'number';
+ const at: number = isTime ? (value as number) * 1000 : 0;
+
+ return (
+
+ {key}
+
+ {formatClaim(value)}
+ {isTime && (
+
+ {new Date(at).toLocaleString()} Β·{' '}
+ {at > now ? `in ${formatDuration(at - now)}` : `${formatDuration(now - at)} ago`}
+
+ )}
+ {!isTime && CLAIM_NOTES[key] && {CLAIM_NOTES[key]} }
+
+
+ );
+ })}
+
+
+);
+
+const TokenCard = ({
+ decoded,
+ label,
+ now,
+ source,
+ token,
+}: {
+ decoded: DecodedJwt | null;
+ label: string;
+ now: number;
+ source: string;
+ token: string | null;
+}): ReactElement => {
+ const [revealed, setRevealed] = useState(false);
+ const [copied, setCopied] = useState(false);
+
+ const copy = async (): Promise => {
+ if (!token) return;
+
+ try {
+ await navigator.clipboard.writeText(token);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
+ } catch {
+ setCopied(false);
+ }
+ };
+
+ return (
+
+
+
+
+ setRevealed(!revealed)}
+ >
+ {revealed ? 'Hide' : 'Reveal'}
+
+ void copy()}>
+ {copied ? 'Copied' : 'Copy'}
+
+
+
+
+
+ {!token && 'No token'}
+ {token && (revealed ? token : `${token.slice(0, 12)}${'β’'.repeat(24)}${token.slice(-6)}`)}
+
+
+ {token && !decoded && Opaque token: not a JWT, nothing to decode.
}
+ {decoded && (
+ <>
+ Header
+
+ Payload
+
+ >
+ )}
+
+ );
+};
+
+const Tokens = (): ReactElement => {
+ const {isSignedIn, isInitialized} = useThunderID();
+ const [refreshKey, setRefreshKey] = useState(0);
+ const session: SessionSnapshot = useSession(refreshKey);
+ const now: number = useNow();
+
+ return (
+
+
+
+
Tokens
+
+ Tokens held by the SDK for this session, decoded locally. Nothing on this page is verified or sent anywhere.
+
+
+
setRefreshKey((key: number) => key + 1)}>
+ Reload from SDK
+
+
+
+ {!isSignedIn && (
+
+ {isInitialized ? 'Sign in from the Overview page to see tokens.' : 'The SDK is still initializing.'}
+
+ )}
+ {session.error && (
+
+ {session.error}
+
+ )}
+
+ {isSignedIn && (
+
+
+
Access token expires
+
+ {session.expiresAt ? formatDuration(session.expiresAt - now) : 'Unknown'}
+ {session.expiresAt ? new Date(session.expiresAt).toLocaleTimeString() : ''}
+
+
+
+
Refresh token
+
+ {session.refreshToken ? 'Present' : 'None'}
+ value never leaves SDK storage
+
+
+
+
Scope
+
+ {session.scope ?? 'unknown'}
+
+
+
+ )}
+
+
+
+
+
+
+ );
+};
+
+export default Tokens;
diff --git a/playgrounds/react/src/pages/capabilities.ts b/playgrounds/react/src/pages/capabilities.ts
new file mode 100644
index 00000000..3fc66e1b
--- /dev/null
+++ b/playgrounds/react/src/pages/capabilities.ts
@@ -0,0 +1,489 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+import {EmbeddedFlowType} from '@thunderid/browser';
+import type {I18nContextValue, ThemeContextValue, ThunderIDContextProps, UserContextProps} from '@thunderid/react';
+import {COMPONENTS, STORY_COUNT} from './explorer/stories';
+import playgroundConfig from '../config';
+import {describeError, track} from '../lib/events';
+import {checkServerHealth} from '../lib/health';
+import {decodeJwt, formatDuration} from '../lib/jwt';
+import {asText, sdkRecover, sdkSignIn, sdkSignOut, sdkSignUp, sdkStorage, SdkStorageManager, toUser} from '../lib/sdk';
+import {getDisplayName} from '../lib/user';
+
+export type CapabilityStatus = 'idle' | 'running' | 'pass' | 'fail' | 'skip' | 'unavailable';
+
+export interface CapabilityResult {
+ note: string;
+ status: Exclude;
+}
+
+export interface CapabilityContext {
+ i18n: I18nContextValue;
+ sdk: ThunderIDContextProps;
+ theme: ThemeContextValue;
+ user: UserContextProps;
+}
+
+export interface RunOptions {
+ /** True when the check was started from its own Run button, so it may navigate away. */
+ interactive: boolean;
+}
+
+export interface Capability {
+ api: string;
+ id: string;
+ name: string;
+ run: (getContext: () => CapabilityContext, options: RunOptions) => Promise | CapabilityResult;
+ section: string;
+}
+
+const pass = (note: string): CapabilityResult => ({note, status: 'pass'});
+const fail = (note: string): CapabilityResult => ({note, status: 'fail'});
+const skip = (note: string): CapabilityResult => ({note, status: 'skip'});
+const unavailable = (note: string): CapabilityResult => ({note, status: 'unavailable'});
+
+const sleep = (ms: number): Promise =>
+ new Promise((resolve: () => void) => {
+ setTimeout(resolve, ms);
+ });
+
+const withTimeout = (promise: Promise, ms: number, label: string): Promise =>
+ Promise.race([
+ promise,
+ new Promise((_resolve: (value: T) => void, reject: (reason: Error) => void) => {
+ setTimeout(() => reject(new Error(`${label} timed out after ${ms / 1000}s`)), ms);
+ }),
+ ]);
+
+/**
+ * Polls the latest context until the predicate holds or the timeout elapses.
+ */
+const waitFor = async (
+ getContext: () => CapabilityContext,
+ predicate: (context: CapabilityContext) => boolean,
+ ms: number,
+): Promise => {
+ const deadline: number = Date.now() + ms;
+
+ while (Date.now() < deadline) {
+ if (predicate(getContext())) return true;
+ await sleep(150);
+ }
+
+ return predicate(getContext());
+};
+
+const requireSignedIn = (context: CapabilityContext): CapabilityResult | null =>
+ context.sdk.isSignedIn ? null : skip('sign in first');
+
+const flowSummary = (response: unknown): string => {
+ const flow: Record = (response ?? {}) as Record;
+ const data: Record = (flow['data'] ?? {}) as Record;
+ const components: unknown[] = Array.isArray(data['components']) ? (data['components'] as unknown[]) : [];
+
+ return `flowStatus=${asText(flow['flowStatus']) || 'unknown'}, ${components.length} components`;
+};
+
+export const CAPABILITIES: Capability[] = [
+ {
+ api: 'GET /health/liveness',
+ id: 'server',
+ name: 'Server reachable',
+ run: async () => {
+ const health = await checkServerHealth();
+
+ return health.state === 'up' ? pass(`${playgroundConfig.baseUrl} is healthy`) : fail(health.detail);
+ },
+ section: 'Setup',
+ },
+ {
+ api: ' ',
+ id: 'config',
+ name: 'Provider configuration',
+ run: (getContext: () => CapabilityContext) => {
+ const {sdk} = getContext();
+
+ if (!sdk.clientId) return fail('VITE_THUNDERID_CLIENT_ID is empty; copy .env.example to .env');
+ if (!sdk.baseUrl) return fail('VITE_THUNDERID_BASE_URL is empty');
+
+ return pass(`clientId ${sdk.clientId}`);
+ },
+ section: 'Setup',
+ },
+ {
+ api: 'useThunderID().isInitialized',
+ id: 'init',
+ name: 'Provider initialized',
+ run: async (getContext: () => CapabilityContext) =>
+ (await waitFor(getContext, (context: CapabilityContext) => context.sdk.isInitialized, 8000))
+ ? pass('client ready')
+ : fail('isInitialized stayed false for 8s'),
+ section: 'Setup',
+ },
+ {
+ api: 'useThunderID().discovery.wellKnown',
+ id: 'discovery',
+ name: 'OIDC discovery',
+ run: async (getContext: () => CapabilityContext) => {
+ await waitFor(getContext, (context: CapabilityContext) => !!context.sdk.discovery.wellKnown?.issuer, 5000);
+
+ const issuer: string = asText(getContext().sdk.discovery.wellKnown?.issuer);
+
+ return issuer
+ ? pass(`issuer ${issuer}`)
+ : fail('wellKnown has no issuer: discovery not loaded into SDK storage yet');
+ },
+ section: 'Setup',
+ },
+ {
+ api: 'useThunderID().meta',
+ id: 'meta',
+ name: 'Flow metadata',
+ run: async (getContext: () => CapabilityContext) => {
+ const loaded: boolean = await waitFor(
+ getContext,
+ (context: CapabilityContext) => !!context.sdk.meta || !context.sdk.isMetaLoading,
+ 5000,
+ );
+ const {meta} = getContext().sdk;
+
+ if (loaded && meta) return pass('GET /flow/meta resolved');
+
+ return fail('meta is null: /flow/meta failed or needs applicationId');
+ },
+ section: 'Setup',
+ },
+ {
+ api: ' Β· signIn()',
+ id: 'redirect',
+ name: 'Sign in (redirect)',
+ run: async (getContext: () => CapabilityContext, {interactive}: RunOptions) => {
+ const {sdk} = getContext();
+
+ if (sdk.isSignedIn) return pass(`signed in as ${getDisplayName(toUser(sdk.user))}`);
+ if (!interactive) return skip('interactive: press Run to redirect to ThunderID');
+
+ await track('signIn', () => sdkSignIn(sdk));
+
+ return skip('redirecting to ThunderID');
+ },
+ section: 'Authentication',
+ },
+ {
+ api: ' Β· signIn({applicationId, flowType})',
+ id: 'native',
+ name: 'Sign in (app-native flow)',
+ run: async (getContext: () => CapabilityContext) => {
+ const {sdk} = getContext();
+
+ if (!sdk.applicationId) return fail('VITE_THUNDERID_APPLICATION_ID is not set');
+
+ const response: unknown = await track(
+ 'signIn:embedded',
+ () =>
+ withTimeout(
+ sdkSignIn(sdk, {applicationId: sdk.applicationId, flowType: EmbeddedFlowType.Authentication}),
+ 10000,
+ 'flow initiation',
+ ),
+ flowSummary,
+ );
+
+ return pass(`flow started: ${flowSummary(response)}`);
+ },
+ section: 'Authentication',
+ },
+ {
+ api: ' Β· signUp({applicationId, flowType})',
+ id: 'signup',
+ name: 'Sign up (app-native flow)',
+ run: async (getContext: () => CapabilityContext) => {
+ const {sdk} = getContext();
+
+ if (!sdk.applicationId) return fail('VITE_THUNDERID_APPLICATION_ID is not set');
+
+ const response: unknown = await track(
+ 'signUp:embedded',
+ () =>
+ withTimeout(
+ sdkSignUp(sdk, {applicationId: sdk.applicationId, flowType: EmbeddedFlowType.Registration}),
+ 10000,
+ 'flow initiation',
+ ),
+ flowSummary,
+ );
+
+ return pass(`flow started: ${flowSummary(response)}`);
+ },
+ section: 'Authentication',
+ },
+ {
+ api: ' Β· recover({applicationId, flowType})',
+ id: 'recovery',
+ name: 'Account recovery',
+ run: async (getContext: () => CapabilityContext) => {
+ const {sdk} = getContext();
+
+ if (!sdk.applicationId) return fail('VITE_THUNDERID_APPLICATION_ID is not set');
+
+ const response: unknown = await track(
+ 'recover',
+ () =>
+ withTimeout(
+ sdkRecover(sdk, {applicationId: sdk.applicationId, flowType: EmbeddedFlowType.Recovery}),
+ 10000,
+ 'flow initiation',
+ ),
+ flowSummary,
+ );
+
+ return pass(`flow started: ${flowSummary(response)}`);
+ },
+ section: 'Authentication',
+ },
+ {
+ api: 'signInSilently()',
+ id: 'silent',
+ name: 'Silent sign-in',
+ run: async (getContext: () => CapabilityContext) => {
+ const {sdk} = getContext();
+ const result: unknown = await track(
+ 'signInSilently',
+ () => withTimeout(sdk.signInSilently(), 15000, 'signInSilently'),
+ (value: unknown) => (value ? 'session renewed' : 'returned false'),
+ );
+
+ return result
+ ? pass('session renewed without interaction')
+ : fail('returned false: login_required or no session');
+ },
+ section: 'Authentication',
+ },
+ {
+ api: 'useThunderID().isSignedIn after reload',
+ id: 'restore',
+ name: 'Session restore',
+ run: async (getContext: () => CapabilityContext) => {
+ await waitFor(getContext, (context: CapabilityContext) => context.sdk.isInitialized, 8000);
+
+ return getContext().sdk.isSignedIn
+ ? pass('session restored from storage')
+ : skip('no session: sign in, then reload the page');
+ },
+ section: 'Authentication',
+ },
+ {
+ api: ' Β· signOut()',
+ id: 'signout',
+ name: 'Sign out',
+ run: async (getContext: () => CapabilityContext, {interactive}: RunOptions) => {
+ const {sdk} = getContext();
+
+ if (!sdk.isSignedIn) return skip('not signed in');
+ if (!interactive) return skip('interactive: press Run to sign out');
+
+ await track('signOut', () => sdkSignOut(sdk));
+
+ return pass('signOut() resolved');
+ },
+ section: 'Authentication',
+ },
+ {
+ api: 'getAccessToken()',
+ id: 'accessToken',
+ name: 'Access token',
+ run: async (getContext: () => CapabilityContext) => {
+ const {sdk} = getContext();
+ const gate: CapabilityResult | null = requireSignedIn(getContext());
+
+ if (gate) return gate;
+
+ const token: string = await track('getAccessToken', () => sdk.getAccessToken());
+
+ if (!token) return fail('empty access token');
+
+ const exp: unknown = decodeJwt(token)?.payload['exp'];
+
+ return pass(
+ typeof exp === 'number'
+ ? `JWT, expires in ${formatDuration(exp * 1000 - Date.now())}`
+ : `opaque token, ${token.length} chars`,
+ );
+ },
+ section: 'Session and tokens',
+ },
+ {
+ api: 'getIdToken() Β· getDecodedIdToken()',
+ id: 'idToken',
+ name: 'ID token and claims',
+ run: async (getContext: () => CapabilityContext) => {
+ const {sdk} = getContext();
+ const gate: CapabilityResult | null = requireSignedIn(getContext());
+
+ if (gate) return gate;
+
+ const idToken: string = await track('getIdToken', () => sdk.getIdToken());
+ const claims: Record = (await track('getDecodedIdToken', () =>
+ sdk.getDecodedIdToken(),
+ )) as Record;
+
+ if (!idToken) return fail('empty ID token');
+
+ return pass(`sub ${asText(claims['sub']) || '?'}, ${Object.keys(claims).length} claims`);
+ },
+ section: 'Session and tokens',
+ },
+ {
+ api: 'getStorageManager().getSessionData()',
+ id: 'refresh',
+ name: 'Token refresh',
+ run: async (getContext: () => CapabilityContext) => {
+ const {sdk} = getContext();
+ const gate: CapabilityResult | null = requireSignedIn(getContext());
+
+ if (gate) return gate;
+
+ const storage: SdkStorageManager | null = await sdkStorage(sdk);
+ const session: Record = (await storage?.getSessionData()) ?? {};
+
+ if (!session['refresh_token']) {
+ return fail('no refresh_token in session: add offline_access or enable refresh tokens for the app');
+ }
+
+ return pass('refresh_token present; ThunderIDProvider schedules auto refresh');
+ },
+ section: 'Session and tokens',
+ },
+ {
+ api: 'http.request()',
+ id: 'http',
+ name: 'Authenticated request',
+ run: async (getContext: () => CapabilityContext) => {
+ const {sdk} = getContext();
+ const gate: CapabilityResult | null = requireSignedIn(getContext());
+
+ if (gate) return gate;
+
+ const response: {status?: number} = await track(
+ 'http.request',
+ () => sdk.http.request({method: 'GET', url: `${sdk.baseUrl}/users/me`}),
+ (value: {status?: number}) => `GET /users/me ${String(value?.status ?? '')}`,
+ );
+
+ return response?.status && response.status < 400
+ ? pass(`GET /users/me ${response.status}`)
+ : fail(`GET /users/me ${String(response?.status ?? 'no response')}`);
+ },
+ section: 'Session and tokens',
+ },
+ {
+ api: 'exchangeToken()',
+ id: 'exchange',
+ name: 'Token exchange',
+ run: () => skip('needs a grant-specific config; call exchangeToken() from your app'),
+ section: 'Session and tokens',
+ },
+ {
+ api: 'useThunderID().user',
+ id: 'user',
+ name: 'User info',
+ run: (getContext: () => CapabilityContext) => {
+ const gate: CapabilityResult | null = requireSignedIn(getContext());
+
+ if (gate) return gate;
+
+ const user = toUser(getContext().sdk.user);
+
+ return user ? pass(getDisplayName(user)) : fail('user is null while signed in');
+ },
+ section: 'User',
+ },
+ {
+ api: 'useUser().revalidateProfile()',
+ id: 'profile',
+ name: 'User profile',
+ run: async (getContext: () => CapabilityContext) => {
+ const gate: CapabilityResult | null = requireSignedIn(getContext());
+
+ if (gate) return gate;
+
+ await track('revalidateProfile', () => getContext().user.revalidateProfile());
+
+ const {profile, userSchema} = getContext().user;
+
+ if (!profile) return fail('profile is null after revalidateProfile()');
+
+ return pass(`${Object.keys(profile).length} fields, schema ${userSchema ? 'loaded' : 'missing'}`);
+ },
+ section: 'User',
+ },
+ {
+ api: ' Β· updateProfile()',
+ id: 'updateProfile',
+ name: 'Update profile',
+ run: (getContext: () => CapabilityContext) => {
+ const gate: CapabilityResult | null = requireSignedIn(getContext());
+
+ if (gate) return gate;
+
+ return skip('writes data: edit a field in Components > UserProfile');
+ },
+ section: 'User',
+ },
+ {
+ api: 'useOrganization() Β· switchOrganization()',
+ id: 'orgs',
+ name: 'Organizations',
+ run: () => unavailable('@thunderid/react has no organization API'),
+ section: 'User',
+ },
+ {
+ api: 'useI18n() Β· preferences.i18n',
+ id: 'i18n',
+ name: 'Translations',
+ run: (getContext: () => CapabilityContext) => {
+ const {i18n} = getContext();
+ const key = 'elements.buttons.signin.text';
+ const text: string = i18n.t(key);
+
+ if (!text || text === key) return fail(`t('${key}') returned the key`);
+
+ return pass(`${i18n.currentLanguage}: "${text}", ${Object.keys(i18n.bundles).length} bundles`);
+ },
+ section: 'UI',
+ },
+ {
+ api: 'useTheme() Β· ThemeProvider',
+ id: 'theme',
+ name: 'Theming and dark mode',
+ run: (getContext: () => CapabilityContext) => {
+ const {theme} = getContext();
+ const count: number = Object.keys(theme.theme.cssVariables ?? {}).length;
+
+ if (!count) return fail('theme exposes no CSS variables');
+
+ return pass(`${theme.colorScheme} scheme, ${count} CSS variables`);
+ },
+ section: 'UI',
+ },
+ {
+ api: 'Component explorer',
+ id: 'components',
+ name: 'Components',
+ run: () => pass(`${COMPONENTS.length} components Β· ${STORY_COUNT} stories`),
+ section: 'UI',
+ },
+];
+
+export const runCapability = async (
+ capability: Capability,
+ getContext: () => CapabilityContext,
+ options: RunOptions,
+): Promise => {
+ try {
+ return await capability.run(getContext, options);
+ } catch (error) {
+ return fail(describeError(error));
+ }
+};
diff --git a/playgrounds/react/src/pages/explorer/Explorer.tsx b/playgrounds/react/src/pages/explorer/Explorer.tsx
new file mode 100644
index 00000000..c7b1e586
--- /dev/null
+++ b/playgrounds/react/src/pages/explorer/Explorer.tsx
@@ -0,0 +1,199 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+import {ChangeEvent, ReactElement, useEffect, useRef, useState} from 'react';
+import {COMPONENTS, GROUPS} from './stories';
+import type {ComponentDef, StoryDef} from './types';
+import Workbench, {PanelTab, PreviewTheme, Viewport} from './Workbench';
+import {Chevron, Desktop, Grid4, GridBg, Mobile, Moon, Search, Sun, Tablet} from '../../components/Icons';
+import {href} from '../../lib/router';
+
+interface ExplorerProps {
+ componentId?: string;
+ storyId?: string;
+}
+
+const VIEWPORTS: [Viewport, string, () => ReactElement][] = [
+ ['desktop', 'Desktop', Desktop],
+ ['tablet', 'Tablet', Tablet],
+ ['mobile', 'Mobile', Mobile],
+];
+
+const Explorer = ({componentId = '', storyId = ''}: ExplorerProps): ReactElement => {
+ const component: ComponentDef =
+ COMPONENTS.find((candidate: ComponentDef) => candidate.id === componentId) ?? COMPONENTS[0];
+ const story: StoryDef =
+ component.stories.find((candidate: StoryDef) => candidate.id === storyId) ?? component.stories[0];
+
+ const [search, setSearch] = useState('');
+ const [collapsed, setCollapsed] = useState([]);
+ const [viewport, setViewport] = useState('desktop');
+ const [previewTheme, setPreviewTheme] = useState('dark');
+ const [grid, setGrid] = useState(true);
+ const [tab, setTab] = useState('controls');
+ const searchRef = useRef(null);
+
+ useEffect(() => {
+ const onKeyDown = (event: KeyboardEvent): void => {
+ const target: HTMLElement | null = event.target as HTMLElement | null;
+
+ if (event.key === '/' && !['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName ?? '')) {
+ event.preventDefault();
+ searchRef.current?.focus();
+ }
+ };
+
+ window.addEventListener('keydown', onKeyDown);
+
+ return () => window.removeEventListener('keydown', onKeyDown);
+ }, []);
+
+ const query: string = search.trim().toLowerCase();
+
+ const toggleGroup = (group: string): void =>
+ setCollapsed((previous: string[]) =>
+ previous.includes(group) ? previous.filter((item: string) => item !== group) : [...previous, group],
+ );
+
+ return (
+
+
+
+
+ ) => setSearch(event.target.value)}
+ />
+ /
+
+
+
+ {GROUPS.map((group: string) => {
+ const items: ComponentDef[] = COMPONENTS.filter(
+ (candidate: ComponentDef) =>
+ candidate.group === group && (!query || candidate.name.toLowerCase().includes(query)),
+ );
+
+ if (!items.length) return null;
+
+ const open: boolean = query ? true : !collapsed.includes(group);
+
+ return (
+
+
toggleGroup(group)}>
+
+ {group}
+ {items.length}
+
+ {open && (
+
+ )}
+
+ );
+ })}
+
+
+
+ Stories render the real @thunderid/react components. Ones marked live run
+ against your local ThunderID server and session.
+
+
+
+
+
+
+ {component.group}
+ /
+ {component.name}
+ /
+ {story.name}
+
+
+
+ {VIEWPORTS.map(([id, label, Icon]: [Viewport, string, () => ReactElement]) => (
+ setViewport(id)}
+ >
+
+
+ ))}
+
+
setPreviewTheme(previewTheme === 'dark' ? 'light' : 'dark')}
+ >
+ {previewTheme === 'dark' ? : }
+ {previewTheme === 'dark' ? 'Dark' : 'Light'}
+
+
setGrid(!grid)}
+ >
+
+
+
+
+
+
+
+ );
+};
+
+export default Explorer;
diff --git a/playgrounds/react/src/pages/explorer/StoryWidgets.tsx b/playgrounds/react/src/pages/explorer/StoryWidgets.tsx
new file mode 100644
index 00000000..ff261f12
--- /dev/null
+++ b/playgrounds/react/src/pages/explorer/StoryWidgets.tsx
@@ -0,0 +1,83 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+import {OtpField, PasswordField, Select} from '@thunderid/react';
+import {ChangeEvent, ReactElement, ReactNode, useState} from 'react';
+import type {Action, Args} from './types';
+
+const str = (args: Args, key: string): string => String(args[key] ?? '');
+const bool = (args: Args, key: string): boolean => args[key] === true;
+const num = (args: Args, key: string): number => Number(args[key] ?? 0);
+
+export const PasswordFieldStory = ({action, args}: {action: Action; args: Args}): ReactElement => {
+ const [value, setValue] = useState('');
+
+ return (
+
+
{
+ setValue(next);
+ action('onChange', `"${'β’'.repeat(next.length)}"`);
+ }}
+ />
+
+ );
+};
+
+export const OtpFieldStory = ({action, args}: {action: Action; args: Args}): ReactElement => {
+ const [value, setValue] = useState('');
+
+ return (
+ {
+ setValue(event.target.value);
+ action('onChange', `"${event.target.value}"`);
+ }}
+ onComplete={(code: string) => action('onComplete', `"${code}"`)}
+ />
+ );
+};
+
+export const SelectStory = ({action, args}: {action: Action; args: Args}): ReactElement => {
+ const [value, setValue] = useState('');
+
+ return (
+
+ ) => {
+ setValue(event.target.value);
+ action('onChange', `"${event.target.value}"`);
+ }}
+ />
+
+ );
+};
+
+export const Narrow = ({children, width = 360}: {children: ReactNode; width?: number}): ReactElement => (
+ {children}
+);
diff --git a/playgrounds/react/src/pages/explorer/Workbench.tsx b/playgrounds/react/src/pages/explorer/Workbench.tsx
new file mode 100644
index 00000000..d716903b
--- /dev/null
+++ b/playgrounds/react/src/pages/explorer/Workbench.tsx
@@ -0,0 +1,295 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+import {createTheme, Theme} from '@thunderid/browser';
+import {ThemeContext, ThemeContextValue} from '@thunderid/react';
+import {ChangeEvent, CSSProperties, ReactElement, useEffect, useMemo, useRef, useState} from 'react';
+import {A11yFinding, auditElement, CANVAS_BACKGROUND} from './a11y';
+import type {Args, ArgValue, ComponentDef, PropControl, StoryDef} from './types';
+import ErrorBoundary from '../../components/ErrorBoundary';
+import {Copy} from '../../components/Icons';
+import {timestamp} from '../../lib/events';
+
+export type Viewport = 'desktop' | 'tablet' | 'mobile';
+export type PreviewTheme = 'dark' | 'light';
+export type PanelTab = 'controls' | 'code' | 'actions' | 'a11y';
+
+interface WorkbenchProps {
+ component: ComponentDef;
+ grid: boolean;
+ onTabChange: (tab: PanelTab) => void;
+ previewTheme: PreviewTheme;
+ story: StoryDef;
+ tab: PanelTab;
+ viewport: Viewport;
+}
+
+interface ActionEntry {
+ detail: string;
+ id: number;
+ name: string;
+ time: string;
+}
+
+const VIEWPORT_WIDTH: Record = {desktop: '100%', mobile: '375px', tablet: '768px'};
+
+const TABS: [PanelTab, string][] = [
+ ['controls', 'Controls'],
+ ['code', 'Code'],
+ ['actions', 'Actions'],
+ ['a11y', 'Accessibility'],
+];
+
+const formatDefault = (value: ArgValue | undefined): string => {
+ if (value === undefined || value === '') return '-';
+ if (typeof value === 'string') return `'${value}'`;
+
+ return String(value);
+};
+
+const ControlInput = ({
+ control,
+ onChange,
+ value,
+}: {
+ control: PropControl;
+ onChange: (value: ArgValue) => void;
+ value: ArgValue | undefined;
+}): ReactElement => {
+ if (control.control === 'bool') {
+ return (
+ onChange(value !== true)}
+ >
+
+
+ );
+ }
+
+ if (control.control === 'text' || control.control === 'number') {
+ const isNumber: boolean = control.control === 'number';
+
+ return (
+ ) =>
+ onChange(isNumber ? Number(event.target.value) : event.target.value)
+ }
+ />
+ );
+ }
+
+ return (
+
+ {control.control.map((option: string) => (
+ onChange(option)}
+ >
+ {option}
+
+ ))}
+
+ );
+};
+
+const buildCode = (component: ComponentDef, args: Args): string => {
+ const snippet = component.code(args);
+ const imports: string[] = Array.from(new Set(snippet.imports)).sort();
+ const body: string = snippet.jsx
+ .split('\n')
+ .map((line: string) => ` ${line}`)
+ .join('\n');
+
+ return `import {${imports.join(', ')}} from '@thunderid/react';\n\nexport default function Example() {\n return (\n${body}\n );\n}`;
+};
+
+const Workbench = ({
+ component,
+ grid,
+ onTabChange,
+ previewTheme,
+ story,
+ tab,
+ viewport,
+}: WorkbenchProps): ReactElement => {
+ const [args, setArgs] = useState(() => ({...component.defaults, ...story.args}));
+ const [log, setLog] = useState([]);
+ const [findings, setFindings] = useState([]);
+ const [copied, setCopied] = useState(false);
+ const frameRef = useRef(null);
+
+ const dark: boolean = previewTheme === 'dark';
+ const theme: Theme = useMemo(() => createTheme({}, dark), [dark]);
+ const themeValue: ThemeContextValue = useMemo(
+ () => ({colorScheme: previewTheme, direction: 'ltr', theme, toggleTheme: () => undefined}),
+ [previewTheme, theme],
+ );
+
+ const action = (name: string, detail = ''): void => {
+ setLog((previous: ActionEntry[]) =>
+ [{detail, id: (previous[0]?.id ?? 0) + 1, name, time: timestamp()}, ...previous].slice(0, 50),
+ );
+ };
+
+ // Re-audit whenever the rendered story changes, including async renders from live components.
+ useEffect(() => {
+ const frame: HTMLDivElement | null = frameRef.current;
+
+ if (!frame) return undefined;
+
+ let pending = 0;
+ const audit = (): void => {
+ cancelAnimationFrame(pending);
+ pending = requestAnimationFrame(() => setFindings(auditElement(frame, CANVAS_BACKGROUND[previewTheme])));
+ };
+ const observer: MutationObserver = new MutationObserver(audit);
+
+ observer.observe(frame, {attributes: true, characterData: true, childList: true, subtree: true});
+ audit();
+
+ return () => {
+ observer.disconnect();
+ cancelAnimationFrame(pending);
+ };
+ }, [previewTheme, viewport]);
+
+ const code: string = buildCode(component, args);
+
+ const copyCode = async (): Promise => {
+ try {
+ await navigator.clipboard.writeText(code);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
+ } catch {
+ setCopied(false);
+ }
+ };
+
+ const frameStyle: CSSProperties = {
+ ...(theme.cssVariables as CSSProperties),
+ width: VIEWPORT_WIDTH[viewport],
+ };
+
+ return (
+ <>
+
+
+
+
+ {component.render(args, action)}
+
+
+
+
+
+
+
+ {TABS.map(([id, label]: [PanelTab, string]) => (
+ onTabChange(id)}>
+ {label}
+ {id === 'actions' && log.length > 0 && {log.length} }
+ {id === 'a11y' && findings.some((finding: A11yFinding) => finding.result !== 'Pass') && (
+
+ {findings.filter((finding: A11yFinding) => finding.result !== 'Pass').length}
+
+ )}
+
+ ))}
+
+
+ {tab === 'controls' &&
+ (component.controls.length ? (
+
+
+ Name
+ Description
+ Default
+ Control
+
+ {component.controls.map((control: PropControl) => (
+
+
+ {control.name}
+
+
+ {control.description}
+ {control.type}
+
+
+ {formatDefault(component.defaults[control.name])}
+
+
+
+ setArgs((previous: Args) => ({...previous, [control.name]: value}))
+ }
+ />
+
+
+ ))}
+
+ ) : (
+
This component has no editable props in the playground.
+ ))}
+
+ {tab === 'code' && (
+
+
+
React Β· generated from the current props
+
+
void copyCode()}>
+
+ {copied ? 'Copied' : 'Copy'}
+
+
+
{code}
+
+ )}
+
+ {tab === 'actions' && (
+
+ {log.map((entry: ActionEntry) => (
+
+ {entry.time}
+ {entry.name}
+ {entry.detail}
+
+ ))}
+ {log.length === 0 &&
Interact with the preview to log events here.
}
+
+ )}
+
+ {tab === 'a11y' && (
+
+ {findings.map((finding: A11yFinding) => (
+
+
+ {finding.result}
+
+ {finding.rule}
+ {finding.detail}
+
+ ))}
+
+ )}
+
+
+ >
+ );
+};
+
+export default Workbench;
diff --git a/playgrounds/react/src/pages/explorer/a11y.ts b/playgrounds/react/src/pages/explorer/a11y.ts
new file mode 100644
index 00000000..a5beaddd
--- /dev/null
+++ b/playgrounds/react/src/pages/explorer/a11y.ts
@@ -0,0 +1,235 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+export type A11yResult = 'Pass' | 'Warn' | 'Fail';
+
+export interface A11yFinding {
+ detail: string;
+ result: A11yResult;
+ rule: string;
+}
+
+type Rgba = [number, number, number, number];
+
+const parseColor = (value: string): Rgba | null => {
+ const match: RegExpExecArray | null = /rgba?\(([^)]+)\)/.exec(value);
+
+ if (!match) return null;
+
+ const parts: number[] = match[1]
+ .split(/[\s,/]+/)
+ .filter(Boolean)
+ .map(Number);
+
+ return [parts[0], parts[1], parts[2], parts[3] ?? 1];
+};
+
+const luminance = ([r, g, b]: Rgba): number => {
+ const channel = (c: number): number => {
+ const v: number = c / 255;
+
+ return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
+ };
+
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
+};
+
+const blend = (top: Rgba, bottom: Rgba): Rgba => {
+ const alpha: number = top[3];
+
+ return [
+ top[0] * alpha + bottom[0] * (1 - alpha),
+ top[1] * alpha + bottom[1] * (1 - alpha),
+ top[2] * alpha + bottom[2] * (1 - alpha),
+ 1,
+ ];
+};
+
+/**
+ * Resolves the effective background behind an element by compositing ancestor backgrounds.
+ */
+const backgroundOf = (element: Element, fallback: Rgba): Rgba => {
+ const layers: Rgba[] = [];
+ let node: Element | null = element;
+
+ while (node) {
+ const color: Rgba | null = parseColor(getComputedStyle(node).backgroundColor);
+
+ if (color && color[3] > 0) {
+ layers.push(color);
+ if (color[3] >= 1) break;
+ }
+ node = node.parentElement;
+ }
+
+ return layers.reverse().reduce((under: Rgba, layer: Rgba) => blend(layer, under), fallback);
+};
+
+const contrast = (a: Rgba, b: Rgba): number => {
+ const [light, dark] = [luminance(a), luminance(b)].sort((x: number, y: number) => y - x);
+
+ return (light + 0.05) / (dark + 0.05);
+};
+
+const accessibleName = (element: Element): string => {
+ const labelledBy: string | null = element.getAttribute('aria-labelledby');
+
+ if (labelledBy) {
+ return labelledBy
+ .split(/\s+/)
+ .map((id: string) => element.ownerDocument.getElementById(id)?.textContent ?? '')
+ .join(' ')
+ .trim();
+ }
+
+ return (
+ element.getAttribute('aria-label') ??
+ element.getAttribute('title') ??
+ (element.textContent?.trim() ? element.textContent : (element.querySelector('img')?.getAttribute('alt') ?? ''))
+ ).trim();
+};
+
+const hasLabel = (input: HTMLElement): boolean => {
+ if (input.getAttribute('aria-label') || input.getAttribute('aria-labelledby')) return true;
+ if (input.closest('label')) return true;
+ if (input.id && input.ownerDocument.querySelector(`label[for="${CSS.escape(input.id)}"]`)) return true;
+
+ return false;
+};
+
+/**
+ * Runs a few lightweight checks against the rendered story. Not a replacement for axe or a screen reader
+ * pass, but enough to catch missing names, labels, and low contrast while iterating on props.
+ */
+export const auditElement = (root: HTMLElement, canvasBackground: Rgba): A11yFinding[] => {
+ const findings: A11yFinding[] = [];
+
+ const buttons: HTMLElement[] = Array.from(root.querySelectorAll('button, [role="button"], a[href]'));
+ const unnamed: HTMLElement[] = buttons.filter((button: HTMLElement) => !accessibleName(button));
+
+ findings.push(
+ buttons.length === 0
+ ? {detail: 'no buttons or links', result: 'Pass', rule: 'Accessible name'}
+ : {
+ detail: unnamed.length ? `${unnamed.length} of ${buttons.length} unnamed` : `${buttons.length} named`,
+ result: unnamed.length ? 'Fail' : 'Pass',
+ rule: 'Accessible name',
+ },
+ );
+
+ const inputs: HTMLElement[] = Array.from(
+ root.querySelectorAll('input:not([type="hidden"]), select, textarea'),
+ );
+ const unlabeled: HTMLElement[] = inputs.filter((input: HTMLElement) => !hasLabel(input));
+
+ findings.push(
+ inputs.length === 0
+ ? {detail: 'no form fields', result: 'Pass', rule: 'Label associated'}
+ : {
+ detail: unlabeled.length ? `${unlabeled.length} of ${inputs.length} unlabeled` : `${inputs.length} labeled`,
+ result: unlabeled.length ? 'Fail' : 'Pass',
+ rule: 'Label associated',
+ },
+ );
+
+ const invalid: HTMLElement[] = inputs.filter((input: HTMLElement) => input.getAttribute('aria-invalid') === 'true');
+ const described: HTMLElement[] = invalid.filter((input: HTMLElement) => input.getAttribute('aria-describedby'));
+
+ if (invalid.length) {
+ findings.push({
+ detail: `${described.length} of ${invalid.length} invalid fields use aria-describedby`,
+ result: described.length === invalid.length ? 'Pass' : 'Warn',
+ rule: 'Error described',
+ });
+ }
+
+ const images: HTMLImageElement[] = Array.from(root.querySelectorAll('img'));
+ const noAlt: HTMLImageElement[] = images.filter((image: HTMLImageElement) => !image.hasAttribute('alt'));
+
+ if (images.length) {
+ findings.push({
+ detail: noAlt.length ? `${noAlt.length} of ${images.length} missing alt` : `${images.length} with alt`,
+ result: noAlt.length ? 'Fail' : 'Pass',
+ rule: 'Image alternative text',
+ });
+ }
+
+ const roles: string[] = Array.from(
+ new Set(Array.from(root.querySelectorAll('[role]')).map((element: Element) => element.getAttribute('role') ?? '')),
+ ).filter(Boolean);
+
+ findings.push({
+ detail: roles.length ? roles.join(', ') : 'none (native semantics only)',
+ result: 'Pass',
+ rule: 'ARIA roles found',
+ });
+
+ const busy: HTMLElement[] = Array.from(root.querySelectorAll('button:disabled, [aria-disabled="true"]'));
+
+ if (
+ root.querySelector('svg[class*="spin"], [class*="spinner"]') &&
+ !root.querySelector('[aria-busy="true"], [role="status"], [role="progressbar"]')
+ ) {
+ findings.push({detail: 'spinner without aria-busy or role="status"', result: 'Warn', rule: 'Busy state announced'});
+ } else if (busy.length) {
+ findings.push({detail: `${busy.length} disabled control(s)`, result: 'Pass', rule: 'Disabled state'});
+ }
+
+ const positiveTab: Element[] = Array.from(root.querySelectorAll('[tabindex]')).filter(
+ (element: Element) => Number(element.getAttribute('tabindex')) > 0,
+ );
+
+ findings.push({
+ detail: positiveTab.length ? `${positiveTab.length} element(s) with tabindex > 0` : 'natural order',
+ result: positiveTab.length ? 'Warn' : 'Pass',
+ rule: 'Focus order',
+ });
+
+ const ids: string[] = Array.from(root.querySelectorAll('[id]')).map((element: Element) => element.id);
+ const duplicates: string[] = Array.from(
+ new Set(ids.filter((id: string, index: number) => ids.indexOf(id) !== index)),
+ );
+
+ if (duplicates.length) {
+ findings.push({detail: duplicates.slice(0, 3).join(', '), result: 'Fail', rule: 'Unique ids'});
+ }
+
+ let lowest = Infinity;
+ let lowestText = '';
+ const walker: TreeWalker = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_TEXT);
+
+ for (let node: Node | null = walker.nextNode(); node; node = walker.nextNode()) {
+ const text: string = node.textContent?.trim() ?? '';
+ const parent: HTMLElement | null = node.parentElement;
+
+ if (text && parent && parent.offsetParent !== null) {
+ const style: CSSStyleDeclaration = getComputedStyle(parent);
+ const foreground: Rgba | null = parseColor(style.color);
+
+ if (foreground) {
+ const background: Rgba = backgroundOf(parent, canvasBackground);
+ const ratio: number = contrast(blend(foreground, background), background);
+
+ if (ratio < lowest) {
+ lowest = ratio;
+ lowestText = text.slice(0, 24);
+ }
+ }
+ }
+ }
+
+ if (lowest !== Infinity) {
+ findings.push({
+ detail: `lowest ${lowest.toFixed(1)} : 1 ("${lowestText}")`,
+ result: lowest >= 4.5 ? 'Pass' : lowest >= 3 ? 'Warn' : 'Fail',
+ rule: 'Color contrast',
+ });
+ }
+
+ return findings;
+};
+
+export const CANVAS_BACKGROUND: Record<'dark' | 'light', Rgba> = {
+ dark: [7, 16, 34, 1],
+ light: [238, 241, 246, 1],
+};
diff --git a/playgrounds/react/src/pages/explorer/stories.tsx b/playgrounds/react/src/pages/explorer/stories.tsx
new file mode 100644
index 00000000..669c8361
--- /dev/null
+++ b/playgrounds/react/src/pages/explorer/stories.tsx
@@ -0,0 +1,1030 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+import {
+ Alert,
+ BaseSignInButton,
+ BaseSignOutButton,
+ BaseSignUpButton,
+ BaseUserAvatar,
+ BaseUserDropdown,
+ BaseUserProfile,
+ Button,
+ Card,
+ Checkbox,
+ Divider,
+ FacebookButton,
+ FlowTimer,
+ GitHubButton,
+ GoogleButton,
+ LanguageSwitcher,
+ LinkedInButton,
+ MicrosoftButton,
+ Recovery,
+ SignedIn,
+ SignedOut,
+ SignIn,
+ SignInButton,
+ SignInWithEthereumButton,
+ SignOutButton,
+ SignUp,
+ SignUpButton,
+ Spinner,
+ TextField,
+ Toggle,
+ Typography,
+ User,
+ UserAvatar,
+ UserDropdown,
+ UserProfile,
+} from '@thunderid/react';
+import {ChangeEvent, FC, ReactNode} from 'react';
+import {Narrow, OtpFieldStory, PasswordFieldStory, SelectStory} from './StoryWidgets';
+import type {Action, Args, ArgValue, ComponentDef, PropControl} from './types';
+import {getDisplayName} from '../../lib/user';
+
+const str = (args: Args, key: string): string => String(args[key] ?? '');
+const bool = (args: Args, key: string): boolean => args[key] === true;
+const num = (args: Args, key: string): number => Number(args[key] ?? 0);
+
+const attr = (key: string, value: ArgValue): string => {
+ if (value === true) return key;
+ if (typeof value === 'number') return `${key}={${value}}`;
+
+ return `${key}="${String(value).replace(/"/g, '"')}"`;
+};
+
+/**
+ * Builds a JSX element string from the props that differ from the component defaults.
+ */
+export const jsx = (
+ tag: string,
+ props: Args,
+ options: {children?: string; defaults?: Args; extra?: string[]} = {},
+): string => {
+ const parts: string[] = Object.entries(props)
+ .filter(
+ ([key, value]: [string, ArgValue]) =>
+ value !== false && value !== '' && value !== undefined && options.defaults?.[key] !== value,
+ )
+ .map(([key, value]: [string, ArgValue]) => attr(key, value))
+ .concat(options.extra ?? []);
+ const inline: string = parts.length ? ` ${parts.join(' ')}` : '';
+ const multiline: boolean = inline.length > 70;
+ const attrs: string = multiline ? `\n ${parts.join('\n ')}\n` : inline;
+
+ if (options.children) return `<${tag}${attrs}>${options.children}${tag}>`;
+
+ return `<${tag}${attrs}${multiline ? '' : ' '}/>`;
+};
+
+const omit = (args: Args, ...keys: string[]): Args =>
+ Object.fromEntries(Object.entries(args).filter(([key]: [string, ArgValue]) => !keys.includes(key)));
+
+const SAMPLE_USER: Record = {
+ email: 'liam.chen@acme.io',
+ family_name: 'Chen',
+ given_name: 'Liam',
+ sub: 'usr_8f2c41',
+ username: 'liam.chen',
+};
+
+const SAMPLE_PROFILE: Record = {
+ country: 'New Zealand',
+ email: 'liam.chen@acme.io',
+ familyName: 'Chen',
+ givenName: 'Liam',
+ username: 'liam.chen',
+};
+
+const SIZE: readonly string[] = ['small', 'medium', 'large'];
+const SOURCE_CONTROL: PropControl = {
+ control: ['base', 'live'],
+ description: 'base renders the presentational Base component with sample data; live uses the SDK context.',
+ name: 'source',
+ type: "'base' | 'live' (playground only)",
+};
+
+const SOCIAL: Record void}>> = {
+ FacebookButton,
+ GitHubButton,
+ GoogleButton,
+ LinkedInButton,
+ MicrosoftButton,
+ SignInWithEthereumButton,
+};
+
+export const COMPONENTS: ComponentDef[] = [
+ {
+ code: (args: Args) => ({
+ imports: ['Button'],
+ jsx: jsx('Button', omit(args, 'children'), {
+ children: str(args, 'children'),
+ defaults: {color: 'primary', size: 'medium', variant: 'solid'},
+ extra: ['onClick={handleClick}'],
+ }),
+ }),
+ controls: [
+ {control: 'text', description: 'Button label.', name: 'children', type: 'ReactNode'},
+ {
+ control: ['solid', 'outline', 'text'],
+ description: 'Visual style of the button.',
+ name: 'variant',
+ type: "'solid' | 'outline' | 'text' | 'icon'",
+ },
+ {
+ control: ['primary', 'secondary', 'tertiary'],
+ description: 'Color intent.',
+ name: 'color',
+ type: "'primary' | 'secondary' | 'tertiary' | string",
+ },
+ {control: SIZE, description: 'Height and padding.', name: 'size', type: "'small' | 'medium' | 'large'"},
+ {control: 'bool', description: 'Shows a spinner and blocks clicks.', name: 'loading', type: 'boolean'},
+ {control: 'bool', description: 'Disables the button.', name: 'disabled', type: 'boolean'},
+ {control: 'bool', description: 'Stretches to the container width.', name: 'fullWidth', type: 'boolean'},
+ ],
+ defaults: {
+ children: 'Sign in',
+ color: 'primary',
+ disabled: false,
+ fullWidth: false,
+ loading: false,
+ size: 'medium',
+ variant: 'solid',
+ },
+ group: 'Primitives',
+ id: 'button',
+ name: 'Button',
+ render: (args: Args, action: Action) => (
+
+ action('onClick', 'MouseEvent { target: button }')}
+ >
+ {str(args, 'children')}
+
+
+ ),
+ stories: [
+ {id: 'playground', name: 'Playground'},
+ {args: {color: 'primary', disabled: false, loading: false, variant: 'solid'}, id: 'primary', name: 'Primary'},
+ {args: {color: 'primary', variant: 'outline'}, id: 'outline', name: 'Outline'},
+ {args: {color: 'secondary', variant: 'text'}, id: 'text', name: 'Text'},
+ {args: {loading: true}, id: 'loading', name: 'Loading'},
+ {args: {disabled: true}, id: 'disabled', name: 'Disabled'},
+ ],
+ },
+ {
+ code: (args: Args) => ({imports: ['TextField'], jsx: jsx('TextField', args, {extra: ['onChange={handleChange}']})}),
+ controls: [
+ {control: 'text', description: 'Visible field label.', name: 'label', type: 'string'},
+ {control: 'text', description: 'Hint shown when empty.', name: 'placeholder', type: 'string'},
+ {control: 'text', description: 'Help text below the field.', name: 'helperText', type: 'string'},
+ {control: 'text', description: 'Error message; marks the field invalid.', name: 'error', type: 'string'},
+ {control: 'bool', description: 'Marks the field as required.', name: 'required', type: 'boolean'},
+ {control: 'bool', description: 'Disables the field.', name: 'disabled', type: 'boolean'},
+ ],
+ defaults: {
+ disabled: false,
+ error: '',
+ helperText: '',
+ label: 'Email address',
+ placeholder: 'you@example.com',
+ required: false,
+ },
+ group: 'Primitives',
+ id: 'textfield',
+ name: 'TextField',
+ render: (args: Args, action: Action) => (
+
+ ) => action('onChange', `"${event.target.value}"`)}
+ onFocus={() => action('onFocus')}
+ onBlur={() => action('onBlur')}
+ />
+
+ ),
+ stories: [
+ {id: 'playground', name: 'Playground'},
+ {args: {error: 'Enter a valid email address.'}, id: 'error', name: 'With error'},
+ {args: {helperText: 'We never share your email.'}, id: 'helper', name: 'With helper text'},
+ {args: {disabled: true, error: ''}, id: 'disabled', name: 'Disabled'},
+ ],
+ },
+ {
+ code: (args: Args) => ({
+ imports: ['PasswordField'],
+ jsx: jsx('PasswordField', args, {extra: ['value={password}', 'onChange={setPassword}']}),
+ }),
+ controls: [
+ {control: 'text', description: 'Visible field label.', name: 'label', type: 'string'},
+ {control: 'text', description: 'Hint shown when empty.', name: 'placeholder', type: 'string'},
+ {control: 'text', description: 'Help text below the field.', name: 'helperText', type: 'string'},
+ {control: 'text', description: 'Error message.', name: 'error', type: 'string'},
+ {control: 'bool', description: 'Marks the field as required.', name: 'required', type: 'boolean'},
+ {control: 'bool', description: 'Disables the field.', name: 'disabled', type: 'boolean'},
+ ],
+ defaults: {disabled: false, error: '', helperText: '', label: 'Password', placeholder: 'Password', required: true},
+ group: 'Primitives',
+ id: 'passwordfield',
+ name: 'PasswordField',
+ render: (args: Args, action: Action) => ,
+ stories: [
+ {id: 'playground', name: 'Playground'},
+ {args: {error: 'Password must be at least 8 characters.'}, id: 'error', name: 'With error'},
+ ],
+ },
+ {
+ code: (args: Args) => ({
+ imports: ['OtpField'],
+ jsx: jsx('OtpField', args, {defaults: {length: 6, type: 'text'}, extra: ['onComplete={verify}']}),
+ }),
+ controls: [
+ {control: 'text', description: 'Visible label.', name: 'label', type: 'string'},
+ {control: 'number', description: 'Number of digits.', name: 'length', type: 'number'},
+ {control: ['text', 'number', 'password'], description: 'Input type.', name: 'type', type: 'OtpFieldType'},
+ {control: 'text', description: 'Error message.', name: 'error', type: 'string'},
+ {control: 'bool', description: 'Disables the inputs.', name: 'disabled', type: 'boolean'},
+ ],
+ defaults: {disabled: false, error: '', label: 'Verification code', length: 6, type: 'text'},
+ group: 'Primitives',
+ id: 'otpfield',
+ name: 'OtpField',
+ render: (args: Args, action: Action) => ,
+ stories: [
+ {id: 'playground', name: 'Playground'},
+ {args: {length: 4, type: 'number'}, id: 'four', name: 'Four digits'},
+ {args: {error: 'That code has expired.'}, id: 'error', name: 'With error'},
+ ],
+ },
+ {
+ code: (args: Args) => ({imports: ['Checkbox'], jsx: jsx('Checkbox', args, {extra: ['onChange={handleChange}']})}),
+ controls: [
+ {control: 'text', description: 'Visible label.', name: 'label', type: 'string'},
+ {control: 'text', description: 'Help text.', name: 'helperText', type: 'string'},
+ {control: 'text', description: 'Error message.', name: 'error', type: 'string'},
+ {control: 'bool', description: 'Marks as required.', name: 'required', type: 'boolean'},
+ {control: 'bool', description: 'Disables the checkbox.', name: 'disabled', type: 'boolean'},
+ ],
+ defaults: {disabled: false, error: '', helperText: '', label: 'Remember this device', required: false},
+ group: 'Primitives',
+ id: 'checkbox',
+ name: 'Checkbox',
+ render: (args: Args, action: Action) => (
+ ) => action('onChange', `checked=${event.target.checked}`)}
+ />
+ ),
+ stories: [
+ {id: 'playground', name: 'Playground'},
+ {
+ args: {error: 'You must accept the terms.', label: 'I accept the terms', required: true},
+ id: 'error',
+ name: 'Required',
+ },
+ ],
+ },
+ {
+ code: (args: Args) => ({imports: ['Toggle'], jsx: jsx('Toggle', args, {extra: ['onChange={handleChange}']})}),
+ controls: [
+ {control: 'text', description: 'Visible label.', name: 'label', type: 'string'},
+ {control: 'text', description: 'Help text.', name: 'helperText', type: 'string'},
+ {control: 'bool', description: 'Disables the toggle.', name: 'disabled', type: 'boolean'},
+ ],
+ defaults: {disabled: false, helperText: '', label: 'Enable passkeys'},
+ group: 'Primitives',
+ id: 'toggle',
+ name: 'Toggle',
+ render: (args: Args, action: Action) => (
+ ) => action('onChange', `checked=${event.target.checked}`)}
+ />
+ ),
+ stories: [
+ {id: 'playground', name: 'Playground'},
+ {args: {disabled: true}, id: 'disabled', name: 'Disabled'},
+ ],
+ },
+ {
+ code: (args: Args) => ({
+ imports: ['Select'],
+ jsx: jsx('Select', args, {extra: ['options={departments}', 'onChange={handleChange}']}),
+ }),
+ controls: [
+ {control: 'text', description: 'Visible label.', name: 'label', type: 'string'},
+ {control: 'text', description: 'Placeholder option.', name: 'placeholder', type: 'string'},
+ {control: 'text', description: 'Help text.', name: 'helperText', type: 'string'},
+ {control: 'text', description: 'Error message.', name: 'error', type: 'string'},
+ {control: 'bool', description: 'Shows a loading state.', name: 'loading', type: 'boolean'},
+ {control: 'bool', description: 'Disables the select.', name: 'disabled', type: 'boolean'},
+ ],
+ defaults: {
+ disabled: false,
+ error: '',
+ helperText: '',
+ label: 'Department',
+ loading: false,
+ placeholder: 'Choose a department',
+ },
+ group: 'Primitives',
+ id: 'select',
+ name: 'Select',
+ render: (args: Args, action: Action) => ,
+ stories: [
+ {id: 'playground', name: 'Playground'},
+ {args: {loading: true}, id: 'loading', name: 'Loading'},
+ ],
+ },
+ {
+ code: (args: Args) => ({
+ imports: ['Alert'],
+ jsx: `\n ${str(args, 'title')} \n ${str(
+ args,
+ 'description',
+ )} \n `,
+ }),
+ controls: [
+ {
+ control: ['info', 'success', 'warning', 'error'],
+ description: 'Color and icon of the alert.',
+ name: 'variant',
+ type: "'info' | 'success' | 'warning' | 'error'",
+ },
+ {control: 'text', description: 'Rendered in .', name: 'title', type: 'ReactNode'},
+ {control: 'text', description: 'Rendered in .', name: 'description', type: 'ReactNode'},
+ {control: 'bool', description: 'Shows the variant icon.', name: 'showIcon', type: 'boolean'},
+ ],
+ defaults: {
+ description: 'We sent a sign-in link to liam.chen@acme.io.',
+ showIcon: true,
+ title: 'Check your inbox',
+ variant: 'info',
+ },
+ group: 'Primitives',
+ id: 'alert',
+ name: 'Alert',
+ render: (args: Args) => (
+
+
+ {str(args, 'title')}
+ {str(args, 'description')}
+
+
+ ),
+ stories: [
+ {args: {variant: 'info'}, id: 'info', name: 'Info'},
+ {
+ args: {
+ description: 'You can now sign in with your new password.',
+ title: 'Password updated',
+ variant: 'success',
+ },
+ id: 'success',
+ name: 'Success',
+ },
+ {
+ args: {description: 'You will be signed out in 2 minutes.', title: 'Session expiring', variant: 'warning'},
+ id: 'warning',
+ name: 'Warning',
+ },
+ {
+ args: {description: 'The email or password is incorrect.', title: 'Sign-in failed', variant: 'error'},
+ id: 'error',
+ name: 'Error',
+ },
+ ],
+ },
+ {
+ code: (args: Args) => ({
+ imports: ['Button', 'Card'],
+ jsx: `\n \n ${str(
+ args,
+ 'title',
+ )} \n ${str(args, 'description')} \n \n ... \n \n Continue \n \n `,
+ }),
+ controls: [
+ {
+ control: ['default', 'outlined', 'elevated'],
+ description: 'Surface style.',
+ name: 'variant',
+ type: "'default' | 'outlined' | 'elevated'",
+ },
+ {control: 'text', description: 'Card.Title content.', name: 'title', type: 'ReactNode'},
+ {control: 'text', description: 'Card.Description content.', name: 'description', type: 'ReactNode'},
+ ],
+ defaults: {
+ description: 'Choose how you want to verify it is you.',
+ title: 'Verify your identity',
+ variant: 'outlined',
+ },
+ group: 'Primitives',
+ id: 'card',
+ name: 'Card',
+ render: (args: Args, action: Action) => (
+
+
+
+ {str(args, 'title')}
+ {str(args, 'description')}
+
+
+ Use a passkey, an authenticator app, or a code sent by email.
+
+
+ action('onClick', 'Continue')}>
+ Continue
+
+
+
+
+ ),
+ stories: [
+ {id: 'outlined', name: 'Outlined'},
+ {args: {variant: 'elevated'}, id: 'elevated', name: 'Elevated'},
+ {args: {variant: 'default'}, id: 'default', name: 'Default'},
+ ],
+ },
+ {
+ code: (args: Args) => ({
+ imports: ['Typography'],
+ jsx: jsx('Typography', omit(args, 'children'), {
+ children: str(args, 'children'),
+ defaults: {align: 'left', color: 'textPrimary', variant: 'body1'},
+ }),
+ }),
+ controls: [
+ {control: 'text', description: 'Text content.', name: 'children', type: 'ReactNode'},
+ {
+ control: [
+ 'h1',
+ 'h2',
+ 'h3',
+ 'h4',
+ 'h5',
+ 'h6',
+ 'subtitle1',
+ 'subtitle2',
+ 'body1',
+ 'body2',
+ 'caption',
+ 'overline',
+ ],
+ description: 'Type scale step.',
+ name: 'variant',
+ type: 'TypographyVariant',
+ },
+ {
+ control: ['textPrimary', 'textSecondary', 'primary', 'error', 'success', 'warning'],
+ description: 'Text color.',
+ name: 'color',
+ type: 'TypographyColor',
+ },
+ {control: ['left', 'center', 'right'], description: 'Alignment.', name: 'align', type: 'TypographyAlign'},
+ ],
+ defaults: {align: 'left', children: 'Welcome back, Liam', color: 'textPrimary', variant: 'h4'},
+ group: 'Primitives',
+ id: 'typography',
+ name: 'Typography',
+ render: (args: Args) => (
+
+ {str(args, 'children')}
+
+ ),
+ stories: [
+ {id: 'heading', name: 'Heading'},
+ {
+ args: {children: 'Signed in with a passkey.', color: 'textSecondary', variant: 'body2'},
+ id: 'body',
+ name: 'Body',
+ },
+ ],
+ },
+ {
+ code: (args: Args) => ({
+ imports: ['Divider'],
+ jsx: str(args, 'children')
+ ? jsx('Divider', omit(args, 'children'), {children: str(args, 'children'), defaults: {variant: 'solid'}})
+ : jsx('Divider', omit(args, 'children'), {defaults: {variant: 'solid'}}),
+ }),
+ controls: [
+ {control: 'text', description: 'Optional label in the middle.', name: 'children', type: 'ReactNode'},
+ {control: ['solid', 'dashed', 'dotted'], description: 'Line style.', name: 'variant', type: 'DividerVariant'},
+ ],
+ defaults: {children: 'or', variant: 'solid'},
+ group: 'Primitives',
+ id: 'divider',
+ name: 'Divider',
+ render: (args: Args) => (
+
+ {str(args, 'children') || undefined}
+
+ ),
+ stories: [
+ {id: 'label', name: 'With label'},
+ {args: {children: '', variant: 'dashed'}, id: 'dashed', name: 'Dashed'},
+ ],
+ },
+ {
+ code: (args: Args) => ({imports: ['Spinner'], jsx: jsx('Spinner', args, {defaults: {size: 'medium'}})}),
+ controls: [{control: SIZE, description: 'Spinner size.', name: 'size', type: 'SpinnerSize'}],
+ defaults: {size: 'medium'},
+ group: 'Primitives',
+ id: 'spinner',
+ name: 'Spinner',
+ render: (args: Args) => ,
+ stories: [
+ {id: 'medium', name: 'Medium'},
+ {args: {size: 'large'}, id: 'large', name: 'Large'},
+ ],
+ },
+ {
+ code: (args: Args) =>
+ str(args, 'source') === 'live'
+ ? {imports: ['SignInButton'], jsx: jsx('SignInButton', {}, {children: str(args, 'children') || undefined})}
+ : {
+ imports: ['BaseSignInButton'],
+ jsx: jsx('BaseSignInButton', {}, {children: str(args, 'children'), extra: ['signIn={handleSignIn}']}),
+ },
+ controls: [
+ SOURCE_CONTROL,
+ {
+ control: 'text',
+ description: 'Button label; empty uses the translated default.',
+ name: 'children',
+ type: 'ReactNode',
+ },
+ ],
+ defaults: {children: 'Sign in', source: 'base'},
+ group: 'Actions',
+ id: 'signinbutton',
+ live: true,
+ name: 'SignInButton',
+ render: (args: Args, action: Action) =>
+ str(args, 'source') === 'live' ? (
+ action('onClick', 'signIn() redirected')}>
+ {str(args, 'children') || undefined}
+
+ ) : (
+ {
+ action('signIn', 'called (base, no redirect)');
+
+ return Promise.resolve();
+ }}
+ onClick={() => action('onClick')}
+ >
+ {str(args, 'children')}
+
+ ),
+ stories: [
+ {id: 'base', name: 'Base (no redirect)'},
+ {args: {children: '', source: 'live'}, id: 'live', name: 'Live (redirects)'},
+ ],
+ },
+ {
+ code: (args: Args) =>
+ str(args, 'source') === 'live'
+ ? {imports: ['SignOutButton'], jsx: jsx('SignOutButton', {}, {children: str(args, 'children') || undefined})}
+ : {
+ imports: ['BaseSignOutButton'],
+ jsx: jsx('BaseSignOutButton', {}, {children: str(args, 'children'), extra: ['signOut={handleSignOut}']}),
+ },
+ controls: [
+ SOURCE_CONTROL,
+ {
+ control: 'text',
+ description: 'Button label; empty uses the translated default.',
+ name: 'children',
+ type: 'ReactNode',
+ },
+ ],
+ defaults: {children: 'Sign out', source: 'base'},
+ group: 'Actions',
+ id: 'signoutbutton',
+ live: true,
+ name: 'SignOutButton',
+ render: (args: Args, action: Action) =>
+ str(args, 'source') === 'live' ? (
+ action('onClick', 'signOut() resolved')}>
+ {str(args, 'children') || undefined}
+
+ ) : (
+ {
+ action('signOut', 'called (base)');
+
+ return Promise.resolve();
+ }}
+ onClick={() => action('onClick')}
+ >
+ {str(args, 'children')}
+
+ ),
+ stories: [
+ {id: 'base', name: 'Base'},
+ {args: {children: '', source: 'live'}, id: 'live', name: 'Live (signs out)'},
+ ],
+ },
+ {
+ code: (args: Args) =>
+ str(args, 'source') === 'live'
+ ? {imports: ['SignUpButton'], jsx: jsx('SignUpButton', {}, {children: str(args, 'children') || undefined})}
+ : {
+ imports: ['BaseSignUpButton'],
+ jsx: jsx('BaseSignUpButton', {}, {children: str(args, 'children'), extra: ['signUp={handleSignUp}']}),
+ },
+ controls: [
+ SOURCE_CONTROL,
+ {
+ control: 'text',
+ description: 'Button label; empty uses the translated default.',
+ name: 'children',
+ type: 'ReactNode',
+ },
+ ],
+ defaults: {children: 'Create account', source: 'base'},
+ group: 'Actions',
+ id: 'signupbutton',
+ live: true,
+ name: 'SignUpButton',
+ render: (args: Args, action: Action) =>
+ str(args, 'source') === 'live' ? (
+ action('onClick', 'signUp() started')}>
+ {str(args, 'children') || undefined}
+
+ ) : (
+ {
+ action('signUp', 'called (base)');
+
+ return Promise.resolve();
+ }}
+ onClick={() => action('onClick')}
+ >
+ {str(args, 'children')}
+
+ ),
+ stories: [
+ {id: 'base', name: 'Base'},
+ {args: {children: '', source: 'live'}, id: 'live', name: 'Live'},
+ ],
+ },
+ {
+ code: (args: Args) => ({
+ imports: [str(args, 'provider')],
+ jsx: jsx(
+ str(args, 'provider'),
+ {},
+ {children: str(args, 'children') || undefined, extra: ['onClick={handleClick}']},
+ ),
+ }),
+ controls: [
+ {
+ control: Object.keys(SOCIAL),
+ description: 'Which adapter button to render.',
+ name: 'provider',
+ type: 'component',
+ },
+ {control: 'text', description: 'Label; empty uses the translated default.', name: 'children', type: 'ReactNode'},
+ ],
+ defaults: {children: '', provider: 'GoogleButton'},
+ group: 'Actions',
+ id: 'social',
+ name: 'Social buttons',
+ render: (args: Args, action: Action) => {
+ const SocialButton: FC<{children?: ReactNode; onClick?: () => void}> =
+ SOCIAL[str(args, 'provider')] ?? GoogleButton;
+
+ return (
+
+ action('onClick', str(args, 'provider'))}>
+ {str(args, 'children') || undefined}
+
+
+ );
+ },
+ stories: [
+ {id: 'google', name: 'Google'},
+ {args: {provider: 'GitHubButton'}, id: 'github', name: 'GitHub'},
+ {args: {provider: 'MicrosoftButton'}, id: 'microsoft', name: 'Microsoft'},
+ {args: {provider: 'SignInWithEthereumButton'}, id: 'ethereum', name: 'Ethereum'},
+ ],
+ },
+ {
+ code: (args: Args) => ({
+ imports: ['SignIn'],
+ jsx: jsx('SignIn', args, {
+ defaults: {size: 'medium', variant: 'outlined'},
+ extra: ['onSuccess={handleSuccess}', 'onError={handleError}'],
+ }),
+ }),
+ controls: [
+ {control: SIZE, description: 'Spacing and control size.', name: 'size', type: "'small' | 'medium' | 'large'"},
+ {
+ control: ['default', 'outlined', 'elevated'],
+ description: 'Card variant of the flow container.',
+ name: 'variant',
+ type: 'CardVariant',
+ },
+ ],
+ defaults: {size: 'medium', variant: 'outlined'},
+ group: 'Auth flows',
+ id: 'signin',
+ live: true,
+ name: 'SignIn',
+ render: (args: Args, action: Action) => (
+
+ ) => action('onSuccess', JSON.stringify(data).slice(0, 120))}
+ onError={(error: Error) => action('onError', error.message)}
+ />
+
+ ),
+ stories: [
+ {id: 'default', name: 'Default'},
+ {args: {size: 'small', variant: 'elevated'}, id: 'compact', name: 'Compact'},
+ ],
+ },
+ {
+ code: (args: Args) => ({
+ imports: ['SignUp'],
+ jsx: jsx('SignUp', args, {
+ defaults: {size: 'medium', variant: 'outlined'},
+ extra: ['onComplete={handleComplete}', 'onError={handleError}'],
+ }),
+ }),
+ controls: [
+ {control: SIZE, description: 'Spacing and control size.', name: 'size', type: "'small' | 'medium' | 'large'"},
+ {
+ control: ['default', 'outlined', 'elevated'],
+ description: 'Card variant of the flow container.',
+ name: 'variant',
+ type: 'CardVariant',
+ },
+ ],
+ defaults: {size: 'medium', variant: 'outlined'},
+ group: 'Auth flows',
+ id: 'signup',
+ live: true,
+ name: 'SignUp',
+ render: (args: Args, action: Action) => (
+
+ action('onComplete')}
+ onError={(error: Error) => action('onError', error.message)}
+ />
+
+ ),
+ stories: [{id: 'default', name: 'Default'}],
+ },
+ {
+ code: () => ({imports: ['Recovery'], jsx: ' '}),
+ controls: [],
+ defaults: {},
+ group: 'Auth flows',
+ id: 'recovery',
+ live: true,
+ name: 'Recovery',
+ render: (_args: Args, action: Action) => (
+
+ action('onComplete')}
+ onError={(error: Error) => action('onError', error.message)}
+ />
+
+ ),
+ stories: [{id: 'default', name: 'Default'}],
+ },
+ {
+ code: (args: Args) =>
+ str(args, 'source') === 'live'
+ ? {imports: ['UserAvatar'], jsx: jsx('UserAvatar', omit(args, 'source'), {defaults: {size: 40}})}
+ : {
+ imports: ['BaseUserAvatar'],
+ jsx: jsx('BaseUserAvatar', omit(args, 'source'), {defaults: {size: 40}, extra: ['user={user}']}),
+ },
+ controls: [SOURCE_CONTROL, {control: 'number', description: 'Diameter in px.', name: 'size', type: 'number'}],
+ defaults: {size: 56, source: 'base'},
+ group: 'User',
+ id: 'useravatar',
+ name: 'UserAvatar',
+ render: (args: Args) =>
+ str(args, 'source') === 'live' ? (
+
+ ) : (
+
+ ),
+ stories: [
+ {id: 'base', name: 'Sample user'},
+ {args: {source: 'live'}, id: 'live', name: 'Signed-in user'},
+ ],
+ },
+ {
+ code: (args: Args) =>
+ str(args, 'source') === 'live'
+ ? {
+ imports: ['UserDropdown'],
+ jsx: jsx('UserDropdown', omit(args, 'source'), {extra: ['onManageProfile={openProfile}']}),
+ }
+ : {
+ imports: ['BaseUserDropdown'],
+ jsx: jsx('BaseUserDropdown', omit(args, 'source'), {
+ extra: ['user={user}', 'onSignOut={signOut}', 'onManageProfile={openProfile}'],
+ }),
+ },
+ controls: [
+ SOURCE_CONTROL,
+ {control: 'bool', description: 'Shows the name next to the avatar.', name: 'showTriggerLabel', type: 'boolean'},
+ {control: 'bool', description: 'Shows the header inside the menu.', name: 'showDropdownHeader', type: 'boolean'},
+ ],
+ defaults: {showDropdownHeader: true, showTriggerLabel: true, source: 'base'},
+ group: 'User',
+ id: 'userdropdown',
+ live: true,
+ name: 'UserDropdown',
+ render: (args: Args, action: Action) =>
+ str(args, 'source') === 'live' ? (
+ action('onManageProfile')}
+ />
+ ) : (
+ action('onSignOut')}
+ onManageProfile={() => action('onManageProfile')}
+ />
+ ),
+ stories: [
+ {id: 'base', name: 'Sample user'},
+ {args: {source: 'live'}, id: 'live', name: 'Signed-in user'},
+ ],
+ },
+ {
+ code: (args: Args) =>
+ str(args, 'source') === 'live'
+ ? {imports: ['UserProfile'], jsx: jsx('UserProfile', omit(args, 'source'), {defaults: {editable: true}})}
+ : {
+ imports: ['BaseUserProfile'],
+ jsx: jsx('BaseUserProfile', omit(args, 'source'), {
+ extra: ['profile={profile}', 'onUpdate={handleUpdate}'],
+ }),
+ },
+ controls: [
+ SOURCE_CONTROL,
+ {control: 'text', description: 'Heading of the profile card.', name: 'title', type: 'string'},
+ {control: 'bool', description: 'Allows editing fields.', name: 'editable', type: 'boolean'},
+ {control: 'bool', description: 'Wraps the profile in a card.', name: 'cardLayout', type: 'boolean'},
+ ],
+ defaults: {cardLayout: true, editable: true, source: 'base', title: 'Profile'},
+ group: 'User',
+ id: 'userprofile',
+ live: true,
+ name: 'UserProfile',
+ render: (args: Args, action: Action) =>
+ str(args, 'source') === 'live' ? (
+
+
+
+ ) : (
+
+ {
+ action('onUpdate', JSON.stringify(payload));
+
+ return Promise.resolve();
+ }}
+ />
+
+ ),
+ stories: [
+ {id: 'base', name: 'Sample profile'},
+ {args: {source: 'live'}, id: 'live', name: 'Signed-in user (editable)'},
+ ],
+ },
+ {
+ code: () => ({
+ imports: ['User'],
+ jsx: 'Not signed in }>\n {(user) => Hello, {user?.username}
}\n',
+ }),
+ controls: [],
+ defaults: {},
+ group: 'User',
+ id: 'user',
+ live: true,
+ name: 'User',
+ render: () => (
+ Not signed in}>
+ {(user: Record | null) => (
+ Hello, {getDisplayName(user)}
+ )}
+
+ ),
+ stories: [{id: 'default', name: 'Render prop'}],
+ },
+ {
+ code: () => ({
+ imports: ['SignedIn', 'SignedOut', 'SignInButton', 'SignOutButton'],
+ jsx: '<>\n \n \n \n \n \n \n>',
+ }),
+ controls: [],
+ defaults: {},
+ group: 'Control',
+ id: 'signedin',
+ live: true,
+ name: 'SignedIn / SignedOut',
+ render: () => (
+
+
+ Rendered by <SignedIn>
+
+
+
+ Rendered by <SignedOut>
+
+
+
+ ),
+ stories: [{id: 'default', name: 'Session gate'}],
+ },
+ {
+ code: () => ({imports: ['LanguageSwitcher'], jsx: ' '}),
+ controls: [],
+ defaults: {},
+ group: 'Control',
+ id: 'languageswitcher',
+ name: 'LanguageSwitcher',
+ render: () => ,
+ stories: [{id: 'default', name: 'Default'}],
+ },
+ {
+ code: (args: Args) => ({imports: ['FlowTimer'], jsx: jsx('FlowTimer', args)}),
+ controls: [
+ {control: 'number', description: 'Seconds to count down from.', name: 'expiresIn', type: 'number'},
+ {control: 'text', description: 'Template; {time} is replaced.', name: 'textTemplate', type: 'string'},
+ ],
+ defaults: {expiresIn: 120, textTemplate: 'Code expires in {time}'},
+ group: 'Control',
+ id: 'flowtimer',
+ name: 'FlowTimer',
+ render: (args: Args) => (
+
+ ),
+ stories: [
+ {id: 'default', name: 'Two minutes'},
+ {args: {expiresIn: 5}, id: 'short', name: 'Expiring'},
+ ],
+ },
+];
+
+export const STORY_COUNT: number = COMPONENTS.reduce(
+ (total: number, component: ComponentDef) => total + component.stories.length,
+ 0,
+);
+
+export const GROUPS: string[] = Array.from(new Set(COMPONENTS.map((component: ComponentDef) => component.group)));
diff --git a/playgrounds/react/src/pages/explorer/types.ts b/playgrounds/react/src/pages/explorer/types.ts
new file mode 100644
index 00000000..2d4e4379
--- /dev/null
+++ b/playgrounds/react/src/pages/explorer/types.ts
@@ -0,0 +1,41 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+import type {ReactNode} from 'react';
+
+export type ArgValue = string | number | boolean;
+export type Args = Record;
+export type Action = (name: string, detail?: string) => void;
+
+export type ControlKind = 'text' | 'bool' | 'number' | readonly string[];
+
+export interface PropControl {
+ control: ControlKind;
+ description: string;
+ name: string;
+ type: string;
+}
+
+export interface StoryDef {
+ args?: Args;
+ id: string;
+ name: string;
+}
+
+export interface Snippet {
+ imports: string[];
+ jsx: string;
+}
+
+export interface ComponentDef {
+ code: (args: Args) => Snippet;
+ controls: PropControl[];
+ defaults: Args;
+ group: string;
+ id: string;
+ /** True when the story drives the live SDK and needs a reachable server or a session. */
+ live?: boolean;
+ name: string;
+ render: (args: Args, action: Action) => ReactNode;
+ stories: StoryDef[];
+}
diff --git a/playgrounds/react/src/styles.css b/playgrounds/react/src/styles.css
new file mode 100644
index 00000000..27fdf3be
--- /dev/null
+++ b/playgrounds/react/src/styles.css
@@ -0,0 +1,1280 @@
+/*
+ * Copyright 2026 The ThunderID Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+:root {
+ --pg-bg: #050b18;
+ --pg-surface: rgba(255, 255, 255, 0.02);
+ --pg-surface-2: rgba(255, 255, 255, 0.05);
+ --pg-border: rgba(255, 255, 255, 0.08);
+ --pg-border-2: rgba(255, 255, 255, 0.1);
+ --pg-text: #fff;
+ --pg-text-2: rgba(255, 255, 255, 0.6);
+ --pg-text-3: rgba(255, 255, 255, 0.45);
+ --pg-primary: #3688ff;
+ --pg-primary-2: #2060c8;
+ --pg-primary-soft: rgba(54, 136, 255, 0.18);
+ --pg-link: #6aa8ff;
+ --pg-accent: #8bf9fa;
+ --pg-code: #cfe3ff;
+ --pg-ok: #6ee7b7;
+ --pg-ok-bg: #34d399;
+ --pg-err: #fca5a5;
+ --pg-err-bg: #ef4444;
+ --pg-warn: #fcd34d;
+ --pg-warn-bg: #f59e0b;
+ --pg-info: #9cc4ff;
+ --pg-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
+ --pg-sans: 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif;
+ --pg-radius-card: 16px;
+ --pg-radius-control: 8px;
+ color-scheme: dark;
+}
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ margin: 0;
+ padding: 0;
+}
+
+body {
+ background: var(--pg-bg);
+ color: var(--pg-text);
+ font-family: var(--pg-sans);
+ font-size: 14px;
+ -webkit-font-smoothing: antialiased;
+}
+
+a {
+ color: var(--pg-link);
+ text-decoration: none;
+}
+
+a:hover {
+ color: var(--pg-info);
+}
+
+.pg-mono {
+ font-family: var(--pg-mono);
+}
+
+@keyframes pg-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.pg-spin {
+ animation: pg-spin 0.8s linear infinite;
+}
+
+/* Shell */
+
+.pg-app {
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+.pg-topbar {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ flex-wrap: wrap;
+ padding: 10px 20px;
+ border-bottom: 1px solid var(--pg-border);
+ background: rgba(255, 255, 255, 0.015);
+}
+
+.pg-brand {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 15px;
+}
+
+.pg-brand strong {
+ font-weight: 600;
+ letter-spacing: -0.2px;
+}
+
+.pg-brand span {
+ color: rgba(255, 255, 255, 0.5);
+}
+
+.pg-sdk-switch {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 10px;
+ border-radius: var(--pg-radius-control);
+ background: var(--pg-surface-2);
+ border: 1px solid var(--pg-border-2);
+}
+
+.pg-sdk-switch select {
+ background: transparent;
+ border: none;
+ color: var(--pg-text);
+ font: 500 13px var(--pg-sans);
+}
+
+.pg-sdk-switch select option {
+ background: #0b1424;
+}
+
+.pg-sdk-badge {
+ width: 18px;
+ height: 18px;
+ border-radius: 5px;
+ background: rgba(97, 218, 251, 0.15);
+ color: #61dafb;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font: 600 10px/1 var(--pg-mono);
+}
+
+.pg-nav {
+ display: flex;
+ gap: 2px;
+ padding: 3px;
+ border-radius: 9px;
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.pg-nav a {
+ padding: 6px 12px;
+ border-radius: 7px;
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--pg-text-2);
+}
+
+.pg-nav a[aria-current='page'] {
+ color: var(--pg-text);
+ background: var(--pg-primary-soft);
+}
+
+.pg-spacer {
+ flex: 1;
+}
+
+.pg-server {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ font: 500 12px/1 var(--pg-mono);
+ color: var(--pg-text-2);
+}
+
+.pg-dot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: #94a3b8;
+}
+
+.pg-dot[data-state='up'] {
+ background: #34d399;
+ box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.15);
+}
+
+.pg-dot[data-state='down'] {
+ background: #ef4444;
+ box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15);
+}
+
+.pg-avatar {
+ width: 30px;
+ height: 30px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 11px;
+ font-weight: 600;
+ background: linear-gradient(135deg, #22d3ee, #0e7490);
+ flex: none;
+}
+
+.pg-avatar[data-size='lg'] {
+ width: 40px;
+ height: 40px;
+ font-size: 13px;
+}
+
+.pg-avatar[data-anon='true'] {
+ background: rgba(255, 255, 255, 0.08);
+ color: var(--pg-text-2);
+}
+
+.pg-main {
+ flex: 1;
+ width: 100%;
+ max-width: 1400px;
+ margin: 0 auto;
+ padding: 28px 24px 40px;
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.pg-heading {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 16px;
+ flex-wrap: wrap;
+}
+
+.pg-heading h1 {
+ font-size: 24px;
+ font-weight: 400;
+ letter-spacing: -0.4px;
+ margin: 0 0 6px;
+}
+
+.pg-heading p {
+ font-size: 13.5px;
+ color: rgba(255, 255, 255, 0.55);
+ margin: 0;
+}
+
+.pg-accent {
+ font-family: var(--pg-mono);
+ color: var(--pg-accent);
+}
+
+/* Controls */
+
+.pg-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 7px;
+ padding: 9px 14px;
+ border-radius: var(--pg-radius-control);
+ background: var(--pg-surface-2);
+ border: 1px solid var(--pg-border-2);
+ font: 500 13px var(--pg-sans);
+ color: rgba(255, 255, 255, 0.85);
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.pg-btn:hover:not(:disabled) {
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.pg-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.pg-btn[data-variant='primary'] {
+ background: linear-gradient(90deg, var(--pg-primary) 0%, var(--pg-primary-2) 100%);
+ border-color: transparent;
+ color: #fff;
+}
+
+.pg-btn[data-variant='danger'] {
+ background: transparent;
+ border-color: rgba(239, 68, 68, 0.45);
+ color: #f87171;
+}
+
+.pg-btn[data-size='sm'] {
+ padding: 6px 12px;
+ font-size: 12px;
+ border-radius: 7px;
+}
+
+.pg-link-btn {
+ background: none;
+ border: none;
+ font: 500 12px var(--pg-sans);
+ color: var(--pg-link);
+ cursor: pointer;
+ padding: 0;
+}
+
+.pg-segmented {
+ display: flex;
+ gap: 2px;
+ padding: 3px;
+ border-radius: var(--pg-radius-control);
+ background: var(--pg-surface-2);
+}
+
+.pg-segmented button {
+ padding: 5px 10px;
+ border-radius: 6px;
+ font: 500 12px var(--pg-sans);
+ color: var(--pg-text-2);
+ background: none;
+ border: none;
+ cursor: pointer;
+}
+
+.pg-segmented button[aria-pressed='true'],
+.pg-segmented button[aria-checked='true'] {
+ background: rgba(54, 136, 255, 0.2);
+ color: var(--pg-info);
+}
+
+.pg-input {
+ width: 100%;
+ background: var(--pg-surface-2);
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 7px;
+ padding: 7px 10px;
+ font: 12.5px var(--pg-sans);
+ color: var(--pg-text);
+}
+
+.pg-input::placeholder {
+ color: rgba(255, 255, 255, 0.32);
+}
+
+/* Cards */
+
+.pg-stats {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 14px;
+}
+
+.pg-stat {
+ padding: 16px 18px;
+ border-radius: 12px;
+ border: 1px solid var(--pg-border);
+ background: var(--pg-surface);
+}
+
+.pg-stat-label {
+ font-size: 12px;
+ color: rgba(255, 255, 255, 0.5);
+ margin-bottom: 8px;
+}
+
+.pg-stat-value {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.pg-stat-value strong {
+ font-size: 26px;
+ font-weight: 400;
+ letter-spacing: -0.5px;
+}
+
+.pg-stat-value span {
+ font-size: 12px;
+ color: var(--pg-text-3);
+}
+
+.pg-columns {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 20px;
+ align-items: flex-start;
+}
+
+.pg-col-main {
+ flex: 999 1 560px;
+ min-width: 0;
+}
+
+.pg-col-side {
+ flex: 1 1 340px;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.pg-card {
+ border: 1px solid var(--pg-border);
+ border-radius: 12px;
+ background: var(--pg-surface);
+ min-width: 0;
+}
+
+.pg-card-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 14px 18px;
+ border-bottom: 1px solid var(--pg-border);
+ flex-wrap: wrap;
+}
+
+.pg-card-head h2 {
+ font-size: 14px;
+ font-weight: 600;
+ margin: 0;
+}
+
+.pg-card-meta {
+ font: 500 11.5px/1 var(--pg-mono);
+ color: var(--pg-text-3);
+}
+
+.pg-card-body {
+ padding: 16px 18px;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.pg-section-label {
+ padding: 12px 18px 6px;
+ font: 600 10.5px/1 var(--pg-sans);
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--pg-text-3);
+}
+
+/* Capability rows */
+
+.pg-cap {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 10px 18px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+}
+
+.pg-cap-text {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.pg-cap-name {
+ font-size: 13.5px;
+ color: rgba(255, 255, 255, 0.92);
+}
+
+.pg-cap-api {
+ font: 400 11.5px/1.4 var(--pg-mono);
+ color: var(--pg-text-3);
+}
+
+.pg-cap-note {
+ font-size: 12px;
+ text-align: right;
+ max-width: 280px;
+ color: var(--pg-text-3);
+ overflow-wrap: anywhere;
+}
+
+.pg-cap-note[data-status='fail'] {
+ color: var(--pg-err);
+}
+
+.pg-cap-note[data-status='unavailable'] {
+ color: var(--pg-warn);
+}
+
+.pg-status-icon {
+ width: 22px;
+ height: 22px;
+ flex: none;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: rgba(255, 255, 255, 0.55);
+ background: rgba(148, 163, 184, 0.14);
+}
+
+.pg-status-icon[data-status='pass'] {
+ color: var(--pg-ok);
+ background: rgba(52, 211, 153, 0.14);
+}
+
+.pg-status-icon[data-status='fail'] {
+ color: var(--pg-err);
+ background: rgba(239, 68, 68, 0.14);
+}
+
+.pg-status-icon[data-status='running'] {
+ color: var(--pg-info);
+ background: rgba(54, 136, 255, 0.14);
+}
+
+.pg-status-icon[data-status='unavailable'] {
+ color: var(--pg-warn);
+ background: rgba(245, 158, 11, 0.14);
+}
+
+.pg-empty {
+ padding: 16px 18px;
+ font-size: 12.5px;
+ color: rgba(255, 255, 255, 0.5);
+}
+
+/* Session */
+
+.pg-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 3px 9px;
+ border-radius: 999px;
+ font-size: 11.5px;
+ font-weight: 500;
+ background: rgba(148, 163, 184, 0.14);
+ color: var(--pg-text-2);
+}
+
+.pg-pill::before {
+ content: '';
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: currentColor;
+}
+
+.pg-pill[data-state='ok'] {
+ background: rgba(52, 211, 153, 0.12);
+ color: var(--pg-ok);
+}
+
+.pg-pill[data-state='warn'] {
+ background: rgba(245, 158, 11, 0.12);
+ color: var(--pg-warn);
+}
+
+.pg-user {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.pg-user-name {
+ font-size: 14px;
+}
+
+.pg-user-sub {
+ font-size: 12px;
+ color: rgba(255, 255, 255, 0.5);
+ overflow-wrap: anywhere;
+}
+
+.pg-progress-head {
+ display: flex;
+ justify-content: space-between;
+ font-size: 12px;
+ color: rgba(255, 255, 255, 0.55);
+ margin-bottom: 6px;
+}
+
+.pg-progress {
+ height: 6px;
+ border-radius: 999px;
+ background: var(--pg-border);
+ overflow: hidden;
+}
+
+.pg-progress > div {
+ height: 100%;
+ border-radius: 999px;
+ background: linear-gradient(90deg, var(--pg-primary), var(--pg-accent));
+ transition: width 0.5s linear;
+}
+
+.pg-dl {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 6px 14px;
+ font-size: 12px;
+ margin: 0;
+}
+
+.pg-dl dt {
+ color: rgba(255, 255, 255, 0.5);
+}
+
+.pg-dl dd {
+ margin: 0;
+ font-family: var(--pg-mono);
+ color: var(--pg-code);
+ overflow-wrap: anywhere;
+}
+
+.pg-row {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.pg-row > .pg-btn {
+ flex: 1;
+ padding: 8px 12px;
+ font-size: 12.5px;
+}
+
+.pg-pre {
+ margin: 0;
+ padding: 14px 18px;
+ font: 400 12px/1.7 var(--pg-mono);
+ color: var(--pg-code);
+ white-space: pre;
+ overflow-x: auto;
+}
+
+.pg-warning {
+ padding: 12px 16px;
+ border-radius: 12px;
+ border: 1px solid rgba(245, 158, 11, 0.35);
+ background: rgba(245, 158, 11, 0.08);
+ color: var(--pg-warn);
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+/* Events */
+
+.pg-events {
+ list-style: none;
+ padding: 6px 0;
+ margin: 0;
+ max-height: 260px;
+ overflow: auto;
+}
+
+.pg-events li {
+ display: flex;
+ gap: 12px;
+ padding: 6px 18px;
+ font: 400 12px/1.4 var(--pg-mono);
+}
+
+.pg-events .pg-ev-time {
+ color: rgba(255, 255, 255, 0.4);
+ flex: none;
+}
+
+.pg-events .pg-ev-name {
+ white-space: nowrap;
+}
+
+.pg-events .pg-ev-name[data-kind='ok'] {
+ color: var(--pg-ok);
+}
+
+.pg-events .pg-ev-name[data-kind='error'] {
+ color: var(--pg-err);
+}
+
+.pg-events .pg-ev-name[data-kind='info'] {
+ color: var(--pg-info);
+}
+
+.pg-events .pg-ev-detail {
+ color: rgba(255, 255, 255, 0.55);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* Hooks and tokens */
+
+.pg-grid-2 {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
+ gap: 20px;
+}
+
+.pg-json {
+ margin: 0;
+ padding: 14px 18px;
+ font: 400 12px/1.6 var(--pg-mono);
+ color: var(--pg-code);
+ white-space: pre-wrap;
+ word-break: break-all;
+ max-height: 320px;
+ overflow: auto;
+}
+
+.pg-token {
+ font: 400 12px/1.6 var(--pg-mono);
+ color: var(--pg-code);
+ word-break: break-all;
+ padding: 12px 14px;
+ border-radius: 10px;
+ background: rgba(0, 0, 0, 0.35);
+ border: 1px solid var(--pg-border);
+}
+
+.pg-token[data-masked='true'] {
+ color: var(--pg-text-3);
+}
+
+.pg-claims {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12.5px;
+}
+
+.pg-claims th,
+.pg-claims td {
+ text-align: left;
+ padding: 7px 18px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+ vertical-align: top;
+}
+
+.pg-claims th {
+ font: 500 12px var(--pg-mono);
+ color: var(--pg-accent);
+ width: 160px;
+}
+
+.pg-claims td {
+ font-family: var(--pg-mono);
+ color: var(--pg-code);
+ word-break: break-all;
+}
+
+.pg-claims td small {
+ display: block;
+ color: var(--pg-text-3);
+ font-family: var(--pg-sans);
+}
+
+.pg-hook-sig {
+ font: 400 12px/1.5 var(--pg-mono);
+ color: var(--pg-text-3);
+}
+
+/* Explorer */
+
+.pg-explorer {
+ flex: 1;
+ display: flex;
+ flex-wrap: wrap;
+ min-height: 0;
+}
+
+.pg-sidebar {
+ flex: 1 1 248px;
+ max-width: 100%;
+ border-right: 1px solid var(--pg-border);
+ padding: 14px 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ background: rgba(255, 255, 255, 0.01);
+}
+
+.pg-search {
+ position: relative;
+ display: block;
+}
+
+.pg-search svg {
+ position: absolute;
+ left: 10px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: rgba(255, 255, 255, 0.4);
+}
+
+.pg-search input {
+ width: 100%;
+ background: var(--pg-surface-2);
+ border: 1px solid var(--pg-border-2);
+ border-radius: var(--pg-radius-control);
+ padding: 8px 40px 8px 32px;
+ font: 13px var(--pg-sans);
+ color: var(--pg-text);
+}
+
+.pg-search kbd {
+ position: absolute;
+ right: 8px;
+ top: 50%;
+ transform: translateY(-50%);
+ font: 500 10.5px/1 var(--pg-mono);
+ color: var(--pg-text-3);
+ border: 1px solid rgba(255, 255, 255, 0.14);
+ border-radius: 4px;
+ padding: 3px 5px;
+}
+
+.pg-tree {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.pg-tree ul {
+ list-style: none;
+ margin: 4px 0 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+}
+
+.pg-tree-group {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ width: 100%;
+ padding: 4px 6px;
+ font: 600 10.5px/1 var(--pg-sans);
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: rgba(255, 255, 255, 0.5);
+ background: none;
+ border: none;
+ cursor: pointer;
+}
+
+.pg-tree-group svg {
+ transition: transform 0.15s;
+}
+
+.pg-tree-group[aria-expanded='true'] svg {
+ transform: rotate(90deg);
+}
+
+.pg-tree-group span {
+ margin-left: auto;
+ font: 500 10.5px/1 var(--pg-mono);
+ letter-spacing: 0;
+ color: rgba(255, 255, 255, 0.35);
+}
+
+.pg-tree-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ padding: 6px 8px;
+ border-radius: 7px;
+ font-size: 13px;
+ color: rgba(255, 255, 255, 0.7);
+}
+
+.pg-tree-item[aria-current='true'] {
+ background: rgba(54, 136, 255, 0.16);
+ color: #fff;
+ font-weight: 500;
+}
+
+.pg-tree-item small {
+ margin-left: auto;
+ font: 500 10px/1 var(--pg-mono);
+ color: var(--pg-warn);
+}
+
+.pg-tree .pg-stories {
+ margin: 2px 0 6px 22px;
+ border-left: 1px solid var(--pg-border-2);
+ gap: 0;
+}
+
+.pg-story-link {
+ display: block;
+ padding: 5px 10px;
+ font-size: 12.5px;
+ border-radius: 0 6px 6px 0;
+ margin-left: -1px;
+ color: rgba(255, 255, 255, 0.55);
+ border-left: 2px solid transparent;
+}
+
+.pg-story-link[aria-current='true'] {
+ color: var(--pg-info);
+ border-left-color: var(--pg-primary);
+ background: rgba(54, 136, 255, 0.06);
+}
+
+.pg-sidebar-note {
+ font-size: 11.5px;
+ line-height: 1.5;
+ color: rgba(255, 255, 255, 0.4);
+ margin: 0;
+}
+
+.pg-stage {
+ flex: 999 1 560px;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+.pg-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex-wrap: wrap;
+ padding: 10px 16px;
+ border-bottom: 1px solid var(--pg-border);
+}
+
+.pg-crumbs {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 13px;
+ color: rgba(255, 255, 255, 0.5);
+ min-width: 0;
+}
+
+.pg-crumbs strong {
+ color: #fff;
+ font-weight: 500;
+}
+
+.pg-icon-btn {
+ width: 30px;
+ height: 28px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 6px;
+ color: var(--pg-text-2);
+ background: none;
+ border: none;
+ cursor: pointer;
+}
+
+.pg-icon-btn[aria-pressed='true'] {
+ background: rgba(54, 136, 255, 0.2);
+ color: var(--pg-info);
+}
+
+.pg-canvas {
+ flex: 1;
+ min-height: 380px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 32px;
+ background-color: #071022;
+ overflow: auto;
+}
+
+.pg-canvas[data-theme='light'] {
+ background-color: #eef1f6;
+}
+
+.pg-canvas[data-grid='true'] {
+ background-image: radial-gradient(rgba(255, 255, 255, 0.08) 1px, transparent 1px);
+ background-size: 16px 16px;
+}
+
+.pg-canvas[data-theme='light'][data-grid='true'] {
+ background-image: radial-gradient(rgba(15, 23, 42, 0.12) 1px, transparent 1px);
+}
+
+.pg-frame {
+ max-width: 100%;
+ min-height: 220px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 32px;
+ border-radius: 12px;
+ transition: width 0.2s;
+ color: var(--thunderid-color-text-primary, inherit);
+}
+
+.pg-frame[data-viewport='tablet'],
+.pg-frame[data-viewport='mobile'] {
+ border: 1px dashed rgba(255, 255, 255, 0.15);
+}
+
+.pg-canvas[data-theme='light'] .pg-frame[data-viewport='tablet'],
+.pg-canvas[data-theme='light'] .pg-frame[data-viewport='mobile'] {
+ border-color: rgba(15, 23, 42, 0.18);
+}
+
+.pg-frame-inner {
+ width: 100%;
+ display: flex;
+ justify-content: center;
+}
+
+.pg-panel {
+ border-top: 1px solid var(--pg-border);
+ background: rgba(255, 255, 255, 0.015);
+ height: 320px;
+ display: flex;
+ flex-direction: column;
+}
+
+.pg-tabs {
+ display: flex;
+ gap: 2px;
+ padding: 0 12px;
+ border-bottom: 1px solid var(--pg-border);
+}
+
+.pg-tabs button {
+ display: inline-flex;
+ align-items: center;
+ padding: 10px 12px;
+ margin-bottom: -1px;
+ font: 500 12.5px var(--pg-sans);
+ color: rgba(255, 255, 255, 0.55);
+ border: none;
+ border-bottom: 2px solid transparent;
+ background: none;
+ cursor: pointer;
+}
+
+.pg-tabs button[aria-selected='true'] {
+ color: #fff;
+ border-bottom-color: var(--pg-primary);
+}
+
+.pg-count {
+ margin-left: 6px;
+ font: 500 10.5px/1 var(--pg-mono);
+ padding: 2px 6px;
+ border-radius: 999px;
+ background: rgba(54, 136, 255, 0.2);
+ color: var(--pg-info);
+}
+
+.pg-panel-body {
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
+}
+
+.pg-props {
+ min-width: 640px;
+}
+
+.pg-props-row {
+ display: grid;
+ grid-template-columns: 160px minmax(0, 1.4fr) 140px minmax(0, 1.2fr);
+ gap: 16px;
+ align-items: center;
+ padding: 10px 20px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+}
+
+.pg-props-row[data-head='true'] {
+ font-size: 11.5px;
+ font-weight: 500;
+ color: var(--pg-text-3);
+}
+
+.pg-prop-name {
+ font: 500 12.5px/1.3 var(--pg-mono);
+ color: var(--pg-accent);
+}
+
+.pg-prop-desc {
+ font-size: 12.5px;
+ line-height: 1.45;
+ color: var(--pg-text-2);
+}
+
+.pg-prop-desc code {
+ display: block;
+ margin-top: 3px;
+ font: 500 11px/1.3 var(--pg-mono);
+ color: rgba(255, 255, 255, 0.4);
+}
+
+.pg-prop-default {
+ font: 500 12px/1 var(--pg-mono);
+ color: rgba(255, 255, 255, 0.55);
+}
+
+.pg-switch {
+ position: relative;
+ width: 36px;
+ height: 20px;
+ border-radius: 999px;
+ border: none;
+ background: rgba(255, 255, 255, 0.15);
+ transition: background 0.15s;
+ cursor: pointer;
+}
+
+.pg-switch[aria-checked='true'] {
+ background: var(--pg-primary);
+}
+
+.pg-switch span {
+ position: absolute;
+ top: 3px;
+ left: 3px;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: #fff;
+ transition: left 0.15s;
+}
+
+.pg-switch[aria-checked='true'] span {
+ left: 19px;
+}
+
+.pg-options {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+}
+
+.pg-options button {
+ padding: 5px 10px;
+ border-radius: 6px;
+ font: 500 12px var(--pg-sans);
+ background: rgba(255, 255, 255, 0.04);
+ color: rgba(255, 255, 255, 0.65);
+ border: 1px solid var(--pg-border-2);
+ cursor: pointer;
+}
+
+.pg-options button[aria-checked='true'] {
+ background: rgba(54, 136, 255, 0.2);
+ color: var(--pg-info);
+ border-color: rgba(54, 136, 255, 0.5);
+}
+
+.pg-code {
+ padding: 14px 20px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.pg-code pre {
+ margin: 0;
+ padding: 16px;
+ border-radius: 10px;
+ background: rgba(0, 0, 0, 0.35);
+ border: 1px solid var(--pg-border);
+ font: 400 12.5px/1.65 var(--pg-mono);
+ color: var(--pg-code);
+ white-space: pre;
+ overflow-x: auto;
+}
+
+.pg-actions-log > div {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 7px 20px;
+ font: 400 12.5px/1.4 var(--pg-mono);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.04);
+}
+
+.pg-actions-log span:nth-child(1) {
+ color: rgba(255, 255, 255, 0.4);
+}
+
+.pg-actions-log span:nth-child(2) {
+ color: var(--pg-accent);
+}
+
+.pg-actions-log span:nth-child(3) {
+ color: var(--pg-text-2);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.pg-a11y {
+ list-style: none;
+ padding: 8px 0;
+ margin: 0;
+}
+
+.pg-a11y li {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 9px 20px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.04);
+}
+
+.pg-a11y-badge {
+ min-width: 46px;
+ text-align: center;
+ padding: 3px 8px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 600;
+ background: rgba(52, 211, 153, 0.14);
+ color: var(--pg-ok);
+}
+
+.pg-a11y-badge[data-result='Warn'] {
+ background: rgba(245, 158, 11, 0.14);
+ color: var(--pg-warn);
+}
+
+.pg-a11y-badge[data-result='Fail'] {
+ background: rgba(239, 68, 68, 0.14);
+ color: var(--pg-err);
+}
+
+.pg-a11y-rule {
+ font-size: 13px;
+ color: rgba(255, 255, 255, 0.85);
+}
+
+.pg-a11y-detail {
+ margin-left: auto;
+ font: 500 12px/1.3 var(--pg-mono);
+ color: rgba(255, 255, 255, 0.5);
+ text-align: right;
+ overflow-wrap: anywhere;
+}
+
+.pg-story-note {
+ font-size: 12.5px;
+ color: var(--thunderid-color-text-secondary, rgba(255, 255, 255, 0.6));
+ text-align: center;
+ max-width: 420px;
+ line-height: 1.5;
+}
+
+@media (max-width: 720px) {
+ .pg-main {
+ padding: 20px 16px 32px;
+ }
+
+ .pg-topbar {
+ padding: 10px 16px;
+ }
+
+ .pg-grid-2 {
+ grid-template-columns: 1fr;
+ }
+
+ .pg-cap {
+ flex-wrap: wrap;
+ }
+
+ .pg-cap-note {
+ text-align: left;
+ max-width: none;
+ }
+}
diff --git a/playgrounds/react/src/vite-env.d.ts b/playgrounds/react/src/vite-env.d.ts
new file mode 100644
index 00000000..efec67e7
--- /dev/null
+++ b/playgrounds/react/src/vite-env.d.ts
@@ -0,0 +1,4 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+///
diff --git a/playgrounds/react/tsconfig.json b/playgrounds/react/tsconfig.json
new file mode 100644
index 00000000..e7ecc902
--- /dev/null
+++ b/playgrounds/react/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "isolatedModules": true,
+ "resolveJsonModule": true,
+ "noFallthroughCasesInSwitch": true,
+ "noImplicitReturns": true,
+ "forceConsistentCasingInFileNames": true,
+ "types": ["vite/client", "node"],
+ "noPropertyAccessFromIndexSignature": true
+ },
+ "include": ["src", "vite.config.ts"]
+}
diff --git a/playgrounds/react/vite.config.ts b/playgrounds/react/vite.config.ts
new file mode 100644
index 00000000..d88f725a
--- /dev/null
+++ b/playgrounds/react/vite.config.ts
@@ -0,0 +1,17 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+import react from '@vitejs/plugin-react';
+import {defineConfig} from 'vite';
+
+export default defineConfig({
+ build: {
+ // The playground bundles the whole SDK on purpose; it is never shipped.
+ chunkSizeWarningLimit: 1500,
+ },
+ plugins: [react()],
+ server: {
+ port: 5173,
+ strictPort: true,
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6496ea87..70ff5781 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -943,64 +943,97 @@ importers:
specifier: 3.5.43
version: 3.5.43(typescript@5.9.3)
- samples/browser/quickstart:
- dependencies:
- '@thunderid/browser':
- specifier: workspace:*
- version: link:../../../packages/browser
- devDependencies:
- vite:
- specifier: ^6.3.5
- version: 6.4.3(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(yaml@2.9.0)
-
- samples/express/quickstart:
+ playgrounds/express:
dependencies:
'@thunderid/express':
specifier: workspace:*
- version: link:../../../packages/express
+ version: link:../../packages/express
cookie-parser:
specifier: ^1.4.7
version: 1.4.7
express:
- specifier: ^4.21.2
- version: 4.22.2(supports-color@10.2.2)
+ specifier: ^5.2.1
+ version: 5.2.1(supports-color@10.2.2)
+ devDependencies:
+ '@thunderid/eslint-plugin':
+ specifier: 'catalog:'
+ version: 1.0.1(@typescript-eslint/utils@8.61.1(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)(vitest@4.1.11)(vue-eslint-parser@10.4.1(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2))
+ '@thunderid/prettier-config':
+ specifier: 'catalog:'
+ version: 1.0.1
+ '@types/cookie-parser':
+ specifier: ^1.4.8
+ version: 1.4.10(@types/express@5.0.6)
+ '@types/express':
+ specifier: ^5.0.3
+ version: 5.0.6
+ '@types/node':
+ specifier: 'catalog:'
+ version: 24.7.2
+ eslint:
+ specifier: 'catalog:'
+ version: 9.39.4(jiti@2.7.0)(supports-color@10.2.2)
+ prettier:
+ specifier: 'catalog:'
+ version: 3.6.2
+ typescript:
+ specifier: 'catalog:'
+ version: 5.9.3
- samples/integrations/better-auth/nextjs:
+ playgrounds/react:
dependencies:
- '@thunderid/better-auth':
+ '@thunderid/browser':
specifier: workspace:*
- version: link:../../../../packages/better-auth
- better-auth:
- specifier: ^1.7.2
- version: 1.7.2(next@15.5.25(@babel/core@7.29.7(supports-color@10.2.2))(@playwright/test@1.60.0)(@types/node@24.7.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@4.1.11)(vue@3.5.43(typescript@5.9.3))
- next:
- specifier: ^15.5.25
- version: 15.5.25(@babel/core@7.29.7(supports-color@10.2.2))(@playwright/test@1.60.0)(@types/node@24.7.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ version: link:../../packages/browser
+ '@thunderid/react':
+ specifier: workspace:*
+ version: link:../../packages/react
react:
- specifier: ^19.2.3
+ specifier: 'catalog:'
version: 19.2.3
react-dom:
- specifier: ^19.2.3
+ specifier: 'catalog:'
version: 19.2.3(react@19.2.3)
devDependencies:
+ '@thunderid/eslint-plugin':
+ specifier: 'catalog:'
+ version: 1.0.1(@typescript-eslint/utils@8.61.1(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)(vitest@4.1.11)(vue-eslint-parser@10.4.1(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2))
+ '@thunderid/prettier-config':
+ specifier: 'catalog:'
+ version: 1.0.1
'@types/node':
- specifier: ^24.7.2
+ specifier: 'catalog:'
version: 24.7.2
'@types/react':
- specifier: ^19.2.14
+ specifier: 'catalog:'
version: 19.2.14
'@types/react-dom':
- specifier: ^19.2.3
+ specifier: 'catalog:'
version: 19.2.3(@types/react@19.2.14)
+ '@vitejs/plugin-react':
+ specifier: ^4.5.2
+ version: 4.7.0(supports-color@10.2.2)(vite@6.4.3(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(yaml@2.9.0))
+ eslint:
+ specifier: 'catalog:'
+ version: 9.39.4(jiti@2.7.0)(supports-color@10.2.2)
+ prettier:
+ specifier: 'catalog:'
+ version: 3.6.2
typescript:
- specifier: ^5.9.3
+ specifier: 'catalog:'
version: 5.9.3
+ vite:
+ specifier: ^6.3.5
+ version: 6.4.3(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(yaml@2.9.0)
- samples/nextjs/quickstart:
+ samples/integrations/better-auth/nextjs:
dependencies:
- '@thunderid/nextjs':
+ '@thunderid/better-auth':
specifier: workspace:*
- version: link:../../../packages/nextjs
+ version: link:../../../../packages/better-auth
+ better-auth:
+ specifier: ^1.7.2
+ version: 1.7.2(next@15.5.25(@babel/core@7.29.7(supports-color@10.2.2))(@playwright/test@1.60.0)(@types/node@24.7.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@4.1.11)(vue@3.5.43(typescript@5.9.3))
next:
specifier: ^15.5.25
version: 15.5.25(@babel/core@7.29.7(supports-color@10.2.2))(@playwright/test@1.60.0)(@types/node@24.7.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -1024,71 +1057,6 @@ importers:
specifier: ^5.9.3
version: 5.9.3
- samples/node/quickstart:
- dependencies:
- '@thunderid/node':
- specifier: workspace:*
- version: link:../../../packages/node
- boxen:
- specifier: ^8.0.1
- version: 8.0.1
- picocolors:
- specifier: ^1.1.1
- version: 1.1.1
-
- samples/nuxt/quickstart:
- dependencies:
- '@thunderid/nuxt':
- specifier: workspace:*
- version: link:../../../packages/nuxt
- nuxt:
- specifier: ^4.5.2
- version: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.143.0)(@parcel/watcher@2.5.6)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(optionator@0.9.4)(oxc-parser@0.143.0)(rolldown@1.2.3)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.3)(rollup@4.62.2))(rollup@4.62.2)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(yaml@2.9.0)
- vue:
- specifier: ^3.5.13
- version: 3.5.43(typescript@5.9.3)
-
- samples/react/quickstart:
- dependencies:
- '@thunderid/react':
- specifier: workspace:*
- version: link:../../../packages/react
- '@thunderid/react-router':
- specifier: workspace:*
- version: link:../../../packages/react-router
- react:
- specifier: ^19.2.3
- version: 19.2.3
- react-dom:
- specifier: ^19.2.3
- version: 19.2.3(react@19.2.3)
- react-router:
- specifier: ^7.18.2
- version: 7.18.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
- devDependencies:
- '@vitejs/plugin-react':
- specifier: ^4.5.2
- version: 4.7.0(supports-color@10.2.2)(vite@6.4.3(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(yaml@2.9.0))
- vite:
- specifier: ^6.3.5
- version: 6.4.3(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(yaml@2.9.0)
-
- samples/vue/quickstart:
- dependencies:
- '@thunderid/vue':
- specifier: workspace:*
- version: link:../../../packages/vue
- vue:
- specifier: ^3.5.13
- version: 3.5.43(typescript@5.9.3)
- devDependencies:
- '@vitejs/plugin-vue':
- specifier: ^5.2.3
- version: 5.2.4(vite@6.4.3(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.43(typescript@5.9.3))
- vite:
- specifier: ^6.3.5
- version: 6.4.3(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(yaml@2.9.0)
-
tests/e2e:
devDependencies:
'@playwright/test':
@@ -1501,12 +1469,6 @@ packages:
cpu: [ppc64]
os: [aix]
- '@esbuild/aix-ppc64@0.27.7':
- resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==}
- engines: {node: '>=18'}
- cpu: [ppc64]
- os: [aix]
-
'@esbuild/aix-ppc64@0.28.1':
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
engines: {node: '>=18'}
@@ -1519,12 +1481,6 @@ packages:
cpu: [arm64]
os: [android]
- '@esbuild/android-arm64@0.27.7':
- resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==}
- engines: {node: '>=18'}
- cpu: [arm64]
- os: [android]
-
'@esbuild/android-arm64@0.28.1':
resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==}
engines: {node: '>=18'}
@@ -1537,12 +1493,6 @@ packages:
cpu: [arm]
os: [android]
- '@esbuild/android-arm@0.27.7':
- resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==}
- engines: {node: '>=18'}
- cpu: [arm]
- os: [android]
-
'@esbuild/android-arm@0.28.1':
resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==}
engines: {node: '>=18'}
@@ -1555,12 +1505,6 @@ packages:
cpu: [x64]
os: [android]
- '@esbuild/android-x64@0.27.7':
- resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==}
- engines: {node: '>=18'}
- cpu: [x64]
- os: [android]
-
'@esbuild/android-x64@0.28.1':
resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==}
engines: {node: '>=18'}
@@ -1573,12 +1517,6 @@ packages:
cpu: [arm64]
os: [darwin]
- '@esbuild/darwin-arm64@0.27.7':
- resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==}
- engines: {node: '>=18'}
- cpu: [arm64]
- os: [darwin]
-
'@esbuild/darwin-arm64@0.28.1':
resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==}
engines: {node: '>=18'}
@@ -1591,12 +1529,6 @@ packages:
cpu: [x64]
os: [darwin]
- '@esbuild/darwin-x64@0.27.7':
- resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==}
- engines: {node: '>=18'}
- cpu: [x64]
- os: [darwin]
-
'@esbuild/darwin-x64@0.28.1':
resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==}
engines: {node: '>=18'}
@@ -1609,12 +1541,6 @@ packages:
cpu: [arm64]
os: [freebsd]
- '@esbuild/freebsd-arm64@0.27.7':
- resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==}
- engines: {node: '>=18'}
- cpu: [arm64]
- os: [freebsd]
-
'@esbuild/freebsd-arm64@0.28.1':
resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==}
engines: {node: '>=18'}
@@ -1627,12 +1553,6 @@ packages:
cpu: [x64]
os: [freebsd]
- '@esbuild/freebsd-x64@0.27.7':
- resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==}
- engines: {node: '>=18'}
- cpu: [x64]
- os: [freebsd]
-
'@esbuild/freebsd-x64@0.28.1':
resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==}
engines: {node: '>=18'}
@@ -1645,12 +1565,6 @@ packages:
cpu: [arm64]
os: [linux]
- '@esbuild/linux-arm64@0.27.7':
- resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==}
- engines: {node: '>=18'}
- cpu: [arm64]
- os: [linux]
-
'@esbuild/linux-arm64@0.28.1':
resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==}
engines: {node: '>=18'}
@@ -1663,12 +1577,6 @@ packages:
cpu: [arm]
os: [linux]
- '@esbuild/linux-arm@0.27.7':
- resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==}
- engines: {node: '>=18'}
- cpu: [arm]
- os: [linux]
-
'@esbuild/linux-arm@0.28.1':
resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==}
engines: {node: '>=18'}
@@ -1681,12 +1589,6 @@ packages:
cpu: [ia32]
os: [linux]
- '@esbuild/linux-ia32@0.27.7':
- resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==}
- engines: {node: '>=18'}
- cpu: [ia32]
- os: [linux]
-
'@esbuild/linux-ia32@0.28.1':
resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==}
engines: {node: '>=18'}
@@ -1699,12 +1601,6 @@ packages:
cpu: [loong64]
os: [linux]
- '@esbuild/linux-loong64@0.27.7':
- resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==}
- engines: {node: '>=18'}
- cpu: [loong64]
- os: [linux]
-
'@esbuild/linux-loong64@0.28.1':
resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==}
engines: {node: '>=18'}
@@ -1717,12 +1613,6 @@ packages:
cpu: [mips64el]
os: [linux]
- '@esbuild/linux-mips64el@0.27.7':
- resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==}
- engines: {node: '>=18'}
- cpu: [mips64el]
- os: [linux]
-
'@esbuild/linux-mips64el@0.28.1':
resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==}
engines: {node: '>=18'}
@@ -1735,12 +1625,6 @@ packages:
cpu: [ppc64]
os: [linux]
- '@esbuild/linux-ppc64@0.27.7':
- resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==}
- engines: {node: '>=18'}
- cpu: [ppc64]
- os: [linux]
-
'@esbuild/linux-ppc64@0.28.1':
resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==}
engines: {node: '>=18'}
@@ -1753,12 +1637,6 @@ packages:
cpu: [riscv64]
os: [linux]
- '@esbuild/linux-riscv64@0.27.7':
- resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==}
- engines: {node: '>=18'}
- cpu: [riscv64]
- os: [linux]
-
'@esbuild/linux-riscv64@0.28.1':
resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==}
engines: {node: '>=18'}
@@ -1771,12 +1649,6 @@ packages:
cpu: [s390x]
os: [linux]
- '@esbuild/linux-s390x@0.27.7':
- resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==}
- engines: {node: '>=18'}
- cpu: [s390x]
- os: [linux]
-
'@esbuild/linux-s390x@0.28.1':
resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==}
engines: {node: '>=18'}
@@ -1789,12 +1661,6 @@ packages:
cpu: [x64]
os: [linux]
- '@esbuild/linux-x64@0.27.7':
- resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==}
- engines: {node: '>=18'}
- cpu: [x64]
- os: [linux]
-
'@esbuild/linux-x64@0.28.1':
resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==}
engines: {node: '>=18'}
@@ -1807,12 +1673,6 @@ packages:
cpu: [arm64]
os: [netbsd]
- '@esbuild/netbsd-arm64@0.27.7':
- resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==}
- engines: {node: '>=18'}
- cpu: [arm64]
- os: [netbsd]
-
'@esbuild/netbsd-arm64@0.28.1':
resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==}
engines: {node: '>=18'}
@@ -1825,12 +1685,6 @@ packages:
cpu: [x64]
os: [netbsd]
- '@esbuild/netbsd-x64@0.27.7':
- resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==}
- engines: {node: '>=18'}
- cpu: [x64]
- os: [netbsd]
-
'@esbuild/netbsd-x64@0.28.1':
resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==}
engines: {node: '>=18'}
@@ -1843,12 +1697,6 @@ packages:
cpu: [arm64]
os: [openbsd]
- '@esbuild/openbsd-arm64@0.27.7':
- resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==}
- engines: {node: '>=18'}
- cpu: [arm64]
- os: [openbsd]
-
'@esbuild/openbsd-arm64@0.28.1':
resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==}
engines: {node: '>=18'}
@@ -1861,12 +1709,6 @@ packages:
cpu: [x64]
os: [openbsd]
- '@esbuild/openbsd-x64@0.27.7':
- resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==}
- engines: {node: '>=18'}
- cpu: [x64]
- os: [openbsd]
-
'@esbuild/openbsd-x64@0.28.1':
resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==}
engines: {node: '>=18'}
@@ -1879,12 +1721,6 @@ packages:
cpu: [arm64]
os: [openharmony]
- '@esbuild/openharmony-arm64@0.27.7':
- resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==}
- engines: {node: '>=18'}
- cpu: [arm64]
- os: [openharmony]
-
'@esbuild/openharmony-arm64@0.28.1':
resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==}
engines: {node: '>=18'}
@@ -1897,12 +1733,6 @@ packages:
cpu: [x64]
os: [sunos]
- '@esbuild/sunos-x64@0.27.7':
- resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==}
- engines: {node: '>=18'}
- cpu: [x64]
- os: [sunos]
-
'@esbuild/sunos-x64@0.28.1':
resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==}
engines: {node: '>=18'}
@@ -1915,12 +1745,6 @@ packages:
cpu: [arm64]
os: [win32]
- '@esbuild/win32-arm64@0.27.7':
- resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==}
- engines: {node: '>=18'}
- cpu: [arm64]
- os: [win32]
-
'@esbuild/win32-arm64@0.28.1':
resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==}
engines: {node: '>=18'}
@@ -1933,12 +1757,6 @@ packages:
cpu: [ia32]
os: [win32]
- '@esbuild/win32-ia32@0.27.7':
- resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==}
- engines: {node: '>=18'}
- cpu: [ia32]
- os: [win32]
-
'@esbuild/win32-ia32@0.28.1':
resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==}
engines: {node: '>=18'}
@@ -1951,12 +1769,6 @@ packages:
cpu: [x64]
os: [win32]
- '@esbuild/win32-x64@0.27.7':
- resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==}
- engines: {node: '>=18'}
- cpu: [x64]
- os: [win32]
-
'@esbuild/win32-x64@0.28.1':
resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==}
engines: {node: '>=18'}
@@ -2380,30 +2192,10 @@ packages:
peerDependencies:
nuxt: ^3.21.11
- '@nuxt/nitro-server@4.5.2':
- resolution: {integrity: sha512-sx/vtT8D1WIRUOMuKQz/9z8nZb7jgVWc6HWwlkXKDjYZ84otPFtYCozKqhXWv6xf3JxJvge/RUAOwh9Z7P4nQQ==}
- engines: {node: ^22.19.0 || ^24.11.0 || >=26.0.0}
- peerDependencies:
- '@babel/plugin-proposal-decorators': ^7.25.0 || ^8.0.0
- '@babel/plugin-syntax-typescript': ^7.25.0 || ^8.0.0
- '@rollup/plugin-babel': ^6.0.0 || ^7.0.0
- nuxt: ^4.5.2
- peerDependenciesMeta:
- '@babel/plugin-proposal-decorators':
- optional: true
- '@babel/plugin-syntax-typescript':
- optional: true
- '@rollup/plugin-babel':
- optional: true
-
'@nuxt/schema@3.21.11':
resolution: {integrity: sha512-5Couz53Pl/SgTwtMQ9jlxVIC6wZk9aAbWyeOD7D6D1UIP5DzV/yV5lPDY/kM0nmiqPSzJf9WLTdJgB2gNm0dOA==}
engines: {node: ^14.18.0 || >=16.10.0}
- '@nuxt/schema@4.5.2':
- resolution: {integrity: sha512-h9g1kt9O2iU9nX6HDFu9gvwtpn+8oSJX0RSTWRBnEx/Pkz+WlOHtjuS0SbkTAXw5aT1+H1GysWVwNTjJfBY/0w==}
- engines: {node: ^14.18.0 || >=16.10.0}
-
'@nuxt/telemetry@2.8.0':
resolution: {integrity: sha512-zAwXY24KYvpLTmiV+osagd2EHkfs5IF+7oDZYTQoit5r0kPlwaCNlzHp5I/wUAWT4LBw6lG8gZ6bWidAdv/erQ==}
engines: {node: '>=18.12.0'}
@@ -2461,26 +2253,6 @@ packages:
rollup-plugin-visualizer:
optional: true
- '@nuxt/vite-builder@4.5.2':
- resolution: {integrity: sha512-fC8KUs5F1VpSEjTIjmx5pdflciMlp3FDCubx3AXElvRPLtBAPmyB69kmqVFjRnVQ8+vRHNyI7iBHUg9Se8srLA==}
- engines: {node: ^22.18.0 || ^24.11.0 || >=26.0.0}
- peerDependencies:
- '@babel/plugin-proposal-decorators': ^7.25.0 || ^8.0.0
- '@babel/plugin-syntax-jsx': ^7.25.0 || ^8.0.0
- nuxt: 4.5.2
- rolldown: ^1.0.0
- rollup-plugin-visualizer: ^6.0.0 || ^7.0.1
- vue: ^3.3.4
- peerDependenciesMeta:
- '@babel/plugin-proposal-decorators':
- optional: true
- '@babel/plugin-syntax-jsx':
- optional: true
- rolldown:
- optional: true
- rollup-plugin-visualizer:
- optional: true
-
'@one-ini/wasm@0.1.1':
resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==}
@@ -3687,52 +3459,10 @@ packages:
resolution: {integrity: sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@unhead/bundler@3.3.1':
- resolution: {integrity: sha512-F9gEgqpUKqHFzOv+7Pgm3TbmXhUdLX+WjZiPAK/huLDzblzZmvAUE9vRDymWaOST7jqGT/tPKkwl2kqbgb3nPw==}
+ '@unhead/vue@2.1.17':
+ resolution: {integrity: sha512-pnC8x9HLV3qQXdvWfylUEU25uhfCAy3ly9nmpQz84j9py818DRfU8jOsQ5wjdWtxyU1vX/fW2udfm4jtxUK8Bg==}
peerDependencies:
- '@unhead/cli': ^3.3.1
- '@vitejs/devtools-kit': ^0.4.1
- esbuild: '>=0.17.0'
- lightningcss: '>=1.20.0'
- oxc-parser: '>=0.98.0'
- rolldown: '>=1.0.0'
- unhead: ^3.3.1
- vite: '>=6.4.2'
- webpack: '>=5.0.0'
- peerDependenciesMeta:
- '@unhead/cli':
- optional: true
- '@vitejs/devtools-kit':
- optional: true
- esbuild:
- optional: true
- lightningcss:
- optional: true
- oxc-parser:
- optional: true
- rolldown:
- optional: true
- vite:
- optional: true
- webpack:
- optional: true
-
- '@unhead/vue@2.1.17':
- resolution: {integrity: sha512-pnC8x9HLV3qQXdvWfylUEU25uhfCAy3ly9nmpQz84j9py818DRfU8jOsQ5wjdWtxyU1vX/fW2udfm4jtxUK8Bg==}
- peerDependencies:
- vue: '>=3.5.18'
-
- '@unhead/vue@3.3.1':
- resolution: {integrity: sha512-iS+eiE1NehbV/eF7wzR7UUuUVTv8fIphFOCekQvEMuPqfKPCB8f3wf7sgPgUngYX6mdgM6PmkGmaOQgTBxqwbw==}
- peerDependencies:
- vite: '>=6.4.2'
vue: '>=3.5.18'
- webpack: '>=5.0.0'
- peerDependenciesMeta:
- vite:
- optional: true
- webpack:
- optional: true
'@unrs/resolver-binding-android-arm-eabi@1.12.2':
resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==}
@@ -3872,13 +3602,6 @@ packages:
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
vue: ^3.0.0
- '@vitejs/plugin-vue@5.2.4':
- resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==}
- engines: {node: ^18.0.0 || >=20.0.0}
- peerDependencies:
- vite: ^5.0.0 || ^6.0.0
- vue: ^3.2.25
-
'@vitejs/plugin-vue@6.0.8':
resolution: {integrity: sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -3957,15 +3680,6 @@ packages:
vue:
optional: true
- '@vue-macros/common@3.1.4':
- resolution: {integrity: sha512-/5Fv+6DgIcM9ajY05ZmKBv+LMX1M9A0X+IUwDRVdt67ciw8OV9bvG2r34p3RiEadlsQybjhKPRKNXDC8Bp23cw==}
- engines: {node: '>=20.19.0'}
- peerDependencies:
- vue: ^2.7.0 || ^3.2.25
- peerDependenciesMeta:
- vue:
- optional: true
-
'@vue/babel-helper-vue-transform-on@2.0.1':
resolution: {integrity: sha512-uZ66EaFbnnZSYqYEyplWvn46GhZ1KuYSThdT68p+am7MgBNbQ3hphTL9L+xSIsWkdktwhPYLwPgVWqo96jDdRA==}
@@ -4012,9 +3726,6 @@ packages:
'@vue/devtools-api@8.1.3':
resolution: {integrity: sha512-73NMCvxXh8Hyozc/jiwqTFWVcCMyi11U1zmrq4DoukQJnuo8JHt6FsNu4HdeUDa8SpIp5vb7Q22GWgIq0efsXg==}
- '@vue/devtools-api@8.2.1':
- resolution: {integrity: sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A==}
-
'@vue/devtools-core@8.2.1':
resolution: {integrity: sha512-s/VfAY9oDTb/kFEWmy461jaFde2MIV1RO/gi1vwM+PAZBZ/Pc2Ndu3BNBdZUze8QDUuyYvElbEEGA83syjJfzA==}
peerDependencies:
@@ -4068,10 +3779,6 @@ packages:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
- accepts@1.3.8:
- resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
- engines: {node: '>= 0.6'}
-
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
@@ -4106,9 +3813,6 @@ packages:
alien-signals@3.2.1:
resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==}
- ansi-align@3.0.1:
- resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==}
-
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
@@ -4159,9 +3863,6 @@ packages:
resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==}
engines: {node: '>= 0.4'}
- array-flatten@1.1.1:
- resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==}
-
array-includes@3.1.9:
resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==}
engines: {node: '>= 0.4'}
@@ -4402,10 +4103,6 @@ packages:
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
- boxen@8.0.1:
- resolution: {integrity: sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==}
- engines: {node: '>=18'}
-
brace-expansion@1.1.21:
resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==}
@@ -4475,16 +4172,9 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
- camelcase@8.0.0:
- resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==}
- engines: {node: '>=16'}
-
caniuse-api@3.0.0:
resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==}
- caniuse-api@4.0.0:
- resolution: {integrity: sha512-B0hQ1OLyJuHTQSOWXvwibWqM6DCoqJdvBA6X1S/53bd4XU7LJ1yurIPlrsouol3mw1jh9pGI4ivubSpmJeIqCA==}
-
caniuse-lite@1.0.30001809:
resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==}
@@ -4496,10 +4186,6 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
- chalk@5.6.2:
- resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
- engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
-
chokidar@5.0.0:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
engines: {node: '>= 20.19.0'}
@@ -4514,10 +4200,6 @@ packages:
citty@0.2.2:
resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==}
- cli-boxes@3.0.0:
- resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==}
- engines: {node: '>=10'}
-
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
@@ -4577,10 +4259,6 @@ packages:
resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
engines: {node: ^14.18.0 || >=16.10.0}
- content-disposition@0.5.4:
- resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
- engines: {node: '>= 0.6'}
-
content-disposition@1.1.0:
resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==}
engines: {node: '>=18'}
@@ -4615,9 +4293,6 @@ packages:
cookie-signature@1.0.6:
resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
- cookie-signature@1.0.7:
- resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==}
-
cookie-signature@1.2.2:
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
engines: {node: '>=6.6.0'}
@@ -4703,36 +4378,18 @@ packages:
peerDependencies:
postcss: 8.5.23
- cssnano-preset-default@8.0.2:
- resolution: {integrity: sha512-+jQAqIKCqMmBjZs7741XkilU93ITZ/EW8gjAkMmujdCzfDkfjrDBv2VqkSu29Fzeig/0rZ3S9IAwfPLlmXEUfQ==}
- engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
- peerDependencies:
- postcss: 8.5.23
-
cssnano-utils@5.0.3:
resolution: {integrity: sha512-ynIREMICLxkxm7e9bCR9sh75s4Q5drICi0ua1yxo5jH2XPBqSKkl4dOh4EbFqtUmnTMhRffHgYL0EKKkMjtJTg==}
engines: {node: ^18.12.0 || ^20.9.0 || >=22.0}
peerDependencies:
postcss: 8.5.23
- cssnano-utils@6.0.1:
- resolution: {integrity: sha512-zk65GIxA8tCjqVk7nTm1mE+ZKxtnxAvU5JSUaBLXbAr3ZF7IOvz3fbPOnEDvZKhnS7GOIitXTS5BgehLzNoc8Q==}
- engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
- peerDependencies:
- postcss: 8.5.23
-
cssnano@7.1.9:
resolution: {integrity: sha512-uPR75+5Dk/WJ/YSPR1/YDHdwMM9c5FsaARljfKWgeCKLKOtJ0we21xy/RcCjn53fZnD/f6yYEIZ8pu18+GnbNQ==}
engines: {node: ^18.12.0 || ^20.9.0 || >=22.0}
peerDependencies:
postcss: 8.5.23
- cssnano@8.0.2:
- resolution: {integrity: sha512-K+a76gA1v0/CsYgcsE95HGGyIuPKxpQSetwSwz4nHEM8fFXqSkzq2JzEXFL8v5+CCjxzVVVhPcTK3Oo8SaF/xA==}
- engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
- peerDependencies:
- postcss: 8.5.23
-
csso@5.0.5:
resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'}
@@ -5013,11 +4670,6 @@ packages:
engines: {node: '>=18'}
hasBin: true
- esbuild@0.27.7:
- resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==}
- engines: {node: '>=18'}
- hasBin: true
-
esbuild@0.28.1:
resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==}
engines: {node: '>=18'}
@@ -5206,10 +4858,6 @@ packages:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
- express@4.22.2:
- resolution: {integrity: sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==}
- engines: {node: '>= 0.10.0'}
-
express@5.2.1:
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
engines: {node: '>= 18'}
@@ -5282,10 +4930,6 @@ packages:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
- finalhandler@1.3.2:
- resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==}
- engines: {node: '>= 0.8'}
-
finalhandler@2.1.1:
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
engines: {node: '>= 18.0.0'}
@@ -5307,9 +4951,6 @@ packages:
flatted@3.4.2:
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
- fnv1a-64@0.1.2:
- resolution: {integrity: sha512-mJbcILTPybAR1jdOwt/lVv8N2cffeJFFP+gVbSAwLEx1ujXH27RpPd8Rokec/KX9c76UYIB6Co+H4lQzxPnJfA==}
-
for-each@0.3.5:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
@@ -5325,10 +4966,6 @@ packages:
fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
- fresh@0.5.2:
- resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
- engines: {node: '>= 0.6'}
-
fresh@2.0.0:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'}
@@ -6045,9 +5682,6 @@ packages:
memory-cache@0.2.0:
resolution: {integrity: sha512-OcjA+jzjOYzKmKS6IQVALHLVz+rNTMPoJvCztFaZxwG14wtAW7VRZjwTQu06vKCYOxh4jVnik7ya0SXTB0W+xA==}
- merge-descriptors@1.0.3:
- resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==}
-
merge-descriptors@2.0.0:
resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
engines: {node: '>=18'}
@@ -6059,10 +5693,6 @@ packages:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
- methods@1.1.2:
- resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==}
- engines: {node: '>= 0.6'}
-
micromatch@4.0.8:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
@@ -6083,11 +5713,6 @@ packages:
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
engines: {node: '>=18'}
- mime@1.6.0:
- resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==}
- engines: {node: '>=4'}
- hasBin: true
-
mime@4.1.0:
resolution: {integrity: sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==}
engines: {node: '>=16'}
@@ -6183,10 +5808,6 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
- negotiator@0.6.3:
- resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
- engines: {node: '>= 0.6'}
-
negotiator@1.0.0:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
@@ -6297,20 +5918,6 @@ packages:
'@types/node':
optional: true
- nuxt@4.5.2:
- resolution: {integrity: sha512-tR3fcqeHlHmmkLMpIg3V7Y+1ltr302lW8djMw/iy+myfo7QSSz+BVJDuQhg5j73b9oteSyBfOKTDYTgvMtj6TA==}
- engines: {node: ^22.19.0 || ^24.11.0 || >=26.0.0}
- hasBin: true
- peerDependencies:
- '@parcel/watcher': ^2.1.0
- '@types/node': '>=18.12.0'
- rolldown: ~1.2.1
- peerDependenciesMeta:
- '@parcel/watcher':
- optional: true
- '@types/node':
- optional: true
-
nypm@0.6.9:
resolution: {integrity: sha512-zxlE2yvSWZWmHcNdT3+5zV2lrCogeE9YOklHrR3dFjqutq5wO7GFDYLFDRXLsYnJzwvy/im9fYoxePvS0VTW0w==}
engines: {node: '>=18'}
@@ -6320,9 +5927,6 @@ packages:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
- object-identity@0.2.3:
- resolution: {integrity: sha512-2J8Joz2Tf7aaylhqFvIUJHNgpuGR38Hh75Voq9GzTbStBxJUaOtN0K1aOd3cV5qp+ij1pMqRbPrGCGMOyX303w==}
-
object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
@@ -6465,9 +6069,6 @@ packages:
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
engines: {node: 18 || 20 || >=22}
- path-to-regexp@0.1.13:
- resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==}
-
path-to-regexp@8.4.2:
resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
@@ -6538,144 +6139,72 @@ packages:
peerDependencies:
postcss: 8.5.23
- postcss-colormin@8.0.1:
- resolution: {integrity: sha512-qBY4ABQ6d8/mk5RRZHwMllrZMxeMey3azVY2dZUEk+RgiUC4ARdPR3/AITzNqqKTbvW/3y/MJKinDrzwqn8RDQ==}
- engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
- peerDependencies:
- postcss: 8.5.23
-
postcss-convert-values@7.0.12:
resolution: {integrity: sha512-xurKu5qqk4viR3Cp3p4xBR4KfnZm4w4ys6+UBwBmeuBSNkH7+DtLnYOYnOffgtE4yx8sH9S1VZ6RAAvROXzP2Q==}
engines: {node: ^18.12.0 || ^20.9.0 || >=22.0}
peerDependencies:
postcss: 8.5.23
- postcss-convert-values@8.0.1:
- resolution: {integrity: sha512-IdOSIX3BzfMvCc1TAHIha2gfy17xnb5vfML8e2BIKARnFOghksESfaSAB/3CXgyLfMozZAbTRPVQF5dbuKOidw==}
- engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
- peerDependencies:
- postcss: 8.5.23
-
postcss-discard-comments@7.0.8:
resolution: {integrity: sha512-CvvS5S9WrXblFXCEJ9nVo+4z+eA7zSC7Z88V1HEJuwlQhlFnYTIjg1xJY+BCUiG2bvICap2tXii4mP22BD108Q==}
engines: {node: ^18.12.0 || ^20.9.0 || >=22.0}
peerDependencies:
postcss: 8.5.23
- postcss-discard-comments@8.0.1:
- resolution: {integrity: sha512-FDvzm3tXlEsQBO2XQgnta5ugsAqwBrgWH+j5QgXpegEIDYA0VPnZg2aP7LtmWtC49POskeIhXesFiU/k3NyFHA==}
- engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
- peerDependencies:
- postcss: 8.5.23
-
postcss-discard-duplicates@7.0.4:
resolution: {integrity: sha512-VBNn1+EuMZkeGVVtz0gRfbNGtx9IFgAsAV+E2pHtXPrp4qfGBkhTIiAuE/wrb+Y6Pakg9NewAlfTpYIFAWODtw==}
engines: {node: ^18.12.0 || ^20.9.0 || >=22.0}
peerDependencies:
postcss: 8.5.23
- postcss-discard-duplicates@8.0.1:
- resolution: {integrity: sha512-stTDXkI8YkCUfADurQhp03oq5ynsgSx6Qrw5B1swds6oTHtAeOZ9I0SHGK8cY/VpWUsIYFDWMs3IWf9jIEfFvA==}
- engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
- peerDependencies:
- postcss: 8.5.23
-
postcss-discard-empty@7.0.3:
resolution: {integrity: sha512-M2pyjQCU+/7cMHVtL6bKTHjv0lZnPLMpicgr67Dlth7AbuV9gjVTtUqaRwn6Pp6BwSDspUzhz8SaUrRykJU5Dw==}
engines: {node: ^18.12.0 || ^20.9.0 || >=22.0}
peerDependencies:
postcss: 8.5.23
- postcss-discard-empty@8.0.1:
- resolution: {integrity: sha512-Zv4fM1Yfhk71tbt6gfiptbL6jDHi+7apSnaMeaO9n1uET+1embrXQw5m93Zp5x28UyQSuv+AVkFY193jdwZ33w==}
- engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
- peerDependencies:
- postcss: 8.5.23
-
postcss-discard-overridden@7.0.3:
resolution: {integrity: sha512-aNovXo9UsZuRNLzHJtp13lHIvinDPfiXBPePpXkSjCbgp++iU2FqE+YxvjIsg6EdyPZsASFbfu+JcBFVsErXIQ==}
engines: {node: ^18.12.0 || ^20.9.0 || >=22.0}
peerDependencies:
postcss: 8.5.23
- postcss-discard-overridden@8.0.1:
- resolution: {integrity: sha512-ykt4fvrC7yYGzbxKyqBVjDCbsjF/11JgWK8enrdkobRyqqEtb/uDUCbKOGdvrK8X7BrShW8Lv5cCRNbdkNHGkQ==}
- engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
- peerDependencies:
- postcss: 8.5.23
-
postcss-merge-longhand@7.0.7:
resolution: {integrity: sha512-b3mfYUxR388u5Pt0HPcVIUtUDn/k15UfTY9M+ORW+meCR6JLNxoZffiYvXyOYQoRYQNZyX/UFkMCM/mNHxe1qA==}
engines: {node: ^18.12.0 || ^20.9.0 || >=22.0}
peerDependencies:
postcss: 8.5.23
- postcss-merge-longhand@8.0.1:
- resolution: {integrity: sha512-huTfSYgQ13O81SFvAuOi7GWnO48vvybjj3xF+X3qUoPjzvvaLpJH5DcUqqXcwOEulZUcvaV4s0V9WtWs+IAQPA==}
- engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
- peerDependencies:
- postcss: 8.5.23
-
postcss-merge-rules@7.0.11:
resolution: {integrity: sha512-SJUPM18g2BmPhf8BVlbwqWz4aK3pLu6u6xjfwEzra7xL6IBR10sUaiB++EzqcVfadPHrKBSMlNdP+XieykhI+Q==}
engines: {node: ^18.12.0 || ^20.9.0 || >=22.0}
peerDependencies:
postcss: 8.5.23
- postcss-merge-rules@8.0.1:
- resolution: {integrity: sha512-o3rk4UpnPNg469tklYwbR/NtvKc/f/wJiVDTnNQ/EFPw/LeiPOHUCvV1GIBQIZHGrBAYdPjToK6a+ojYprsrxQ==}
- engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
- peerDependencies:
- postcss: 8.5.23
-
postcss-minify-font-values@7.0.3:
resolution: {integrity: sha512-yilG/VOaNI74IylQvAQQxm3/wZVBkXyYUqNUAdxqwtbWUXPsbK1q8Ms0mL83v+f8YicgcyfYCRZtWACUdYajpA==}
engines: {node: ^18.12.0 || ^20.9.0 || >=22.0}
peerDependencies:
postcss: 8.5.23
- postcss-minify-font-values@8.0.1:
- resolution: {integrity: sha512-L8Nzs/PRlBSPrLdY/7rAiU5ZN5800+2J/4LRbfyG8SJnPljmgMaXVmQiCklvRS+yObfVRNtvmk/Ean/eoYcSeg==}
- engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
- peerDependencies:
- postcss: 8.5.23
-
postcss-minify-gradients@7.0.5:
resolution: {integrity: sha512-YraROyQRg3BI1+Hg8E05B/JPdnTm8EDSVu4P2BxdM+CRiOyfmou809+chGIqo6fQqwjPGQ947nbGncSjmTU1WQ==}
engines: {node: ^18.12.0 || ^20.9.0 || >=22.0}
peerDependencies:
postcss: 8.5.23
- postcss-minify-gradients@8.0.1:
- resolution: {integrity: sha512-qf+4s/hZMqTwpWN2teqf6+1yvR/SZK5HgHqXYuACeJXV7ABe7AXtBEomgxagUzcN4bSnmqBh5vnIml0dYqykYg==}
- engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
- peerDependencies:
- postcss: 8.5.23
-
postcss-minify-params@7.0.9:
resolution: {integrity: sha512-R8itbB8BhlpoYyBm1ou0dD+vJnQ3F6adQipR4UnkCHUwlo+S9WXJaDRg1RHjC8YVAtIdrQzSWvJl40HnGDTKjA==}
engines: {node: ^18.12.0 || ^20.9.0 || >=22.0}
peerDependencies:
postcss: 8.5.23
- postcss-minify-params@8.0.1:
- resolution: {integrity: sha512-L0h3H59deFfFg0wQN1NVaS/8E/LfGvaMuZKGO7siwlG995zo3OshtQyRkqKdVqcBwAORBvZ1nDZrKPLRapYkQw==}
- engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
- peerDependencies:
- postcss: 8.5.23
-
postcss-minify-selectors@7.1.2:
resolution: {integrity: sha512-aQtrEWKwqafNlExcKHQvPGsXR2+vlUqqJtf5XsCQcgsSb5PL4wlujWBYDJuWsP4UnQX1YHDHU8qRlD+1PzTQ+Q==}
engines: {node: ^18.12.0 || ^20.9.0 || >=22.0}
peerDependencies:
postcss: 8.5.23
- postcss-minify-selectors@8.0.2:
- resolution: {integrity: sha512-3icdxc/zght5UAizdwqZBDE2KOWHf1jMQCxET6iLACeNlRxfTPyXS0/COpGk8CQ2cECyaEKTRUd/i/k8Gxmz4g==}
- engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
- peerDependencies:
- postcss: 8.5.23
-
postcss-nested@7.0.2:
resolution: {integrity: sha512-5osppouFc0VR9/VYzYxO03VaDa3e8F23Kfd6/9qcZTUI8P58GIYlArOET2Wq0ywSl2o2PjELhYOFI4W7l5QHKw==}
engines: {node: '>=18.0'}
@@ -6688,144 +6217,72 @@ packages:
peerDependencies:
postcss: 8.5.23
- postcss-normalize-charset@8.0.1:
- resolution: {integrity: sha512-xzqr36F8UeIZOvOHsf3aul+RVJCADvSwuwpMLgizqKjisHZpBfztgW0XFLBfJvz9pJgaStaOXAtGb0zLqT6B0w==}
- engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
- peerDependencies:
- postcss: 8.5.23
-
postcss-normalize-display-values@7.0.3:
resolution: {integrity: sha512-ldsCX0QIt05pKIOobZtVQ48wXJecr+czw4+e1/YjVhLMqslShgpVxgPtI2CefURR8oyVoYaU/l829MMwExDMLw==}
engines: {node: ^18.12.0 || ^20.9.0 || >=22.0}
peerDependencies:
postcss: 8.5.23
- postcss-normalize-display-values@8.0.1:
- resolution: {integrity: sha512-ZDWOijOK1FFMlpgiQCUO9fCNKd7HJ9L7z9HWEq4iyubnUFWzdTSwm/LcrMbNW6iZ1oAtqeLYA0WA3xHszOI08g==}
- engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
- peerDependencies:
- postcss: 8.5.23
-
postcss-normalize-positions@7.0.4:
resolution: {integrity: sha512-VEvlpeGd3Ju1Hqa/oN4jaP3+ms4laYwkEL9N9u+B6k54PZjXbW1n6wI+aVprf1BQXlCYpS5+1pl/7/vHiKgARg==}
engines: {node: ^18.12.0 || ^20.9.0 || >=22.0}
peerDependencies:
postcss: 8.5.23
- postcss-normalize-positions@8.0.1:
- resolution: {integrity: sha512-uuivan2poSqbE48ST4do20dGaFUeXey9/H8rhHzoyVHB2I6BmkoVLZ/C9+BRjUlpaAFYVOoDY7epkiidzaYbvA==}
- engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
- peerDependencies:
- postcss: 8.5.23
-
postcss-normalize-repeat-style@7.0.4:
resolution: {integrity: sha512-6mPKlY/8cSaDHxX502wERADarJsccwlky6yIrOapHH2ZgfoKAV94SbiTKfKEs4EEpdazuc3J72WsqeYk7hp9+Q==}
engines: {node: ^18.12.0 || ^20.9.0 || >=22.0}
peerDependencies:
postcss: 8.5.23
- postcss-normalize-repeat-style@8.0.1:
- resolution: {integrity: sha512-q2hq5fmKxk29K6DjKA3nZ17Q2dtjhLYFNmFweKALmooUqx6UWAHF1bBoWTu/EqlJ88josb82A/J0Atj9LJUmpQ==}
- engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
- peerDependencies:
- postcss: 8.5.23
-
postcss-normalize-string@7.0.3:
resolution: {integrity: sha512-HnEQPUchi1eznmDKEYrKUTqrprEq97SrpUYClgUkv7V2zRODD9DFoUsYU+m9ZOetmD5ku7fEMZB/lwy8IT6xVQ==}
engines: {node: ^18.12.0 || ^20.9.0 || >=22.0}
peerDependencies:
postcss: 8.5.23
- postcss-normalize-string@8.0.1:
- resolution: {integrity: sha512-+Wf+kQJhm1WgSGEAuUaswE9rdpR9QbrKRVemcVHs6rhOoOTVIdAbgaicftfYA6vLM346P8onRzkEVbFN29ktKQ==}
- engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
- peerDependencies:
- postcss: 8.5.23
-
postcss-normalize-timing-functions@7.0.3:
resolution: {integrity: sha512-zmEzHdvpZBZu0OKlbJSfgASQvaayyAoVuWtvyr34IJ/LyS+DaOKvvR3EvFJ9RWWtNIx+CMvO125OVophaxNYew==}
engines: {node: ^18.12.0 || ^20.9.0 || >=22.0}
peerDependencies:
postcss: 8.5.23
- postcss-normalize-timing-functions@8.0.1:
- resolution: {integrity: sha512-W8/tvwRlm3T+yjGkg0IRTF4bvHj0vILYr/LOogCrJKHz2ey2HFRwfsAA8Bk9N4BGR7z7WmmDu/KzzwhJ6FoGPQ==}
- engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
- peerDependencies:
- postcss: 8.5.23
-
postcss-normalize-unicode@7.0.9:
resolution: {integrity: sha512-DRAdWfeh/TjmhLJsw91vdiWCnUod9iwvM7xyS02/nF/sLsCR3A8l3pztrSUrWG8DSBqfX7yEk9FM0USaVJ2mSg==}
engines: {node: ^18.12.0 || ^20.9.0 || >=22.0}
peerDependencies:
postcss: 8.5.23
- postcss-normalize-unicode@8.0.1:
- resolution: {integrity: sha512-Ad0YHNRBp4WHEOYUM/4wL/8MoL2fimEF8se/0q+Rt/owMzYpbxsypC1P8fN/oluwoRmRKdNVX7X2oycEobPWcQ==}
- engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
- peerDependencies:
- postcss: 8.5.23
-
postcss-normalize-url@7.0.3:
resolution: {integrity: sha512-CL93wmloq5qsffmFv+bw24MIRbmhHrp53qoh1LDAb/5TtjWEXI/np4xcP/Gw9oWCb2XyWnqHYLDUwiKRoJBA1Q==}
engines: {node: ^18.12.0 || ^20.9.0 || >=22.0}
peerDependencies:
postcss: 8.5.23
- postcss-normalize-url@8.0.1:
- resolution: {integrity: sha512-tkYcip6pCDY806xuxpJYqMW2M3/623jzGFJmz3m5Us47q8P28+gbRZxaea3Rr/CmwwLUiVlh+BTGYwQ6gvaP8A==}
- engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
- peerDependencies:
- postcss: 8.5.23
-
postcss-normalize-whitespace@7.0.3:
resolution: {integrity: sha512-FdHjjn+Ht5Z2ZRjNOmeCbNq6lq09sUYKpmlF/Aq0XjVNSLTL6fmHlA/3swN2wP2caY9GV/tjSDcIIyS7aN7W0A==}
engines: {node: ^18.12.0 || ^20.9.0 || >=22.0}
peerDependencies:
postcss: 8.5.23
- postcss-normalize-whitespace@8.0.1:
- resolution: {integrity: sha512-XzORadNfSrKWDZZpgAEHPKINKx8r9r9RIfE9c70g/HThdpbmPHhDYCodHSVESDxmKeySAYw1p4liuBCf7j6LyA==}
- engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
- peerDependencies:
- postcss: 8.5.23
-
postcss-ordered-values@7.0.4:
resolution: {integrity: sha512-nubSi49hDHQk4E8KIj+IbLY8Bg+8OcSUEhgyolgM+atnOvXjV7EjaR6bac4YGZoFyPa9mWoAF3EaYbWdFkKqVg==}
engines: {node: ^18.12.0 || ^20.9.0 || >=22.0}
peerDependencies:
postcss: 8.5.23
- postcss-ordered-values@8.0.1:
- resolution: {integrity: sha512-OLXq5lR1yk3KWQ1FPK6aWjFFdktHE9f9kb8cnt4LmIw7w30DnzgD9+sOVYJc5HenkWCX8i1MJhhFwmqc/GYqLg==}
- engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
- peerDependencies:
- postcss: 8.5.23
-
postcss-reduce-initial@7.0.9:
resolution: {integrity: sha512-ztTNPdIxXTxtBcG03E9u8v44M4ElXbMIRT7pf2onlquGula0Y83nKKxqM22FA/hMgkfCjN7ohevkVlaNwI8iOQ==}
engines: {node: ^18.12.0 || ^20.9.0 || >=22.0}
peerDependencies:
postcss: 8.5.23
- postcss-reduce-initial@8.0.1:
- resolution: {integrity: sha512-+aQsR6+61KRoIfcFNLP3v9RM7+0iYOTtPnjl1wr6JqMW1zx6S+t2ktHRefXwacFdHIDj5+ETG0KY7K3+SGQ4Nw==}
- engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
- peerDependencies:
- postcss: 8.5.23
-
postcss-reduce-transforms@7.0.3:
resolution: {integrity: sha512-FXsnN9ZwcZTT8Yf8cAHA8qIGUXcX6WfLd9JoYhrdDfmvsVhhfqkkv7m4AC3rwFOfz+GzkUa87OCKF9dUcicd+g==}
engines: {node: ^18.12.0 || ^20.9.0 || >=22.0}
peerDependencies:
postcss: 8.5.23
- postcss-reduce-transforms@8.0.1:
- resolution: {integrity: sha512-x71slHVykiFi5RuKEXM0wgYpY2PngC78x6R8TnZhHF3lhqt+u/w3MGwYLX+2t5O87ssRiMfEAhQH+3J4QwVzCw==}
- engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
- peerDependencies:
- postcss: 8.5.23
-
postcss-selector-parser@7.1.4:
resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==}
engines: {node: '>=4'}
@@ -6836,24 +6293,12 @@ packages:
peerDependencies:
postcss: 8.5.23
- postcss-svgo@8.0.1:
- resolution: {integrity: sha512-HpnvWii7W0/FPrsejJa6ZTi0kNtTJP/Iba7CUMPX0xPV6QpnndOp+SDP74tFtgjA2cYKYNWJPOlmLXMsvi/9yA==}
- engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
- peerDependencies:
- postcss: 8.5.23
-
postcss-unique-selectors@7.0.7:
resolution: {integrity: sha512-d+sCkaRnSefghOUdH8CMJZV9yUQhj2ojpe8Nw/lA+LV1UOfeleGkLTl6XdCFFSai9UJ+DJPb69FFuqthXYsY8w==}
engines: {node: ^18.12.0 || ^20.9.0 || >=22.0}
peerDependencies:
postcss: 8.5.23
- postcss-unique-selectors@8.0.1:
- resolution: {integrity: sha512-+xvKI5+/Cl8yYQwxDV39Uhuc4WV951xngFvPPjiPj2NIbIfm6vbbRTXblyw0FioLkIoGlw+7qUcY1h2YhaZYgw==}
- engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
- peerDependencies:
- postcss: 8.5.23
-
postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
@@ -6906,10 +6351,6 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
- qs@6.15.3:
- resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==}
- engines: {node: '>=0.6'}
-
qs@6.16.0:
resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==}
engines: {node: '>=0.6'}
@@ -7042,15 +6483,6 @@ packages:
engines: {node: 20 || >=22}
hasBin: true
- rolldown-string@0.3.1:
- resolution: {integrity: sha512-dv8GOXkYqUQdI0rsXB8hmzO95pKWSuX2eg5/JSJa9czfEVIFuKNR7ZJDfat8LlmD35RAhWY+evS7/wXXqFDfSg==}
- engines: {node: '>=20.19.0'}
- peerDependencies:
- rolldown: '*'
- peerDependenciesMeta:
- rolldown:
- optional: true
-
rolldown@1.0.0-beta.45:
resolution: {integrity: sha512-iMmuD72XXLf26Tqrv1cryNYLX6NNPLhZ3AmNkSf8+xda0H+yijjGJ+wVT9UdBUHOpKzq9RjKtQKRCWoEKQQBZQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -7155,10 +6587,6 @@ packages:
engines: {node: '>=10'}
hasBin: true
- send@0.19.2:
- resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==}
- engines: {node: '>= 0.8.0'}
-
send@1.2.1:
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
engines: {node: '>= 18'}
@@ -7180,10 +6608,6 @@ packages:
serve-placeholder@2.0.2:
resolution: {integrity: sha512-/TMG8SboeiQbZJWRlfTCqMs2DD3SZgWp0kDQePz9yUuCnDfDh/92gf7/PxGhzXTKBIPASIHxFcZndoNbp6QOLQ==}
- serve-static@1.16.3:
- resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==}
- engines: {node: '>= 0.8.0'}
-
serve-static@2.2.1:
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
engines: {node: '>= 18'}
@@ -7413,12 +6837,6 @@ packages:
peerDependencies:
postcss: 8.5.23
- stylehacks@8.0.1:
- resolution: {integrity: sha512-Gv095oTD0N+BdJALNFDsxZpETHZLTxbOl5RyIO7y6VAE6sR3z0MnV3Nix7N0IATNldNTrkvSASp2KR1Yt526HA==}
- engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
- peerDependencies:
- postcss: 8.5.23
-
stylis@4.2.0:
resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==}
@@ -7554,10 +6972,6 @@ packages:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
- type-fest@4.41.0:
- resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
- engines: {node: '>=16'}
-
type-fest@5.7.0:
resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==}
engines: {node: '>=20'}
@@ -7656,24 +7070,12 @@ packages:
resolution: {integrity: sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==}
engines: {node: '>=20.18.1'}
- undici@8.11.2:
- resolution: {integrity: sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==}
- engines: {node: '>=22.19.0'}
-
unenv@2.0.0-rc.24:
resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==}
unhead@2.1.17:
resolution: {integrity: sha512-HLMKXOszRhAPBrr6VlqCeVeJq2kbC4kXwzGLEZvvojPLWNYTJw22xG7Bfwhsvs31+IBet3Wl8ADg9dwYdyphfQ==}
- unhead@3.3.1:
- resolution: {integrity: sha512-eqHlbLyuvIXw898WopQmosTml4PdYW5ZhXDom/sf7ysAqQB9uvYrw2/dNvbrmkJTufSkmNmgGCjoQcvoT2mS/A==}
- peerDependencies:
- vite: '>=6.4.2'
- peerDependenciesMeta:
- vite:
- optional: true
-
unicorn-magic@0.3.0:
resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
engines: {node: '>=18'}
@@ -7773,9 +7175,6 @@ packages:
webpack:
optional: true
- unrouting@0.2.2:
- resolution: {integrity: sha512-EoCab68s1o9AWFq9fjKsMEsmjKrPO11SAsvopikks2eEm/KLXgZMCof4cgpVgdy0Vz71OFBJw6DoDqu1oOSFGw==}
-
unrs-resolver@1.12.2:
resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==}
@@ -7872,10 +7271,6 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
- utils-merge@1.0.1:
- resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
- engines: {node: '>= 0.4.0'}
-
uuid@11.1.1:
resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==}
hasBin: true
@@ -7903,11 +7298,6 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
- vite-node@6.0.0:
- resolution: {integrity: sha512-oj4PVrT+pDh6GYf5wfUXkcZyekYS8kKPfLPXVl8qe324Ec6l4K2DUKNadRbZ3LQl0qGcDz+PyOo7ZAh00Y+JjQ==}
- engines: {node: ^20.19.0 || >=22.12.0}
- hasBin: true
-
vite-plugin-checker@0.14.5:
resolution: {integrity: sha512-c9lQ92eisUO+F7Fd93aelojmiOS+NQpPgQ1XR2LTQHox1/laZf4yAoQj+L3RA9Vgh10e2nFd9b8r2LLyYZsbpA==}
engines: {node: '>=20.19.0'}
@@ -7995,46 +7385,6 @@ packages:
yaml:
optional: true
- vite@7.3.5:
- resolution: {integrity: sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==}
- engines: {node: ^20.19.0 || >=22.12.0}
- hasBin: true
- peerDependencies:
- '@types/node': ^20.19.0 || >=22.12.0
- jiti: '>=1.21.0'
- less: ^4.0.0
- lightningcss: ^1.21.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
- jiti:
- optional: true
- less:
- optional: true
- lightningcss:
- optional: true
- sass:
- optional: true
- sass-embedded:
- optional: true
- stylus:
- optional: true
- sugarss:
- optional: true
- terser:
- optional: true
- tsx:
- optional: true
- yaml:
- optional: true
-
vite@7.3.6:
resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -8168,9 +7518,6 @@ packages:
vue-component-type-helpers@2.2.12:
resolution: {integrity: sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==}
- vue-component-type-helpers@3.3.9:
- resolution: {integrity: sha512-3c/UfMe0SqyEfcGTyH7mfshHagJ9QTCbppCb0/uGpHZpFug7+If3GeGZN7I0YheKEExemx3xldQPoO7PQSOLQg==}
-
vue-devtools-stub@0.1.0:
resolution: {integrity: sha512-RutnB7X8c5hjq39NceArgXg28WZtZpGc3+J16ljMiYnFhKvd8hITxSWQSQ5bvldxMDU6gG5mkxl1MTQLXckVSQ==}
@@ -8203,24 +7550,6 @@ packages:
vite:
optional: true
- vue-router@5.2.0:
- resolution: {integrity: sha512-QAC5i0LEb1GLG0LXDQmHu8L7FX12j0KwU/JTKmLQUJMrn04gQdKP6Du+p0QwpHb3iy71vBlqnHQ8WAfOSAWhqw==}
- peerDependencies:
- '@pinia/colada': '>=0.21.2'
- '@vue/compiler-sfc': ^3.5.34 || ^4.0.0
- pinia: ^3.0.4 || ^4.0.2
- vite: ^7.3.0 || ^8.0.0
- vue: ^3.5.34 || ^4.0.0
- peerDependenciesMeta:
- '@pinia/colada':
- optional: true
- '@vue/compiler-sfc':
- optional: true
- pinia:
- optional: true
- vite:
- optional: true
-
vue-sfc-transformer@0.1.17:
resolution: {integrity: sha512-0mpkDTWm1ybtp/Mp3vhrXP4r8yxcGF+quxGyJfrHDl2tl5naQjK3xkIGaVR5BtR5KG1LWJbdCrqn7I6f460j9A==}
engines: {node: '>=18.0.0'}
@@ -8302,10 +7631,6 @@ packages:
engines: {node: '>=8'}
hasBin: true
- widest-line@5.0.0:
- resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==}
- engines: {node: '>=18'}
-
word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
@@ -8557,7 +7882,7 @@ snapshots:
'@babel/parser@7.29.7':
dependencies:
- '@babel/types': 7.29.7
+ '@babel/types': 7.29.8
'@babel/parser@7.29.8':
dependencies:
@@ -8839,234 +8164,156 @@ snapshots:
'@esbuild/aix-ppc64@0.25.12':
optional: true
- '@esbuild/aix-ppc64@0.27.7':
- optional: true
-
'@esbuild/aix-ppc64@0.28.1':
optional: true
'@esbuild/android-arm64@0.25.12':
optional: true
- '@esbuild/android-arm64@0.27.7':
- optional: true
-
'@esbuild/android-arm64@0.28.1':
optional: true
'@esbuild/android-arm@0.25.12':
optional: true
- '@esbuild/android-arm@0.27.7':
- optional: true
-
'@esbuild/android-arm@0.28.1':
optional: true
'@esbuild/android-x64@0.25.12':
optional: true
- '@esbuild/android-x64@0.27.7':
- optional: true
-
'@esbuild/android-x64@0.28.1':
optional: true
'@esbuild/darwin-arm64@0.25.12':
optional: true
- '@esbuild/darwin-arm64@0.27.7':
- optional: true
-
- '@esbuild/darwin-arm64@0.28.1':
+ '@esbuild/darwin-arm64@0.28.1':
optional: true
'@esbuild/darwin-x64@0.25.12':
optional: true
- '@esbuild/darwin-x64@0.27.7':
- optional: true
-
'@esbuild/darwin-x64@0.28.1':
optional: true
'@esbuild/freebsd-arm64@0.25.12':
optional: true
- '@esbuild/freebsd-arm64@0.27.7':
- optional: true
-
'@esbuild/freebsd-arm64@0.28.1':
optional: true
'@esbuild/freebsd-x64@0.25.12':
optional: true
- '@esbuild/freebsd-x64@0.27.7':
- optional: true
-
'@esbuild/freebsd-x64@0.28.1':
optional: true
'@esbuild/linux-arm64@0.25.12':
optional: true
- '@esbuild/linux-arm64@0.27.7':
- optional: true
-
'@esbuild/linux-arm64@0.28.1':
optional: true
'@esbuild/linux-arm@0.25.12':
optional: true
- '@esbuild/linux-arm@0.27.7':
- optional: true
-
'@esbuild/linux-arm@0.28.1':
optional: true
'@esbuild/linux-ia32@0.25.12':
optional: true
- '@esbuild/linux-ia32@0.27.7':
- optional: true
-
'@esbuild/linux-ia32@0.28.1':
optional: true
'@esbuild/linux-loong64@0.25.12':
optional: true
- '@esbuild/linux-loong64@0.27.7':
- optional: true
-
'@esbuild/linux-loong64@0.28.1':
optional: true
'@esbuild/linux-mips64el@0.25.12':
optional: true
- '@esbuild/linux-mips64el@0.27.7':
- optional: true
-
'@esbuild/linux-mips64el@0.28.1':
optional: true
'@esbuild/linux-ppc64@0.25.12':
optional: true
- '@esbuild/linux-ppc64@0.27.7':
- optional: true
-
'@esbuild/linux-ppc64@0.28.1':
optional: true
'@esbuild/linux-riscv64@0.25.12':
optional: true
- '@esbuild/linux-riscv64@0.27.7':
- optional: true
-
'@esbuild/linux-riscv64@0.28.1':
optional: true
'@esbuild/linux-s390x@0.25.12':
optional: true
- '@esbuild/linux-s390x@0.27.7':
- optional: true
-
'@esbuild/linux-s390x@0.28.1':
optional: true
'@esbuild/linux-x64@0.25.12':
optional: true
- '@esbuild/linux-x64@0.27.7':
- optional: true
-
'@esbuild/linux-x64@0.28.1':
optional: true
'@esbuild/netbsd-arm64@0.25.12':
optional: true
- '@esbuild/netbsd-arm64@0.27.7':
- optional: true
-
'@esbuild/netbsd-arm64@0.28.1':
optional: true
'@esbuild/netbsd-x64@0.25.12':
optional: true
- '@esbuild/netbsd-x64@0.27.7':
- optional: true
-
'@esbuild/netbsd-x64@0.28.1':
optional: true
'@esbuild/openbsd-arm64@0.25.12':
optional: true
- '@esbuild/openbsd-arm64@0.27.7':
- optional: true
-
'@esbuild/openbsd-arm64@0.28.1':
optional: true
'@esbuild/openbsd-x64@0.25.12':
optional: true
- '@esbuild/openbsd-x64@0.27.7':
- optional: true
-
'@esbuild/openbsd-x64@0.28.1':
optional: true
'@esbuild/openharmony-arm64@0.25.12':
optional: true
- '@esbuild/openharmony-arm64@0.27.7':
- optional: true
-
'@esbuild/openharmony-arm64@0.28.1':
optional: true
'@esbuild/sunos-x64@0.25.12':
optional: true
- '@esbuild/sunos-x64@0.27.7':
- optional: true
-
'@esbuild/sunos-x64@0.28.1':
optional: true
'@esbuild/win32-arm64@0.25.12':
optional: true
- '@esbuild/win32-arm64@0.27.7':
- optional: true
-
'@esbuild/win32-arm64@0.28.1':
optional: true
'@esbuild/win32-ia32@0.25.12':
optional: true
- '@esbuild/win32-ia32@0.27.7':
- optional: true
-
'@esbuild/win32-ia32@0.28.1':
optional: true
'@esbuild/win32-x64@0.25.12':
optional: true
- '@esbuild/win32-x64@0.27.7':
- optional: true
-
'@esbuild/win32-x64@0.28.1':
optional: true
@@ -9418,44 +8665,6 @@ snapshots:
- magicast
- supports-color
- '@nuxt/cli@3.37.0(@nuxt/schema@4.5.2)(cac@6.7.14)(magicast@0.5.4)(supports-color@10.2.2)':
- dependencies:
- '@bomb.sh/tab': 0.0.19(cac@6.7.14)(citty@0.2.2)
- '@clack/prompts': 1.7.0
- c12: 3.3.4(magicast@0.5.4)
- citty: 0.2.2
- confbox: 0.2.4
- consola: 3.4.2
- debug: 4.4.3(supports-color@10.2.2)
- defu: 6.1.7
- exsolve: 1.1.1
- fuse.js: 7.4.2
- fzf: 0.5.2
- giget: 3.3.0
- jiti: 2.7.0
- listhen: 1.10.0(srvx@0.11.22)
- nypm: 0.6.9
- ofetch: 1.5.1
- ohash: 2.0.11
- pathe: 2.0.3
- perfect-debounce: 2.1.0
- pkg-types: 2.3.1
- scule: 1.3.0
- semver: 7.8.5
- srvx: 0.11.22
- std-env: 4.2.0
- tinyclip: 0.1.15
- tinyexec: 1.2.4
- ufo: 1.6.4
- youch: 4.1.1
- optionalDependencies:
- '@nuxt/schema': 4.5.2
- transitivePeerDependencies:
- - cac
- - commander
- - magicast
- - supports-color
-
'@nuxt/devalue@2.0.2': {}
'@nuxt/devtools-kit@3.4.1(magic-string@0.30.21)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.3)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))':
@@ -9806,91 +9015,6 @@ snapshots:
- webpack
- xml2js
- '@nuxt/nitro-server@4.5.2(dc2235b049bcdc63cc648371a1423346)':
- dependencies:
- '@nuxt/devalue': 2.0.2
- '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.3)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)))
- '@unhead/vue': 3.3.1(@oxc-project/types@0.143.0)(esbuild@0.28.1)(lightningcss@1.33.0)(oxc-parser@0.143.0)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.43(typescript@5.9.3))
- '@vue/shared': 3.5.41
- consola: 3.4.2
- defu: 6.1.7
- destr: 2.0.5
- devalue: 5.9.4
- errx: 0.1.2
- escape-string-regexp: 5.0.0
- exsolve: 1.1.1
- h3: 1.15.11
- impound: 1.1.6
- klona: 2.0.6
- mocked-exports: 0.1.1
- nitropack: 2.13.4(oxc-parser@0.143.0)(rolldown@1.2.3)(srvx@0.11.22)(supports-color@10.2.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))
- nostics: 1.2.0
- nuxt: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.143.0)(@parcel/watcher@2.5.6)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(optionator@0.9.4)(oxc-parser@0.143.0)(rolldown@1.2.3)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.3)(rollup@4.62.2))(rollup@4.62.2)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(yaml@2.9.0)
- nypm: 0.6.9
- ohash: 2.0.11
- pathe: 2.0.3
- rou3: 0.9.1
- std-env: 4.2.0
- ufo: 1.6.4
- unctx: 3.0.0(magic-string@1.1.0)(oxc-parser@0.143.0)(rolldown@1.2.3)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)))
- unstorage: 1.17.5(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2))
- vue: 3.5.43(typescript@5.9.3)
- vue-bundle-renderer: 2.3.2
- vue-devtools-stub: 0.1.0
- optionalDependencies:
- '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))
- transitivePeerDependencies:
- - '@azure/app-configuration'
- - '@azure/cosmos'
- - '@azure/data-tables'
- - '@azure/identity'
- - '@azure/keyvault-secrets'
- - '@azure/storage-blob'
- - '@capacitor/preferences'
- - '@deno/kv'
- - '@electric-sql/pglite'
- - '@farmfe/core'
- - '@libsql/client'
- - '@netlify/blobs'
- - '@oxc-project/types'
- - '@planetscale/database'
- - '@rspack/core'
- - '@unhead/cli'
- - '@upstash/redis'
- - '@vercel/blob'
- - '@vercel/functions'
- - '@vercel/kv'
- - '@vitejs/devtools-kit'
- - aws4fetch
- - bare-abort-controller
- - bare-buffer
- - better-sqlite3
- - bun-types-no-globals
- - db0
- - drizzle-orm
- - encoding
- - esbuild
- - idb-keyval
- - ioredis
- - lightningcss
- - magic-string
- - magicast
- - mysql2
- - oxc-parser
- - react-native-b4a
- - rolldown
- - rollup
- - sqlite3
- - srvx
- - supports-color
- - typescript
- - unloader
- - unplugin
- - uploadthing
- - vite
- - webpack
- - xml2js
-
'@nuxt/schema@3.21.11':
dependencies:
'@vue/shared': 3.5.41
@@ -9899,15 +9023,6 @@ snapshots:
pkg-types: 2.3.1
std-env: 4.2.0
- '@nuxt/schema@4.5.2':
- dependencies:
- '@vue/shared': 3.5.41
- defu: 6.1.7
- nostics: 1.2.0
- pathe: 2.0.3
- pkg-types: 2.3.1
- std-env: 4.2.0
-
'@nuxt/telemetry@2.8.0(@nuxt/kit@3.21.11(magicast@0.5.4))':
dependencies:
'@nuxt/kit': 3.21.11(magicast@0.5.4)
@@ -9917,15 +9032,6 @@ snapshots:
rc9: 3.0.1
std-env: 4.2.0
- '@nuxt/telemetry@2.8.0(@nuxt/kit@4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.3)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))))':
- dependencies:
- '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.3)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)))
- citty: 0.2.2
- consola: 3.4.2
- ofetch: 2.0.0-alpha.3
- rc9: 3.0.1
- std-env: 4.2.0
-
'@nuxt/test-utils@3.17.2(@playwright/test@1.60.0)(@types/node@24.7.2)(@vue/test-utils@2.4.6)(jiti@2.7.0)(jsdom@27.0.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(playwright-core@1.60.0)(terser@5.48.0)(typescript@5.9.3)(vitest@4.1.11)(yaml@2.9.0)':
dependencies:
'@nuxt/kit': 3.21.11(magicast@0.5.4)
@@ -10037,75 +9143,6 @@ snapshots:
- vue-tsc
- yaml
- '@nuxt/vite-builder@4.5.2(5ac2fde01a19fe5d6caf00292c982247)':
- dependencies:
- '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.3)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)))
- '@vitejs/plugin-vue': 6.0.8(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.43(typescript@5.9.3))
- '@vitejs/plugin-vue-jsx': 5.1.6(supports-color@10.2.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.43(typescript@5.9.3))
- autoprefixer: 10.5.4(postcss@8.5.23)
- consola: 3.4.2
- cssnano: 8.0.2(postcss@8.5.23)
- defu: 6.1.7
- escape-string-regexp: 5.0.0
- exsolve: 1.1.1
- generic-names: 4.0.0
- get-port-please: 3.2.0
- jiti: 2.7.0
- js-tokens: 10.0.0
- knitwork: 1.3.0
- mlly: 1.8.2
- mocked-exports: 0.1.1
- nuxt: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.143.0)(@parcel/watcher@2.5.6)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(optionator@0.9.4)(oxc-parser@0.143.0)(rolldown@1.2.3)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.3)(rollup@4.62.2))(rollup@4.62.2)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(yaml@2.9.0)
- nypm: 0.6.9
- pathe: 2.0.3
- pkg-types: 2.3.1
- postcss: 8.5.23
- rolldown-string: 0.3.1(rolldown@1.2.3)
- seroval: 1.6.8
- std-env: 4.2.0
- ufo: 1.6.4
- unenv: 2.0.0-rc.24
- unplugin: 3.3.0(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))
- vite: 8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)
- vite-node: 6.0.0(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)
- vite-plugin-checker: 0.14.5(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(optionator@0.9.4)(typescript@5.9.3)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))
- vue: 3.5.43(typescript@5.9.3)
- vue-bundle-renderer: 2.3.2
- optionalDependencies:
- '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))
- rolldown: 1.2.3
- rollup-plugin-visualizer: 7.0.1(rolldown@1.2.3)(rollup@4.62.2)
- transitivePeerDependencies:
- - '@biomejs/biome'
- - '@farmfe/core'
- - '@rspack/core'
- - '@types/node'
- - '@vitejs/devtools'
- - bun-types-no-globals
- - esbuild
- - eslint
- - less
- - magic-string
- - magicast
- - meow
- - optionator
- - oxc-parser
- - oxlint
- - rollup
- - sass
- - sass-embedded
- - stylelint
- - stylus
- - sugarss
- - supports-color
- - terser
- - tsx
- - typescript
- - unloader
- - vue-tsc
- - webpack
- - yaml
-
'@one-ini/wasm@0.1.1': {}
'@opentelemetry/semantic-conventions@1.43.0': {}
@@ -10749,24 +9786,24 @@ snapshots:
'@types/babel__core@7.20.5':
dependencies:
- '@babel/parser': 7.29.7
- '@babel/types': 7.29.7
+ '@babel/parser': 7.29.8
+ '@babel/types': 7.29.8
'@types/babel__generator': 7.27.0
'@types/babel__template': 7.4.4
'@types/babel__traverse': 7.28.0
'@types/babel__generator@7.27.0':
dependencies:
- '@babel/types': 7.29.7
+ '@babel/types': 7.29.8
'@types/babel__template@7.4.4':
dependencies:
- '@babel/parser': 7.29.7
- '@babel/types': 7.29.7
+ '@babel/parser': 7.29.8
+ '@babel/types': 7.29.8
'@types/babel__traverse@7.28.0':
dependencies:
- '@babel/types': 7.29.7
+ '@babel/types': 7.29.8
'@types/body-parser@1.19.6':
dependencies:
@@ -10981,62 +10018,19 @@ snapshots:
'@typescript-eslint/visitor-keys@8.57.2':
dependencies:
- '@typescript-eslint/types': 8.57.2
- eslint-visitor-keys: 5.0.1
-
- '@typescript-eslint/visitor-keys@8.61.1':
- dependencies:
- '@typescript-eslint/types': 8.61.1
- eslint-visitor-keys: 5.0.1
-
- '@unhead/bundler@3.3.1(@oxc-project/types@0.143.0)(esbuild@0.28.1)(lightningcss@1.33.0)(oxc-parser@0.143.0)(rolldown@1.2.3)(rollup@4.62.2)(unhead@3.3.1(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))':
- dependencies:
- magic-string: 1.1.0
- oxc-walker: 1.1.1(@oxc-project/types@0.143.0)(oxc-parser@0.143.0)(rolldown@1.2.3)
- unhead: 3.3.1(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))
- unplugin: 3.3.0(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))
- optionalDependencies:
- esbuild: 0.28.1
- lightningcss: 1.33.0
- oxc-parser: 0.143.0
- rolldown: 1.2.3
- vite: 8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)
- transitivePeerDependencies:
- - '@farmfe/core'
- - '@oxc-project/types'
- - '@rspack/core'
- - bun-types-no-globals
- - rollup
- - unloader
+ '@typescript-eslint/types': 8.57.2
+ eslint-visitor-keys: 5.0.1
- '@unhead/vue@2.1.17(vue@3.5.43(typescript@5.9.3))':
+ '@typescript-eslint/visitor-keys@8.61.1':
dependencies:
- hookable: 6.1.1
- unhead: 2.1.17
- vue: 3.5.43(typescript@5.9.3)
+ '@typescript-eslint/types': 8.61.1
+ eslint-visitor-keys: 5.0.1
- '@unhead/vue@3.3.1(@oxc-project/types@0.143.0)(esbuild@0.28.1)(lightningcss@1.33.0)(oxc-parser@0.143.0)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.43(typescript@5.9.3))':
+ '@unhead/vue@2.1.17(vue@3.5.43(typescript@5.9.3))':
dependencies:
- '@unhead/bundler': 3.3.1(@oxc-project/types@0.143.0)(esbuild@0.28.1)(lightningcss@1.33.0)(oxc-parser@0.143.0)(rolldown@1.2.3)(rollup@4.62.2)(unhead@3.3.1(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))
hookable: 6.1.1
- unhead: 3.3.1(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))
- unplugin: 3.3.0(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))
+ unhead: 2.1.17
vue: 3.5.43(typescript@5.9.3)
- optionalDependencies:
- vite: 8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)
- transitivePeerDependencies:
- - '@farmfe/core'
- - '@oxc-project/types'
- - '@rspack/core'
- - '@unhead/cli'
- - '@vitejs/devtools-kit'
- - bun-types-no-globals
- - esbuild
- - lightningcss
- - oxc-parser
- - rolldown
- - rollup
- - unloader
'@unrs/resolver-binding-android-arm-eabi@1.12.2':
optional: true
@@ -11151,35 +10145,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@vitejs/plugin-vue-jsx@5.1.6(supports-color@10.2.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.43(typescript@5.9.3))':
- dependencies:
- '@babel/core': 7.29.7(supports-color@10.2.2)
- '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))
- '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)
- '@rolldown/pluginutils': 1.0.1
- '@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)
- vite: 8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)
- vue: 3.5.43(typescript@5.9.3)
- transitivePeerDependencies:
- - supports-color
-
- '@vitejs/plugin-vue@5.2.4(vite@6.4.3(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.43(typescript@5.9.3))':
- dependencies:
- vite: 6.4.3(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(yaml@2.9.0)
- vue: 3.5.43(typescript@5.9.3)
-
'@vitejs/plugin-vue@6.0.8(vite@7.3.6(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.43(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.1
vite: 7.3.6(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(yaml@2.9.0)
vue: 3.5.43(typescript@5.9.3)
- '@vitejs/plugin-vue@6.0.8(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.43(typescript@5.9.3))':
- dependencies:
- '@rolldown/pluginutils': 1.0.1
- vite: 8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)
- vue: 3.5.43(typescript@5.9.3)
-
'@vitest/browser-playwright@4.1.11(playwright@1.60.0)(vite@8.2.1(@types/node@22.15.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.11)':
dependencies:
'@vitest/browser': 4.1.11(vite@8.2.1(@types/node@22.15.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.11)
@@ -11319,16 +10290,6 @@ snapshots:
optionalDependencies:
vue: 3.5.43(typescript@5.9.3)
- '@vue-macros/common@3.1.4(vue@3.5.43(typescript@5.9.3))':
- dependencies:
- '@vue/compiler-sfc': 3.5.41
- ast-kit: 2.2.0
- local-pkg: 1.2.1
- magic-string-ast: 1.0.3
- unplugin-utils: 0.3.1
- optionalDependencies:
- vue: 3.5.43(typescript@5.9.3)
-
'@vue/babel-helper-vue-transform-on@2.0.1': {}
'@vue/babel-plugin-jsx@2.0.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)':
@@ -11338,7 +10299,7 @@ snapshots:
'@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))
'@babel/template': 7.29.7
'@babel/traverse': 7.29.7(supports-color@10.2.2)
- '@babel/types': 7.29.7
+ '@babel/types': 7.29.8
'@vue/babel-helper-vue-transform-on': 2.0.1
'@vue/babel-plugin-resolve-type': 2.0.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)
'@vue/shared': 3.5.41
@@ -11424,10 +10385,6 @@ snapshots:
dependencies:
'@vue/devtools-kit': 8.1.3
- '@vue/devtools-api@8.2.1':
- dependencies:
- '@vue/devtools-kit': 8.2.1
-
'@vue/devtools-core@8.2.1(vue@3.5.43(typescript@5.9.3))':
dependencies:
'@vue/devtools-kit': 8.2.1
@@ -11501,11 +10458,6 @@ snapshots:
dependencies:
event-target-shim: 5.0.1
- accepts@1.3.8:
- dependencies:
- mime-types: 2.1.35
- negotiator: 0.6.3
-
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
@@ -11538,10 +10490,6 @@ snapshots:
alien-signals@3.2.1: {}
- ansi-align@3.0.1:
- dependencies:
- string-width: 4.2.3
-
ansi-regex@5.0.1: {}
ansi-regex@6.2.2: {}
@@ -11598,8 +10546,6 @@ snapshots:
call-bound: 1.0.4
is-array-buffer: 3.0.5
- array-flatten@1.1.1: {}
-
array-includes@3.1.9:
dependencies:
call-bind: 1.0.9
@@ -11824,17 +10770,6 @@ snapshots:
boolbase@1.0.0: {}
- boxen@8.0.1:
- dependencies:
- ansi-align: 3.0.1
- camelcase: 8.0.0
- chalk: 5.6.2
- cli-boxes: 3.0.0
- string-width: 7.2.0
- type-fest: 4.41.0
- widest-line: 5.0.0
- wrap-ansi: 9.0.2
-
brace-expansion@1.1.21:
dependencies:
balanced-match: 1.0.2
@@ -11932,8 +10867,6 @@ snapshots:
callsites@3.1.0: {}
- camelcase@8.0.0: {}
-
caniuse-api@3.0.0:
dependencies:
browserslist: 4.28.8
@@ -11941,11 +10874,6 @@ snapshots:
lodash.memoize: 4.1.2
lodash.uniq: 4.5.0
- caniuse-api@4.0.0:
- dependencies:
- browserslist: 4.28.8
- caniuse-lite: 1.0.30001809
-
caniuse-lite@1.0.30001809: {}
chai@6.2.2: {}
@@ -11955,8 +10883,6 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
- chalk@5.6.2: {}
-
chokidar@5.0.0:
dependencies:
readdirp: 5.0.0
@@ -11969,8 +10895,6 @@ snapshots:
citty@0.2.2: {}
- cli-boxes@3.0.0: {}
-
client-only@0.0.1: {}
cliui@9.0.1:
@@ -12020,10 +10944,6 @@ snapshots:
consola@3.4.2: {}
- content-disposition@0.5.4:
- dependencies:
- safe-buffer: 5.2.1
-
content-disposition@1.1.0: {}
content-type@1.0.5: {}
@@ -12047,8 +10967,6 @@ snapshots:
cookie-signature@1.0.6: {}
- cookie-signature@1.0.7: {}
-
cookie-signature@1.2.2: {}
cookie@0.7.2: {}
@@ -12156,59 +11074,16 @@ snapshots:
postcss-svgo: 7.1.3(postcss@8.5.23)
postcss-unique-selectors: 7.0.7(postcss@8.5.23)
- cssnano-preset-default@8.0.2(postcss@8.5.23):
- dependencies:
- browserslist: 4.28.8
- cssnano-utils: 6.0.1(postcss@8.5.23)
- postcss: 8.5.23
- postcss-calc: 10.1.1(postcss@8.5.23)
- postcss-colormin: 8.0.1(postcss@8.5.23)
- postcss-convert-values: 8.0.1(postcss@8.5.23)
- postcss-discard-comments: 8.0.1(postcss@8.5.23)
- postcss-discard-duplicates: 8.0.1(postcss@8.5.23)
- postcss-discard-empty: 8.0.1(postcss@8.5.23)
- postcss-discard-overridden: 8.0.1(postcss@8.5.23)
- postcss-merge-longhand: 8.0.1(postcss@8.5.23)
- postcss-merge-rules: 8.0.1(postcss@8.5.23)
- postcss-minify-font-values: 8.0.1(postcss@8.5.23)
- postcss-minify-gradients: 8.0.1(postcss@8.5.23)
- postcss-minify-params: 8.0.1(postcss@8.5.23)
- postcss-minify-selectors: 8.0.2(postcss@8.5.23)
- postcss-normalize-charset: 8.0.1(postcss@8.5.23)
- postcss-normalize-display-values: 8.0.1(postcss@8.5.23)
- postcss-normalize-positions: 8.0.1(postcss@8.5.23)
- postcss-normalize-repeat-style: 8.0.1(postcss@8.5.23)
- postcss-normalize-string: 8.0.1(postcss@8.5.23)
- postcss-normalize-timing-functions: 8.0.1(postcss@8.5.23)
- postcss-normalize-unicode: 8.0.1(postcss@8.5.23)
- postcss-normalize-url: 8.0.1(postcss@8.5.23)
- postcss-normalize-whitespace: 8.0.1(postcss@8.5.23)
- postcss-ordered-values: 8.0.1(postcss@8.5.23)
- postcss-reduce-initial: 8.0.1(postcss@8.5.23)
- postcss-reduce-transforms: 8.0.1(postcss@8.5.23)
- postcss-svgo: 8.0.1(postcss@8.5.23)
- postcss-unique-selectors: 8.0.1(postcss@8.5.23)
-
cssnano-utils@5.0.3(postcss@8.5.23):
dependencies:
postcss: 8.5.23
- cssnano-utils@6.0.1(postcss@8.5.23):
- dependencies:
- postcss: 8.5.23
-
cssnano@7.1.9(postcss@8.5.23):
dependencies:
cssnano-preset-default: 7.0.17(postcss@8.5.23)
lilconfig: 3.1.3
postcss: 8.5.23
- cssnano@8.0.2(postcss@8.5.23):
- dependencies:
- cssnano-preset-default: 8.0.2(postcss@8.5.23)
- lilconfig: 3.1.3
- postcss: 8.5.23
-
csso@5.0.5:
dependencies:
css-tree: 2.2.1
@@ -12537,35 +11412,6 @@ snapshots:
'@esbuild/win32-ia32': 0.25.12
'@esbuild/win32-x64': 0.25.12
- esbuild@0.27.7:
- optionalDependencies:
- '@esbuild/aix-ppc64': 0.27.7
- '@esbuild/android-arm': 0.27.7
- '@esbuild/android-arm64': 0.27.7
- '@esbuild/android-x64': 0.27.7
- '@esbuild/darwin-arm64': 0.27.7
- '@esbuild/darwin-x64': 0.27.7
- '@esbuild/freebsd-arm64': 0.27.7
- '@esbuild/freebsd-x64': 0.27.7
- '@esbuild/linux-arm': 0.27.7
- '@esbuild/linux-arm64': 0.27.7
- '@esbuild/linux-ia32': 0.27.7
- '@esbuild/linux-loong64': 0.27.7
- '@esbuild/linux-mips64el': 0.27.7
- '@esbuild/linux-ppc64': 0.27.7
- '@esbuild/linux-riscv64': 0.27.7
- '@esbuild/linux-s390x': 0.27.7
- '@esbuild/linux-x64': 0.27.7
- '@esbuild/netbsd-arm64': 0.27.7
- '@esbuild/netbsd-x64': 0.27.7
- '@esbuild/openbsd-arm64': 0.27.7
- '@esbuild/openbsd-x64': 0.27.7
- '@esbuild/openharmony-arm64': 0.27.7
- '@esbuild/sunos-x64': 0.27.7
- '@esbuild/win32-arm64': 0.27.7
- '@esbuild/win32-ia32': 0.27.7
- '@esbuild/win32-x64': 0.27.7
-
esbuild@0.28.1:
optionalDependencies:
'@esbuild/aix-ppc64': 0.28.1
@@ -12836,42 +11682,6 @@ snapshots:
expect-type@1.3.0: {}
- express@4.22.2(supports-color@10.2.2):
- dependencies:
- accepts: 1.3.8
- array-flatten: 1.1.1
- body-parser: 1.20.6(supports-color@10.2.2)
- content-disposition: 0.5.4
- content-type: 1.0.5
- cookie: 0.7.2
- cookie-signature: 1.0.7
- debug: 2.6.9(supports-color@10.2.2)
- depd: 2.0.0
- encodeurl: 2.0.0
- escape-html: 1.0.3
- etag: 1.8.1
- finalhandler: 1.3.2(supports-color@10.2.2)
- fresh: 0.5.2
- http-errors: 2.0.1
- merge-descriptors: 1.0.3
- methods: 1.1.2
- on-finished: 2.4.1
- parseurl: 1.3.3
- path-to-regexp: 0.1.13
- proxy-addr: 2.0.8
- qs: 6.15.3
- range-parser: 1.2.1
- safe-buffer: 5.2.1
- send: 0.19.2(supports-color@10.2.2)
- serve-static: 1.16.3(supports-color@10.2.2)
- setprototypeof: 1.2.0
- statuses: 2.0.2
- type-is: 1.6.18
- utils-merge: 1.0.1
- vary: 1.1.2
- transitivePeerDependencies:
- - supports-color
-
express@5.2.1(supports-color@10.2.2):
dependencies:
accepts: 2.0.0
@@ -12954,10 +11764,6 @@ snapshots:
dependencies:
reusify: 1.1.0
- fdir@6.5.0(picomatch@4.0.4):
- optionalDependencies:
- picomatch: 4.0.4
-
fdir@6.5.0(picomatch@4.0.5):
optionalDependencies:
picomatch: 4.0.5
@@ -12972,18 +11778,6 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
- finalhandler@1.3.2(supports-color@10.2.2):
- dependencies:
- debug: 2.6.9(supports-color@10.2.2)
- encodeurl: 2.0.0
- escape-html: 1.0.3
- on-finished: 2.4.1
- parseurl: 1.3.3
- statuses: 2.0.2
- unpipe: 1.0.0
- transitivePeerDependencies:
- - supports-color
-
finalhandler@2.1.1(supports-color@10.2.2):
dependencies:
debug: 4.4.3(supports-color@10.2.2)
@@ -13015,8 +11809,6 @@ snapshots:
flatted@3.4.2: {}
- fnv1a-64@0.1.2: {}
-
for-each@0.3.5:
dependencies:
is-callable: 1.2.7
@@ -13030,8 +11822,6 @@ snapshots:
fraction.js@5.3.4: {}
- fresh@0.5.2: {}
-
fresh@2.0.0: {}
fsevents@2.3.2:
@@ -13743,16 +12533,12 @@ snapshots:
memory-cache@0.2.0: {}
- merge-descriptors@1.0.3: {}
-
merge-descriptors@2.0.0: {}
merge-stream@2.0.0: {}
merge2@1.4.1: {}
- methods@1.1.2: {}
-
micromatch@4.0.8:
dependencies:
braces: 3.0.3
@@ -13770,8 +12556,6 @@ snapshots:
dependencies:
mime-db: 1.54.0
- mime@1.6.0: {}
-
mime@4.1.0: {}
mimic-fn@4.0.0: {}
@@ -13849,8 +12633,6 @@ snapshots:
natural-compare@1.4.0: {}
- negotiator@0.6.3: {}
-
negotiator@1.0.0: {}
next@15.5.25(@babel/core@7.29.7(supports-color@10.2.2))(@playwright/test@1.60.0)(@types/node@24.7.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
@@ -14095,141 +12877,7 @@ snapshots:
unplugin-vue-router: 0.19.2(@vue/compiler-sfc@3.5.43)(vue-router@4.6.4(vue@3.5.43(typescript@5.9.3)))(vue@3.5.43(typescript@5.9.3))
untyped: 2.0.0
vue: 3.5.43(typescript@5.9.3)
- vue-router: 4.6.4(vue@3.5.43(typescript@5.9.3))
- optionalDependencies:
- '@parcel/watcher': 2.5.6
- '@types/node': 24.7.2
- transitivePeerDependencies:
- - '@azure/app-configuration'
- - '@azure/cosmos'
- - '@azure/data-tables'
- - '@azure/identity'
- - '@azure/keyvault-secrets'
- - '@azure/storage-blob'
- - '@biomejs/biome'
- - '@capacitor/preferences'
- - '@deno/kv'
- - '@electric-sql/pglite'
- - '@farmfe/core'
- - '@libsql/client'
- - '@netlify/blobs'
- - '@oxc-project/types'
- - '@planetscale/database'
- - '@rspack/core'
- - '@upstash/redis'
- - '@vercel/blob'
- - '@vercel/functions'
- - '@vercel/kv'
- - '@vitejs/devtools'
- - '@vue/compiler-sfc'
- - aws4fetch
- - bare-abort-controller
- - bare-buffer
- - better-sqlite3
- - bufferutil
- - bun-types-no-globals
- - cac
- - commander
- - db0
- - drizzle-orm
- - encoding
- - esbuild
- - eslint
- - idb-keyval
- - ioredis
- - less
- - lightningcss
- - magicast
- - meow
- - mysql2
- - optionator
- - oxlint
- - react-native-b4a
- - rolldown
- - rollup
- - rollup-plugin-visualizer
- - sass
- - sass-embedded
- - sqlite3
- - srvx
- - stylelint
- - stylus
- - sugarss
- - supports-color
- - terser
- - tsx
- - typescript
- - unloader
- - uploadthing
- - utf-8-validate
- - vite
- - vue-tsc
- - webpack
- - xml2js
- - yaml
-
- nuxt@4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.143.0)(@parcel/watcher@2.5.6)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(optionator@0.9.4)(oxc-parser@0.143.0)(rolldown@1.2.3)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.3)(rollup@4.62.2))(rollup@4.62.2)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(yaml@2.9.0):
- dependencies:
- '@dxup/nuxt': 0.5.6(esbuild@0.28.1)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))
- '@nuxt/cli': 3.37.0(@nuxt/schema@4.5.2)(cac@6.7.14)(magicast@0.5.4)(supports-color@10.2.2)
- '@nuxt/devtools': 3.4.1(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2))(magic-string@1.1.0)(oxc-parser@0.143.0)(rolldown@1.2.3)(supports-color@10.2.2)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.43(typescript@5.9.3))
- '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.3)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)))
- '@nuxt/nitro-server': 4.5.2(dc2235b049bcdc63cc648371a1423346)
- '@nuxt/schema': 4.5.2
- '@nuxt/telemetry': 2.8.0(@nuxt/kit@4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.3)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))))
- '@nuxt/vite-builder': 4.5.2(5ac2fde01a19fe5d6caf00292c982247)
- '@unhead/vue': 3.3.1(@oxc-project/types@0.143.0)(esbuild@0.28.1)(lightningcss@1.33.0)(oxc-parser@0.143.0)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.43(typescript@5.9.3))
- '@vue/shared': 3.5.41
- chokidar: 5.0.0
- compatx: 0.2.0
- consola: 3.4.2
- cookie-es: 3.1.1
- defu: 6.1.7
- devalue: 5.9.4
- errx: 0.1.2
- escape-string-regexp: 5.0.0
- exsolve: 1.1.1
- fnv1a-64: 0.1.2
- hookable: 6.1.1
- ignore: 7.0.6
- impound: 1.1.6
- jiti: 2.7.0
- klona: 2.0.6
- knitwork: 1.3.0
- magic-string: 1.1.0
- mlly: 1.8.2
- nanotar: 0.3.0
- nostics: 1.2.0
- nypm: 0.6.9
- object-identity: 0.2.3
- ofetch: 1.5.1
- ohash: 2.0.11
- on-change: 6.0.2
- oxc-walker: 1.1.1(@oxc-project/types@0.143.0)(oxc-parser@0.143.0)(rolldown@1.2.3)
- pathe: 2.0.3
- perfect-debounce: 2.1.0
- picomatch: 4.0.5
- pkg-types: 2.3.1
- rolldown: 1.2.3
- rolldown-string: 0.3.1(rolldown@1.2.3)
- rou3: 0.9.1
- scule: 1.3.0
- std-env: 4.2.0
- tinyglobby: 0.2.17
- ufo: 1.6.4
- ultrahtml: 1.7.0
- uncrypto: 0.1.3
- unctx: 3.0.0(magic-string@1.1.0)(oxc-parser@0.143.0)(rolldown@1.2.3)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)))
- undici: 8.11.2
- unhead: 3.3.1(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))
- unimport: 6.4.0(esbuild@0.28.1)(oxc-parser@0.143.0)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))
- unplugin: 3.3.0(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))
- unrouting: 0.2.2
- untyped: 2.0.0
- verkit: 0.3.2
- vue: 3.5.43(typescript@5.9.3)
- vue-component-type-helpers: 3.3.9
- vue-router: 5.2.0(@vue/compiler-sfc@3.5.43)(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.43(typescript@5.9.3))
+ vue-router: 4.6.4(vue@3.5.43(typescript@5.9.3))
optionalDependencies:
'@parcel/watcher': 2.5.6
'@types/node': 24.7.2
@@ -14240,9 +12888,6 @@ snapshots:
- '@azure/identity'
- '@azure/keyvault-secrets'
- '@azure/storage-blob'
- - '@babel/plugin-proposal-decorators'
- - '@babel/plugin-syntax-jsx'
- - '@babel/plugin-syntax-typescript'
- '@biomejs/biome'
- '@capacitor/preferences'
- '@deno/kv'
@@ -14251,17 +12896,13 @@ snapshots:
- '@libsql/client'
- '@netlify/blobs'
- '@oxc-project/types'
- - '@pinia/colada'
- '@planetscale/database'
- - '@rollup/plugin-babel'
- '@rspack/core'
- - '@unhead/cli'
- '@upstash/redis'
- '@vercel/blob'
- '@vercel/functions'
- '@vercel/kv'
- '@vitejs/devtools'
- - '@vitejs/devtools-kit'
- '@vue/compiler-sfc'
- aws4fetch
- bare-abort-controller
@@ -14284,10 +12925,9 @@ snapshots:
- meow
- mysql2
- optionator
- - oxc-parser
- oxlint
- - pinia
- react-native-b4a
+ - rolldown
- rollup
- rollup-plugin-visualizer
- sass
@@ -14318,8 +12958,6 @@ snapshots:
object-assign@4.1.1: {}
- object-identity@0.2.3: {}
-
object-inspect@1.13.4: {}
object-keys@1.1.1: {}
@@ -14525,8 +13163,6 @@ snapshots:
lru-cache: 11.5.1
minipass: 7.1.3
- path-to-regexp@0.1.13: {}
-
path-to-regexp@8.4.2: {}
path-type@4.0.0: {}
@@ -14585,72 +13221,35 @@ snapshots:
postcss: 8.5.23
postcss-value-parser: 4.2.0
- postcss-colormin@8.0.1(postcss@8.5.23):
- dependencies:
- '@colordx/core': 5.4.3
- browserslist: 4.28.8
- caniuse-api: 4.0.0
- postcss: 8.5.23
- postcss-value-parser: 4.2.0
-
postcss-convert-values@7.0.12(postcss@8.5.23):
dependencies:
browserslist: 4.28.8
postcss: 8.5.23
postcss-value-parser: 4.2.0
- postcss-convert-values@8.0.1(postcss@8.5.23):
- dependencies:
- browserslist: 4.28.8
- postcss: 8.5.23
- postcss-value-parser: 4.2.0
-
postcss-discard-comments@7.0.8(postcss@8.5.23):
dependencies:
postcss: 8.5.23
postcss-selector-parser: 7.1.4
- postcss-discard-comments@8.0.1(postcss@8.5.23):
- dependencies:
- postcss: 8.5.23
- postcss-selector-parser: 7.1.4
-
postcss-discard-duplicates@7.0.4(postcss@8.5.23):
dependencies:
postcss: 8.5.23
- postcss-discard-duplicates@8.0.1(postcss@8.5.23):
- dependencies:
- postcss: 8.5.23
-
postcss-discard-empty@7.0.3(postcss@8.5.23):
dependencies:
postcss: 8.5.23
- postcss-discard-empty@8.0.1(postcss@8.5.23):
- dependencies:
- postcss: 8.5.23
-
postcss-discard-overridden@7.0.3(postcss@8.5.23):
dependencies:
postcss: 8.5.23
- postcss-discard-overridden@8.0.1(postcss@8.5.23):
- dependencies:
- postcss: 8.5.23
-
postcss-merge-longhand@7.0.7(postcss@8.5.23):
dependencies:
postcss: 8.5.23
postcss-value-parser: 4.2.0
stylehacks: 7.0.11(postcss@8.5.23)
- postcss-merge-longhand@8.0.1(postcss@8.5.23):
- dependencies:
- postcss: 8.5.23
- postcss-value-parser: 4.2.0
- stylehacks: 8.0.1(postcss@8.5.23)
-
postcss-merge-rules@7.0.11(postcss@8.5.23):
dependencies:
browserslist: 4.28.8
@@ -14659,24 +13258,11 @@ snapshots:
postcss: 8.5.23
postcss-selector-parser: 7.1.4
- postcss-merge-rules@8.0.1(postcss@8.5.23):
- dependencies:
- browserslist: 4.28.8
- caniuse-api: 4.0.0
- cssnano-utils: 6.0.1(postcss@8.5.23)
- postcss: 8.5.23
- postcss-selector-parser: 7.1.4
-
postcss-minify-font-values@7.0.3(postcss@8.5.23):
dependencies:
postcss: 8.5.23
postcss-value-parser: 4.2.0
- postcss-minify-font-values@8.0.1(postcss@8.5.23):
- dependencies:
- postcss: 8.5.23
- postcss-value-parser: 4.2.0
-
postcss-minify-gradients@7.0.5(postcss@8.5.23):
dependencies:
'@colordx/core': 5.4.3
@@ -14684,13 +13270,6 @@ snapshots:
postcss: 8.5.23
postcss-value-parser: 4.2.0
- postcss-minify-gradients@8.0.1(postcss@8.5.23):
- dependencies:
- '@colordx/core': 5.4.3
- cssnano-utils: 6.0.1(postcss@8.5.23)
- postcss: 8.5.23
- postcss-value-parser: 4.2.0
-
postcss-minify-params@7.0.9(postcss@8.5.23):
dependencies:
browserslist: 4.28.8
@@ -14698,13 +13277,6 @@ snapshots:
postcss: 8.5.23
postcss-value-parser: 4.2.0
- postcss-minify-params@8.0.1(postcss@8.5.23):
- dependencies:
- browserslist: 4.28.8
- cssnano-utils: 6.0.1(postcss@8.5.23)
- postcss: 8.5.23
- postcss-value-parser: 4.2.0
-
postcss-minify-selectors@7.1.2(postcss@8.5.23):
dependencies:
browserslist: 4.28.8
@@ -14713,14 +13285,6 @@ snapshots:
postcss: 8.5.23
postcss-selector-parser: 7.1.4
- postcss-minify-selectors@8.0.2(postcss@8.5.23):
- dependencies:
- browserslist: 4.28.8
- caniuse-api: 4.0.0
- cssesc: 3.0.0
- postcss: 8.5.23
- postcss-selector-parser: 7.1.4
-
postcss-nested@7.0.2(postcss@8.5.23):
dependencies:
postcss: 8.5.23
@@ -14730,126 +13294,64 @@ snapshots:
dependencies:
postcss: 8.5.23
- postcss-normalize-charset@8.0.1(postcss@8.5.23):
- dependencies:
- postcss: 8.5.23
-
postcss-normalize-display-values@7.0.3(postcss@8.5.23):
dependencies:
postcss: 8.5.23
postcss-value-parser: 4.2.0
- postcss-normalize-display-values@8.0.1(postcss@8.5.23):
- dependencies:
- postcss: 8.5.23
- postcss-value-parser: 4.2.0
-
postcss-normalize-positions@7.0.4(postcss@8.5.23):
dependencies:
postcss: 8.5.23
postcss-value-parser: 4.2.0
- postcss-normalize-positions@8.0.1(postcss@8.5.23):
- dependencies:
- postcss: 8.5.23
- postcss-value-parser: 4.2.0
-
postcss-normalize-repeat-style@7.0.4(postcss@8.5.23):
dependencies:
postcss: 8.5.23
postcss-value-parser: 4.2.0
- postcss-normalize-repeat-style@8.0.1(postcss@8.5.23):
- dependencies:
- postcss: 8.5.23
- postcss-value-parser: 4.2.0
-
postcss-normalize-string@7.0.3(postcss@8.5.23):
dependencies:
postcss: 8.5.23
postcss-value-parser: 4.2.0
- postcss-normalize-string@8.0.1(postcss@8.5.23):
- dependencies:
- postcss: 8.5.23
- postcss-value-parser: 4.2.0
-
postcss-normalize-timing-functions@7.0.3(postcss@8.5.23):
dependencies:
postcss: 8.5.23
postcss-value-parser: 4.2.0
- postcss-normalize-timing-functions@8.0.1(postcss@8.5.23):
- dependencies:
- postcss: 8.5.23
- postcss-value-parser: 4.2.0
-
postcss-normalize-unicode@7.0.9(postcss@8.5.23):
dependencies:
browserslist: 4.28.8
postcss: 8.5.23
postcss-value-parser: 4.2.0
- postcss-normalize-unicode@8.0.1(postcss@8.5.23):
- dependencies:
- browserslist: 4.28.8
- postcss: 8.5.23
- postcss-value-parser: 4.2.0
-
postcss-normalize-url@7.0.3(postcss@8.5.23):
dependencies:
postcss: 8.5.23
postcss-value-parser: 4.2.0
- postcss-normalize-url@8.0.1(postcss@8.5.23):
- dependencies:
- postcss: 8.5.23
- postcss-value-parser: 4.2.0
-
postcss-normalize-whitespace@7.0.3(postcss@8.5.23):
dependencies:
postcss: 8.5.23
postcss-value-parser: 4.2.0
- postcss-normalize-whitespace@8.0.1(postcss@8.5.23):
- dependencies:
- postcss: 8.5.23
- postcss-value-parser: 4.2.0
-
postcss-ordered-values@7.0.4(postcss@8.5.23):
dependencies:
cssnano-utils: 5.0.3(postcss@8.5.23)
postcss: 8.5.23
postcss-value-parser: 4.2.0
- postcss-ordered-values@8.0.1(postcss@8.5.23):
- dependencies:
- cssnano-utils: 6.0.1(postcss@8.5.23)
- postcss: 8.5.23
- postcss-value-parser: 4.2.0
-
postcss-reduce-initial@7.0.9(postcss@8.5.23):
dependencies:
browserslist: 4.28.8
caniuse-api: 3.0.0
postcss: 8.5.23
- postcss-reduce-initial@8.0.1(postcss@8.5.23):
- dependencies:
- browserslist: 4.28.8
- caniuse-api: 4.0.0
- postcss: 8.5.23
-
postcss-reduce-transforms@7.0.3(postcss@8.5.23):
dependencies:
postcss: 8.5.23
postcss-value-parser: 4.2.0
- postcss-reduce-transforms@8.0.1(postcss@8.5.23):
- dependencies:
- postcss: 8.5.23
- postcss-value-parser: 4.2.0
-
postcss-selector-parser@7.1.4:
dependencies:
cssesc: 3.0.0
@@ -14861,22 +13363,11 @@ snapshots:
postcss-value-parser: 4.2.0
svgo: 4.1.0
- postcss-svgo@8.0.1(postcss@8.5.23):
- dependencies:
- postcss: 8.5.23
- postcss-value-parser: 4.2.0
- svgo: 4.1.0
-
postcss-unique-selectors@7.0.7(postcss@8.5.23):
dependencies:
postcss: 8.5.23
postcss-selector-parser: 7.1.4
- postcss-unique-selectors@8.0.1(postcss@8.5.23):
- dependencies:
- postcss: 8.5.23
- postcss-selector-parser: 7.1.4
-
postcss-value-parser@4.2.0: {}
postcss@8.5.23:
@@ -14924,11 +13415,6 @@ snapshots:
punycode@2.3.1: {}
- qs@6.15.3:
- dependencies:
- es-define-property: 1.0.1
- side-channel: 1.1.1
-
qs@6.16.0:
dependencies:
es-define-property: 1.0.1
@@ -15070,12 +13556,6 @@ snapshots:
glob: 13.0.6
package-json-from-dist: 1.0.1
- rolldown-string@0.3.1(rolldown@1.2.3):
- dependencies:
- magic-string: 1.1.0
- optionalDependencies:
- rolldown: 1.2.3
-
rolldown@1.0.0-beta.45(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3):
dependencies:
'@oxc-project/types': 0.95.0
@@ -15238,24 +13718,6 @@ snapshots:
semver@7.8.5: {}
- send@0.19.2(supports-color@10.2.2):
- dependencies:
- debug: 2.6.9(supports-color@10.2.2)
- depd: 2.0.0
- destroy: 1.2.0
- encodeurl: 2.0.0
- escape-html: 1.0.3
- etag: 1.8.1
- fresh: 0.5.2
- http-errors: 2.0.1
- mime: 1.6.0
- ms: 2.1.3
- on-finished: 2.4.1
- range-parser: 1.2.1
- statuses: 2.0.2
- transitivePeerDependencies:
- - supports-color
-
send@1.2.1(supports-color@10.2.2):
dependencies:
debug: 4.4.3(supports-color@10.2.2)
@@ -15284,15 +13746,6 @@ snapshots:
dependencies:
defu: 6.1.5
- serve-static@1.16.3(supports-color@10.2.2):
- dependencies:
- encodeurl: 2.0.0
- escape-html: 1.0.3
- parseurl: 1.3.3
- send: 0.19.2(supports-color@10.2.2)
- transitivePeerDependencies:
- - supports-color
-
serve-static@2.2.1(supports-color@10.2.2):
dependencies:
encodeurl: 2.0.0
@@ -15587,12 +14040,6 @@ snapshots:
postcss: 8.5.23
postcss-selector-parser: 7.1.4
- stylehacks@8.0.1(postcss@8.5.23):
- dependencies:
- browserslist: 4.28.8
- postcss: 8.5.23
- postcss-selector-parser: 7.1.4
-
stylis@4.2.0: {}
supports-color@10.2.2: {}
@@ -15724,8 +14171,6 @@ snapshots:
dependencies:
prelude-ls: 1.2.1
- type-fest@4.41.0: {}
-
type-fest@5.7.0:
dependencies:
tagged-tag: 1.0.0
@@ -15865,8 +14310,6 @@ snapshots:
undici@7.30.0: {}
- undici@8.11.2: {}
-
unenv@2.0.0-rc.24:
dependencies:
pathe: 2.0.3
@@ -15875,22 +14318,6 @@ snapshots:
dependencies:
hookable: 6.1.1
- unhead@3.3.1(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)):
- dependencies:
- hookable: 6.1.1
- unplugin: 3.3.0(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))
- optionalDependencies:
- vite: 8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)
- transitivePeerDependencies:
- - '@farmfe/core'
- - '@rspack/core'
- - bun-types-no-globals
- - esbuild
- - rolldown
- - rollup
- - unloader
- - webpack
-
unicorn-magic@0.3.0: {}
unicorn-magic@0.4.0: {}
@@ -16019,11 +14446,6 @@ snapshots:
rollup: 4.62.2
vite: 8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)
- unrouting@0.2.2:
- dependencies:
- escape-string-regexp: 5.0.0
- ufo: 1.6.4
-
unrs-resolver@1.12.2:
dependencies:
napi-postinstall: 0.3.4
@@ -16106,8 +14528,6 @@ snapshots:
util-deprecate@1.0.2: {}
- utils-merge@1.0.1: {}
-
uuid@11.1.1: {}
vary@1.1.2: {}
@@ -16130,7 +14550,7 @@ snapshots:
es-module-lexer: 2.1.0
obug: 2.1.3
pathe: 2.0.3
- vite: 7.3.5(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(yaml@2.9.0)
+ vite: 7.3.6(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(yaml@2.9.0)
transitivePeerDependencies:
- '@types/node'
- jiti
@@ -16144,27 +14564,6 @@ snapshots:
- tsx
- yaml
- vite-node@6.0.0(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0):
- dependencies:
- cac: 7.0.0
- es-module-lexer: 2.1.0
- obug: 2.1.3
- pathe: 2.0.3
- vite: 8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)
- transitivePeerDependencies:
- - '@types/node'
- - '@vitejs/devtools'
- - esbuild
- - jiti
- - less
- - sass
- - sass-embedded
- - stylus
- - sugarss
- - terser
- - tsx
- - yaml
-
vite-plugin-checker@0.14.5(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(optionator@0.9.4)(typescript@5.9.3)(vite@7.3.6(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(yaml@2.9.0)):
dependencies:
'@babel/code-frame': 7.29.7
@@ -16180,21 +14579,6 @@ snapshots:
optionator: 0.9.4
typescript: 5.9.3
- vite-plugin-checker@0.14.5(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(optionator@0.9.4)(typescript@5.9.3)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)):
- dependencies:
- '@babel/code-frame': 7.29.7
- chokidar: 5.0.0
- npm-run-path: 6.0.0
- picocolors: 1.1.1
- picomatch: 4.0.5
- proper-lockfile: 4.1.2
- tiny-invariant: 1.3.3
- vite: 8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)
- optionalDependencies:
- eslint: 9.39.4(jiti@2.7.0)(supports-color@10.2.2)
- optionator: 0.9.4
- typescript: 5.9.3
-
vite-plugin-inspect@11.4.1(@nuxt/kit@4.5.2(magic-string@0.30.21)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.3)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))))(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)):
dependencies:
ansis: 4.3.1
@@ -16238,22 +14622,6 @@ snapshots:
vite@6.4.3(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(yaml@2.9.0):
dependencies:
esbuild: 0.25.12
- fdir: 6.5.0(picomatch@4.0.4)
- picomatch: 4.0.4
- postcss: 8.5.23
- rollup: 4.62.2
- tinyglobby: 0.2.17
- optionalDependencies:
- '@types/node': 24.7.2
- fsevents: 2.3.3
- jiti: 2.7.0
- lightningcss: 1.33.0
- terser: 5.48.0
- yaml: 2.9.0
-
- vite@7.3.5(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(yaml@2.9.0):
- dependencies:
- esbuild: 0.27.7
fdir: 6.5.0(picomatch@4.0.5)
picomatch: 4.0.5
postcss: 8.5.23
@@ -16405,8 +14773,6 @@ snapshots:
vue-component-type-helpers@2.2.12: {}
- vue-component-type-helpers@3.3.9: {}
-
vue-devtools-stub@0.1.0: {}
vue-eslint-parser@10.4.1(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2):
@@ -16450,40 +14816,6 @@ snapshots:
'@vue/compiler-sfc': 3.5.43
vite: 8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)
- vue-router@5.2.0(@vue/compiler-sfc@3.5.43)(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.43(typescript@5.9.3)):
- dependencies:
- '@babel/generator': 8.0.0
- '@vue-macros/common': 3.1.4(vue@3.5.43(typescript@5.9.3))
- '@vue/devtools-api': 8.2.1
- ast-walker-scope: 0.9.0
- chokidar: 5.0.0
- json5: 2.2.3
- local-pkg: 1.2.1
- magic-string: 0.30.21
- mlly: 1.8.2
- muggle-string: 0.4.1
- nostics: 1.2.0
- pathe: 2.0.3
- picomatch: 4.0.5
- scule: 1.3.0
- tinyglobby: 0.2.17
- unplugin: 3.3.0(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))
- unplugin-utils: 0.3.2
- vue: 3.5.43(typescript@5.9.3)
- yaml: 2.9.0
- optionalDependencies:
- '@vue/compiler-sfc': 3.5.43
- vite: 8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)
- transitivePeerDependencies:
- - '@farmfe/core'
- - '@rspack/core'
- - bun-types-no-globals
- - esbuild
- - rolldown
- - rollup
- - unloader
- - webpack
-
vue-sfc-transformer@0.1.17(@vue/compiler-core@3.5.43)(esbuild@0.28.1)(vue@3.5.43(typescript@5.9.3)):
dependencies:
'@babel/parser': 7.29.7
@@ -16583,10 +14915,6 @@ snapshots:
siginfo: 2.0.0
stackback: 0.0.2
- widest-line@5.0.0:
- dependencies:
- string-width: 7.2.0
-
word-wrap@1.2.5: {}
wrap-ansi@7.0.0:
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index d8b5bb9a..568b19a0 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,6 +1,6 @@
packages:
- packages/*
- - samples/*/*
+ - playgrounds/*
- samples/integrations/*/*
- tests/*
@@ -14,9 +14,9 @@ allowBuilds:
overrides:
# JUSTIFICATION: Fixes GHSA-v422-hmwv-36x6 β body-parser silently disables its size limit on an
- # invalid `limit` value, allowing a DoS. Transitive via samples/express/quickstart's `express`
- # dependency (express > body-parser). express@4's own body-parser range (~1.20.5) already
- # permits 1.20.6; remove once a fresh install picks it up without the override.
+ # invalid `limit` value, allowing a DoS. Transitive via packages/express's `express`
+ # devDependency (express > body-parser). express's own body-parser range already permits
+ # 1.20.6; remove once a fresh install picks it up without the override.
body-parser: 1.20.6
# JUSTIFICATION: Fixes GHSA-x5fp-wj9c-mxmx (array-limit bypass via bracket-key comma parsing) /
# GHSA-4mjr-xmp4-gh2g (DoS via attacker-controlled isBuffer). Transitive via body-parser's own
@@ -41,7 +41,7 @@ overrides:
# JUSTIFICATION: Fixes GHSA-j22f-vq7h-c4qm / GHSA-mcm9-63f2-9j32 / GHSA-x5rw-q4pp-hg5g β devalue
# `stringify`/`uneval` serializing shared memory, quadratic expansion of repeated primitive
# strings in `uneval`, and an unhandled rejection from `stringifyAsync`. Transitive via
- # packages/nuxt and samples/nuxt/quickstart's `nuxt` and `@nuxt/nitro-server`. Added 2026-10-02;
+ # packages/nuxt's `nuxt` and `@nuxt/nitro-server`. Added 2026-10-02;
# remove once nuxt's own lockfile entry resolves devalue >=5.9.3 without the override.
devalue: 5.9.4
# JUSTIFICATION: Fixes GHSA-52cp-r559-cp3m / GHSA-5p4m-2wfm-xmqj β js-yaml quadratic CPU
@@ -60,8 +60,8 @@ overrides:
nanoid@5: 5.1.16
# JUSTIFICATION: Fixes GHSA-qx2v-qp2m-jg93 (XSS via unescaped `` in stringify output),
# GHSA-6g55-p6wh-862q / GHSA-r28c-9q8g-f849 / GHSA-fxqj-rqcc-2cmp (arbitrary file read via
- # attacker-controlled `sourceMappingURL`). Transitive via packages/nextjs and
- # samples/nextjs/quickstart's `next` dependency, which still bundles `postcss@8.4.31` internally
+ # attacker-controlled `sourceMappingURL`). Transitive via packages/nextjs's `next` dependency,
+ # which still bundles `postcss@8.4.31` internally
# even on the latest 15.5.x release β this is a genuine upstream lag, not a stale lockfile; keep
# the override until Next.js bumps its own postcss pin (https://github.com/vercel/next.js).
postcss: 8.5.23
@@ -99,7 +99,7 @@ overrides:
# JUSTIFICATION: Fixes 5 node-tar advisories (GHSA-w8wr-v893-vjvp, GHSA-23hp-3jrh-7fpw,
# GHSA-8x88-c5mf-7j5w, GHSA-gvwx-54wh-qm9j, GHSA-r292-9mhp-454m) β PAX path/NUL-byte parsing
# crashes, an unbounded-decompression DoS, and a mapHas/filesFilter stack-overflow DoS. Transitive
- # via packages/nuxt and samples/nuxt/quickstart's SSR build tooling
+ # via packages/nuxt's SSR build tooling
# (`nuxt` > `@nuxt/nitro-server` > `nitropack` > `@vercel/nft` > `@mapbox/node-pre-gyp` > `tar`).
# `@mapbox/node-pre-gyp`@latest already depends on `tar: ^7.4.0`, which permits 7.5.21 β this pin
# can likely be dropped once nitropack/@vercel/nft's own lockfile entries are refreshed.
@@ -111,10 +111,10 @@ auditConfig:
# only when running on Windows with the dev/serve command exposed on the network; it does not
# affect `vite build`/`vitest run` output. Transitive via `vitest` > `vite` > `esbuild`
# (pulled into every package that runs vitest). The only patched line is 0.28.x, and
- # overriding to it breaks samples/vue/quickstart's production build: esbuild 0.28 dropped
- # object-destructuring transform support for the sample's configured legacy browser targets
- # (chrome87/edge88/firefox78/safari14). Revisit once esbuild backports the fix to 0.27.x or
- # the sample's target list is modernized.
+ # overriding to it broke the Vue quickstart's production build (the quickstart now lives in
+ # thunder-id/samples): esbuild 0.28 dropped object-destructuring transform support for its
+ # configured legacy browser targets (chrome87/edge88/firefox78/safari14). Revisit whether the
+ # override now holds for the packages alone, or once esbuild backports the fix to 0.27.x.
- GHSA-g7r4-m6w7-qqqr
# JUSTIFICATION: GHSA-86w9-cpqp-85rv β node-forge accepts extra nested DigestAlgorithm elements
# during RSA PKCS#1 v1.5 signature verification. No patched release exists (1.4.0 is the
@@ -134,7 +134,7 @@ auditConfig:
# GHSA-v5rq-49vh-5v5c (`@simple-git/argv-parser`) β simple-git's unsafe-operations guard can be
# bypassed (`trailer..cmd`, config includes, long-option abbreviations, `VISUAL`), but
# only when untrusted values reach `SimpleGitOptions.config` or the arguments passed to git.
- # Transitive via packages/nuxt and samples/nuxt/quickstart's `nuxt` > `@nuxt/devtools` >
+ # Transitive via packages/nuxt's `nuxt` > `@nuxt/devtools` >
# `simple-git`, where devtools (a dev-time tool) only runs `branch()`, `revparse(['--short',
# 'HEAD'])` and `status()` with fixed arguments on the project's own checkout. The patched
# 4.x line cannot be forced with an override: it drops the default export that
diff --git a/samples/browser/quickstart/.env.example b/samples/browser/quickstart/.env.example
deleted file mode 100644
index e38f4022..00000000
--- a/samples/browser/quickstart/.env.example
+++ /dev/null
@@ -1,5 +0,0 @@
-# Base URL of your ThunderID deployment. ex: https://localhost:8090
-VITE_THUNDERID_BASE_URL=https://localhost:8090
-
-# OAuth2 Client ID for this app. ThunderID Console -> your application -> Overview.
-VITE_THUNDERID_CLIENT_ID=your-client-id-here
diff --git a/samples/browser/quickstart/.stackblitzrc b/samples/browser/quickstart/.stackblitzrc
deleted file mode 100644
index 5f0a2000..00000000
--- a/samples/browser/quickstart/.stackblitzrc
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "installDependencies": false,
- "startCommand": "npm run prepare-dev && npm install && npm run dev"
-}
diff --git a/samples/browser/quickstart/README.md b/samples/browser/quickstart/README.md
deleted file mode 100644
index 78ad0dd9..00000000
--- a/samples/browser/quickstart/README.md
+++ /dev/null
@@ -1,37 +0,0 @@
-# ThunderID Browser Quickstart
-
-
-
-A minimal Vite + vanilla JS app demonstrating sign-in and sign-out with the ThunderID JavaScript SDK (`@thunderid/browser`).
-
-## Prerequisites
-
-- Node.js 18+
-- A running ThunderID instance (default: `https://localhost:8090`)
-- A configured application with an authorized redirect URI set to your app's origin
-
-## Getting started
-
-1. Copy the environment file and fill in your values:
- ```sh
- cp .env.example .env
- ```
-
-2. Edit `.env` with your application's credentials:
- ```
- VITE_THUNDERID_CLIENT_ID=
- VITE_THUNDERID_BASE_URL=https://localhost:8090
- ```
-
-3. Install dependencies and start the dev server:
- ```sh
- pnpm install
- pnpm dev
- ```
-
-Open [http://localhost:5173](http://localhost:5173) in your browser.
-
-## Learn more
-
-- [ThunderID Docs](https://thunderid.dev/docs)
-- [`@thunderid/browser` SDK reference](https://thunderid.dev/docs/sdks/javascript/browser)
diff --git a/samples/browser/quickstart/index.html b/samples/browser/quickstart/index.html
deleted file mode 100644
index 8e933764..00000000
--- a/samples/browser/quickstart/index.html
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
- ThunderID JavaScript Quickstart
-
-
-
-
-
-
diff --git a/samples/browser/quickstart/package.json b/samples/browser/quickstart/package.json
deleted file mode 100644
index dbf28600..00000000
--- a/samples/browser/quickstart/package.json
+++ /dev/null
@@ -1,18 +0,0 @@
-{
- "name": "@thunderid/browser-quickstart",
- "private": true,
- "version": "0.0.0",
- "type": "module",
- "scripts": {
- "prepare-dev": "node scripts/prepare-dev.cjs",
- "dev": "vite",
- "build": "vite build",
- "preview": "vite preview"
- },
- "dependencies": {
- "@thunderid/browser": "workspace:*"
- },
- "devDependencies": {
- "vite": "^6.3.5"
- }
-}
diff --git a/samples/browser/quickstart/scripts/prepare-dev.cjs b/samples/browser/quickstart/scripts/prepare-dev.cjs
deleted file mode 100644
index 8e20fa43..00000000
--- a/samples/browser/quickstart/scripts/prepare-dev.cjs
+++ /dev/null
@@ -1,34 +0,0 @@
-// Copyright 2026 The ThunderID Authors
-// SPDX-License-Identifier: Apache-2.0
-
-const fs = require('node:fs');
-const path = require('node:path');
-
-const root = path.join(__dirname, '..');
-
-const envExample = path.join(root, '.env.example');
-const envTarget = path.join(root, '.env');
-if (fs.existsSync(envExample) && !fs.existsSync(envTarget)) {
- // Blank placeholder values (e.g. `your-client-id-here`, the sample base URL)
- // so the copied .env still trips the app's missing-env-var check until real
- // values are filled in.
- const envContent = fs
- .readFileSync(envExample, 'utf8')
- .replace(/^([A-Z0-9_]+)=(your-\S*|generate-with-\S*|https:\/\/localhost:8090)$/gm, '$1=');
- fs.writeFileSync(envTarget, envContent);
-}
-
-const pkgPath = path.join(root, 'package.json');
-const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
-
-for (const field of ['dependencies', 'devDependencies']) {
- const deps = pkg[field];
- if (!deps) continue;
- for (const [name, version] of Object.entries(deps)) {
- if (version.startsWith('workspace:')) {
- deps[name] = 'latest';
- }
- }
-}
-
-fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
diff --git a/samples/browser/quickstart/src/auth.js b/samples/browser/quickstart/src/auth.js
deleted file mode 100644
index bad532be..00000000
--- a/samples/browser/quickstart/src/auth.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import { ThunderIDBrowserClient } from '@thunderid/browser'
-
-const REQUIRED_ENV_VARS = ['VITE_THUNDERID_CLIENT_ID', 'VITE_THUNDERID_BASE_URL']
-
-export const missingEnvVars = REQUIRED_ENV_VARS.filter((key) => !import.meta.env[key])
-
-const auth = new ThunderIDBrowserClient()
-
-if (missingEnvVars.length === 0) {
- await auth.initialize({
- clientId: import.meta.env.VITE_THUNDERID_CLIENT_ID,
- baseUrl: import.meta.env.VITE_THUNDERID_BASE_URL,
- afterSignInUrl: window.location.origin,
- afterSignOutUrl: window.location.origin,
- })
-}
-
-export default auth
diff --git a/samples/browser/quickstart/src/components/nav.js b/samples/browser/quickstart/src/components/nav.js
deleted file mode 100644
index 25b1ca74..00000000
--- a/samples/browser/quickstart/src/components/nav.js
+++ /dev/null
@@ -1,171 +0,0 @@
-const ICON_MOON = ` `
-const ICON_SUN = ` `
-const ICON_KEY = ` `
-const ICON_USER = ` `
-const ICON_CHEVRON_LEFT = ` `
-const ICON_CHEVRON_DOWN = ` `
-const ICON_TECH = `JS `
-
-function escapeHtml(str) {
- if (str == null) return ''
- return String(str)
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''')
-}
-
-function getInitials(user) {
- const given = user?.given_name || ''
- const family = user?.family_name || ''
- if (given && family) return (given[0] + family[0]).toUpperCase()
- const name = user?.name || user?.username || user?.email || '?'
- const parts = name.trim().split(/\s+/)
- if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
- return name.slice(0, 2).toUpperCase()
-}
-
-function getAvatarUrl(user) {
- return user?.profile || user?.profileUrl || user?.picture || user?.URL || null
-}
-
-function renderAvatar(user, className) {
- const avatarUrl = getAvatarUrl(user)
- if (avatarUrl) {
- return ` `
- }
- return `${escapeHtml(getInitials(user))} `
-}
-
-function getFullName(user) {
- const given = user?.given_name || ''
- const family = user?.family_name || ''
- if (given && family) return `${given} ${family}`
- return user?.name || user?.username || 'User'
-}
-
-export function renderSignedOutNav({ isDark, hideSignIn = false }) {
- return `
-
-
- ${ICON_TECH}
- Quickstart
-
-
-
- ${isDark ? ICON_SUN : ICON_MOON}
-
- ${hideSignIn ? '' : 'Sign in '}
-
- `
-}
-
-export function renderSignedInNav({ user, isDark, currentPage }) {
- const name = escapeHtml(getFullName(user))
- const email = escapeHtml(user?.email || '')
-
- return `
-
-
- ${ICON_TECH}
- Quickstart
-
-
- ${currentPage !== 'home' ? `
-
- ${ICON_CHEVRON_LEFT} Home
- ` : ''}
-
- ${isDark ? ICON_SUN : ICON_MOON}
-
-
-
- ${renderAvatar(user, 'ud-avatar')}
- ${name}
- ${ICON_CHEVRON_DOWN}
-
-
-
-
- `
-}
-
-export function attachNavHandlers({ isDark, setIsDark, navigateTo, auth }) {
- document.getElementById('dark-toggle')?.addEventListener('click', () => {
- const next = !isDark()
- setIsDark(next)
- document.documentElement.classList.toggle('dark', next)
- const dt = document.getElementById('dark-toggle')
- if (dt) dt.innerHTML = next ? ICON_SUN : ICON_MOON
- document.querySelectorAll('#hero-mark-top').forEach(el =>
- el.setAttribute('fill', next ? '#E8F4FF' : '#05213F')
- )
- })
-
- document.getElementById('nav-logo-btn')?.addEventListener('click', (e) => {
- e.preventDefault()
- navigateTo('home')
- })
-
- document.getElementById('nav-back-btn')?.addEventListener('click', () => {
- navigateTo('home')
- })
-
- const trigger = document.getElementById('ud-trigger')
- const menu = document.getElementById('ud-menu')
- const wrapper = document.getElementById('ud-wrapper')
- if (trigger && menu) {
- trigger.addEventListener('click', (e) => {
- e.stopPropagation()
- menu.classList.toggle('ud-menu--open')
- })
- document.addEventListener('click', (e) => {
- if (!wrapper?.contains(e.target)) menu.classList.remove('ud-menu--open')
- }, { once: true })
- }
-
- document.getElementById('ud-token-debug')?.addEventListener('click', () => {
- menu?.classList.remove('ud-menu--open')
- navigateTo('token')
- })
-
- document.getElementById('ud-manage-profile')?.addEventListener('click', () => {
- menu?.classList.remove('ud-menu--open')
- navigateTo('account')
- })
-
- document.getElementById('ud-sign-out')?.addEventListener('click', () => {
- auth.signOut()
- })
-}
-
-export function attachSignedOutNavHandlers({ auth }) {
- document.getElementById('sign-in-btn')?.addEventListener('click', () => auth.signIn())
- document.getElementById('dark-toggle')?.addEventListener('click', () => {
- const next = !document.documentElement.classList.contains('dark')
- document.documentElement.classList.toggle('dark', next)
- const dt = document.getElementById('dark-toggle')
- if (dt) dt.innerHTML = next ? ICON_SUN : ICON_MOON
- document.querySelectorAll('#hero-mark-top').forEach(el =>
- el.setAttribute('fill', next ? '#E8F4FF' : '#05213F')
- )
- })
-}
diff --git a/samples/browser/quickstart/src/components/profileFields.js b/samples/browser/quickstart/src/components/profileFields.js
deleted file mode 100644
index f0fd0452..00000000
--- a/samples/browser/quickstart/src/components/profileFields.js
+++ /dev/null
@@ -1,305 +0,0 @@
-import { deepMerge, getUsersMe, getUsersMeMeta, updateMeProfile } from '@thunderid/browser'
-
-const ICON_PENCIL = ` `
-
-// Attributes that are always read-only regardless of schema mutability
-const ALWAYS_READONLY_KEYS = [
- 'attributes',
- 'id',
- 'isReadOnly',
- 'isReadonly',
- 'ouId',
- 'sub',
- 'username',
- 'userName',
- 'user_name',
-]
-
-export function escapeHtml(str) {
- if (str == null) return ''
- return String(str)
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''')
-}
-
-function formatLabel(key) {
- return key
- .split(/(?=[A-Z])|[_.]/)
- .filter(Boolean)
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
- .join(' ')
-}
-
-function fieldLabel(key, schemaEntry) {
- return schemaEntry?.displayName || formatLabel(key)
-}
-
-function isFieldEditable(key, schemaEntry) {
- if (ALWAYS_READONLY_KEYS.includes(key)) return false
- if (!schemaEntry) return false
- if (schemaEntry.credential) return false
- if (schemaEntry.readOnly || schemaEntry.mutability === 'READ_ONLY') return false
- return true
-}
-
-function getAvatarUrl(user) {
- return user?.profile || user?.profileUrl || user?.picture || user?.URL || null
-}
-
-// Deterministic gradient background derived from a name, matching `@thunderid/react`'s
-// `Avatar` `background="random"` (default) behavior β same hash and HSL formula, so a
-// given user gets the same avatar color here as they would in the React/Vue quickstarts.
-function generateAvatarBackground(name) {
- const hash = name.split('').reduce((acc, char) => ((acc << 5) - acc + char.charCodeAt(0)) & 0xffffffff, 0)
- const seed = Math.abs(hash)
- const hue1 = seed % 360
- const hue2 = (hue1 + 60 + (seed % 120)) % 360
- const saturation = 70 + (seed % 20)
- const lightness1 = 55 + (seed % 15)
- const lightness2 = 60 + (seed % 15)
- const angle = 45 + (seed % 91)
- return `linear-gradient(${angle}deg, hsl(${hue1}, ${saturation}%, ${lightness1}%), hsl(${hue2}, ${saturation}%, ${lightness2}%))`
-}
-
-function getInitials(user) {
- const given = user?.given_name || ''
- const family = user?.family_name || ''
- if (given && family) return (given[0] + family[0]).toUpperCase()
- const name = user?.name || user?.username || user?.email || '?'
- const parts = name.trim().split(/\s+/)
- if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
- return name.slice(0, 2).toUpperCase()
-}
-
-// `getDisplayName`: first + last name, falling back to
-// username, email, then the `name` attribute.
-function getDisplayName(user, attributes) {
- const given = attributes?.given_name || user?.given_name
- const family = attributes?.family_name || user?.family_name
- if (given && family) return `${given} ${family}`
- return user?.username || user?.email || attributes?.name || 'User'
-}
-
-function renderAvatarInner(user, displayName) {
- const avatarUrl = getAvatarUrl(user)
- if (avatarUrl) {
- return { className: '', html: ` ` }
- }
- return {
- className: 'has-gradient',
- html: escapeHtml(getInitials(user)),
- style: `background:${generateAvatarBackground(displayName || 'User')}`,
- }
-}
-
-export function createFetcher(auth) {
- return async (url, config) => {
- const token = await auth.getAccessToken()
- return fetch(url, {
- ...config,
- headers: { ...config.headers, Authorization: `Bearer ${token}` },
- })
- }
-}
-
-// Fetches the schema and the current attribute values needed to render and validate the profile view.
-export async function fetchProfileFormContext({ baseUrl, auth }) {
- const fetcher = createFetcher(auth)
-
- // A failed fetch stays `null`, not `{}` β `supportsCredential` treats those differently: an
- // unresolved schema is "not known yet" (every credential stays available), while `{}` is a
- // known schema declaring nothing (every credential becomes unavailable). Collapsing a fetch
- // failure into `{}` would make a transient network error look identical to "no credentials
- // configured".
- const [metaRes, profile] = await Promise.all([
- getUsersMeMeta({ baseUrl, fetcher }).catch(() => null),
- getUsersMe({ baseUrl, fetcher }).catch(() => null),
- ])
-
- return { schema: metaRes?.schema ?? null, profile }
-}
-
-function renderFieldRow(key, schemaEntry, value) {
- if (schemaEntry?.credential) return ''
-
- const label = fieldLabel(key, schemaEntry)
- const editable = isFieldEditable(key, schemaEntry)
- const hasValue = value !== undefined && value !== null && value !== ''
-
- // BaseUserProfile `shouldShow`: an empty read-only field is hidden entirely rather than rendered as a dash.
- if (!hasValue && !editable) return ''
-
- const displayValue = hasValue ? escapeHtml(String(value)) : `Enter your ${label.toLowerCase()}`
-
- return `
-
-
${escapeHtml(label)}
-
- ${displayValue}
- ${editable ? `${ICON_PENCIL} ` : ''}
-
-
`
-}
-
-// Renders the "Personal info" section: avatar/name summary plus the schema-driven,
-// per-field editable rows. Used by the account page's Personal info tab.
-export function renderProfileFields(user, { schema = {}, profile } = {}) {
- const attributes = profile?.attributes || {}
- const displayName = getDisplayName(user, attributes)
- const avatar = renderAvatarInner(user, displayName)
- const email = escapeHtml(user?.email || user?.username || '')
-
- // `schema` may be `null` (the meta fetch failed) β a default parameter only covers
- // `undefined`, not an explicit `null`, so this still needs its own fallback.
- const rows = Object.entries(schema || {})
- .map(([key, schemaEntry]) => renderFieldRow(key, schemaEntry, attributes[key]))
- .join('')
-
- return `
-
-
${avatar.html}
-
-
${escapeHtml(displayName)}
- ${email ? `
${email}
` : ''}
-
-
-
- ${rows}
`
-}
-
-// Validates a field value against its schema entry (required + regex), matching
-// BaseUserProfile `handleFieldSave`. Returns an error message, or
-// `null` when valid.
-function validateField(schemaEntry, label, value) {
- if (!schemaEntry) return null
-
- if (schemaEntry.required && !value) {
- return `${label} is required.`
- }
-
- if (schemaEntry.regex && value) {
- try {
- if (!new RegExp(schemaEntry.regex).test(value)) {
- return `${label} is not in a valid format.`
- }
- } catch {
- // Invalid regex on the schema itself β nothing to enforce client-side.
- }
- }
-
- return null
-}
-
-// Wires up inline per-field editing for the rows rendered by `renderProfileFields`. Safe to
-// call once after that markup is in the DOM.
-export function attachProfileFieldHandlers({ user, auth, schema, profile, onSaved }) {
- // `schema` may be `null` (the meta fetch failed) β a default parameter only covers
- // `undefined`, not an explicit `null`, so this still needs its own fallback.
- const resolvedSchema = schema || {}
- const errorEl = document.getElementById('profile-error')
- const fieldList = document.getElementById('profile-field-list')
- const baseUrl = import.meta.env.VITE_THUNDERID_BASE_URL
- const fetcher = createFetcher(auth)
-
- // Tracks the latest known attributes so successive per-field edits merge against
- // up-to-date values without refetching the whole profile on every save.
- let currentAttributes = { ...(profile?.attributes || {}) }
-
- const refreshHeader = () => {
- const mergedUser = { ...user, ...currentAttributes }
- const displayName = getDisplayName(mergedUser, currentAttributes)
-
- const avatarEl = document.querySelector('.profile-summary-avatar')
- if (avatarEl) {
- const avatar = renderAvatarInner(mergedUser, displayName)
- avatarEl.className = `profile-summary-avatar ${avatar.className}`
- avatarEl.setAttribute('style', avatar.style || '')
- avatarEl.innerHTML = avatar.html
- }
-
- const nameEl = document.querySelector('.profile-summary-name')
- if (nameEl) nameEl.textContent = displayName
- }
-
- const showError = (message) => {
- if (!errorEl) return
- errorEl.hidden = false
- errorEl.textContent = message
- }
- const clearError = () => {
- if (!errorEl) return
- errorEl.hidden = true
- errorEl.textContent = ''
- }
-
- const startEdit = (row, key, schemaEntry) => {
- const display = row.querySelector('.profile-field-row-display')
- if (!display) return
- const currentValue = currentAttributes[key] ?? ''
-
- display.innerHTML = `
-
-
- Save
- Cancel
-
`
-
- const input = display.querySelector('input')
- input?.focus()
-
- const cancel = () => {
- row.outerHTML = renderFieldRow(key, schemaEntry, currentAttributes[key])
- }
-
- const save = async () => {
- const value = input?.value.trim() || ''
- const label = fieldLabel(key, schemaEntry)
- const fieldError = validateField(schemaEntry, label, value)
- if (fieldError) {
- showError(fieldError)
- return
- }
- clearError()
-
- const saveBtn = display.querySelector('[data-action="save"]')
- if (saveBtn) saveBtn.disabled = true
-
- try {
- const mergedAttributes = deepMerge(currentAttributes, { [key]: value })
- const updatedUser = await updateMeProfile({ baseUrl, payload: mergedAttributes, fetcher })
-
- currentAttributes = { ...currentAttributes, [key]: updatedUser?.[key] ?? value }
- row.outerHTML = renderFieldRow(key, schemaEntry, currentAttributes[key])
- refreshHeader()
-
- onSaved?.({ ...user, ...currentAttributes })
- } catch (err) {
- showError(err?.message || 'Failed to update profile. Please try again.')
- if (saveBtn) saveBtn.disabled = false
- }
- }
-
- display.addEventListener('click', (e) => {
- const action = e.target.closest('[data-action]')?.dataset.action
- if (action === 'save') save()
- if (action === 'cancel') cancel()
- })
-
- input?.addEventListener('keydown', (e) => {
- if (e.key === 'Enter') save()
- if (e.key === 'Escape') cancel()
- })
- }
-
- fieldList?.addEventListener('click', (e) => {
- const editBtn = e.target.closest('[data-action="edit"]')
- if (!editBtn) return
- const row = editBtn.closest('.profile-field-row')
- const key = row?.dataset.field
- if (!row || !key) return
- startEdit(row, key, resolvedSchema[key])
- })
-}
diff --git a/samples/browser/quickstart/src/main.js b/samples/browser/quickstart/src/main.js
deleted file mode 100644
index 06eb46ae..00000000
--- a/samples/browser/quickstart/src/main.js
+++ /dev/null
@@ -1,101 +0,0 @@
-import './style.css'
-import auth, { missingEnvVars } from './auth.js'
-import { renderSignedOutNav, renderSignedInNav, attachNavHandlers, attachSignedOutNavHandlers } from './components/nav.js'
-import { mountAccountPage } from './pages/account.js'
-import { renderSignedOut, renderHome, renderConfigNeeded, startCountdown, attachSignedOutHandlers, attachConfigNeededHandlers } from './pages/home.js'
-import { renderTokenDebug, attachTokenHandlers } from './pages/token.js'
-
-let isDark = false
-let currentPage = 'home'
-let user = null
-let idToken = null
-let rawToken = null
-let timer = null
-
-function navigateTo(page) {
- if (timer) { clearInterval(timer); timer = null }
- currentPage = page
- renderSignedInPage()
-}
-
-function renderSignedInPage() {
- const app = document.getElementById('app')
- if (!app) return
-
- if (currentPage === 'account') {
- app.innerHTML = renderSignedInNav({ user, isDark, currentPage }) + '
'
- attachNavHandlers({
- isDark: () => isDark,
- setIsDark: (v) => { isDark = v },
- navigateTo,
- auth,
- })
- mountAccountPage(document.getElementById('account-root'), {
- user,
- auth,
- onUserUpdated: (updatedUser) => {
- user = updatedUser
- },
- })
- return
- }
-
- let content
- if (currentPage === 'token') {
- content = renderTokenDebug({ rawToken })
- } else {
- content = renderHome({ user, idToken })
- }
-
- app.innerHTML = renderSignedInNav({ user, isDark, currentPage }) + content
-
- attachNavHandlers({
- isDark: () => isDark,
- setIsDark: (v) => { isDark = v },
- navigateTo,
- auth,
- })
-
- if (currentPage === 'token') {
- attachTokenHandlers({ rawToken })
- } else if (currentPage === 'home') {
- timer = startCountdown({ idToken })
- }
-}
-
-async function renderApp() {
- const app = document.getElementById('app')
- if (!app) return
-
- if (missingEnvVars.length > 0) {
- app.innerHTML = renderSignedOutNav({ isDark, hideSignIn: true }) + renderConfigNeeded(missingEnvVars)
- attachSignedOutNavHandlers({ auth })
- attachConfigNeededHandlers()
- return
- }
-
- if (timer) { clearInterval(timer); timer = null }
-
- await auth.signIn({ callOnlyOnRedirect: true })
-
- const signedIn = await auth.isSignedIn()
-
- if (signedIn) {
- user = await auth.getUser()
- try { idToken = await auth.getDecodedIdToken() } catch { idToken = null }
- try { rawToken = await auth.getAccessToken() } catch { rawToken = null }
- currentPage = 'home'
- renderSignedInPage()
- } else {
- user = null
- idToken = null
- rawToken = null
- currentPage = 'home'
-
- app.innerHTML = renderSignedOutNav({ isDark }) + renderSignedOut()
- attachSignedOutNavHandlers({ auth })
- attachSignedOutHandlers({ auth })
- }
-}
-
-renderApp()
diff --git a/samples/browser/quickstart/src/pages/account.js b/samples/browser/quickstart/src/pages/account.js
deleted file mode 100644
index 3e2fcb3b..00000000
--- a/samples/browser/quickstart/src/pages/account.js
+++ /dev/null
@@ -1,307 +0,0 @@
-import {
- CredentialConstants,
- evaluateChangePasswordForm,
- mapCredentialUpdateError,
- resolveChangeCredentialPolicy,
- supportsCredential,
- updateMeCredentials,
-} from '@thunderid/browser'
-import { createFetcher, escapeHtml, fetchProfileFormContext, renderProfileFields, attachProfileFieldHandlers } from '../components/profileFields.js'
-
-const ICON_HOME = ` `
-const ICON_PERSON = ` `
-const ICON_SHIELD = ` `
-const ICON_EYE = ` `
-const ICON_CHECK = ` `
-const ICON_X = ` `
-const ICON_CHEVRON = ` `
-
-const TABS = [
- { id: 'home', label: 'Home', icon: ICON_HOME },
- { id: 'personal', label: 'Personal info', icon: ICON_PERSON },
- { id: 'security', label: 'Security', icon: ICON_SHIELD },
-]
-
-const TILES = [
- { id: 'personal', icon: ICON_PERSON, tone: 'blue', title: 'Personal info', description: 'Name, email, phone, and profile photo' },
- { id: 'security', icon: ICON_SHIELD, tone: 'green', title: 'Security', description: 'Password and other credentials' },
-]
-
-// Mirrors the React/Vue quickstarts' Security tab: one credential per row.
-const CREDENTIALS = [
- {
- attribute: CredentialConstants.PASSWORD,
- title: 'Password',
- description: 'Used to sign in to your account.',
- cta: 'Change password',
- submitLabel: 'Update Password',
- },
-]
-
-function renderHomeTab(user) {
- const givenName = user?.given_name || user?.givenName || user?.name || user?.username || 'there'
-
- const tiles = TILES.map(
- (tile) => `
-
- ${tile.icon}
- ${escapeHtml(tile.title)}
- ${escapeHtml(tile.description)}
- `,
- ).join('')
-
- return `
- Hi, ${escapeHtml(givenName)}
- Manage your info and security across ThunderID apps.
- ${tiles}
`
-}
-
-function renderCredentialCard(credential, { schema, openAttribute }) {
- const isOpen = openAttribute === credential.attribute
-
- return `
-
-
-
-
${escapeHtml(credential.title)}
-
${escapeHtml(credential.description)}
-
-
- ${escapeHtml(credential.cta)}
- ${ICON_CHEVRON}
-
-
- ${isOpen ? renderCredentialForm(credential, schema) : ''}
-
`
-}
-
-// The form fields, always rendered so the "unavailable" state has something to sit behind
-// (blurred, disabled) rather than showing nothing at all β matches
-// `BaseChangeCredential`'s unavailable rendering in the React/Vue SDKs.
-function renderCredentialFields(credential) {
- const nameLower = credential.title.toLowerCase()
- const newFieldId = `cred-${credential.attribute}-new`
- const confirmFieldId = `cred-${credential.attribute}-confirm`
-
- return `
-
-
New ${escapeHtml(credential.title)}
-
-
- ${ICON_EYE}
-
-
-
-
Your ${escapeHtml(nameLower)} must have:
-
-
-
-
Confirm New ${escapeHtml(credential.title)}
-
-
- ${ICON_EYE}
-
-
-
- Your ${escapeHtml(nameLower)} has been updated.
-
- ${escapeHtml(credential.submitLabel)}
-
`
-}
-
-function renderCredentialForm(credential, schema) {
- const unavailable = !supportsCredential(schema, credential.attribute)
- const fields = renderCredentialFields(credential)
-
- const body = unavailable
- ? `
-
-
${fields}
-
-
${escapeHtml(credential.title)} changes unavailable
-
Please contact your administrator.
-
-
`
- : `${fields}
`
-
- return `${body}
`
-}
-
-function renderSecurityTab({ schema, openAttribute }) {
- const cards = CREDENTIALS.map((credential) => renderCredentialCard(credential, { schema, openAttribute })).join('')
-
- return `
- Security
- Manage your password and other credentials.
- ${cards}
`
-}
-
-function renderPersonalTab(user, { schema, profile }) {
- return `
- Personal info
- Manage the basic profile info others may see.
- ${renderProfileFields(user, { schema, profile })}
`
-}
-
-function renderAccountPage({ tab, user, schema, profile, openAttribute }) {
- const nav = TABS.map(
- (t) => `
-
- ${t.icon}
- ${escapeHtml(t.label)}
- `,
- ).join('')
-
- let content
- if (tab === 'personal') {
- content = renderPersonalTab(user, { schema, profile })
- } else if (tab === 'security') {
- content = renderSecurityTab({ schema, openAttribute })
- } else {
- content = renderHomeTab(user)
- }
-
- return `
- `
-}
-
-function attachCredentialForm(formEl, { attribute, schema, baseUrl, auth, onDone }) {
- const policy = resolveChangeCredentialPolicy(schema, attribute)
- const newInput = formEl.querySelector('[data-cred-field="newValue"]')
- const confirmInput = formEl.querySelector('[data-cred-field="confirmValue"]')
- const reqBox = formEl.querySelector('[data-cred-requirements]')
- const reqList = formEl.querySelector('[data-cred-requirements-list]')
- const submitBtn = formEl.querySelector('[data-cred-submit]')
- const errorBox = formEl.querySelector('[data-cred-error]')
- const successBox = formEl.querySelector('[data-cred-success]')
-
- const evaluate = () => {
- const { ruleResults, isValid } = evaluateChangePasswordForm(
- { newPassword: newInput.value, confirmPassword: confirmInput.value },
- policy,
- )
-
- if (ruleResults.length > 0) {
- reqBox.hidden = false
- reqList.innerHTML = ruleResults
- .map(
- (result) =>
- `${result.passed ? ICON_CHECK : ICON_X} Matches the required format `,
- )
- .join('')
- } else {
- reqBox.hidden = true
- }
-
- submitBtn.disabled = !isValid
- }
-
- newInput.addEventListener('input', evaluate)
- confirmInput.addEventListener('input', evaluate)
-
- formEl.querySelectorAll('[data-cred-toggle-visibility]').forEach((btn) => {
- btn.addEventListener('click', () => {
- const input = btn.previousElementSibling
- input.type = input.type === 'password' ? 'text' : 'password'
- })
- })
-
- submitBtn.addEventListener('click', async () => {
- errorBox.hidden = true
- successBox.hidden = true
- submitBtn.disabled = true
-
- try {
- const fetcher = createFetcher(auth)
- await updateMeCredentials({ baseUrl, payload: { [attribute]: newInput.value }, fetcher })
-
- successBox.hidden = false
- newInput.value = ''
- confirmInput.value = ''
- evaluate()
- onDone?.()
- } catch (err) {
- const { message } = mapCredentialUpdateError(err)
- errorBox.hidden = false
- errorBox.textContent = message || 'An error occurred while updating your credential. Please try again.'
- submitBtn.disabled = false
- }
- })
-}
-
-// Self-mounting Account page: fetches the schema/profile once, then owns its own tab and
-// per-credential open/closed state, re-rendering just its own container on every change
-// rather than the whole app shell.
-export async function mountAccountPage(container, { user, auth, initialTab = 'home', onUserUpdated }) {
- const baseUrl = import.meta.env.VITE_THUNDERID_BASE_URL
-
- let tab = initialTab
- let openAttribute = null
- let currentUser = user
-
- const { schema, profile } = await fetchProfileFormContext({ baseUrl, auth })
-
- const render = () => {
- container.innerHTML = renderAccountPage({ tab, user: currentUser, schema, profile, openAttribute })
- attachHandlers()
- }
-
- const attachHandlers = () => {
- container.querySelectorAll('[data-tab]').forEach((btn) => {
- btn.addEventListener('click', () => {
- tab = btn.dataset.tab
- openAttribute = null
- render()
- })
- })
-
- if (tab === 'personal') {
- attachProfileFieldHandlers({
- user: currentUser,
- auth,
- schema,
- profile,
- onSaved: (updatedUser) => {
- currentUser = updatedUser
- onUserUpdated?.(updatedUser)
- },
- })
- }
-
- if (tab === 'security') {
- // The same button opens and closes the form: clicking it while its own credential is
- // already open collapses it, same as clicking the chevron.
- container.querySelectorAll('[data-cred-toggle]').forEach((btn) => {
- btn.addEventListener('click', () => {
- const attribute = btn.dataset.credToggle
- openAttribute = openAttribute === attribute ? null : attribute
- render()
- })
- })
-
- const formEl = container.querySelector('[data-cred-form]')
- if (formEl) {
- attachCredentialForm(formEl, {
- attribute: formEl.dataset.credForm,
- schema,
- baseUrl,
- auth,
- onDone: () => {
- openAttribute = null
- render()
- },
- })
- }
- }
- }
-
- render()
-}
diff --git a/samples/browser/quickstart/src/pages/home.js b/samples/browser/quickstart/src/pages/home.js
deleted file mode 100644
index 24be3a57..00000000
--- a/samples/browser/quickstart/src/pages/home.js
+++ /dev/null
@@ -1,321 +0,0 @@
-const HERO_MARK = `
-
-
-
- `
-
-const ICON_CLOCK = ` `
-const ICON_TIMER = ` `
-const ICON_BUILDING = ` `
-const ICON_ARROW = ` `
-
-const NEXT_STEPS = [
- {
- n: '01',
- title: 'Explore use cases',
- body: 'See what you can build β auth flows for web, mobile, APIs, and agents.',
- cta: 'Browse use cases',
- href: 'https://thunderid.dev/docs/next/use-cases/overview/',
- },
- {
- n: '02',
- title: 'Learn about flows',
- body: 'Understand how authorization code, PKCE, client credentials, and device flows work.',
- cta: 'Read guide',
- href: 'https://thunderid.dev/docs/next/guides/flows/what-are-flows/',
- },
- {
- n: '03',
- title: 'Style your experience',
- body: 'Customize the login UI, branding, and email templates to match your product.',
- cta: 'Design guide',
- href: 'https://thunderid.dev/docs/next/guides/design/overview/',
- },
- {
- n: '04',
- title: 'Explore SDK APIs',
- body: 'Full Browser SDK reference β client methods, PKCE configuration, and token management.',
- cta: 'SDK reference',
- href: 'https://thunderid.dev/docs/next/sdks/browser/overview/',
- },
-]
-
-function escapeHtml(str) {
- if (str == null) return ''
- return String(str)
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''')
-}
-
-function getInitials(user) {
- const given = user?.given_name || ''
- const family = user?.family_name || ''
- if (given && family) return (given[0] + family[0]).toUpperCase()
- const name = user?.name || user?.username || user?.email || '?'
- const parts = name.trim().split(/\s+/)
- if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
- return name.slice(0, 2).toUpperCase()
-}
-
-function getAvatarUrl(user) {
- return user?.profile || user?.profileUrl || user?.picture || user?.URL || null
-}
-
-function renderAvatar(user, className) {
- const avatarUrl = getAvatarUrl(user)
- if (avatarUrl) {
- return ``
- }
- return `${escapeHtml(getInitials(user))}
`
-}
-
-function greeting(name) {
- const h = new Date().getHours()
- const tod = h < 12 ? 'morning' : h < 17 ? 'afternoon' : 'evening'
- return `Good ${tod}, ${name}.`
-}
-
-function formatTime(unixSeconds) {
- if (!unixSeconds) return 'β'
- return new Date(unixSeconds * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
-}
-
-export function formatCountdown(secsLeft) {
- if (secsLeft <= 0) return { text: 'Expired', color: '#d95757' }
- if (secsLeft < 300) {
- const m = Math.floor(secsLeft / 60), s = secsLeft % 60
- return { text: `${m}m ${s}s`, color: '#e88b3a' }
- }
- if (secsLeft < 3600) {
- const m = Math.floor(secsLeft / 60), s = secsLeft % 60
- return { text: `${m}m ${s}s`, color: '#2fbd6b' }
- }
- const hr = Math.floor(secsLeft / 3600), m = Math.floor((secsLeft % 3600) / 60)
- return { text: `${hr}h ${m}m`, color: '#2fbd6b' }
-}
-
-export function renderSignedOut() {
- return `
-
-
-
${HERO_MARK}
-
-
- Open source
-
-
-
Auth for Modern Apps and Agents
-
- ThunderID gives you OAuth 2.0, PKCE, MFA, and JWT out of the box.
- Clone the Quickstart and ship auth before lunch.
-
-
- Sign in
-
-
-
-
- OAuth 2.0
- Authorization standard
-
-
- < 5 min
- Integration time
-
-
- Apache 2.0
- License
-
-
-
- `
-}
-
-export function attachSignedOutHandlers({ auth }) {
- document.getElementById('hero-sign-in-btn')?.addEventListener('click', () => auth.signIn())
-}
-
-export function renderConfigNeeded(missing) {
- const origin = typeof window !== 'undefined' ? window.location.origin : ''
-
- return `
-
-
-
${HERO_MARK}
-
-
- Setup required
-
-
-
Configuration needed
-
- This quickstart can't reach ThunderID yet. Follow the steps below,
- then restart the dev server.
-
-
-
-
Step 1 · Set environment variables
-
- ${missing.map(key => `${escapeHtml(key)} `).join('')}
-
-
- Copy .env.example to .env, fill in the
- values from your ThunderID application, then run npm run dev again.
-
-
-
-
-
Step 2 · Allow this origin for CORS
-
-
- Sign-in requests from this origin will be blocked by the browser
- until it's added to your ThunderID deployment's allowed CORS
- origins. In the ThunderID Console , go to
- Settings → CORS → Allowed origins and
- add it.
-
-
- ${escapeHtml(origin)}
- Copy
-
-
-
-
-
-
Step 3 · Register redirect URIs
-
-
- This origin also doubles as this app's Authorized redirect URI
- and Post-Logout Redirect URI. In the ThunderID
- Console , open this application and go to
- Advanced Settings → OAuth2 Configuration ,
- then add it to both fields below.
-
-
-
-
Authorized redirect URI
-
- ${escapeHtml(origin)}
- Copy
-
-
-
-
Post-Logout Redirect URI
-
- ${escapeHtml(origin)}
- Copy
-
-
-
-
-
-
-
- Need more info? Take a look at the
- Browser quickstart guide.
-
-
- `
-}
-
-export function attachConfigNeededHandlers() {
- if (!navigator.clipboard) return
- document.querySelectorAll('.config-copy-btn').forEach((btn) => {
- btn.addEventListener('click', () => {
- navigator.clipboard.writeText(btn.getAttribute('data-copy') || '').then(() => {
- const original = btn.textContent
- btn.textContent = 'Copied!'
- setTimeout(() => { btn.textContent = original }, 1500)
- })
- })
- })
-}
-
-export function renderHome({ user, idToken }) {
- const now = Math.floor(Date.now() / 1000)
- const givenName = user?.given_name || user?.name || user?.username || 'there'
- const email = user?.email || ''
-
- const authTime = idToken?.auth_time
- const exp = idToken?.exp
- const secsLeft = exp ? Math.max(0, exp - now) : null
- const countdown = secsLeft !== null ? formatCountdown(secsLeft) : null
- const orgName = escapeHtml(idToken?.org_name || idToken?.org_handle || 'Default')
-
- return `
-
-
- ${renderAvatar(user, 'home-avatar-initials')}
-
-
${escapeHtml(greeting(givenName))}
-
- ${email ? `${escapeHtml(email)} ` : ''}
-
-
- Session active
-
-
-
-
-
-
-
-
${ICON_CLOCK}
-
-
${formatTime(authTime)}
-
Signed in at
-
-
-
-
${ICON_TIMER}
-
-
${countdown ? countdown.text : 'β'}
-
Session expires in
-
-
-
-
${ICON_BUILDING}
-
-
${orgName}
-
Organisation
-
-
-
-
- What's next
-
- `
-}
-
-export function startCountdown({ idToken }) {
- const timer = setInterval(() => {
- const el = document.getElementById('countdown-value')
- if (!el) { clearInterval(timer); return }
- const now = Math.floor(Date.now() / 1000)
- const exp = idToken?.exp
- const secsLeft = exp ? Math.max(0, exp - now) : null
- if (secsLeft !== null) {
- const cd = formatCountdown(secsLeft)
- el.textContent = cd.text
- el.style.color = cd.color
- }
- }, 1000)
- return timer
-}
diff --git a/samples/browser/quickstart/src/pages/token.js b/samples/browser/quickstart/src/pages/token.js
deleted file mode 100644
index 9c65551f..00000000
--- a/samples/browser/quickstart/src/pages/token.js
+++ /dev/null
@@ -1,118 +0,0 @@
-const ICON_COPY = ` `
-
-function escapeHtml(str) {
- if (str == null) return ''
- return String(str)
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''')
-}
-
-function decodeJwtPart(part) {
- try {
- const base64 = part.replace(/-/g, '+').replace(/_/g, '/')
- const padded = base64.padEnd(base64.length + (4 - (base64.length % 4)) % 4, '=')
- return JSON.parse(atob(padded))
- } catch {
- return null
- }
-}
-
-export function renderTokenDebug({ rawToken }) {
- const now = Math.floor(Date.now() / 1000)
- const parts = rawToken ? rawToken.split('.') : []
- const jwtHeader = parts[0] ? decodeJwtPart(parts[0]) : null
- const jwtPayload = parts[1] ? decodeJwtPart(parts[1]) : null
-
- const exp = jwtPayload?.exp
- const expiresIn = exp ? exp - now : null
- const expired = expiresIn !== null && expiresIn <= 0
- const minsLeft = expiresIn !== null ? Math.abs(Math.floor(expiresIn / 60)) : null
-
- const issuer = jwtPayload?.iss
- const audience = Array.isArray(jwtPayload?.aud) ? jwtPayload.aud.join(', ') : jwtPayload?.aud
- const scopes = jwtPayload?.scope
-
- return `
-
-
-
- ${rawToken ? `
-
-
- Access token
- ${ICON_COPY} Copy
-
-
-
- .
- ${escapeHtml(parts[1] || '')}
- .
- ${escapeHtml(parts[2] || '')}
-
-
` : `
-
-
- Access token
-
-
Loadingβ¦
-
`}
-
-
-
-
-
${jwtHeader ? escapeHtml(JSON.stringify(jwtHeader, null, 2)) : 'β¦'}
-
-
-
-
${jwtPayload ? escapeHtml(JSON.stringify(jwtPayload, null, 2)) : 'β¦'}
-
-
-
- ${issuer || audience || scopes ? `
- ` : ''}
- `
-}
-
-export function attachTokenHandlers({ rawToken }) {
- document.getElementById('copy-btn')?.addEventListener('click', async () => {
- if (!rawToken || !navigator.clipboard) return
- await navigator.clipboard.writeText(rawToken)
- const btn = document.getElementById('copy-btn')
- if (btn) {
- btn.innerHTML = `${ICON_COPY} Copied!`
- setTimeout(() => {
- const b = document.getElementById('copy-btn')
- if (b) b.innerHTML = `${ICON_COPY} Copy`
- }, 1500)
- }
- })
-}
diff --git a/samples/browser/quickstart/src/style.css b/samples/browser/quickstart/src/style.css
deleted file mode 100644
index 7b5ba13b..00000000
--- a/samples/browser/quickstart/src/style.css
+++ /dev/null
@@ -1,1595 +0,0 @@
-*, *::before, *::after {
- box-sizing: border-box;
- margin: 0;
- padding: 0;
-}
-
-:root {
- --bg: #F7F9FC;
- --card: #ffffff;
- --border: #DDE3EC;
- --text: #05213F;
- --muted: #5A7085;
- --blue: #3688FF;
- --blue-hover: #2270e8;
- --blue-subtle: rgba(54, 136, 255, 0.08);
- --secondary: #424242;
- --shadow-sm: 0 1px 3px rgba(5, 33, 63, 0.08);
- --shadow-md: 0 4px 16px rgba(5, 33, 63, 0.10);
- --radius: 10px;
- --radius-sm: 6px;
-}
-
-.dark {
- --bg: #080f1c;
- --card: #0f1a2d;
- --border: rgba(255, 255, 255, 0.09);
- --text: #E0EAFF;
- --muted: rgba(224, 234, 255, 0.48);
- --blue-subtle: rgba(54, 136, 255, 0.14);
- --secondary: #8b8b8b;
- --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
- --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.4);
-}
-
-html {
- color-scheme: light;
-}
-
-html.dark {
- color-scheme: dark;
-}
-
-body {
- font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
- background: var(--bg);
- color: var(--text);
- min-height: 100vh;
- line-height: 1.5;
- transition: background 0.2s ease, color 0.2s ease;
-}
-
-/* Nav */
-.nav {
- display: flex;
- align-items: center;
- gap: 12px;
- padding: 0 28px;
- height: 52px;
- border-bottom: 1px solid var(--border);
- background: rgba(247, 249, 252, 0.92);
- backdrop-filter: blur(12px);
- -webkit-backdrop-filter: blur(12px);
- position: sticky;
- top: 0;
- z-index: 100;
-}
-
-.dark .nav {
- background: rgba(8, 15, 28, 0.92);
-}
-
-.nav-logo {
- display: flex;
- align-items: center;
- gap: 10px;
- text-decoration: none;
- color: inherit;
- cursor: pointer;
- flex: 1;
-}
-
-.wordmark {
- display: flex;
- flex-direction: column;
- gap: 1px;
-}
-
-.wordmark-name {
- font-size: 14px;
- font-weight: 700;
- letter-spacing: -0.03em;
- color: var(--text);
- line-height: 1;
-}
-
-.wordmark-sub {
- font-size: 8.5px;
- font-weight: 700;
- letter-spacing: 0.1em;
- text-transform: uppercase;
- color: var(--blue);
- line-height: 1;
-}
-
-.nav-actions {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-/* Hero */
-.hero {
- display: flex;
- flex-direction: column;
- align-items: center;
- text-align: center;
- padding: 96px 32px 80px;
- max-width: 560px;
- margin: 0 auto;
-}
-
-.hero-mark {
- margin-bottom: 32px;
- opacity: 0.9;
-}
-
-.hero-badge {
- display: inline-flex;
- align-items: center;
- gap: 8px;
- font-size: 11px;
- font-weight: 700;
- letter-spacing: 0.1em;
- text-transform: uppercase;
- color: var(--muted);
- margin-bottom: 28px;
-}
-
-.hero-badge-line {
- display: block;
- width: 24px;
- height: 1px;
- background: var(--border);
- flex-shrink: 0;
-}
-
-.hero-title {
- font-size: 42px;
- font-weight: 700;
- letter-spacing: -0.05em;
- line-height: 1.1;
- color: var(--text);
- margin-bottom: 18px;
-}
-
-.hero-subtitle {
- font-size: 16px;
- color: var(--muted);
- line-height: 1.8;
- letter-spacing: -0.01em;
- max-width: 420px;
- margin-bottom: 36px;
-}
-
-.hero-ctas {
- display: flex;
- gap: 10px;
- flex-wrap: wrap;
- justify-content: center;
- margin-bottom: 52px;
-}
-
-.hero-divider {
- width: 100%;
- border: none;
- border-top: 1px solid var(--border);
- margin: 4px 0;
-}
-
-.hero-stats {
- width: 100%;
- max-width: 480px;
- display: grid;
- grid-template-columns: repeat(3, 1fr);
-}
-
-.stat {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 4px;
- padding: 16px 8px;
-}
-
-.stat:not(:last-child) {
- border-right: 1px solid var(--border);
-}
-
-.stat-value {
- font-size: 15px;
- font-weight: 700;
- color: var(--text);
-}
-
-.stat-label {
- font-size: 12px;
- color: var(--muted);
-}
-
-/* Config notice */
-.config-badge {
- color: #e88b3a;
-}
-
-.config-list {
- width: 100%;
- max-width: 420px;
- text-align: left;
- list-style: none;
- background: var(--card);
- border: 1px solid var(--border);
- border-radius: var(--radius-sm);
- padding: 0 16px;
- margin: 4px 0 28px;
-}
-
-.config-list-item {
- padding: 10px 0;
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
- font-size: 13px;
- color: var(--text);
-}
-
-.config-list-item:not(:last-child) {
- border-bottom: 1px solid var(--border);
-}
-
-.config-hint {
- font-size: 14px;
- color: var(--muted);
- line-height: 1.7;
- max-width: 420px;
-}
-
-.config-hint code {
- background: var(--blue-subtle);
- border-radius: 4px;
- padding: 2px 6px;
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
- font-size: 13px;
-}
-
-.config-step {
- width: 100%;
- max-width: 420px;
- margin: 0 auto 28px;
-}
-
-.config-step-label {
- font-size: 11px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.09em;
- color: var(--muted);
- margin-bottom: 10px;
- text-align: left;
-}
-
-.config-step .config-list,
-.config-step .config-hint {
- margin-left: 0;
- margin-right: 0;
-}
-
-.config-box {
- text-align: left;
- background: var(--card);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 16px;
-}
-
-.config-box-body {
- font-size: 13px;
- color: var(--muted);
- line-height: 1.6;
- margin-bottom: 14px;
-}
-
-.config-value-row {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.config-value {
- flex: 1;
- min-width: 0;
- overflow-x: auto;
- white-space: nowrap;
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
- font-size: 12px;
- color: var(--text);
- background: var(--blue-subtle);
- border-radius: 4px;
- padding: 8px 10px;
-}
-
-.config-value-label {
- font-size: 11px;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.06em;
- color: var(--muted);
- margin-bottom: 4px;
-}
-
-.config-value-group {
- display: flex;
- flex-direction: column;
- gap: 12px;
-}
-
-.config-docs-note {
- font-size: 13px;
- color: var(--muted);
- max-width: 420px;
- margin: 0 auto;
-}
-
-.config-docs-note a {
- color: var(--blue);
- font-weight: 600;
-}
-
-/* Buttons */
-.btn-primary,
-.btn-outline,
-.btn-ghost {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- height: 46px;
- padding: 0 24px;
- border-radius: 8px;
- font-size: 15px;
- font-weight: 600;
- cursor: pointer;
- transition: opacity 0.15s ease, background 0.15s ease, border-color 0.15s ease;
- white-space: nowrap;
- text-decoration: none;
- font-family: inherit;
-}
-
-.btn-primary {
- background: var(--blue);
- color: #ffffff;
- border: 1.5px solid var(--blue);
-}
-
-.btn-primary:disabled {
- opacity: 0.6;
- cursor: not-allowed;
-}
-
-.btn-primary:hover:not(:disabled) {
- opacity: 0.88;
-}
-
-.btn-outline {
- background: transparent;
- color: var(--blue);
- border: 1.5px solid rgba(54, 136, 255, 0.4);
-}
-
-.btn-outline:hover {
- background: var(--blue-subtle);
-}
-
-.btn-ghost {
- background: transparent;
- color: var(--muted);
- border: none;
-}
-
-.btn-ghost:hover {
- background: var(--blue-subtle);
- color: var(--text);
-}
-
-.nav .btn-primary,
-.nav .btn-outline,
-.nav .btn-ghost {
- height: 36px;
- padding: 0 16px;
- font-size: 14px;
-}
-
-.dark-toggle {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 34px;
- height: 34px;
- padding: 0;
- background: transparent;
- border: 1px solid var(--border);
- border-radius: 8px;
- color: var(--muted);
- cursor: pointer;
- transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
- flex-shrink: 0;
-}
-
-.dark-toggle:hover {
- color: var(--text);
- background: var(--border);
-}
-
-/* User card */
-.user-card {
- position: relative;
- background: var(--card);
- border: 1px solid var(--border);
- border-radius: 16px;
- padding: 40px 48px;
- box-shadow: var(--shadow-md);
- text-align: left;
- min-width: 320px;
- max-width: 460px;
- width: 100%;
- margin-top: 24px;
-}
-
-.user-avatar {
- width: 64px;
- height: 64px;
- border-radius: 50%;
- background: var(--blue);
- color: #ffffff;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 22px;
- font-weight: 700;
- letter-spacing: -0.5px;
- margin-bottom: 20px;
- box-shadow: 0 4px 12px rgba(54, 136, 255, 0.3);
-}
-
-.user-details {
- display: flex;
- flex-direction: column;
- gap: 12px;
-}
-
-.user-name {
- font-size: 22px;
- font-weight: 700;
- letter-spacing: -0.4px;
- color: var(--text);
- margin-bottom: 4px;
-}
-
-.user-field {
- display: flex;
- flex-direction: column;
- gap: 2px;
-}
-
-.user-field-label {
- font-size: 11px;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.07em;
- color: var(--muted);
-}
-
-.user-field-value {
- font-size: 14px;
- color: var(--text);
- font-weight: 500;
-}
-
-.user-badge {
- display: inline-flex;
- align-items: center;
- gap: 5px;
- margin-top: 24px;
- font-size: 12px;
- font-weight: 600;
- color: #16a34a;
- background: #f0fdf4;
- border: 1px solid #bbf7d0;
- padding: 4px 10px;
- border-radius: 100px;
-}
-
-.dark .user-badge {
- color: #4ade80;
- background: rgba(74, 222, 128, 0.1);
- border-color: rgba(74, 222, 128, 0.25);
-}
-
-/* Nav back button */
-.nav-back-btn {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- height: 32px;
- padding: 0 10px;
- border-radius: 6px;
- border: none;
- background: transparent;
- color: var(--muted);
- font-size: 13px;
- font-family: inherit;
- letter-spacing: -0.01em;
- cursor: pointer;
- transition: background 0.15s ease, color 0.15s ease;
-}
-
-.nav-back-btn:hover {
- background: var(--border);
- color: var(--text);
-}
-
-/* User dropdown */
-.ud-wrapper {
- position: relative;
-}
-
-.ud-trigger {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- padding: 4px 8px 4px 4px;
- border-radius: 24px;
- border: 1px solid var(--border);
- background: transparent;
- color: var(--text);
- font-size: 13px;
- font-weight: 600;
- cursor: pointer;
- font-family: inherit;
- transition: background 0.15s ease, border-color 0.15s ease;
-}
-
-.ud-trigger:hover {
- background: var(--blue-subtle);
- border-color: rgba(54, 136, 255, 0.3);
-}
-
-.ud-avatar {
- width: 28px;
- height: 28px;
- border-radius: 50%;
- background: var(--blue);
- color: #fff;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 11px;
- font-weight: 700;
- flex-shrink: 0;
- overflow: hidden;
-}
-
-.ud-avatar img {
- width: 100%;
- height: 100%;
- border-radius: 50%;
- object-fit: cover;
-}
-
-.ud-avatar--header {
- width: 36px;
- height: 36px;
- font-size: 13px;
-}
-
-.ud-name {
- max-width: 120px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.ud-menu {
- display: none;
- position: absolute;
- right: 0;
- top: calc(100% + 6px);
- min-width: 200px;
- background: var(--card);
- border: 1px solid var(--border);
- border-radius: 10px;
- box-shadow: var(--shadow-md);
- overflow: hidden;
- z-index: 200;
-}
-
-.ud-menu.ud-menu--open {
- display: block;
-}
-
-.ud-header {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 12px 14px;
-}
-
-.ud-header-name {
- font-size: 13px;
- font-weight: 600;
- color: var(--text);
-}
-
-.ud-header-email {
- font-size: 12px;
- color: var(--muted);
- margin-top: 2px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.ud-divider {
- height: 1px;
- background: var(--border);
-}
-
-.ud-item {
- display: flex;
- align-items: center;
- gap: 8px;
- width: 100%;
- padding: 10px 14px;
- border: none;
- background: transparent;
- color: var(--text);
- font-size: 13px;
- font-weight: 500;
- cursor: pointer;
- text-align: left;
- font-family: inherit;
- transition: background 0.15s ease;
-}
-
-.ud-item:hover {
- background: var(--blue-subtle);
-}
-
-/* Signed-in home page */
-.home-main {
- width: 100%;
- max-width: 900px;
- margin: 0 auto;
- padding: 52px 28px 80px;
-}
-
-.home-avatar-initials {
- width: 52px;
- height: 52px;
- border-radius: 50%;
- background: var(--blue);
- color: #fff;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 18px;
- font-weight: 700;
- letter-spacing: -0.3px;
- flex-shrink: 0;
- box-shadow: 0 4px 12px rgba(54, 136, 255, 0.3);
- overflow: hidden;
-}
-
-.home-avatar-initials img {
- width: 100%;
- height: 100%;
- border-radius: 50%;
- object-fit: cover;
-}
-
-.home-greeting {
- display: flex;
- align-items: flex-start;
- gap: 16px;
- margin-bottom: 36px;
-}
-
-.home-greeting-text {
- min-width: 0;
-}
-
-.home-greeting-name {
- margin: 0 0 6px;
- font-size: 26px;
- font-weight: 700;
- color: var(--text);
- letter-spacing: -0.04em;
-}
-
-.home-greeting-meta {
- display: flex;
- align-items: center;
- gap: 8px;
- font-size: 13px;
- color: var(--muted);
- letter-spacing: -0.01em;
- flex-wrap: wrap;
-}
-
-.home-dot {
- display: inline-block;
- width: 3px;
- height: 3px;
- border-radius: 50%;
- background: var(--border);
- flex-shrink: 0;
-}
-
-.home-session-active {
- display: inline-flex;
- align-items: center;
- gap: 5px;
- color: #2fbd6b;
-}
-
-.home-session-dot {
- display: inline-block;
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: #2fbd6b;
- flex-shrink: 0;
-}
-
-.home-stats {
- display: flex;
- gap: 0;
- padding: 20px 0;
- border-top: 1px solid var(--border);
- border-bottom: 1px solid var(--border);
- margin-bottom: 52px;
-}
-
-.home-stat {
- flex: 1;
- display: flex;
- align-items: center;
- gap: 10px;
- min-width: 0;
-}
-
-.home-stat--bordered {
- padding-left: 32px;
- border-left: 1px solid var(--border);
-}
-
-.home-stat-icon {
- width: 32px;
- height: 32px;
- border-radius: 8px;
- background: rgba(54, 136, 255, 0.1);
- display: flex;
- align-items: center;
- justify-content: center;
- flex-shrink: 0;
- color: #3688FF;
-}
-
-.home-stat-value {
- font-size: 14px;
- font-weight: 700;
- color: var(--text);
- letter-spacing: -0.02em;
-}
-
-.home-stat-value--mono {
- font-family: monospace;
- letter-spacing: 0.01em;
-}
-
-.home-stat-label {
- font-size: 11px;
- font-weight: 600;
- color: var(--muted);
- text-transform: uppercase;
- letter-spacing: 0.07em;
- margin-top: 2px;
-}
-
-.home-next-label {
- font-size: 11px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.09em;
- color: var(--muted);
- margin-bottom: 20px;
-}
-
-.home-next-list {
- display: flex;
- flex-direction: column;
-}
-
-.home-next-item {
- display: grid;
- grid-template-columns: 28px 1fr auto;
- gap: 16px;
- align-items: start;
- padding: 20px 0;
- border-top: 1px solid var(--border);
- text-decoration: none;
- color: inherit;
- cursor: pointer;
- transition: background 0.2s ease;
- border-radius: 4px;
-}
-
-.home-next-item:hover .home-next-cta {
- opacity: 1;
-}
-
-.home-next-n {
- font-size: 11px;
- font-weight: 700;
- color: #3688FF;
- letter-spacing: 0.05em;
- padding-top: 2px;
- font-variant-numeric: tabular-nums;
-}
-
-.home-next-body {
- min-width: 0;
-}
-
-.home-next-title {
- font-size: 14px;
- font-weight: 600;
- color: var(--text);
- letter-spacing: -0.02em;
- margin-bottom: 4px;
-}
-
-.home-next-desc {
- font-size: 13px;
- color: var(--muted);
- line-height: 1.6;
- letter-spacing: -0.01em;
-}
-
-.home-next-cta {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- font-size: 13px;
- font-weight: 600;
- color: #3688FF;
- white-space: nowrap;
- opacity: 0.7;
- transition: opacity 0.2s ease;
- padding-top: 2px;
-}
-
-/* Token debug page */
-.token-main {
- width: 100%;
- max-width: 780px;
- margin: 0 auto;
- padding: 52px 28px 80px;
-}
-
-.token-header {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 16px;
- margin-bottom: 36px;
- flex-wrap: wrap;
-}
-
-.token-title {
- margin: 0 0 5px;
- font-size: 22px;
- font-weight: 700;
- letter-spacing: -0.04em;
- color: var(--text);
-}
-
-.token-subtitle {
- margin: 0;
- font-size: 13px;
- color: var(--muted);
- letter-spacing: -0.01em;
-}
-
-.token-badge {
- display: inline-flex;
- align-items: center;
- gap: 7px;
- padding: 6px 12px;
- border-radius: 6px;
- font-size: 12px;
- font-weight: 700;
- letter-spacing: -0.01em;
- flex-shrink: 0;
-}
-
-.token-badge--expired {
- background: rgba(220, 38, 38, 0.08);
- border: 1px solid rgba(220, 38, 38, 0.22);
- color: #dc2626;
-}
-
-.token-badge--valid {
- background: rgba(47, 189, 107, 0.08);
- border: 1px solid rgba(47, 189, 107, 0.22);
- color: #2fbd6b;
-}
-
-.token-badge-dot {
- display: inline-block;
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: currentColor;
- flex-shrink: 0;
-}
-
-.token-raw-section {
- margin-bottom: 20px;
-}
-
-.token-raw-label-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 8px;
-}
-
-.token-section-label {
- font-size: 11px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.09em;
- color: var(--muted);
-}
-
-.token-copy-btn {
- display: inline-flex;
- align-items: center;
- gap: 5px;
- padding: 4px 10px;
- border-radius: 5px;
- background: transparent;
- border: 1px solid var(--border);
- font-size: 11px;
- font-weight: 600;
- color: var(--muted);
- cursor: pointer;
- font-family: inherit;
- letter-spacing: -0.01em;
- transition: border-color 0.15s ease, color 0.15s ease;
-}
-
-.token-copy-btn:hover {
- border-color: #3688FF;
- color: #3688FF;
-}
-
-.token-raw {
- display: block;
- font-size: 11px;
- font-family: monospace;
- word-break: break-all;
- line-height: 1.8;
- background: #0b1120;
- padding: 14px 16px;
- border-radius: 8px;
- overflow-x: auto;
-}
-
-.token-raw--loading {
- color: rgba(255, 255, 255, 0.3);
-}
-
-.token-part--header { color: #ff7b72; }
-.token-part--payload { color: #79c0ff; }
-.token-part--signature { color: #3fb950; }
-.token-dot { color: rgba(255, 255, 255, 0.2); }
-
-.token-decoded-grid {
- display: grid;
- grid-template-columns: 1fr 1.7fr;
- gap: 12px;
- margin-bottom: 20px;
-}
-
-.token-code-box {
- background: #0b1120;
- border-radius: 10px;
- overflow: hidden;
-}
-
-.token-code-box-header {
- padding: 9px 14px;
- font-size: 10px;
- font-weight: 700;
- letter-spacing: 0.09em;
- text-transform: uppercase;
-}
-
-.token-code-box-header--header {
- color: #ff7b72;
- border-bottom: 1px solid rgba(255, 123, 114, 0.15);
-}
-
-.token-code-box-header--payload {
- color: #79c0ff;
- border-bottom: 1px solid rgba(121, 192, 255, 0.15);
-}
-
-.token-code-pre {
- margin: 0;
- padding: 14px;
- font-size: 11.5px;
- line-height: 1.8;
- font-family: monospace;
- color: #c9d1d9;
- overflow-x: auto;
- max-height: 220px;
-}
-
-.token-meta-row {
- display: flex;
- gap: 32px;
- padding-top: 20px;
- border-top: 1px solid var(--border);
- flex-wrap: wrap;
-}
-
-.token-meta-label {
- font-size: 10px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.09em;
- color: var(--muted);
- margin-bottom: 5px;
-}
-
-.token-meta-value {
- font-size: 12px;
- font-family: monospace;
- color: var(--text);
- letter-spacing: -0.01em;
-}
-
-/* Personal info (account page) */
-.profile-summary {
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- gap: 12px;
- margin-bottom: 12px;
-}
-
-.profile-summary-avatar {
- width: 70px;
- height: 70px;
- border-radius: 50%;
- border: 1px solid var(--border);
- background: var(--card);
- color: var(--text);
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 28px;
- font-weight: 600;
- overflow: hidden;
-}
-
-.profile-summary-avatar.has-gradient {
- color: #fff;
- text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
- border: none;
-}
-
-.profile-summary-avatar img {
- width: 100%;
- height: 100%;
- border-radius: 50%;
- object-fit: cover;
-}
-
-.profile-error {
- font-size: 12px;
- color: #dc2626;
- background: rgba(220, 38, 38, 0.08);
- border-radius: var(--radius-sm);
- padding: 8px 10px;
- margin-bottom: 14px;
-}
-
-.profile-summary-name {
- font-size: 24px;
- font-weight: 600;
- color: var(--text);
-}
-
-.profile-summary-subtitle {
- font-size: 14px;
- color: var(--muted);
-}
-
-.profile-field-list {
- display: flex;
- flex-direction: column;
-}
-
-.profile-field-row {
- display: flex;
- align-items: center;
- padding: 12px 0;
- border-bottom: 1px solid var(--border);
-}
-
-.profile-field-row-label {
- font-size: 14px;
- font-weight: 500;
- color: var(--muted);
- width: 120px;
- flex-shrink: 0;
- line-height: 28px;
-}
-
-.profile-field-row-display {
- flex: 1;
- display: flex;
- align-items: center;
- gap: 8px;
- min-width: 0;
-}
-
-.profile-field-row-value {
- flex: 1;
- font-size: 14px;
- color: var(--text);
- line-height: 28px;
- word-break: break-word;
- text-align: left;
-}
-
-.profile-field-row-value.placeholder {
- font-style: italic;
- opacity: 0.7;
-}
-
-.profile-field-edit-btn {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 28px;
- height: 28px;
- flex-shrink: 0;
- border: none;
- background: transparent;
- color: var(--muted);
- border-radius: var(--radius-sm);
- cursor: pointer;
- opacity: 0.7;
-}
-
-.profile-field-edit-btn:hover {
- opacity: 1;
- color: var(--blue);
-}
-
-.profile-field-row-input {
- flex: 1;
- padding: 4px 8px;
- font-size: 14px;
- font-family: inherit;
- color: var(--text);
- background: var(--bg);
- border: 1px solid var(--border);
- border-radius: var(--radius-sm);
- min-width: 0;
-}
-
-.profile-field-row-input:focus {
- outline: none;
- border-color: var(--blue);
-}
-
-.profile-field-row-actions {
- display: flex;
- gap: 6px;
-}
-
-.profile-field-btn {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- height: 28px;
- padding: 0 12px;
- border: none;
- border-radius: var(--radius-sm);
- font-size: 12px;
- font-weight: 600;
- font-family: inherit;
- cursor: pointer;
- white-space: nowrap;
- transition: opacity 0.15s ease;
-}
-
-.profile-field-btn--save {
- background: var(--blue);
- color: #ffffff;
-}
-
-.profile-field-btn--save:hover:not(:disabled) {
- opacity: 0.9;
-}
-
-.profile-field-btn--save:disabled {
- opacity: 0.6;
- cursor: not-allowed;
-}
-
-.profile-field-btn--cancel {
- background: var(--secondary);
- color: #ffffff;
-}
-
-.profile-field-btn--cancel:hover {
- opacity: 0.9;
-}
-
-/* Account page (Home / Personal info / Security, left-nav layout) */
-.account-page {
- display: flex;
- align-items: flex-start;
- min-height: calc(100vh - 52px);
-}
-
-.account-sidebar {
- width: 280px;
- flex-shrink: 0;
- padding: 40px 24px;
- position: sticky;
- top: 52px;
-}
-
-.account-sidebar-title {
- font-size: 22px;
- font-weight: 700;
- letter-spacing: -0.03em;
- color: var(--text);
- margin: 0 0 24px 16px;
-}
-
-.account-nav {
- display: flex;
- flex-direction: column;
- gap: 2px;
-}
-
-.account-nav-item {
- display: flex;
- align-items: center;
- gap: 14px;
- width: 100%;
- padding: 11px 16px;
- border: none;
- border-radius: 20px;
- background: transparent;
- color: var(--muted);
- font-size: 14px;
- font-weight: 500;
- font-family: inherit;
- text-align: left;
- cursor: pointer;
- white-space: nowrap;
-}
-
-.account-nav-item:hover {
- background: var(--border);
- color: var(--text);
-}
-
-.account-nav-item--active,
-.account-nav-item--active:hover {
- background: var(--blue-subtle);
- color: var(--blue);
-}
-
-.account-content {
- flex: 1;
- min-width: 0;
- display: flex;
- justify-content: center;
- padding: 40px 32px 80px;
- border-left: 1px solid var(--border);
-}
-
-.account-content-inner {
- width: 100%;
- max-width: 720px;
-}
-
-.account-content-title {
- font-size: 26px;
- font-weight: 700;
- letter-spacing: -0.03em;
- color: var(--text);
- margin: 0 0 6px;
-}
-
-.account-content-subtitle {
- font-size: 14px;
- color: var(--muted);
- margin: 0 0 32px;
-}
-
-.account-tiles {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
- gap: 16px;
- margin-top: 8px;
-}
-
-.account-tile {
- text-align: left;
- background: var(--card);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 22px;
- cursor: pointer;
- font-family: inherit;
- display: flex;
- flex-direction: column;
-}
-
-.account-tile:hover {
- border-color: var(--blue);
-}
-
-.account-tile-icon {
- width: 36px;
- height: 36px;
- border-radius: 10px;
- display: flex;
- align-items: center;
- justify-content: center;
- margin-bottom: 14px;
-}
-
-.account-tile-icon--blue {
- background: var(--blue-subtle);
- color: var(--blue);
-}
-
-.account-tile-icon--green {
- background: rgba(16, 185, 129, 0.12);
- color: #10b981;
-}
-
-.account-tile-title {
- font-size: 15px;
- font-weight: 600;
- color: var(--text);
- margin-bottom: 4px;
-}
-
-.account-tile-desc {
- font-size: 12.5px;
- color: var(--muted);
- line-height: 1.5;
-}
-
-/* Generic bordered content box, matching the Home tiles and Security cards. */
-.account-box {
- background: var(--card);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 24px;
-}
-
-/* Security tab: one credential per row, collapsed into an expandable form */
-.account-security-list {
- display: flex;
- flex-direction: column;
- gap: 16px;
-}
-
-.account-security-card {
- background: var(--card);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 20px 22px;
-}
-
-.account-security-card-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 16px;
-}
-
-.account-security-card-title {
- font-size: 14px;
- font-weight: 600;
- color: var(--text);
- margin-bottom: 2px;
-}
-
-.account-security-card-desc {
- font-size: 12.5px;
- color: var(--muted);
-}
-
-.account-security-card-cta {
- flex-shrink: 0;
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 0;
- background: none;
- border: none;
- color: var(--blue);
- font-size: 13px;
- font-weight: 500;
- font-family: inherit;
- cursor: pointer;
-}
-
-.account-security-card-cta:hover {
- opacity: 0.8;
-}
-
-.account-security-card-chevron {
- display: inline-flex;
- transition: transform 0.2s;
-}
-
-.account-security-card-chevron--open {
- transform: rotate(180deg);
-}
-
-.account-security-card-form {
- margin-top: 18px;
- padding-top: 18px;
- border-top: 1px solid var(--border);
-}
-
-.account-security-card-error {
- font-size: 12px;
- color: #dc2626;
- background: rgba(220, 38, 38, 0.08);
- border-radius: var(--radius-sm);
- padding: 8px 10px;
- margin-bottom: 14px;
-}
-
-.account-security-card-success {
- font-size: 12px;
- color: #059669;
- background: rgba(5, 150, 105, 0.08);
- border-radius: var(--radius-sm);
- padding: 8px 10px;
- margin-bottom: 14px;
-}
-
-/* Change-credential form fields */
-.cred-field {
- margin-bottom: 14px;
-}
-
-.cred-label {
- display: block;
- font-size: 13px;
- font-weight: 500;
- color: var(--text);
- margin-bottom: 6px;
-}
-
-.cred-input-wrap {
- position: relative;
-}
-
-.cred-input {
- width: 100%;
- padding: 9px 38px 9px 12px;
- font-size: 14px;
- font-family: inherit;
- color: var(--text);
- background: var(--bg);
- border: 1px solid var(--border);
- border-radius: var(--radius-sm);
-}
-
-.cred-input:focus {
- outline: none;
- border-color: var(--blue);
-}
-
-.cred-eye-btn {
- position: absolute;
- top: 50%;
- right: 8px;
- transform: translateY(-50%);
- display: flex;
- align-items: center;
- justify-content: center;
- width: 24px;
- height: 24px;
- border: none;
- background: transparent;
- color: var(--muted);
- cursor: pointer;
-}
-
-.cred-requirements {
- margin: -6px 0 14px;
-}
-
-.cred-requirements-heading {
- font-size: 12px;
- color: var(--muted);
- margin-bottom: 6px;
-}
-
-.cred-requirements-list {
- list-style: none;
- display: flex;
- flex-direction: column;
- gap: 4px;
-}
-
-.cred-requirements-list li {
- display: flex;
- align-items: center;
- gap: 8px;
- font-size: 12.5px;
- color: var(--muted);
-}
-
-.cred-requirements-list li.passed {
- color: #059669;
-}
-
-.cred-requirements-list li svg {
- flex-shrink: 0;
-}
-
-.cred-form-actions {
- display: flex;
- justify-content: flex-end;
-}
-
-.cred-unavailable {
- position: relative;
-}
-
-.cred-unavailable-content {
- filter: blur(3px);
- opacity: 0.55;
- pointer-events: none;
- user-select: none;
-}
-
-.cred-unavailable-overlay {
- position: absolute;
- inset: 0;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- text-align: center;
- gap: 4px;
- padding: 16px;
- background: rgba(217, 119, 6, 0.08);
- border: 1px solid rgba(217, 119, 6, 0.3);
- border-radius: var(--radius-sm);
- color: #d97706;
- font-size: 13px;
-}
-
-.cred-unavailable-overlay strong {
- font-size: 13.5px;
-}
diff --git a/samples/browser/quickstart/vite.config.js b/samples/browser/quickstart/vite.config.js
deleted file mode 100644
index 0515e7ae..00000000
--- a/samples/browser/quickstart/vite.config.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import { defineConfig } from 'vite'
-
-export default defineConfig({
- build: {
- target: 'esnext',
- },
-})
diff --git a/samples/express/quickstart/.env.example b/samples/express/quickstart/.env.example
deleted file mode 100644
index 968abea4..00000000
--- a/samples/express/quickstart/.env.example
+++ /dev/null
@@ -1,11 +0,0 @@
-# Base URL of your ThunderID deployment. ex: https://localhost:8090
-THUNDERID_BASE_URL=https://localhost:8090
-
-# OAuth2 Client ID for this app. ThunderID Console -> your application -> Overview.
-THUNDERID_CLIENT_ID=your-client-id-here
-# OAuth2 Client Secret for this app. Shown once when the application is created;
-# if lost, regenerate it from the app's Credentials tab (under Edit). Server-only, never expose to the browser.
-THUNDERID_CLIENT_SECRET=your-client-secret-here
-
-# DANGER: Disables ALL TLS verification. Only for local development with self-signed certs. NEVER use in production.
-NODE_TLS_REJECT_UNAUTHORIZED=0
diff --git a/samples/express/quickstart/.stackblitzrc b/samples/express/quickstart/.stackblitzrc
deleted file mode 100644
index 5f0a2000..00000000
--- a/samples/express/quickstart/.stackblitzrc
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "installDependencies": false,
- "startCommand": "npm run prepare-dev && npm install && npm run dev"
-}
diff --git a/samples/express/quickstart/README.md b/samples/express/quickstart/README.md
deleted file mode 100644
index 14055ff5..00000000
--- a/samples/express/quickstart/README.md
+++ /dev/null
@@ -1,99 +0,0 @@
-# ThunderID Express Quickstart
-
-
-
-A minimal Express.js **API** protected by ThunderID access tokens, using the ThunderID JavaScript
-SDK (`@thunderid/express`). Unlike the browser-focused quickstarts, this sample doesn't have a
-sign-in button β it's a resource server. Open [http://localhost:3000](http://localhost:3000) and
-you'll get API documentation (endpoints, curl examples, a downloadable Postman collection) instead
-of a login screen.
-
-Protected routes validate the `Authorization: Bearer ` header against ThunderID's OIDC
-`/oauth2/userinfo` endpoint β the same check any OAuth 2.0 resource server performs. A `/login` β
-`/token` helper is included purely so you have a fast way to mint a demo access token to test with.
-
-## Prerequisites
-
-- Node.js 18+
-- A running ThunderID instance (default: `https://localhost:8090`)
-- A configured application with `http://localhost:3000/login` added as an authorized redirect URI
-
-## Getting started
-
-1. Copy the environment file and fill in your values:
- ```sh
- cp .env.example .env
- ```
-
-2. Edit `.env` with your application's credentials:
- ```
- THUNDERID_CLIENT_ID=
- THUNDERID_CLIENT_SECRET=
- THUNDERID_BASE_URL=https://localhost:8090
- ```
-
-3. Install dependencies and start the server:
- ```sh
- pnpm install
- pnpm start
- ```
-
-Open [http://localhost:3000](http://localhost:3000) for the API documentation page.
-
-## Trying the API
-
-- `GET /api/public` β no auth required.
-- `GET /api/protected`, `GET /api/me`, and `PATCH /api/me/credentials` β require `Authorization: Bearer `.
-
-To get a token to test with, open [http://localhost:3000/login](http://localhost:3000/login),
-sign in once, and your access token (decoded claims included) will be shown at
-[http://localhost:3000/token](http://localhost:3000/token). Then:
-
-```sh
-curl http://localhost:3000/api/me \
- -H "Authorization: Bearer $ACCESS_TOKEN"
-```
-
-Prefer a GUI? Download the Postman collection from the API docs page (or directly at
-`/postman-collection.json`), paste your token into the `accessToken` collection variable, and run
-the requests from there.
-
-## Back-channel logout
-
-The demo session behind `/login` and `/token` can be ended by ThunderID itself. When the user's
-ThunderID session ends somewhere else, for example by signing out of another application,
-ThunderID posts a logout token to this app, and the SDK ends the local session. The route is one
-line in `index.mjs`:
-
-```js
-app.post('/backchannel-logout', handleBackchannelLogout());
-```
-
-To try it:
-
-1. In the ThunderID Console, open this application, go to **Advanced Settings β OAuth2
- Configuration**, and set **Back-Channel Logout URI** to
- `http://localhost:3000/backchannel-logout`.
-2. ThunderID refuses localhost and private network addresses by default. For local testing only,
- turn that off in the server's `deployment.yaml` and restart it:
- ```yaml
- oauth:
- logout:
- backchannel:
- reject_private_addresses: false
- ```
-3. Sign in at [http://localhost:3000/login](http://localhost:3000/login), then sign in to a second
- application in the same browser, so both share one ThunderID session.
-4. Sign out of the second application. Open [http://localhost:3000/token](http://localhost:3000/token):
- you are sent back to sign-in, because this app's session was ended too.
-
-The Bearer-token API routes (`/api/*`) are not affected: they check each access token with
-ThunderID on every request and hold no session.
-
-The SDK keeps sessions in memory by default. If you run more than one instance of an app, give the
-SDK a shared store, since ThunderID's request reaches only one of them.
-
-## Learn more
-
-- [ThunderID Docs](https://thunderid.dev/docs)
-- [`@thunderid/express` SDK reference](https://thunderid.dev/docs/sdks/javascript/express)
diff --git a/samples/express/quickstart/index.mjs b/samples/express/quickstart/index.mjs
deleted file mode 100644
index 2b2c1b3a..00000000
--- a/samples/express/quickstart/index.mjs
+++ /dev/null
@@ -1,406 +0,0 @@
-import path from 'node:path';
-import {fileURLToPath} from 'node:url';
-import express from 'express';
-import cookieParser from 'cookie-parser';
-import {
- thunderID,
- handleBackchannelLogout,
- handleSignIn,
- handleSignOut,
- protect,
- updateMeCredentials,
-} from '@thunderid/express';
-import {verifyBearerToken} from './lib/auth.mjs';
-import {layout, esc, escAttr, COPY_ICON} from './lib/layout.mjs';
-import {thunderMark} from './lib/thunderMark.mjs';
-
-const __dirname = path.dirname(fileURLToPath(import.meta.url));
-
-const app = express();
-const port = 3000;
-const baseUrl = process.env.THUNDERID_BASE_URL || 'https://localhost:8090';
-
-const REQUIRED_ENV_VARS = ['THUNDERID_CLIENT_ID', 'THUNDERID_CLIENT_SECRET'];
-const missingEnvVars = REQUIRED_ENV_VARS.filter((key) => !process.env[key]);
-
-app.use(cookieParser());
-app.use(express.json());
-app.use(express.static(path.join(__dirname, 'public')));
-
-// The thunderID() middleware is only used here to power the `/login` and
-// `/token` convenience routes, which exist so you have a fast way to obtain
-// a real access token to try against the API below. The API routes
-// themselves (`/api/*`) don't rely on cookies or sessions at all β see
-// lib/auth.mjs.
-if (missingEnvVars.length === 0) {
- app.use(
- thunderID({
- baseUrl,
- clientId: process.env.THUNDERID_CLIENT_ID,
- clientSecret: process.env.THUNDERID_CLIENT_SECRET,
- // afterSignInUrl doubles as the OAuth2 redirect_uri sent to ThunderID, so
- // it must match the redirect URI registered for this app (`/login`,
- // where handleSignIn() below actually exchanges the code). Where the
- // user lands once sign-in completes is controlled separately via
- // onSignIn.
- afterSignInUrl: 'http://localhost:3000/login',
- // Same story as afterSignInUrl: this is the post_logout_redirect_uri
- // sent to ThunderID, so it must match handleSignOut()'s mount path
- // (`/logout`) for the `?state=sign_out_success` callback below to fire.
- afterSignOutUrl: 'http://localhost:3000/logout',
- onSignIn: (res) => res.redirect('/'),
- onSignOut: (res) => res.redirect('/'),
- }),
- );
-}
-
-const requireBearer = verifyBearerToken(baseUrl);
-
-async function getSession(req) {
- const client = req.thunderIDAuth;
- const sessionId = client && req.cookies?.[client.getSessionCookieName()];
- if (!client || !sessionId) return {signedIn: false, accessToken: null, user: null};
- const signedIn = (await client.isSignedIn(sessionId)) ?? false;
- if (!signedIn) return {signedIn: false, accessToken: null, user: null};
- const [accessToken, user] = await Promise.all([client.getAccessToken(sessionId), client.getUser(sessionId)]);
- return {signedIn, accessToken, user};
-}
-
-// ββ Configuration notice ββββββββββββββββββββββββββββββββββββββββββββββββ
-
-function renderConfigNeeded() {
- return layout({
- title: 'Configuration needed',
- body: `
-
-
${thunderMark(40)}
-
Setup required
-
Configuration needed
-
This quickstart can't reach ThunderID yet. Follow the steps below, then
- restart the server.
-
-
-
Step 1 · Set environment variables
-
- ${missingEnvVars.map((key) => `${esc(key)} `).join('')}
-
-
Copy .env.example to .env, fill in the values from
- your ThunderID application, then run npm run dev again.
-
-
-
-
Step 2 · Register redirect URIs
-
-
Sign-in and sign-out are handled by this app's server, so no CORS
- configuration is needed. In the ThunderID Console , open this application and go to
- Advanced Settings → OAuth2 Configuration , then add the exact URIs below.
-
-
-
Authorized redirect URI
-
http://localhost:3000/login
-
-
-
Post-Logout Redirect URI
-
http://localhost:3000/logout
-
-
-
Back-Channel Logout URI (optional)
-
http://localhost:3000/backchannel-logout
-
-
-
-
-
-
Need more info? Take a look at the
- Express quickstart guide.
-
- `,
- });
-}
-
-app.use((req, res, next) => {
- if (missingEnvVars.length > 0 && req.path !== '/styles.css') {
- return res.status(503).send(renderConfigNeeded());
- }
- next();
-});
-
-// ββ API documentation / landing page ββββββββββββββββββββββββββββββββββββ
-
-function curlBlock(lines) {
- return ``;
-}
-
-/** Joins a multi-line, backslash-continued curl command into one pasteable line. */
-function toSingleLineCommand(lines) {
- return lines
- .map((line) => line.trim().replace(/\\$/, '').trim())
- .join(' ')
- .replace(/\s+/g, ' ')
- .trim();
-}
-
-function copyButton(text) {
- return `${COPY_ICON}Copy `;
-}
-
-function endpoint({method, path: routePath, summary, protectedRoute, curl, sample}) {
- return `
-
-
- ${esc(method)}
- ${esc(routePath)}
- ${esc(summary)}
- ${protectedRoute ? 'Protected' : 'Public'}
-
-
-
- Request
- ${copyButton(toSingleLineCommand(curl))}
-
- ${curlBlock(curl)}
-
Sample response
-
-
- `;
-}
-
-function bearerLine(accessToken) {
- return accessToken ? `Bearer ${accessToken}` : 'Bearer $ACCESS_TOKEN';
-}
-
-app.get('/', async (req, res) => {
- const {signedIn, accessToken, user} = await getSession(req);
-
- const hero = `
-
-
-
${thunderMark(40)}
-
Open source
-
Auth for Modern Apps and Agents
-
ThunderID gives you OAuth 2.0, PKCE, MFA, and JWT out of
- the box. Clone the Quickstart and ship auth before lunch.
-
-
-
OAuth 2.0 Authorization standard
-
< 5 min Integration time
-
Apache 2.0 License
-
-
- `;
-
- const authCard = signedIn
- ? `
-
You're signed in. Here's your access token β it's already substituted into the curl
- examples below, so you can copy and run them as-is.
-
- Access token
- ${copyButton(accessToken || '')}
-
-
${esc(accessToken)}
-
See the full decoded token, expiry, and claims at /token .
-
`
- : `
-
Authorization header
-
${curlBlock(['Authorization: Bearer
'])}
-
Sign in above for a demo token, or obtain one via OAuth 2.0 in your own app.
-
`;
-
- const body = `${hero}
-
-
Authentication
- ${authCard}
-
-
Endpoints
- ${endpoint({
- method: 'GET',
- path: '/api/public',
- summary: 'No authentication required.',
- protectedRoute: false,
- curl: ['curl http://localhost:3000/api/public'],
- sample: JSON.stringify({message: 'This endpoint is public. No token needed.'}, null, 2),
- })}
- ${endpoint({
- method: 'GET',
- path: '/api/protected',
- summary: 'Returns a sample resource for the authenticated caller.',
- protectedRoute: true,
- curl: [
- 'curl http://localhost:3000/api/protected \\',
- ` -H "Authorization: ${bearerLine(accessToken)}"`,
- ],
- sample: JSON.stringify(
- {message: 'You are authenticated.', subject: '
'},
- null,
- 2,
- ),
- })}
- ${endpoint({
- method: 'GET',
- path: '/api/me',
- summary: "Proxies ThunderID's userinfo claims for the token's owner.",
- protectedRoute: true,
- curl: ['curl http://localhost:3000/api/me \\', ` -H "Authorization: ${bearerLine(accessToken)}"`],
- sample: JSON.stringify({sub: '...', email: 'jane@example.com', given_name: 'Jane'}, null, 2),
- })}
- ${endpoint({
- method: 'PATCH',
- path: '/api/me/credentials',
- summary: 'Updates credentials (such as password) for the authenticated caller.',
- protectedRoute: true,
- curl: [
- 'curl -X PATCH http://localhost:3000/api/me/credentials \\',
- ` -H "Authorization: ${bearerLine(accessToken)}" \\`,
- ' -H "Content-Type: application/json" \\',
- ' -d \'{"password": "n3wP@ssword!"}\'',
- ],
- sample: '204 No Content',
- })}
-
- Postman
-
-
Prefer a GUI? Download the collection below β it ships with requests for every
- endpoint above and a {{accessToken}} variable you can fill in from
- /token .
-
-
-
- `;
- res.send(layout({title: 'API docs', signedIn, user, body}));
-});
-
-// ββ Demo token helper (browser-based, cookie session) βββββββββββββββββββ
-
-app.get('/login', handleSignIn());
-app.get('/logout', handleSignOut());
-
-// ThunderID posts a logout token here when the session behind the demo cookie ends somewhere
-// else, such as a sign-out from another application. The SDK validates the token and ends the
-// local session, so `/token` sends you back to sign-in. Register this path as the application's
-// Back-Channel Logout URI to use it (see the README).
-app.post('/backchannel-logout', handleBackchannelLogout());
-
-app.get('/token', protect((res) => res.redirect('/login')), async (req, res) => {
- const {accessToken, user} = await getSession(req);
-
- const parts = accessToken ? accessToken.split('.') : [];
- const decodePart = (part) => {
- try {
- return JSON.parse(Buffer.from(part.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'));
- } catch {
- return null;
- }
- };
- const header = parts[0] ? decodePart(parts[0]) : null;
- const payload = parts[1] ? decodePart(parts[1]) : null;
- const issuer = payload?.iss;
- const audience = Array.isArray(payload?.aud) ? payload.aud.join(', ') : payload?.aud;
- const scopes = payload?.scope;
-
- const body = `
-
-
-
-
- Access token
- ${copyButton(accessToken || '')}
-
-
. ${esc(parts[1])} . ${esc(parts[2])}
-
-
-
-
-
-
${esc(JSON.stringify(header, null, 2))}
-
-
-
-
${esc(JSON.stringify(payload, null, 2))}
-
-
-
- ${
- issuer || audience || scopes
- ? `
`
- : ''
- }
-
-
-
`;
- res.send(layout({title: 'Token debug', signedIn: true, showBack: true, user, body}));
-});
-
-// ββ API routes (bearer-token protected, no cookies involved) ββββββββββββ
-
-app.get('/api/public', (_req, res) => {
- res.json({message: 'This endpoint is public. No token needed.'});
-});
-
-app.get('/api/protected', requireBearer, (req, res) => {
- res.json({
- message: 'You are authenticated.',
- subject: req.thunderIDUserInfo.sub ?? req.thunderIDUserInfo.id,
- });
-});
-
-app.get('/api/me', requireBearer, (req, res) => {
- res.json(req.thunderIDUserInfo);
-});
-
-app.patch('/api/me/credentials', requireBearer, async (req, res) => {
- try {
- const authHeader = req.headers.authorization;
- const token = authHeader?.split(' ')[1];
- await updateMeCredentials({
- baseUrl,
- headers: {Authorization: `Bearer ${token}`},
- payload: req.body,
- });
- res.status(204).end();
- } catch (error) {
- res.status(error?.statusCode || 500).json({
- error: error?.code || 'CREDENTIAL_UPDATE_FAILED',
- message: error?.message || 'Failed to update credentials',
- });
- }
-});
-
-// ββ Postman collection download βββββββββββββββββββββββββββββββββββββββββ
-
-app.get('/postman-collection.json', (_req, res) => {
- res.download(path.join(__dirname, 'postman', 'ThunderID-Express-Quickstart.postman_collection.json'));
-});
-
-app.listen(port, () => {
- console.log(`Server running on http://localhost:${port}`);
-});
diff --git a/samples/express/quickstart/lib/auth.mjs b/samples/express/quickstart/lib/auth.mjs
deleted file mode 100644
index 61be3f38..00000000
--- a/samples/express/quickstart/lib/auth.mjs
+++ /dev/null
@@ -1,47 +0,0 @@
-/**
- * Bearer-token protection for the API routes in this quickstart.
- *
- * This is a resource-server style check: it does not depend on a browser
- * session or cookie. Any client (curl, Postman, another service) can call
- * a protected endpoint by sending a ThunderID-issued access token in the
- * `Authorization: Bearer ` header. The token is validated by asking
- * ThunderID's OIDC userinfo endpoint whether it's still valid β the same
- * endpoint any OAuth 2.0 resource server would call.
- */
-export function verifyBearerToken(baseUrl) {
- return async function bearerAuth(req, res, next) {
- const header = req.headers.authorization || '';
- const [scheme, token] = header.split(' ');
-
- if (scheme !== 'Bearer' || !token) {
- res.set('WWW-Authenticate', 'Bearer realm="thunderid-express-quickstart"');
- return res.status(401).json({
- error: 'unauthorized',
- message: 'Missing or malformed Authorization header. Expected: Authorization: Bearer ',
- });
- }
-
- let response;
- try {
- response = await fetch(`${baseUrl}/oauth2/userinfo`, {
- headers: {Authorization: `Bearer ${token}`},
- });
- } catch {
- return res.status(502).json({
- error: 'bad_gateway',
- message: 'Could not reach ThunderID to validate the access token.',
- });
- }
-
- if (!response.ok) {
- res.set('WWW-Authenticate', 'Bearer error="invalid_token"');
- return res.status(401).json({
- error: 'invalid_token',
- message: 'The access token is invalid or expired. Get a fresh one at /token.',
- });
- }
-
- req.thunderIDUserInfo = await response.json();
- next();
- };
-}
diff --git a/samples/express/quickstart/lib/expressLogo.mjs b/samples/express/quickstart/lib/expressLogo.mjs
deleted file mode 100644
index 733e4bcf..00000000
--- a/samples/express/quickstart/lib/expressLogo.mjs
+++ /dev/null
@@ -1,10 +0,0 @@
-export function expressLogo(size = 24) {
- return `
-
-
-
-
-
-
- `;
-}
diff --git a/samples/express/quickstart/lib/layout.mjs b/samples/express/quickstart/lib/layout.mjs
deleted file mode 100644
index 9b4f97d5..00000000
--- a/samples/express/quickstart/lib/layout.mjs
+++ /dev/null
@@ -1,96 +0,0 @@
-import {expressLogo} from './expressLogo.mjs';
-
-export function esc(value) {
- if (value === null || value === undefined) return '';
- return String(value)
- .replace(/&/g, '&')
- .replace(//g, '>');
-}
-
-/** Safe for embedding inside a double-quoted HTML attribute. */
-export function escAttr(value) {
- return esc(value).replace(/"/g, '"');
-}
-
-const MOON_ICON = ` `;
-const SUN_ICON = ` `;
-const CHEVRON_LEFT_ICON = ` `;
-const KEY_ICON = ` `;
-const SIGN_OUT_ICON = ` `;
-export const COPY_ICON = ` `;
-
-function userMenu(user) {
- const displayName = user?.name || [user?.given_name, user?.family_name].filter(Boolean).join(' ') || user?.username || user?.email || 'Account';
- const initial = displayName.charAt(0).toUpperCase();
-
- return ``;
-}
-
-function nav({signedIn, showBack, user}) {
- const authAction = signedIn ? userMenu(user) : `Sign in `;
-
- return `
- ${expressLogo(24)}Quickstart
-
-
- `;
-}
-
-/**
- * Wraps page content in the shared ThunderID quickstart shell. Uses the same
- * nav/hero/button classnames and design tokens as the React and Next.js
- * quickstarts so the sample apps read as one family.
- */
-export function layout({title, body, signedIn = false, showBack = false, user = null}) {
- return `
-
-
-
-
-${esc(title)} Β· ThunderID Express Quickstart
-
-
-
-
-
- ${nav({signedIn, showBack, user})}
- ${body}
-
-
-
-`;
-}
diff --git a/samples/express/quickstart/lib/thunderMark.mjs b/samples/express/quickstart/lib/thunderMark.mjs
deleted file mode 100644
index 32c210d2..00000000
--- a/samples/express/quickstart/lib/thunderMark.mjs
+++ /dev/null
@@ -1,8 +0,0 @@
-export function thunderMark(height = 40) {
- const width = Math.round((height / 257) * 207);
- return `
-
-
-
- `;
-}
diff --git a/samples/express/quickstart/package.json b/samples/express/quickstart/package.json
deleted file mode 100644
index d903c381..00000000
--- a/samples/express/quickstart/package.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
- "name": "@thunderid/express-quickstart",
- "private": true,
- "version": "0.0.0",
- "type": "module",
- "scripts": {
- "prepare-dev": "node scripts/prepare-dev.cjs",
- "start": "node --env-file-if-exists=.env index.mjs",
- "dev": "node --env-file-if-exists=.env --watch index.mjs"
- },
- "dependencies": {
- "@thunderid/express": "workspace:*",
- "cookie-parser": "^1.4.7",
- "express": "^4.21.2"
- }
-}
diff --git a/samples/express/quickstart/postman/ThunderID-Express-Quickstart.postman_collection.json b/samples/express/quickstart/postman/ThunderID-Express-Quickstart.postman_collection.json
deleted file mode 100644
index d2acf645..00000000
--- a/samples/express/quickstart/postman/ThunderID-Express-Quickstart.postman_collection.json
+++ /dev/null
@@ -1,62 +0,0 @@
-{
- "info": {
- "name": "ThunderID Express Quickstart",
- "description": "Sample requests for the ThunderID Express API quickstart. Public endpoints work as-is; protected endpoints need an `accessToken` collection variable β grab one from http://localhost:3000/token after signing in through the sample app, or run the OAuth 2.0 flow yourself against your ThunderID instance.",
- "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
- },
- "variable": [
- {"key": "baseUrl", "value": "http://localhost:3000"},
- {"key": "thunderIdBaseUrl", "value": "https://localhost:8090"},
- {"key": "accessToken", "value": ""}
- ],
- "item": [
- {
- "name": "Public endpoint",
- "request": {
- "method": "GET",
- "header": [],
- "url": {"raw": "{{baseUrl}}/api/public", "host": ["{{baseUrl}}"], "path": ["api", "public"]}
- }
- },
- {
- "name": "Protected resource",
- "request": {
- "method": "GET",
- "header": [{"key": "Authorization", "value": "Bearer {{accessToken}}"}],
- "url": {"raw": "{{baseUrl}}/api/protected", "host": ["{{baseUrl}}"], "path": ["api", "protected"]}
- }
- },
- {
- "name": "Current user (userinfo)",
- "request": {
- "method": "GET",
- "header": [{"key": "Authorization", "value": "Bearer {{accessToken}}"}],
- "url": {"raw": "{{baseUrl}}/api/me", "host": ["{{baseUrl}}"], "path": ["api", "me"]}
- }
- },
- {
- "name": "Update credentials",
- "request": {
- "method": "PATCH",
- "header": [
- {"key": "Authorization", "value": "Bearer {{accessToken}}"},
- {"key": "Content-Type", "value": "application/json"}
- ],
- "body": {
- "mode": "raw",
- "raw": "{\n \"password\": \"n3wP@ssword!\"\n}"
- },
- "url": {"raw": "{{baseUrl}}/api/me/credentials", "host": ["{{baseUrl}}"], "path": ["api", "me", "credentials"]}
- }
- },
- {
- "name": "ThunderID userinfo (direct)",
- "request": {
- "method": "GET",
- "header": [{"key": "Authorization", "value": "Bearer {{accessToken}}"}],
- "url": {"raw": "{{thunderIdBaseUrl}}/oauth2/userinfo", "host": ["{{thunderIdBaseUrl}}"], "path": ["oauth2", "userinfo"]},
- "description": "Calls ThunderID directly, bypassing the sample app, to show what /api/me is proxying."
- }
- }
- ]
-}
diff --git a/samples/express/quickstart/public/styles.css b/samples/express/quickstart/public/styles.css
deleted file mode 100644
index 26fffc19..00000000
--- a/samples/express/quickstart/public/styles.css
+++ /dev/null
@@ -1,991 +0,0 @@
-/* ββ Reset ββ */
-*,
-::before,
-::after {
- box-sizing: border-box;
- margin: 0;
- padding: 0;
-}
-
-/* ββ Design tokens (shared with the ThunderID React / Next.js quickstarts) ββ */
-:root {
- --color-bg: #f7f9fc;
- --color-card: #ffffff;
- --color-border: #dde3ec;
- --color-text: #05213f;
- --color-text-muted: #5a7085;
- --color-muted: #5a7085;
- --color-primary: #3688ff;
- --color-primary-hover: #1f72f0;
- --color-nav-bg: rgba(247, 249, 252, 0.92);
- --color-nav-border: #dde3ec;
- --color-code-bg: #0b1120;
- --nav-height: 52px;
- --shadow-card: 0 1px 3px 0 rgba(5, 33, 63, 0.06), 0 4px 12px 0 rgba(5, 33, 63, 0.08);
- --font-sans: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
- --font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
- --radius-card: 12px;
- --radius-btn: 8px;
- --transition: 0.2s ease;
-}
-
-.dark,
-html.dark {
- --color-bg: #080f1c;
- --color-card: #0f1a2d;
- --color-border: rgba(255, 255, 255, 0.09);
- --color-text: #e0eaff;
- --color-text-muted: rgba(224, 234, 255, 0.48);
- --color-muted: rgba(224, 234, 255, 0.48);
- --color-nav-bg: rgba(8, 15, 28, 0.92);
-}
-
-html,
-body {
- height: 100%;
-}
-
-body {
- font-family: var(--font-sans);
- background: var(--color-bg);
- color: var(--color-text);
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
- line-height: 1.5;
-}
-
-a {
- color: var(--color-primary);
-}
-
-.app {
- min-height: 100vh;
- background: var(--color-bg);
- color: var(--color-text);
- transition: background var(--transition), color var(--transition);
-}
-
-/* ββ Nav ββ */
-.nav {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- height: var(--nav-height);
- display: flex;
- align-items: center;
- padding: 0 28px;
- background: var(--color-nav-bg);
- backdrop-filter: blur(12px);
- -webkit-backdrop-filter: blur(12px);
- border-bottom: 1px solid var(--color-nav-border);
- z-index: 100;
- gap: 12px;
-}
-
-.nav-logo {
- display: flex;
- align-items: center;
- gap: 10px;
- cursor: pointer;
- text-decoration: none;
- color: inherit;
-}
-
-.wordmark-name {
- font-size: 14px;
- font-weight: 700;
- letter-spacing: -0.03em;
- line-height: 1.1;
- color: var(--color-text);
-}
-
-.nav-actions {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.nav-back-btn {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- height: 32px;
- padding: 0 10px;
- border-radius: 6px;
- color: var(--color-muted);
- font-size: 13px;
- text-decoration: none;
-}
-
-.nav-back-btn:hover {
- background: var(--color-border);
- color: var(--color-text);
-}
-
-/* ββ Dark-mode toggle ββ */
-.dark-toggle {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 34px;
- height: 34px;
- border-radius: 8px;
- border: 1px solid var(--color-border);
- background: transparent;
- color: var(--color-muted);
- cursor: pointer;
- flex-shrink: 0;
- transition: border-color var(--transition), color var(--transition), background var(--transition);
-}
-
-.dark-toggle:hover {
- background: var(--color-border);
- color: var(--color-text);
-}
-
-.dark-toggle .icon-sun {
- display: none;
-}
-
-.dark .dark-toggle .icon-moon {
- display: none;
-}
-
-.dark .dark-toggle .icon-sun {
- display: flex;
-}
-
-/* ββ Buttons ββ */
-button {
- font-family: var(--font-sans);
-}
-
-.btn-primary,
-.btn-outline,
-.btn-ghost {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- height: 46px;
- padding: 0 24px;
- border-radius: var(--radius-btn);
- font-size: 15px;
- font-weight: 600;
- cursor: pointer;
- border: none;
- transition: opacity var(--transition), background var(--transition), border-color var(--transition);
- white-space: nowrap;
- text-decoration: none;
- font-family: inherit;
-}
-
-.btn-primary {
- background: var(--color-primary);
- color: #fff;
- border: 1.5px solid var(--color-primary);
-}
-
-.btn-primary:hover {
- opacity: 0.88;
-}
-
-.btn-outline {
- background: transparent;
- color: var(--color-primary);
- border: 1.5px solid rgba(54, 136, 255, 0.4);
-}
-
-.btn-outline:hover {
- background: rgba(54, 136, 255, 0.08);
-}
-
-.btn-ghost {
- background: transparent;
- color: var(--color-muted);
-}
-
-.btn-ghost:hover {
- background: rgba(0, 0, 0, 0.04);
-}
-
-.nav .btn-primary,
-.nav .btn-outline,
-.nav .btn-ghost {
- height: 36px;
- padding: 0 16px;
- font-size: 14px;
-}
-
-/* ββ User menu (signed-in nav) ββ */
-.user-menu {
- position: relative;
-}
-
-.user-menu-trigger {
- display: flex;
- align-items: center;
- gap: 8px;
- height: 36px;
- padding: 0 12px 0 6px;
- border-radius: 999px;
- background: transparent;
- border: none;
- cursor: pointer;
- list-style: none;
- color: var(--color-text);
- font-size: 13px;
- font-weight: 600;
-}
-
-.user-menu-trigger:hover {
- background: var(--color-border);
-}
-
-.user-menu-trigger::-webkit-details-marker {
- display: none;
-}
-
-.user-menu-trigger:focus,
-.user-menu-trigger:focus-visible {
- outline: none;
-}
-
-.user-avatar-sm {
- width: 26px;
- height: 26px;
- border-radius: 50%;
- background: var(--color-primary);
- color: #fff;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 12px;
- font-weight: 700;
- flex-shrink: 0;
-}
-
-.user-avatar-lg {
- width: 40px;
- height: 40px;
- border-radius: 50%;
- background: var(--color-primary);
- color: #fff;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 16px;
- font-weight: 700;
- flex-shrink: 0;
-}
-
-.user-menu-list {
- position: absolute;
- top: calc(100% + 8px);
- right: 0;
- min-width: 220px;
- background: var(--color-card);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-card);
- box-shadow: var(--shadow-card);
- overflow: hidden;
- z-index: 200;
-}
-
-.user-menu-header {
- display: flex;
- align-items: center;
- gap: 12px;
- padding: 16px;
-}
-
-.user-menu-header-name {
- font-size: 14px;
- font-weight: 700;
- color: var(--color-text);
- letter-spacing: -0.02em;
-}
-
-.user-menu-item {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 12px 16px;
- font-size: 13.5px;
- font-weight: 600;
- color: var(--color-text);
- text-decoration: none;
- border-top: 1px solid var(--color-border);
-}
-
-.user-menu-item:hover {
- background: var(--color-bg);
-}
-
-/* ββ Hero ββ */
-.hero {
- margin-top: var(--nav-height);
- padding: 88px 32px 56px;
-}
-
-.hero-inner {
- width: 100%;
- max-width: 560px;
- margin: 0 auto;
- display: flex;
- flex-direction: column;
- align-items: center;
- text-align: center;
-}
-
-.hero-mark {
- display: flex;
- justify-content: center;
- margin-bottom: 32px;
- color: var(--color-primary);
-}
-
-.hero-badge {
- display: inline-flex;
- align-items: center;
- gap: 8px;
- font-size: 11px;
- font-weight: 700;
- letter-spacing: 0.1em;
- text-transform: uppercase;
- color: var(--color-muted);
- margin-bottom: 28px;
-}
-
-.hero-badge-line {
- display: block;
- height: 1px;
- width: 24px;
- background: var(--color-border);
- flex-shrink: 0;
-}
-
-.config-badge {
- color: #e88b3a;
-}
-
-.hero-title {
- font-size: 42px;
- font-weight: 700;
- letter-spacing: -0.05em;
- line-height: 1.1;
- color: var(--color-text);
- margin: 0 0 18px;
-}
-
-.hero-subtitle {
- font-size: 16px;
- color: var(--color-muted);
- max-width: 440px;
- line-height: 1.8;
- letter-spacing: -0.01em;
- margin: 0 auto 36px;
-}
-
-.hero-divider {
- width: 100%;
- border: none;
- border-top: 1px solid var(--color-border);
- margin: 4px 0;
-}
-
-.hero-stats {
- width: 100%;
- display: grid;
- grid-template-columns: repeat(3, 1fr);
-}
-
-.hero-stat {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 4px;
- padding: 16px 8px;
- position: relative;
-}
-
-.hero-stat + .hero-stat::before {
- content: '';
- position: absolute;
- left: 0;
- top: 20%;
- height: 60%;
- width: 1px;
- background: var(--color-border);
-}
-
-.hero-stat-value {
- font-size: 15px;
- font-weight: 700;
- color: var(--color-text);
-}
-
-.hero-stat-label {
- font-size: 12px;
- color: var(--color-muted);
-}
-
-/* ββ Content shell (non-hero pages / sections below the hero) ββ */
-.page {
- width: 100%;
- max-width: 780px;
- margin: 0 auto;
- margin-top: var(--nav-height);
- padding: 40px 28px 96px;
-}
-
-.docs-shell {
- width: 100%;
- max-width: 780px;
- margin: 0 auto;
- padding: 0 28px 96px;
-}
-
-.eyebrow {
- display: inline-flex;
- align-items: center;
- gap: 8px;
- font-size: 11px;
- font-weight: 700;
- letter-spacing: 0.1em;
- text-transform: uppercase;
- color: var(--color-text-muted);
- margin-bottom: 16px;
-}
-
-.eyebrow-dot {
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: #2fbd6b;
- flex-shrink: 0;
-}
-
-.page-title {
- font-size: 28px;
- font-weight: 700;
- letter-spacing: -0.04em;
- line-height: 1.15;
- margin-bottom: 12px;
-}
-
-.page-subtitle {
- font-size: 15px;
- color: var(--color-text-muted);
- max-width: 560px;
- line-height: 1.7;
- margin-bottom: 32px;
-}
-
-.section-label {
- font-size: 11px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.09em;
- color: var(--color-muted);
- margin: 40px 0 14px;
-}
-
-.section-label:first-of-type {
- margin-top: 0;
-}
-
-/* ββ Cards ββ */
-.card {
- background: var(--color-card);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-card);
- box-shadow: var(--shadow-card);
- padding: 24px;
-}
-
-.card + .card {
- margin-top: 14px;
-}
-
-.card p {
- font-size: 13.5px;
- color: var(--color-text-muted);
- line-height: 1.7;
-}
-
-.card p + p {
- margin-top: 10px;
-}
-
-.card p code {
- background: rgba(54, 136, 255, 0.1);
- border-radius: 4px;
- padding: 2px 6px;
- font-family: var(--font-mono);
- font-size: 12.5px;
- color: var(--color-text);
-}
-
-/* ββ Endpoint list (swagger-like) ββ */
-.endpoint {
- border: 1px solid var(--color-border);
- border-radius: var(--radius-card);
- background: var(--color-card);
- overflow: hidden;
-}
-
-.endpoint + .endpoint {
- margin-top: 12px;
-}
-
-.endpoint-summary {
- display: flex;
- align-items: center;
- gap: 12px;
- padding: 16px 20px;
- cursor: pointer;
- list-style: none;
-}
-
-.endpoint-summary::-webkit-details-marker {
- display: none;
-}
-
-.method-badge {
- flex-shrink: 0;
- font-family: var(--font-mono);
- font-size: 11px;
- font-weight: 700;
- letter-spacing: 0.03em;
- padding: 4px 9px;
- border-radius: 5px;
- color: #fff;
- background: #2fbd6b;
-}
-
-.endpoint-path {
- font-family: var(--font-mono);
- font-size: 14px;
- font-weight: 600;
- color: var(--color-text);
-}
-
-.endpoint-desc {
- font-size: 13px;
- color: var(--color-text-muted);
- flex: 1;
- min-width: 0;
-}
-
-.tag {
- flex-shrink: 0;
- font-size: 10.5px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.06em;
- padding: 3px 9px;
- border-radius: 999px;
-}
-
-.tag-public {
- color: #2fbd6b;
- background: rgba(47, 189, 107, 0.12);
-}
-
-.tag-protected {
- color: #e88b3a;
- background: rgba(232, 139, 58, 0.12);
-}
-
-.endpoint-body {
- padding: 0 20px 20px;
- border-top: 1px solid var(--color-border);
-}
-
-.endpoint-body-label {
- font-size: 11px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.08em;
- color: var(--color-muted);
- margin: 16px 0 8px;
-}
-
-/* ββ Code blocks ββ */
-.code-block {
- position: relative;
- background: var(--color-code-bg);
- border-radius: 8px;
- padding: 14px 16px;
- overflow-x: auto;
-}
-
-.code-block pre,
-.code-block code {
- font-family: var(--font-mono);
- font-size: 12.5px;
- line-height: 1.7;
- color: #c9d1d9;
- background: none;
- padding: 0;
- white-space: pre;
-}
-
-/* ββ Config notice ββ */
-.config-list {
- text-align: left;
- list-style: none;
- background: var(--color-card);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-btn);
- padding: 0 16px;
- margin: 4px 0 24px;
- width: 100%;
- max-width: 420px;
-}
-
-.config-list-item {
- padding: 10px 0;
- font-family: var(--font-mono);
- font-size: 13px;
-}
-
-.config-list-item:not(:last-child) {
- border-bottom: 1px solid var(--color-border);
-}
-
-.config-hint {
- font-size: 14px;
- color: var(--color-muted);
- line-height: 1.7;
- max-width: 420px;
- margin: 0 auto;
-}
-
-.config-hint code {
- background: rgba(54, 136, 255, 0.08);
- border-radius: 4px;
- padding: 2px 6px;
- font-family: var(--font-mono);
- font-size: 13px;
-}
-
-.config-step {
- width: 100%;
- max-width: 420px;
- margin: 0 auto 28px;
-}
-
-.config-step-label {
- font-size: 11px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.09em;
- color: var(--color-muted);
- margin-bottom: 10px;
- text-align: left;
-}
-
-.config-step .config-list,
-.config-step .config-hint {
- margin-left: 0;
- margin-right: 0;
-}
-
-.config-box {
- text-align: left;
- background: var(--color-card);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-card);
- padding: 16px;
-}
-
-.config-box-body {
- font-size: 13px;
- color: var(--color-muted);
- line-height: 1.6;
- margin-bottom: 14px;
-}
-
-.config-value-group {
- display: flex;
- flex-direction: column;
- gap: 12px;
-}
-
-.config-value-label {
- font-size: 11px;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.06em;
- color: var(--color-muted);
- margin-bottom: 4px;
-}
-
-.config-value {
- display: block;
- overflow-x: auto;
- white-space: nowrap;
- font-family: var(--font-mono);
- font-size: 12px;
- color: var(--color-text);
- background: rgba(54, 136, 255, 0.08);
- border-radius: 4px;
- padding: 8px 10px;
-}
-
-.config-docs-note {
- font-size: 13px;
- color: var(--color-muted);
- max-width: 420px;
- margin: 0 auto;
-}
-
-.config-docs-note a {
- color: var(--color-primary);
- font-weight: 600;
-}
-
-/* ββ Token debug page ββ */
-.token-header {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 16px;
- margin-bottom: 36px;
- flex-wrap: wrap;
-}
-
-.token-title {
- margin: 0 0 5px;
- font-size: 22px;
- font-weight: 700;
- letter-spacing: -0.04em;
- color: var(--color-text);
-}
-
-.token-subtitle {
- margin: 0;
- font-size: 13px;
- color: var(--color-muted);
- letter-spacing: -0.01em;
-}
-
-.token-badge {
- display: inline-flex;
- align-items: center;
- gap: 7px;
- padding: 6px 12px;
- border-radius: 6px;
- font-size: 12px;
- font-weight: 700;
- letter-spacing: -0.01em;
- flex-shrink: 0;
- background: rgba(47, 189, 107, 0.08);
- border: 1px solid rgba(47, 189, 107, 0.22);
- color: #2fbd6b;
-}
-
-.token-badge--expired {
- background: rgba(220, 38, 38, 0.08);
- border: 1px solid rgba(220, 38, 38, 0.22);
- color: #dc2626;
-}
-
-.token-badge-dot {
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: currentColor;
- flex-shrink: 0;
-}
-
-.token-raw-section {
- margin-bottom: 20px;
-}
-
-.token-raw-label-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 8px;
-}
-
-.token-section-label {
- font-size: 11px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.09em;
- color: var(--color-muted);
-}
-
-.token-copy-btn {
- display: inline-flex;
- align-items: center;
- gap: 5px;
- padding: 4px 10px;
- border-radius: 5px;
- background: transparent;
- border: 1px solid var(--color-border);
- font-size: 11px;
- font-weight: 600;
- color: var(--color-muted);
- cursor: pointer;
- font-family: var(--font-sans);
- letter-spacing: -0.01em;
- transition: border-color 0.2s, color 0.2s;
-}
-
-.token-copy-btn:hover {
- border-color: var(--color-primary);
- color: var(--color-primary);
-}
-
-.token-raw {
- display: block;
- font-size: 11px;
- font-family: var(--font-mono);
- word-break: break-all;
- line-height: 1.8;
- color: #c9d1d9;
- background: var(--color-code-bg);
- padding: 14px 16px;
- border-radius: 8px;
- overflow-x: auto;
-}
-
-.token-part--header {
- color: #ff7b72;
-}
-
-.token-part--payload {
- color: #79c0ff;
-}
-
-.token-part--signature {
- color: #3fb950;
-}
-
-.token-dot {
- color: rgba(255, 255, 255, 0.2);
-}
-
-.token-decoded-grid {
- display: grid;
- grid-template-columns: 1fr 1.7fr;
- gap: 12px;
- margin-bottom: 20px;
-}
-
-.token-code-box {
- background: var(--color-code-bg);
- border-radius: 10px;
- overflow: hidden;
-}
-
-.token-code-box-header {
- padding: 9px 14px;
- font-size: 10px;
- font-weight: 700;
- letter-spacing: 0.09em;
- text-transform: uppercase;
-}
-
-.token-code-box-header--header {
- color: #ff7b72;
- border-bottom: 1px solid rgba(255, 123, 114, 0.15);
-}
-
-.token-code-box-header--payload {
- color: #79c0ff;
- border-bottom: 1px solid rgba(121, 192, 255, 0.15);
-}
-
-.token-code-pre {
- margin: 0;
- padding: 14px;
- font-size: 11.5px;
- line-height: 1.8;
- font-family: var(--font-mono);
- color: #c9d1d9;
- overflow-x: auto;
- max-height: 220px;
-}
-
-.token-meta-row {
- display: flex;
- gap: 32px;
- padding-top: 20px;
- border-top: 1px solid var(--color-border);
- flex-wrap: wrap;
-}
-
-.token-meta-label {
- font-size: 10px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.09em;
- color: var(--color-muted);
- margin-bottom: 5px;
-}
-
-.token-meta-value {
- font-size: 12px;
- font-family: var(--font-mono);
- color: var(--color-text);
- letter-spacing: -0.01em;
-}
-
-/* ββ Profile table ββ */
-.kv-table {
- width: 100%;
- border-collapse: collapse;
-}
-
-.kv-table th,
-.kv-table td {
- padding: 12px 0;
- text-align: left;
- font-size: 13.5px;
- border-bottom: 1px solid var(--color-border);
-}
-
-.kv-table th {
- width: 140px;
- font-weight: 600;
- color: var(--color-muted);
- text-transform: uppercase;
- font-size: 11px;
- letter-spacing: 0.06em;
-}
-
-.kv-table td {
- font-family: var(--font-mono);
- word-break: break-all;
-}
-
-.footer-note {
- margin-top: 48px;
- padding-top: 24px;
- border-top: 1px solid var(--color-border);
- font-size: 12.5px;
- color: var(--color-muted);
- display: flex;
- gap: 20px;
- flex-wrap: wrap;
-}
-
-@media (max-width: 640px) {
- .token-decoded-grid {
- grid-template-columns: 1fr;
- }
-
- .endpoint-summary {
- flex-wrap: wrap;
- }
-
- .hero-title {
- font-size: 32px;
- }
-}
diff --git a/samples/express/quickstart/scripts/prepare-dev.cjs b/samples/express/quickstart/scripts/prepare-dev.cjs
deleted file mode 100644
index 8e20fa43..00000000
--- a/samples/express/quickstart/scripts/prepare-dev.cjs
+++ /dev/null
@@ -1,34 +0,0 @@
-// Copyright 2026 The ThunderID Authors
-// SPDX-License-Identifier: Apache-2.0
-
-const fs = require('node:fs');
-const path = require('node:path');
-
-const root = path.join(__dirname, '..');
-
-const envExample = path.join(root, '.env.example');
-const envTarget = path.join(root, '.env');
-if (fs.existsSync(envExample) && !fs.existsSync(envTarget)) {
- // Blank placeholder values (e.g. `your-client-id-here`, the sample base URL)
- // so the copied .env still trips the app's missing-env-var check until real
- // values are filled in.
- const envContent = fs
- .readFileSync(envExample, 'utf8')
- .replace(/^([A-Z0-9_]+)=(your-\S*|generate-with-\S*|https:\/\/localhost:8090)$/gm, '$1=');
- fs.writeFileSync(envTarget, envContent);
-}
-
-const pkgPath = path.join(root, 'package.json');
-const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
-
-for (const field of ['dependencies', 'devDependencies']) {
- const deps = pkg[field];
- if (!deps) continue;
- for (const [name, version] of Object.entries(deps)) {
- if (version.startsWith('workspace:')) {
- deps[name] = 'latest';
- }
- }
-}
-
-fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
diff --git a/samples/nextjs/quickstart/.env.example b/samples/nextjs/quickstart/.env.example
deleted file mode 100644
index 66cdaddf..00000000
--- a/samples/nextjs/quickstart/.env.example
+++ /dev/null
@@ -1,20 +0,0 @@
-# Base URL of your ThunderID deployment. ex: https://localhost:8090
-NEXT_PUBLIC_THUNDERID_BASE_URL=https://localhost:8090
-
-# Flow Secret for this app. Sent in the `Flow-Secret` header to authenticate this app when the
-# native (embedded) flow starts. Shown once when the application is created; if lost, regenerate it
-# from the app's Credentials tab (under Edit). Server-only, never expose to the browser.
-THUNDERID_FLOW_SECRET=your-flow-secret-here
-
-# Secret used to encrypt this app's session cookie. Generate locally, not from the console.
-THUNDERID_SECRET=generate-with-openssl-rand-base64-32
-
-# Application ID (spId) for this app. ThunderID Console -> your application -> Overview.
-NEXT_PUBLIC_THUNDERID_APPLICATION_ID=your-application-id-here
-# Local app route that renders the sign-in page. Not from the console.
-NEXT_PUBLIC_THUNDERID_SIGN_IN_URL=/signin
-# Local app route that renders the sign-up page. Not from the console.
-NEXT_PUBLIC_THUNDERID_SIGN_UP_URL=/signup
-
-# DANGER: Disables ALL TLS verification. Only for local development with self-signed certs. NEVER use in production.
-NODE_TLS_REJECT_UNAUTHORIZED=0
diff --git a/samples/nextjs/quickstart/.stackblitzrc b/samples/nextjs/quickstart/.stackblitzrc
deleted file mode 100644
index 5f0a2000..00000000
--- a/samples/nextjs/quickstart/.stackblitzrc
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "installDependencies": false,
- "startCommand": "npm run prepare-dev && npm install && npm run dev"
-}
diff --git a/samples/nextjs/quickstart/README.md b/samples/nextjs/quickstart/README.md
deleted file mode 100644
index e8e46eed..00000000
--- a/samples/nextjs/quickstart/README.md
+++ /dev/null
@@ -1,117 +0,0 @@
-# ThunderID Next.js Quickstart
-
-
-
-A minimal Next.js 15 App Router application demonstrating ThunderID authentication with OAuth 2.0, PKCE, and JWT out of the box.
-
-## Prerequisites
-
-- Node.js 18+
-- pnpm
-- A ThunderID application
-
-## Getting started
-
-1. Copy the example environment file:
-
- ```bash
- cp .env.example .env
- ```
-
-2. Fill in your ThunderID credentials in `.env`. By default the file is set up for the native flow:
-
- ```dotenv
- NEXT_PUBLIC_THUNDERID_BASE_URL=https://localhost:8090
- NEXT_PUBLIC_THUNDERID_APPLICATION_ID=
- THUNDERID_FLOW_SECRET=
- NEXT_PUBLIC_THUNDERID_SIGN_IN_URL=/signin
- NEXT_PUBLIC_THUNDERID_SIGN_UP_URL=/signup
- THUNDERID_SECRET=
- ```
-
- The Flow Secret is generated by the server when the application is created, shown once, and only
- retrievable afterward from the app's Credentials tab (under Edit). It's a separate credential from
- the OAuth2 Client Secret used by the redirect-based flow below β an embedded app (no OAuth2
- configuration) has a Flow Secret and no client secret; a redirect-based `authorization_code` app has
- a client secret and no Flow Secret.
-
-3. Start the development server:
-
- ```bash
- pnpm dev
- ```
-
- The app is now running at [http://localhost:3000](http://localhost:3000).
-
-
-Redirect-based flow
-
-By default this quickstart uses the native (embedded) flow, where sign-in/sign-up render inline on this
-app's own `/signin` and `/signup` routes with no redirect to ThunderID's hosted pages.
-
-To send the user to ThunderID's hosted sign-in page instead, switch to the redirect-based flow:
-
-1. Register a redirect URI for your application (see the app's config notice in the console for the
- exact value to use).
-2. Regenerate `.env` for the redirect flow:
-
- ```bash
- npm run prepare-dev:redirect
- ```
-
- This comments out the native-flow vars (`NEXT_PUBLIC_THUNDERID_APPLICATION_ID`,
- `NEXT_PUBLIC_THUNDERID_SIGN_IN_URL`, `NEXT_PUBLIC_THUNDERID_SIGN_UP_URL`, `THUNDERID_FLOW_SECRET`) and
- adds:
-
- ```dotenv
- NEXT_PUBLIC_THUNDERID_CLIENT_ID=
- THUNDERID_CLIENT_SECRET=
- ```
-
- Both values come from the application's Credentials tab in the console. To switch back to the native
- flow, run `node scripts/prepare-dev.cjs --flow=native` (or manually re-enable the native-flow vars and
- comment out the two above).
-3. Fill in `NEXT_PUBLIC_THUNDERID_CLIENT_ID` and `THUNDERID_CLIENT_SECRET` in `.env`, then restart the dev
- server.
-
-
-
-## Back-channel logout
-
-ThunderID can end this app's session itself. When the user's ThunderID session ends somewhere else,
-for example by signing out of another application, ThunderID posts a logout token to this app. The
-route is one line in `app/api/auth/backchannel-logout/route.ts`:
-
-```ts
-export const {POST} = backchannelLogout()
-```
-
-The session lives in a cookie that only the browser can delete, so the SDK records the logout and
-treats every later request with that session as signed out.
-
-To try it, use the redirect-based flow above, since the logout token is addressed to the client ID:
-
-1. In the ThunderID Console, open this application, go to **Advanced Settings β OAuth2
- Configuration**, and set **Back-Channel Logout URI** to
- `http://localhost:3000/api/auth/backchannel-logout`.
-2. ThunderID refuses localhost and private network addresses by default. For local testing only,
- turn that off in the server's `deployment.yaml` and restart it:
- ```yaml
- oauth:
- logout:
- backchannel:
- reject_private_addresses: false
- ```
-3. Sign in at [http://localhost:3000](http://localhost:3000), then sign in to a second application
- in the same browser, so both share one ThunderID session.
-4. Sign out of the second application, then reload this app: you are signed out here too.
-
-Things to know before production:
-
-- The SDK records ended sessions in memory by default. If you run more than one instance, pass a
- shared `store` to both `backchannelLogout()` and `thunderIDProxy()`, since ThunderID's request
- reaches only one instance.
-- Route protection in `thunderIDProxy()` sees the in-memory record only when the proxy runs on the
- Node.js runtime. On Next.js 15, add `runtime: 'nodejs'` to the `config` export of your
- `middleware.ts`. On the Edge runtime, use a shared `store`.
-- An access token already handed to the browser or to another API stays valid until it expires.
diff --git a/samples/nextjs/quickstart/app/account/page.tsx b/samples/nextjs/quickstart/app/account/page.tsx
deleted file mode 100644
index d0e17a40..00000000
--- a/samples/nextjs/quickstart/app/account/page.tsx
+++ /dev/null
@@ -1,212 +0,0 @@
-'use client'
-import { ChangeCredential, SignedIn, SignedOut, User, UserProfile } from '@thunderid/nextjs'
-import Link from 'next/link'
-import { useRouter, useSearchParams } from 'next/navigation'
-import { useState } from 'react'
-import Nav from '../components/Nav'
-
-function HomeIcon() {
- return (
-
-
-
-
- )
-}
-
-function PersonIcon() {
- return (
-
-
-
-
- )
-}
-
-function ShieldIcon() {
- return (
-
-
-
- )
-}
-
-const TABS = [
- { id: 'home', label: 'Home', icon: HomeIcon },
- { id: 'personal', label: 'Personal info', icon: PersonIcon },
- { id: 'security', label: 'Security', icon: ShieldIcon },
-]
-
-const CREDENTIALS = [
- { attribute: 'password', title: 'Password', description: 'Used to sign in to your account.', cta: 'Change password' },
-]
-
-function ChevronIcon() {
- return (
-
-
-
- )
-}
-
-function CredentialCard({ attribute, title, description, cta }: { attribute: string; title: string; description: string; cta: string }) {
- const [open, setOpen] = useState(false)
- const close = () => setOpen(false)
-
- return (
-
-
-
-
{title}
-
{description}
-
-
(open ? close() : setOpen(true))}
- >
- {cta}
-
-
-
-
-
- {open && (
-
-
-
- )}
-
- )
-}
-
-const TILES = [
- {
- id: 'personal',
- icon: PersonIcon,
- tone: 'blue',
- title: 'Personal info',
- description: 'Name, email, phone, and profile photo',
- },
- {
- id: 'security',
- icon: ShieldIcon,
- tone: 'green',
- title: 'Security',
- description: 'Password and other credentials',
- },
-]
-
-// Google Account-style layout: a persistent left nav switching between tabs of
-// content on the right, rather than a single scrolling page or a modal.
-function AccountTabs() {
- const router = useRouter()
- const searchParams = useSearchParams()
- const requestedTab = searchParams.get('tab')
- const [tab, setTab] = useState(TABS.some((t) => t.id === requestedTab) ? requestedTab! : 'home')
-
- const selectTab = (id: string) => {
- setTab(id)
- router.replace(id === 'home' ? '/account' : `/account?tab=${id}`)
- }
-
- return (
-
-
- Account
-
- {TABS.map(({ id, label, icon: Icon }) => (
- selectTab(id)}
- >
-
- {label}
-
- ))}
-
-
-
-
-
- {tab === 'home' && (
-
- {(user: { givenName?: string; given_name?: string; displayName?: string; username?: string } | null) => {
- const givenName = user?.givenName ?? user?.given_name ?? user?.displayName ?? user?.username ?? 'there'
-
- return (
- <>
- Hi, {givenName}
-
- Manage your info and security across ThunderID apps.
-
-
- {TILES.map(({ id, icon: Icon, tone, title, description }) => (
- selectTab(id)}
- >
-
-
-
- {title}
- {description}
-
- ))}
-
- >
- )
- }}
-
- )}
-
- {tab === 'personal' && (
- <>
-
Personal info
-
Manage the basic profile info others may see.
-
- >
- )}
-
- {tab === 'security' && (
- <>
-
Security
-
Manage your password and other credentials.
-
- {CREDENTIALS.map((credential) => (
-
- ))}
-
- >
- )}
-
-
-
- )
-}
-
-export default function AccountPage() {
- return (
-
-
-
-
-
-
-
-
-
-
-
Sign in to manage your account.
-
Sign in
-
-
-
-
- )
-}
diff --git a/samples/nextjs/quickstart/app/actions.ts b/samples/nextjs/quickstart/app/actions.ts
deleted file mode 100644
index 6a72ed31..00000000
--- a/samples/nextjs/quickstart/app/actions.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-'use server'
-
-import { thunderid } from '@thunderid/nextjs/server'
-
-/**
- * Fetch the raw access token JWT for the current session.
- * Tokens are kept server-side β this Server Action is the only way a
- * client component can retrieve them without exposing the session cookie.
- */
-export async function fetchAccessToken(): Promise {
- try {
- const sdk = await thunderid()
- const sessionId = await sdk.getSessionId()
- if (!sessionId) return undefined
- return await sdk.getAccessToken(sessionId)
- } catch {
- return undefined
- }
-}
diff --git a/samples/nextjs/quickstart/app/api/auth/backchannel-logout/route.ts b/samples/nextjs/quickstart/app/api/auth/backchannel-logout/route.ts
deleted file mode 100644
index c09cc0df..00000000
--- a/samples/nextjs/quickstart/app/api/auth/backchannel-logout/route.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import {backchannelLogout} from '@thunderid/nextjs/server'
-
-// ThunderID posts a logout token here when the user's session ends somewhere else, such as a
-// sign-out from another application. The SDK records the logout, and from then on this app treats
-// the matching session cookie as signed out. Register this path as the application's
-// Back-Channel Logout URI to use it (see the README).
-export const {POST} = backchannelLogout()
diff --git a/samples/nextjs/quickstart/app/components/ConfigNotice.tsx b/samples/nextjs/quickstart/app/components/ConfigNotice.tsx
deleted file mode 100644
index 4d581cb2..00000000
--- a/samples/nextjs/quickstart/app/components/ConfigNotice.tsx
+++ /dev/null
@@ -1,121 +0,0 @@
-'use client'
-import { useState } from 'react'
-import ThunderMark from './ThunderMark'
-import NextLogo from './icons/NextLogo'
-
-function MoonIcon() {
- return (
-
-
-
- )
-}
-
-function SunIcon() {
- return (
-
-
-
-
-
-
-
- )
-}
-
-export default function ConfigNotice({ missing, isRedirectFlow }: { missing: string[]; isRedirectFlow: boolean }) {
- const [dark, setDark] = useState(false)
-
- const toggle = () => {
- const next = !dark
- setDark(next)
- document.documentElement.classList.toggle('dark', next)
- }
-
- return (
-
-
-
-
- Quickstart
-
-
-
-
- {dark ? : }
-
-
-
-
-
-
-
-
-
-
-
-
- Setup required
-
-
-
-
Configuration needed
-
-
- This quickstart can't reach ThunderID yet. Follow the steps
- below, then restart the dev server.
-
-
-
-
Step 1 · Set environment variables
-
-
- {missing.map((key) => (
- {key}
- ))}
-
-
-
- Copy .env.example to .env, fill in the
- values from your ThunderID application, then run npm run dev again.
-
-
-
- {isRedirectFlow && (
-
-
Step 2 · Register redirect URIs
-
-
-
- Sign-in and sign-out are handled by this app's server, so
- no CORS configuration is needed. In the{' '}
- ThunderID Console , open this application and go
- to Advanced Settings → OAuth2 Configuration ,
- then add the exact URIs below.
-
-
-
-
-
Authorized redirect URI
-
http://localhost:3000
-
-
-
Post-Logout Redirect URI
-
http://localhost:3000
-
-
-
-
- )}
-
-
- Need more info? Take a look at the{' '}
-
- Next.js quickstart guide.
-
-
-
-
-
- )
-}
diff --git a/samples/nextjs/quickstart/app/components/HeroCtas.tsx b/samples/nextjs/quickstart/app/components/HeroCtas.tsx
deleted file mode 100644
index ea8e91b4..00000000
--- a/samples/nextjs/quickstart/app/components/HeroCtas.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-'use client'
-import { SignInButton } from '@thunderid/nextjs'
-
-export default function HeroCtas() {
- return (
-
-
- {({ signIn, isLoading }) => (
- { void signIn?.() }} disabled={isLoading}>
- {isLoading ? 'Signing inβ¦' : 'Sign in'}
-
- )}
-
-
- )
-}
diff --git a/samples/nextjs/quickstart/app/components/Nav.tsx b/samples/nextjs/quickstart/app/components/Nav.tsx
deleted file mode 100644
index e660f18e..00000000
--- a/samples/nextjs/quickstart/app/components/Nav.tsx
+++ /dev/null
@@ -1,102 +0,0 @@
-'use client'
-import { SignedIn, SignedOut, SignInButton, UserDropdown } from '@thunderid/nextjs'
-import Link from 'next/link'
-import { usePathname, useRouter } from 'next/navigation'
-import { useState } from 'react'
-import NextLogo from './icons/NextLogo'
-
-function MoonIcon() {
- return (
-
-
-
- )
-}
-
-function SunIcon() {
- return (
-
-
-
-
-
-
-
- )
-}
-
-function ChevronLeftIcon() {
- return (
-
-
-
- )
-}
-
-function KeyIcon() {
- return (
-
-
-
-
-
- )
-}
-
-export default function Nav() {
- const [dark, setDark] = useState(false)
- const pathname = usePathname()
- const router = useRouter()
- const isHome = pathname === '/'
-
- const toggle = () => {
- const next = !dark
- setDark(next)
- document.documentElement.classList.toggle('dark', next)
- }
-
- const menuItems = [
- {
- label: Token debug ,
- href: '/token',
- },
- ]
-
- return (
-
-
-
- Quickstart
-
-
-
- {!isHome && (
-
-
- Home
-
- )}
-
- {dark ? : }
-
-
- router.push('/account')}
- />
-
-
-
- {({ signIn, isLoading }) => (
- { void signIn?.() }} disabled={isLoading}>
- {isLoading ? 'Signing inβ¦' : 'Sign in'}
-
- )}
-
-
-
-
- )
-}
diff --git a/samples/nextjs/quickstart/app/components/ThunderMark.tsx b/samples/nextjs/quickstart/app/components/ThunderMark.tsx
deleted file mode 100644
index d8cf0ab0..00000000
--- a/samples/nextjs/quickstart/app/components/ThunderMark.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-export default function ThunderMark({
- height = 40,
- className,
- darkMode = false,
-}: {
- height?: number
- className?: string
- darkMode?: boolean
-}) {
- const width = Math.round((height / 257) * 207)
-
- return (
-
-
-
-
-
- )
-}
diff --git a/samples/nextjs/quickstart/app/components/icons/NextLogo.tsx b/samples/nextjs/quickstart/app/components/icons/NextLogo.tsx
deleted file mode 100644
index b78a7665..00000000
--- a/samples/nextjs/quickstart/app/components/icons/NextLogo.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-export default function NextLogo({ size = 16, fill = 'currentColor' }: { size?: number; fill?: string }) {
- return (
-
-
-
-
-
-
-
-
-
-
- )
-}
diff --git a/samples/nextjs/quickstart/app/globals.css b/samples/nextjs/quickstart/app/globals.css
deleted file mode 100644
index 6972b479..00000000
--- a/samples/nextjs/quickstart/app/globals.css
+++ /dev/null
@@ -1,1521 +0,0 @@
-/* ββ Reset ββ */
-*,
-::before,
-::after {
- box-sizing: border-box;
- margin: 0;
- padding: 0;
-}
-
-/* ββ Design tokens ββ */
-:root {
- --color-bg: #f7f9fc;
- --color-card: #ffffff;
- --color-border: #dde3ec;
- --color-text: #05213f;
- --color-text-muted: #5a7085;
- --color-muted: #5a7085;
- --color-primary: #3688ff;
- --color-primary-hover: #1f72f0;
- --color-nav-bg: rgba(247, 249, 252, 0.92);
- --color-nav-border: #dde3ec;
- --shadow-card: 0 1px 3px 0 rgba(5, 33, 63, 0.06),
- 0 4px 12px 0 rgba(5, 33, 63, 0.08);
- --font-sans: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
- sans-serif;
- --radius-card: 12px;
- --radius-btn: 8px;
-}
-
-/* ββ Dark mode ββ */
-.dark,
-html.dark {
- --color-bg: #080f1c;
- --color-card: #0f1a2d;
- --color-border: rgba(255, 255, 255, 0.09);
- --color-text: #E0EAFF;
- --color-text-muted: rgba(224, 234, 255, 0.48);
- --color-muted: rgba(224, 234, 255, 0.48);
- --color-nav-bg: rgba(8, 15, 28, 0.92);
-
- /* ThunderID Design Tokens for Dark Mode */
- --thunderid-color-background-surface: #0f1a2d;
- --thunderid-color-background-body: #080f1c;
- --thunderid-color-background-disabled: rgba(255, 255, 255, 0.06);
- --thunderid-color-background-muted: rgba(255, 255, 255, 0.05);
- --thunderid-color-text-primary: #E0EAFF;
- --thunderid-color-text-secondary: rgba(224, 234, 255, 0.65);
- --thunderid-color-border: rgba(255, 255, 255, 0.09);
- --thunderid-color-border-focus: var(--color-primary);
- --thunderid-input-borderColor: rgba(255, 255, 255, 0.16);
- --thunderid-input-focusBorderColor: var(--color-primary);
- --thunderid-input-focusRing: 0 0 0 3px rgba(54, 136, 255, 0.25);
- --thunderid-card-borderColor: rgba(255, 255, 255, 0.09);
- --thunderid-card-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
- --thunderid-color-action-hover: rgba(255, 255, 255, 0.06);
-}
-
-/* ββ Base ββ */
-html,
-body {
- height: 100%;
-}
-
-body {
- font-family: var(--font-sans);
- background: var(--color-bg);
- color: var(--color-text);
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
- line-height: 1.5;
-}
-
-/* ββ App shell ββ */
-.app {
- min-height: 100vh;
- display: flex;
- flex-direction: column;
- background: var(--color-bg);
- color: var(--color-text);
- transition: background 0.2s, color 0.2s;
-}
-
-/* ββ Navigation ββ */
-.nav {
- position: fixed;
- inset-block-start: 0;
- inset-inline: 0;
- height: 52px;
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 0 28px;
- background: var(--color-nav-bg);
- backdrop-filter: blur(12px);
- -webkit-backdrop-filter: blur(12px);
- border-bottom: 1px solid var(--color-nav-border);
- z-index: 100;
- transition: background 0.2s, border-color 0.2s;
-}
-
-.nav-brand {
- display: flex;
- align-items: center;
- gap: 10px;
- text-decoration: none;
- color: var(--color-text);
-}
-
-.nav-logo {
- display: flex;
- align-items: center;
- gap: 10px;
- text-decoration: none;
- color: var(--color-text);
-}
-
-.nav-wordmark {
- font-size: 15px;
- font-weight: 700;
- letter-spacing: -0.02em;
- color: var(--color-text);
-}
-
-.wordmark {
- display: flex;
- flex-direction: column;
- gap: 1px;
-}
-
-.wordmark-name {
- font-size: 14px;
- font-weight: 700;
- letter-spacing: -0.03em;
- color: var(--color-text);
- line-height: 1;
-}
-
-.wordmark-sub {
- font-size: 8.5px;
- font-weight: 700;
- letter-spacing: 0.1em;
- text-transform: uppercase;
- color: var(--color-primary);
- line-height: 1;
-}
-
-.nav-actions {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.dark-toggle {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 34px;
- height: 34px;
- padding: 0;
- background: transparent;
- border: 1px solid var(--color-border);
- border-radius: var(--radius-btn);
- color: var(--color-text-muted);
- cursor: pointer;
- transition: color 0.15s, background 0.15s, border-color 0.15s;
- flex-shrink: 0;
-}
-
-.dark-toggle:hover {
- color: var(--color-text);
- background: var(--color-border);
-}
-
-/* ββ Buttons ββ */
-button {
- font-family: var(--font-sans);
- cursor: pointer;
- border: none;
- border-radius: var(--radius-btn);
- font-weight: 600;
- transition: background 0.15s, color 0.15s, border-color 0.15s, opacity 0.15s;
- line-height: 1;
- white-space: nowrap;
-}
-
-button:focus-visible {
- outline: 2px solid var(--color-primary);
- outline-offset: 2px;
-}
-
-.btn-primary,
-.btn-outline,
-.btn-ghost {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- height: 46px;
- padding: 0 24px;
- font-size: 15px;
- text-decoration: none;
-}
-
-.btn-primary {
- background: var(--color-primary);
- color: #ffffff;
- border: 1.5px solid var(--color-primary);
-}
-
-.btn-primary:hover {
- opacity: 0.88;
-}
-
-.btn-outline {
- background: transparent;
- color: var(--color-primary);
- border: 1.5px solid rgba(54, 136, 255, 0.4);
-}
-
-.btn-outline:hover {
- background: rgba(54, 136, 255, 0.08);
-}
-
-.btn-ghost {
- background: transparent;
- color: var(--color-muted);
- border: none;
-}
-
-.btn-ghost:hover {
- color: var(--color-text);
- background: rgba(0, 0, 0, 0.04);
-}
-
-/* Nav-sized buttons */
-.nav .btn-primary,
-.nav .btn-outline,
-.nav .btn-ghost {
- height: 36px;
- padding: 0 16px;
- font-size: 14px;
-}
-
-/* ββ Hero section ββ */
-.hero {
- flex: 1;
- padding: 96px 32px 80px;
- min-height: 100vh;
-}
-
-.hero-inner {
- display: flex;
- flex-direction: column;
- align-items: center;
- text-align: center;
- gap: 0;
- max-width: 560px;
- width: 100%;
- margin: 0 auto;
-}
-
-.hero-mark {
- display: flex;
- justify-content: center;
- margin-bottom: 32px;
-}
-
-.hero-badge {
- display: inline-flex;
- align-items: center;
- gap: 8px;
- font-size: 11px;
- font-weight: 700;
- letter-spacing: 0.1em;
- text-transform: uppercase;
- color: var(--color-text-muted);
- margin-bottom: 28px;
-}
-
-.hero-badge-line {
- display: block;
- width: 24px;
- height: 1px;
- background: var(--color-border);
- flex-shrink: 0;
-}
-
-.hero-title {
- font-size: 42px;
- font-weight: 700;
- letter-spacing: -0.05em;
- color: var(--color-text);
- line-height: 1.1;
- margin: 0 0 18px;
-}
-
-.hero-subtitle {
- font-size: 16px;
- color: var(--color-text-muted);
- max-width: 420px;
- line-height: 1.8;
- letter-spacing: -0.01em;
- margin: 0 auto 36px;
-}
-
-.hero-ctas {
- display: flex;
- gap: 12px;
- flex-wrap: wrap;
- justify-content: center;
- margin-bottom: 52px;
-}
-
-.hero-divider {
- width: 100%;
- border: none;
- border-top: 1px solid var(--color-border);
- margin: 4px 0;
-}
-
-/* ββ Stats row ββ */
-.hero-stats {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- width: 100%;
-}
-
-.hero-stat {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 4px;
- padding: 16px 8px;
- position: relative;
-}
-
-.hero-stat + .hero-stat::before {
- content: '';
- position: absolute;
- left: 0;
- top: 20%;
- height: 60%;
- width: 1px;
- background: var(--color-border);
-}
-
-.hero-stat-value {
- font-size: 15px;
- font-weight: 700;
- color: var(--color-text);
- letter-spacing: -0.02em;
-}
-
-.hero-stat-label {
- font-size: 12px;
- color: var(--color-text-muted);
-}
-
-/* ββ Config notice ββ */
-.config-badge {
- color: #e88b3a;
-}
-
-.config-list {
- width: 100%;
- max-width: 420px;
- text-align: left;
- list-style: none;
- background: var(--color-card);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-btn);
- padding: 0 16px;
- margin: 4px 0 28px;
-}
-
-.config-list-item {
- padding: 10px 0;
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
- font-size: 13px;
- color: var(--color-text);
-}
-
-.config-list-item:not(:last-child) {
- border-bottom: 1px solid var(--color-border);
-}
-
-.config-hint {
- font-size: 14px;
- color: var(--color-text-muted);
- line-height: 1.7;
- max-width: 420px;
- margin: 0 auto;
-}
-
-.config-hint code {
- background: rgba(54, 136, 255, 0.1);
- border-radius: 4px;
- padding: 2px 6px;
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
- font-size: 13px;
-}
-
-.config-step {
- width: 100%;
- max-width: 420px;
- margin: 0 auto 28px;
-}
-
-.config-step-label {
- font-size: 11px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.09em;
- color: var(--color-text-muted);
- margin-bottom: 10px;
- text-align: left;
-}
-
-.config-step .config-list,
-.config-step .config-hint {
- margin-left: 0;
- margin-right: 0;
-}
-
-.config-box {
- text-align: left;
- background: var(--color-card);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-card);
- padding: 16px;
-}
-
-.config-box-body {
- font-size: 13px;
- color: var(--color-text-muted);
- line-height: 1.6;
- margin-bottom: 14px;
-}
-
-.config-value-group {
- display: flex;
- flex-direction: column;
- gap: 12px;
-}
-
-.config-value-label {
- font-size: 11px;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.06em;
- color: var(--color-text-muted);
- margin-bottom: 4px;
-}
-
-.config-value {
- display: block;
- overflow-x: auto;
- white-space: nowrap;
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
- font-size: 12px;
- color: var(--color-text);
- background: rgba(54, 136, 255, 0.1);
- border-radius: 4px;
- padding: 8px 10px;
-}
-
-.config-docs-note {
- font-size: 13px;
- color: var(--color-text-muted);
- max-width: 420px;
- margin: 0 auto;
-}
-
-.config-docs-note a {
- color: var(--color-primary);
- font-weight: 600;
-}
-
-/* ββ User / welcome card ββ */
-.user-card {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 16px;
- background: var(--color-card);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-card);
- padding: 32px;
- max-width: 480px;
- width: 100%;
- box-shadow: var(--shadow-card);
-}
-
-.user-avatar {
- width: 48px;
- height: 48px;
- border-radius: 50%;
- background: var(--color-primary);
- color: #ffffff;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 17px;
- font-weight: 700;
- letter-spacing: 0.02em;
- flex-shrink: 0;
-}
-
-.user-welcome {
- font-size: 1.375rem;
- font-weight: 700;
- letter-spacing: -0.02em;
- color: var(--color-text);
- text-align: center;
-}
-
-.user-details {
- display: flex;
- flex-direction: column;
- gap: 0;
- width: 100%;
- border: 1px solid var(--color-border);
- border-radius: 8px;
- overflow: hidden;
-}
-
-.user-detail-row {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 10px 16px;
- gap: 16px;
-}
-
-.user-detail-row + .user-detail-row {
- border-top: 1px solid var(--color-border);
-}
-
-.user-detail-row dt {
- font-size: 13px;
- color: var(--color-text-muted);
- font-weight: 500;
- flex-shrink: 0;
-}
-
-.user-detail-row dd {
- font-size: 13px;
- color: var(--color-text);
- font-weight: 500;
- text-align: right;
- word-break: break-all;
-}
-
-/* ββ Signed-in home shell ββ */
-.home-shell {
- min-height: calc(100vh - 52px);
- margin-top: 52px;
-}
-
-/* ββ Signed-in home page ββ */
-.home-main {
- width: 100%;
- max-width: 900px;
- margin: 0 auto;
- padding: 52px 28px 80px;
-}
-
-/* Greeting */
-.home-greeting {
- display: flex;
- align-items: flex-start;
- gap: 16px;
- margin-bottom: 36px;
-}
-
-.home-avatar {
- flex-shrink: 0;
-}
-
-.home-greeting-text {
- min-width: 0;
-}
-
-.home-greeting-name {
- margin: 0 0 6px;
- font-size: 26px;
- font-weight: 700;
- color: var(--color-text);
- letter-spacing: -0.04em;
-}
-
-.home-greeting-meta {
- display: flex;
- align-items: center;
- gap: 8px;
- font-size: 13px;
- color: var(--color-muted);
- letter-spacing: -0.01em;
- flex-wrap: wrap;
-}
-
-.home-dot {
- display: inline-block;
- width: 3px;
- height: 3px;
- border-radius: 50%;
- background: var(--color-border);
- flex-shrink: 0;
-}
-
-.home-session-active {
- display: inline-flex;
- align-items: center;
- gap: 5px;
- color: #2fbd6b;
-}
-
-.home-session-dot {
- display: inline-block;
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: #2fbd6b;
- flex-shrink: 0;
-}
-
-/* Session stats row */
-.home-stats {
- display: flex;
- gap: 0;
- padding: 20px 0;
- border-top: 1px solid var(--color-border);
- border-bottom: 1px solid var(--color-border);
- margin-bottom: 52px;
-}
-
-.home-stat {
- flex: 1;
- display: flex;
- align-items: center;
- gap: 10px;
- min-width: 0;
-}
-
-.home-stat--bordered {
- padding-left: 32px;
- border-left: 1px solid var(--color-border);
-}
-
-.home-stat-icon {
- width: 32px;
- height: 32px;
- border-radius: 8px;
- background: rgba(54, 136, 255, 0.1);
- display: flex;
- align-items: center;
- justify-content: center;
- flex-shrink: 0;
- color: #3688ff;
-}
-
-.home-stat-value {
- font-size: 14px;
- font-weight: 700;
- color: var(--color-text);
- letter-spacing: -0.02em;
-}
-
-.home-stat-value--mono {
- font-family: monospace;
- letter-spacing: 0.01em;
-}
-
-.home-stat-label {
- font-size: 11px;
- font-weight: 600;
- color: var(--color-muted);
- text-transform: uppercase;
- letter-spacing: 0.07em;
- margin-top: 2px;
-}
-
-/* What's next */
-.home-next-label {
- font-size: 11px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.09em;
- color: var(--color-muted);
- margin-bottom: 20px;
-}
-
-.home-next-list {
- display: flex;
- flex-direction: column;
-}
-
-.home-next-item {
- display: grid;
- grid-template-columns: 28px 1fr auto;
- gap: 16px;
- align-items: start;
- padding: 20px 0;
- border-top: 1px solid var(--color-border);
- text-decoration: none;
- color: inherit;
- cursor: pointer;
- transition: background 0.2s;
- border-radius: 4px;
-}
-
-.home-next-item:hover .home-next-cta {
- opacity: 1;
-}
-
-.home-next-n {
- font-size: 11px;
- font-weight: 700;
- color: #3688ff;
- letter-spacing: 0.05em;
- padding-top: 2px;
- font-variant-numeric: tabular-nums;
-}
-
-.home-next-body {
- min-width: 0;
-}
-
-.home-next-title {
- font-size: 14px;
- font-weight: 600;
- color: var(--color-text);
- letter-spacing: -0.02em;
- margin-bottom: 4px;
-}
-
-.home-next-desc {
- font-size: 13px;
- color: var(--color-muted);
- line-height: 1.6;
- letter-spacing: -0.01em;
-}
-
-.home-next-cta {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- font-size: 13px;
- font-weight: 600;
- color: #3688ff;
- white-space: nowrap;
- opacity: 0.7;
- transition: opacity 0.2s;
- padding-top: 2px;
-}
-
-/* ββ Nav back button ββ */
-.nav-back-btn {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- height: 32px;
- padding: 0 10px;
- border-radius: 6px;
- border: none;
- background: transparent;
- color: var(--color-muted);
- font-size: 13px;
- font-family: var(--font-sans);
- letter-spacing: -0.01em;
- cursor: pointer;
- transition: background 0.2s, color 0.2s;
-}
-
-.nav-back-btn:hover {
- background: var(--color-border);
- color: var(--color-text);
-}
-
-/* ββ Token debug page ββ */
-.token-main {
- width: 100%;
- max-width: 780px;
- margin: 0 auto;
- margin-top: 52px;
- padding: 52px 28px 80px;
-}
-
-.token-header {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 16px;
- margin-bottom: 36px;
- flex-wrap: wrap;
-}
-
-.token-title {
- margin: 0 0 5px;
- font-size: 22px;
- font-weight: 700;
- letter-spacing: -0.04em;
- color: var(--color-text);
-}
-
-.token-subtitle {
- margin: 0;
- font-size: 13px;
- color: var(--color-muted);
- letter-spacing: -0.01em;
-}
-
-/* ββ Sign in / Sign up pages ββ */
-.auth-main {
- width: 100%;
- min-height: calc(100vh - 52px);
- margin-top: 52px;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 40px 20px;
-}
-
-.auth-card {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 8px;
- background: var(--color-card);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-card);
- box-shadow: var(--shadow-card);
- padding: 40px 32px;
- max-width: 420px;
- width: 100%;
-}
-
-.auth-title {
- font-size: 1.5rem;
- font-weight: 700;
- letter-spacing: -0.02em;
- color: var(--color-text);
- text-align: center;
-}
-
-.auth-subtitle {
- margin: 0 0 16px;
- font-size: 13px;
- color: var(--color-text-muted);
- text-align: center;
-}
-
-.auth-switch {
- margin-top: 20px;
- font-size: 13px;
- color: var(--color-text-muted);
- text-align: center;
-}
-
-.auth-switch a {
- color: var(--color-primary);
- font-weight: 600;
- text-decoration: none;
-}
-
-.auth-switch a:hover {
- text-decoration: underline;
-}
-
-.token-badge {
- display: inline-flex;
- align-items: center;
- gap: 7px;
- padding: 6px 12px;
- border-radius: 6px;
- font-size: 12px;
- font-weight: 700;
- letter-spacing: -0.01em;
- flex-shrink: 0;
-}
-
-.token-badge--expired {
- background: rgba(220, 38, 38, 0.08);
- border: 1px solid rgba(220, 38, 38, 0.22);
- color: #dc2626;
-}
-
-.token-badge--valid {
- background: rgba(47, 189, 107, 0.08);
- border: 1px solid rgba(47, 189, 107, 0.22);
- color: #2fbd6b;
-}
-
-.token-badge-dot {
- display: inline-block;
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: currentColor;
- flex-shrink: 0;
-}
-
-/* Raw token section */
-.token-raw-section {
- margin-bottom: 20px;
-}
-
-.token-raw-label-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 8px;
-}
-
-.token-section-label {
- font-size: 11px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.09em;
- color: var(--color-muted);
-}
-
-.token-copy-btn {
- display: inline-flex;
- align-items: center;
- gap: 5px;
- padding: 4px 10px;
- border-radius: 5px;
- background: transparent;
- border: 1px solid var(--color-border);
- font-size: 11px;
- font-weight: 600;
- color: var(--color-muted);
- cursor: pointer;
- font-family: var(--font-sans);
- letter-spacing: -0.01em;
- transition: border-color 0.2s, color 0.2s;
-}
-
-.token-copy-btn:hover {
- border-color: #3688ff;
- color: #3688ff;
-}
-
-.token-raw {
- display: block;
- font-size: 11px;
- font-family: monospace;
- word-break: break-all;
- line-height: 1.8;
- background: #0b1120;
- padding: 14px 16px;
- border-radius: 8px;
- overflow-x: auto;
-}
-
-.token-raw--loading {
- color: rgba(255, 255, 255, 0.3);
-}
-
-.token-part--header { color: #ff7b72; }
-.token-part--payload { color: #79c0ff; }
-.token-part--signature { color: #3fb950; }
-
-.token-dot {
- color: rgba(255, 255, 255, 0.2);
-}
-
-/* Decoded grid */
-.token-decoded-grid {
- display: grid;
- grid-template-columns: 1fr 1.7fr;
- gap: 12px;
- margin-bottom: 20px;
-}
-
-.token-code-box {
- background: #0b1120;
- border-radius: 10px;
- overflow: hidden;
-}
-
-.token-code-box-header {
- padding: 9px 14px;
- font-size: 10px;
- font-weight: 700;
- letter-spacing: 0.09em;
- text-transform: uppercase;
-}
-
-.token-code-box-header--header {
- color: #ff7b72;
- border-bottom: 1px solid rgba(255, 123, 114, 0.15);
-}
-
-.token-code-box-header--payload {
- color: #79c0ff;
- border-bottom: 1px solid rgba(121, 192, 255, 0.15);
-}
-
-.token-code-pre {
- margin: 0;
- padding: 14px;
- font-size: 11.5px;
- line-height: 1.8;
- font-family: monospace;
- color: #c9d1d9;
- overflow-x: auto;
- max-height: 220px;
-}
-
-/* Meta row */
-.token-meta-row {
- display: flex;
- gap: 32px;
- padding-top: 20px;
- border-top: 1px solid var(--color-border);
- flex-wrap: wrap;
-}
-
-.token-meta-label {
- font-size: 10px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.09em;
- color: var(--color-muted);
- margin-bottom: 5px;
-}
-
-.token-meta-value {
- font-size: 12px;
- font-family: monospace;
- color: var(--color-text);
- letter-spacing: -0.01em;
-}
-
-/* ββ Responsive ββ */
-@media (max-width: 480px) {
- .nav {
- padding: 0 16px;
- }
-
- .nav-wordmark {
- display: none;
- }
-
- .hero {
- padding: 80px 16px 32px;
- }
-
- .hero-title {
- font-size: 2rem;
- }
-
- .hero-stats {
- grid-template-columns: 1fr;
- }
-
- .hero-stat + .hero-stat::before {
- top: 0;
- left: 20%;
- width: 60%;
- height: 1px;
- }
-
- .hero-ctas {
- flex-direction: column;
- align-items: stretch;
- }
-
- .user-card {
- padding: 24px 16px;
- }
-}
-
-/* βββ Account page (Google Account-style left-nav layout) βββββββββββ */
-.account-page {
- display: flex;
- align-items: flex-start;
- min-height: calc(100vh - 52px);
- margin-top: 52px;
-}
-
-.account-sidebar {
- width: 280px;
- flex-shrink: 0;
- padding: 40px 24px;
- position: sticky;
- top: 52px;
-}
-
-.account-sidebar-title {
- font-size: 22px;
- font-weight: 700;
- letter-spacing: -0.03em;
- color: var(--color-text);
- margin: 0 0 24px 16px;
-}
-
-.account-nav {
- display: flex;
- flex-direction: column;
- gap: 2px;
-}
-
-.account-nav-item {
- display: flex;
- align-items: center;
- gap: 14px;
- width: 100%;
- padding: 11px 16px;
- border: none;
- border-radius: 20px;
- background: transparent;
- color: var(--color-muted);
- font-size: 14px;
- font-weight: 500;
- font-family: inherit;
- text-align: left;
- cursor: pointer;
- white-space: nowrap;
- transition: background 0.2s, color 0.2s;
-}
-
-.account-nav-item:hover {
- background: var(--color-border);
- color: var(--color-text);
-}
-
-.account-nav-item--active,
-.account-nav-item--active:hover {
- background: rgba(54, 136, 255, 0.12);
- color: var(--color-primary);
-}
-
-.account-content {
- flex: 1;
- min-width: 0;
- display: flex;
- justify-content: center;
- padding: 40px 32px 80px;
- border-left: 1px solid var(--color-border);
-}
-
-.account-content-inner {
- width: 100%;
- max-width: 720px;
-}
-
-/* UserProfile's `min-width: 600px` (a floor meant to keep the field grid from getting
- cramped in other contexts) fights this page's own responsive layout, refusing to shrink
- below 600px even when the viewport is narrower. The Security/Home boxes have no such floor
- and already shrink fluidly with their container, so match that here. */
-.account-content .thunderid-user-profile {
- min-width: 0;
- background: var(--color-card) !important;
- border: 1px solid var(--color-border) !important;
- border-radius: var(--radius-card) !important;
- box-shadow: var(--shadow-card);
- color: var(--color-text);
- box-sizing: border-box;
-}
-
-.account-content .thunderid-user-profile .thunderid-user-profile__hero {
- border-bottom: 1px solid var(--color-border);
-}
-
-.account-content .thunderid-user-profile .thunderid-user-profile__hero-name {
- color: var(--color-text);
-}
-
-.account-content .thunderid-user-profile .thunderid-user-profile__hero-subtitle {
- color: var(--color-text-muted);
-}
-
-.account-content .thunderid-user-profile .thunderid-user-profile__field {
- border-bottom: 1px solid var(--color-border);
-}
-
-.account-content .thunderid-user-profile .thunderid-user-profile__field-label {
- color: var(--color-text-muted);
-}
-
-.account-content .thunderid-user-profile .thunderid-user-profile__field-value {
- color: var(--color-text);
-}
-
-.account-content .thunderid-user-profile .thunderid-user-profile__field-placeholder {
- color: var(--color-text-muted);
-}
-
-.account-content .thunderid-user-profile .thunderid-user-profile__field-edit-btn {
- color: var(--color-text-muted);
-}
-
-.account-content .thunderid-user-profile .thunderid-user-profile__field-edit-btn:hover {
- color: var(--color-primary);
-}
-
-.account-content .thunderid-user-profile input,
-.account-content .thunderid-user-profile select,
-.account-content .thunderid-user-profile textarea {
- background: rgba(255, 255, 255, 0.05);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-btn);
- color: var(--color-text);
- padding: 6px 10px;
-}
-
-.account-content-title {
- font-size: 26px;
- font-weight: 700;
- letter-spacing: -0.03em;
- color: var(--color-text);
- margin: 0 0 6px;
-}
-
-.account-content-subtitle {
- font-size: 14px;
- color: var(--color-muted);
- margin: 0 0 32px;
-}
-
-.account-tiles {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
- gap: 16px;
- margin-top: 8px;
-}
-
-.account-tile {
- text-align: left;
- background: var(--color-card);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-card);
- padding: 22px;
- cursor: pointer;
- font-family: inherit;
- display: flex;
- flex-direction: column;
- transition: border-color 0.2s;
-}
-
-.account-tile:hover {
- border-color: rgba(54, 136, 255, 0.35);
-}
-
-.account-tile-icon {
- width: 36px;
- height: 36px;
- border-radius: 10px;
- display: flex;
- align-items: center;
- justify-content: center;
- margin-bottom: 14px;
-}
-
-.account-tile-icon--blue {
- background: rgba(54, 136, 255, 0.12);
- color: var(--color-primary);
-}
-
-.account-tile-icon--green {
- background: rgba(16, 185, 129, 0.12);
- color: #10b981;
-}
-
-.account-tile-title {
- font-size: 15px;
- font-weight: 600;
- color: var(--color-text);
- margin-bottom: 4px;
-}
-
-.account-tile-desc {
- font-size: 12.5px;
- color: var(--color-muted);
- line-height: 1.5;
-}
-
-.account-security-list {
- display: flex;
- flex-direction: column;
- gap: 16px;
-}
-
-.account-security-card {
- background: var(--color-card);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-card);
- padding: 20px 22px;
-}
-
-.account-security-card-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 16px;
-}
-
-.account-security-card-title {
- font-size: 14px;
- font-weight: 600;
- color: var(--color-text);
- margin-bottom: 2px;
-}
-
-.account-security-card-desc {
- font-size: 12.5px;
- color: var(--color-muted);
-}
-
-.account-security-card-cta {
- flex-shrink: 0;
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 0;
- background: none;
- border: none;
- color: var(--color-primary);
- font-size: 13px;
- font-weight: 500;
- font-family: inherit;
- cursor: pointer;
- transition: opacity 0.2s;
-}
-
-.account-security-card-cta:hover {
- opacity: 0.8;
-}
-
-.account-security-card-chevron {
- display: inline-flex;
- transition: transform 0.2s;
-}
-
-.account-security-card-chevron--open {
- transform: rotate(180deg);
-}
-
-.account-security-card-form {
- margin-top: 18px;
- padding-top: 18px;
- border-top: 1px solid var(--color-border);
-}
-
-@media (max-width: 760px) {
- .account-page {
- flex-direction: column;
- }
- .account-sidebar {
- width: 100%;
- position: static;
- padding: 20px 16px 8px;
- }
- .account-nav {
- flex-direction: row;
- overflow-x: auto;
- }
- .account-content {
- max-width: 100%;
- border-left: none;
- border-top: 1px solid var(--color-border);
- padding: 24px 20px 60px;
- }
-}
-
-/* ββ ThunderID inputs & forms dark mode ββ */
-.dark .thunderid-password-field__wrapper,
-html.dark .thunderid-password-field__wrapper,
-.app.dark .thunderid-password-field__wrapper,
-.dark .thunderid-text-field__wrapper,
-html.dark .thunderid-text-field__wrapper,
-.app.dark .thunderid-text-field__wrapper,
-.dark .thunderid-text-field__input,
-html.dark .thunderid-text-field__input,
-.app.dark .thunderid-text-field__input {
- background-color: rgba(255, 255, 255, 0.05) !important;
- border-color: rgba(255, 255, 255, 0.16) !important;
- color: var(--color-text) !important;
-}
-
-.dark .thunderid-password-field__label,
-html.dark .thunderid-password-field__label,
-.app.dark .thunderid-password-field__label,
-.dark .thunderid-text-field__label,
-html.dark .thunderid-text-field__label,
-.app.dark .thunderid-text-field__label,
-.dark .thunderid-input-label,
-html.dark .thunderid-input-label,
-.app.dark .thunderid-input-label {
- color: var(--color-text) !important;
-}
-
-.dark .thunderid-password-field__input,
-html.dark .thunderid-password-field__input,
-.app.dark .thunderid-password-field__input {
- background: transparent !important;
- color: var(--color-text) !important;
-}
-
-.dark .thunderid-password-field__input::placeholder,
-html.dark .thunderid-password-field__input::placeholder,
-.app.dark .thunderid-password-field__input::placeholder,
-.dark .thunderid-text-field__input::placeholder,
-html.dark .thunderid-text-field__input::placeholder,
-.app.dark .thunderid-text-field__input::placeholder {
- color: var(--color-text-muted) !important;
-}
-
-.dark .thunderid-password-field__toggle,
-html.dark .thunderid-password-field__toggle,
-.app.dark .thunderid-password-field__toggle {
- color: var(--color-text-muted) !important;
- background: transparent !important;
-}
-
-.dark .thunderid-password-field__toggle:hover,
-html.dark .thunderid-password-field__toggle:hover,
-.app.dark .thunderid-password-field__toggle:hover {
- color: var(--color-text) !important;
-}
-
-.dark .thunderid-change-credential__requirements-heading,
-html.dark .thunderid-change-credential__requirements-heading,
-.app.dark .thunderid-change-credential__requirements-heading,
-.dark .thunderid-change-credential__requirement,
-html.dark .thunderid-change-credential__requirement,
-.app.dark .thunderid-change-credential__requirement {
- color: var(--color-text-muted) !important;
-}
-
-.dark .thunderid-change-credential__requirement--passed,
-html.dark .thunderid-change-credential__requirement--passed,
-.app.dark .thunderid-change-credential__requirement--passed {
- color: #38c172 !important;
-}
-
-/* ββ ThunderID UserDropdown dark mode ββ */
-.dark .thunderid-user-dropdown__menu,
-html.dark .thunderid-user-dropdown__menu,
-.app.dark .thunderid-user-dropdown__menu,
-.dark .thunderid-user-dropdown__content,
-html.dark .thunderid-user-dropdown__content,
-.app.dark .thunderid-user-dropdown__content {
- background-color: var(--color-card) !important;
- border: 1px solid var(--color-border) !important;
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45) !important;
- color: var(--color-text) !important;
-}
-
-.dark .thunderid-user-dropdown__menu-header,
-html.dark .thunderid-user-dropdown__menu-header,
-.app.dark .thunderid-user-dropdown__menu-header,
-.dark .thunderid-user-dropdown__header,
-html.dark .thunderid-user-dropdown__header,
-.app.dark .thunderid-user-dropdown__header {
- border-bottom: 1px solid var(--color-border) !important;
-}
-
-.dark .thunderid-user-dropdown__menu-header-name,
-html.dark .thunderid-user-dropdown__menu-header-name,
-.app.dark .thunderid-user-dropdown__menu-header-name,
-.dark .thunderid-user-dropdown__header-name,
-html.dark .thunderid-user-dropdown__header-name,
-.app.dark .thunderid-user-dropdown__header-name {
- color: var(--color-text) !important;
-}
-
-.dark .thunderid-user-dropdown__menu-header-subtitle,
-html.dark .thunderid-user-dropdown__menu-header-subtitle,
-.app.dark .thunderid-user-dropdown__menu-header-subtitle,
-.dark .thunderid-user-dropdown__header-subtitle,
-html.dark .thunderid-user-dropdown__header-subtitle,
-.app.dark .thunderid-user-dropdown__header-subtitle {
- color: var(--color-text-muted) !important;
-}
-
-.dark .thunderid-user-dropdown__menu-divider,
-html.dark .thunderid-user-dropdown__menu-divider,
-.app.dark .thunderid-user-dropdown__menu-divider,
-.dark .thunderid-divider,
-html.dark .thunderid-divider,
-.app.dark .thunderid-divider {
- background-color: var(--color-border) !important;
- border-color: var(--color-border) !important;
-}
-
-.dark .thunderid-user-dropdown__item,
-html.dark .thunderid-user-dropdown__item,
-.app.dark .thunderid-user-dropdown__item {
- color: var(--color-text) !important;
-}
-
-.dark .thunderid-user-dropdown__item:hover,
-html.dark .thunderid-user-dropdown__item:hover,
-.app.dark .thunderid-user-dropdown__item:hover {
- background-color: rgba(255, 255, 255, 0.06) !important;
- color: var(--color-text) !important;
-}
-
-.dark .thunderid-user-dropdown__item--danger,
-html.dark .thunderid-user-dropdown__item--danger,
-.app.dark .thunderid-user-dropdown__item--danger {
- color: #f87171 !important;
-}
-
-.dark .thunderid-user-dropdown__item--danger:hover,
-html.dark .thunderid-user-dropdown__item--danger:hover,
-.app.dark .thunderid-user-dropdown__item--danger:hover {
- background-color: rgba(239, 68, 68, 0.12) !important;
-}
-
-.dark .thunderid-card,
-html.dark .thunderid-card,
-.app.dark .thunderid-card {
- background-color: var(--color-card) !important;
- border-color: var(--color-border) !important;
- color: var(--color-text) !important;
-}
-
diff --git a/samples/nextjs/quickstart/app/layout.tsx b/samples/nextjs/quickstart/app/layout.tsx
deleted file mode 100644
index d67dade0..00000000
--- a/samples/nextjs/quickstart/app/layout.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import type { Metadata } from 'next'
-import { ThunderIDProvider } from '@thunderid/nextjs/server'
-import ConfigNotice from './components/ConfigNotice'
-import './globals.css'
-
-export const dynamic = 'force-dynamic'
-
-export const metadata: Metadata = {
- title: 'ThunderID Next.js Quickstart',
- description: 'ThunderID authentication with Next.js',
-}
-
-// Redirect-based flow (NEXT_PUBLIC_THUNDERID_CLIENT_ID set) sends the user to
-// ThunderID's hosted pages and needs a registered redirect URI. The default,
-// native flow renders sign-in/sign-up inline via the app's own routes and
-// needs an application ID instead β no redirect URI or CORS setup required.
-const isRedirectFlow = Boolean(process.env.NEXT_PUBLIC_THUNDERID_CLIENT_ID)
-
-const REQUIRED_ENV_VARS = [
- 'NEXT_PUBLIC_THUNDERID_BASE_URL',
- 'THUNDERID_SECRET',
- ...(isRedirectFlow
- ? ['NEXT_PUBLIC_THUNDERID_CLIENT_ID', 'THUNDERID_CLIENT_SECRET']
- : ['NEXT_PUBLIC_THUNDERID_APPLICATION_ID', 'NEXT_PUBLIC_THUNDERID_SIGN_IN_URL', 'NEXT_PUBLIC_THUNDERID_SIGN_UP_URL', 'THUNDERID_FLOW_SECRET']),
-]
-
-export default function RootLayout({
- children,
-}: {
- children: React.ReactNode
-}) {
- const missingEnvVars = REQUIRED_ENV_VARS.filter((key) => !process.env[key])
-
- return (
-
-
- {missingEnvVars.length > 0 ? (
-
- ) : (
-
- {children}
-
- )}
-
-
- )
-}
diff --git a/samples/nextjs/quickstart/app/not-found.tsx b/samples/nextjs/quickstart/app/not-found.tsx
deleted file mode 100644
index 53bd47dd..00000000
--- a/samples/nextjs/quickstart/app/not-found.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import Link from 'next/link'
-
-export default function NotFound() {
- return (
-
-
- Page not found
- The page you're looking for doesn't exist.
- Return home
-
-
- )
-}
diff --git a/samples/nextjs/quickstart/app/page.tsx b/samples/nextjs/quickstart/app/page.tsx
deleted file mode 100644
index 825ba9f2..00000000
--- a/samples/nextjs/quickstart/app/page.tsx
+++ /dev/null
@@ -1,264 +0,0 @@
-'use client'
-import { SignedIn, SignedOut, User, UserAvatar, useThunderID } from '@thunderid/nextjs'
-import { useState, useEffect } from 'react'
-import { fetchAccessToken } from './actions'
-import HeroCtas from './components/HeroCtas'
-import Nav from './components/Nav'
-import ThunderMark from './components/ThunderMark'
-
-function greeting(name: string): string {
- const h = new Date().getHours()
- const tod = h < 12 ? 'morning' : h < 17 ? 'afternoon' : 'evening'
- return `Good ${tod}, ${name}.`
-}
-
-function formatTime(unixSeconds: number | undefined): string {
- if (!unixSeconds) return 'β'
- return new Date(unixSeconds * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
-}
-
-function formatCountdown(secsLeft: number): { text: string; color: string } {
- if (secsLeft <= 0) return { text: 'Expired', color: '#d95757' }
- if (secsLeft < 300) {
- const m = Math.floor(secsLeft / 60), s = secsLeft % 60
- return { text: `${m}m ${s}s`, color: '#e88b3a' }
- }
- if (secsLeft < 3600) {
- const m = Math.floor(secsLeft / 60), s = secsLeft % 60
- return { text: `${m}m ${s}s`, color: '#2fbd6b' }
- }
- const hr = Math.floor(secsLeft / 3600), m = Math.floor((secsLeft % 3600) / 60)
- return { text: `${hr}h ${m}m`, color: '#2fbd6b' }
-}
-
-const NEXT_STEPS = [
- {
- n: '01',
- title: 'Explore use cases',
- body: 'See what you can build β auth flows for web, mobile, APIs, and agents.',
- cta: 'Browse use cases',
- href: 'https://thunderid.dev/docs/next/use-cases/overview/',
- },
- {
- n: '02',
- title: 'Learn about flows',
- body: 'Understand how authorization code, PKCE, client credentials, and device flows work.',
- cta: 'Read guide',
- href: 'https://thunderid.dev/docs/next/guides/flows/what-are-flows/',
- },
- {
- n: '03',
- title: 'Style your experience',
- body: 'Customize the login UI, branding, and email templates to match your product.',
- cta: 'Design guide',
- href: 'https://thunderid.dev/docs/next/guides/design/overview/',
- },
- {
- n: '04',
- title: 'Explore SDK APIs',
- body: 'Full Next.js SDK reference β hooks, components, and configuration options.',
- cta: 'SDK reference',
- href: 'https://thunderid.dev/docs/next/sdks/nextjs/overview/',
- },
-]
-
-function ClockIcon() {
- return (
-
-
-
- )
-}
-
-function TimerIcon() {
- return (
-
-
-
-
-
- )
-}
-
-function BuildingIcon() {
- return (
-
-
-
-
- )
-}
-
-function ArrowIcon() {
- return (
-
-
-
- )
-}
-
-function HomeContent() {
- const { organizationHandle } = useThunderID()
- const [accessToken, setAccessToken] = useState(null)
- const [now, setNow] = useState(Math.floor(Date.now() / 1000))
-
- useEffect(() => {
- void fetchAccessToken().then((t) => { if (t) setAccessToken(t) })
- }, [])
-
- useEffect(() => {
- const id = setInterval(() => setNow(Math.floor(Date.now() / 1000)), 1000)
- return () => clearInterval(id)
- }, [])
-
- const jwtPayload = accessToken ? (() => {
- try {
- const part = accessToken.split('.')[1] ?? ''
- const base64 = part.replace(/-/g, '+').replace(/_/g, '/')
- const padded = base64.padEnd(base64.length + (4 - (base64.length % 4)) % 4, '=')
- return JSON.parse(atob(padded)) as Record
- } catch { return null }
- })() : null
-
- const authTime = jwtPayload?.auth_time as number | undefined
- const exp = jwtPayload?.exp as number | undefined
- const secsLeft = exp ? Math.max(0, exp - now) : null
- const countdown = secsLeft !== null ? formatCountdown(secsLeft) : null
-
- return (
-
- {(user: { givenName?: string; given_name?: string; familyName?: string; email?: string } | null) => {
- const givenName = user?.givenName ?? user?.given_name ?? user?.email?.split('@')[0] ?? 'there'
- const email = user?.email
- const ouName = organizationHandle ?? 'Default'
-
- return (
-
-
-
-
-
{greeting(givenName)}
-
- {email && {email} }
- {email && }
-
-
- Session active
-
-
-
-
-
-
-
-
-
-
{formatTime(authTime)}
-
Signed in at
-
-
-
-
-
-
- {countdown ? countdown.text : 'β'}
-
-
Session expires in
-
-
-
-
-
-
{ouName}
-
Organisation
-
-
-
-
- What's next
-
-
- )
- }}
-
- )
-}
-
-export default function HomePage() {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
- Open source
-
-
-
-
Auth for Modern Apps and Agents
-
-
- ThunderID gives you OAuth 2.0, PKCE, MFA, and JWT out of
- the box. Clone the Quickstart and ship auth before lunch.
-
-
-
-
-
-
-
-
- OAuth 2.0
- Authorization standard
-
-
- < 5 min
- Integration time
-
-
- Apache 2.0
- License
-
-
-
-
-
-
-
-
-
-
-
-
- )
-}
diff --git a/samples/nextjs/quickstart/app/signin/page.tsx b/samples/nextjs/quickstart/app/signin/page.tsx
deleted file mode 100644
index 26ab4ce2..00000000
--- a/samples/nextjs/quickstart/app/signin/page.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-'use client'
-import Link from 'next/link'
-import { useRouter } from 'next/navigation'
-import { SignedIn, SignedOut, SignIn } from '@thunderid/nextjs'
-import Nav from '../components/Nav'
-
-export default function SignInPage() {
- const router = useRouter()
-
- return (
-
-
-
-
-
-
-
You're already signed in.
-
Go home
-
-
-
-
-
-
- router.push('/')} />
-
-
-
- )
-}
diff --git a/samples/nextjs/quickstart/app/signup/page.tsx b/samples/nextjs/quickstart/app/signup/page.tsx
deleted file mode 100644
index d185861c..00000000
--- a/samples/nextjs/quickstart/app/signup/page.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-'use client'
-import Link from 'next/link'
-import { SignedIn, SignedOut, SignUp } from '@thunderid/nextjs'
-import Nav from '../components/Nav'
-
-export default function SignUpPage() {
- return (
-
-
-
-
-
-
-
You're already signed in.
-
Go home
-
-
-
-
-
-
-
-
-
-
- )
-}
diff --git a/samples/nextjs/quickstart/app/token/page.tsx b/samples/nextjs/quickstart/app/token/page.tsx
deleted file mode 100644
index 9d7f7416..00000000
--- a/samples/nextjs/quickstart/app/token/page.tsx
+++ /dev/null
@@ -1,143 +0,0 @@
-'use client'
-import { useState, useEffect } from 'react'
-import { fetchAccessToken } from '../actions'
-import Nav from '../components/Nav'
-
-function decodeJwtPart(part: string): Record | null {
- try {
- const base64 = part.replace(/-/g, '+').replace(/_/g, '/')
- const padded = base64.padEnd(base64.length + (4 - (base64.length % 4)) % 4, '=')
- return JSON.parse(atob(padded)) as Record
- } catch {
- return null
- }
-}
-
-function CopyIcon() {
- return (
-
-
-
-
- )
-}
-
-export default function TokenDebugPage() {
- const [rawToken, setRawToken] = useState(null)
- const [copied, setCopied] = useState(false)
- const [now, setNow] = useState(Math.floor(Date.now() / 1000))
-
- useEffect(() => {
- void fetchAccessToken().then((t) => { if (t) setRawToken(t) })
- }, [])
-
- useEffect(() => {
- const id = setInterval(() => setNow(Math.floor(Date.now() / 1000)), 1000)
- return () => clearInterval(id)
- }, [])
-
- const parts = rawToken ? rawToken.split('.') : []
- const jwtHeader = parts[0] ? decodeJwtPart(parts[0]) : null
- const jwtPayload = parts[1] ? decodeJwtPart(parts[1]) : null
-
- const exp = jwtPayload?.exp as number | undefined
- const expiresIn = exp ? exp - now : null
- const expired = expiresIn !== null && expiresIn <= 0
- const minsLeft = expiresIn !== null ? Math.abs(Math.floor(expiresIn / 60)) : null
-
- const handleCopy = () => {
- if (rawToken && navigator.clipboard) {
- void navigator.clipboard.writeText(rawToken)
- setCopied(true)
- setTimeout(() => setCopied(false), 1500)
- }
- }
-
- const issuer = jwtPayload?.iss as string | undefined
- const audience = Array.isArray(jwtPayload?.aud) ? (jwtPayload.aud as string[]).join(', ') : jwtPayload?.aud as string | undefined
- const scopes = jwtPayload?.scope as string | undefined
-
- return (
-
-
-
-
-
-
Token debug
-
Inspect your access token and decoded claims.
-
- {expiresIn !== null && (
-
-
- {expired ? 'Expired' : `Valid Β· expires in ${minsLeft} min`}
-
- )}
-
-
- {rawToken ? (
-
-
- Access token
-
-
- {copied ? 'Copied!' : 'Copy'}
-
-
-
- {parts[0]}
- .
- {parts[1]}
- .
- {parts[2]}
-
-
- ) : (
-
-
- Access token
-
-
Loadingβ¦
-
- )}
-
-
-
-
JWT Header
-
- {jwtHeader ? JSON.stringify(jwtHeader, null, 2) : 'β¦'}
-
-
-
-
JWT Payload
-
- {jwtPayload ? JSON.stringify(jwtPayload, null, 2) : 'β¦'}
-
-
-
-
- {(issuer ?? audience ?? scopes) && (
-
- {issuer && (
-
- )}
- {audience && (
-
-
Audience
-
{audience}
-
- )}
- {scopes && (
-
- )}
-
- )}
-
-
- )
-}
diff --git a/samples/nextjs/quickstart/next-env.d.ts b/samples/nextjs/quickstart/next-env.d.ts
deleted file mode 100644
index 830fb594..00000000
--- a/samples/nextjs/quickstart/next-env.d.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-///
-///
-///
-
-// NOTE: This file should not be edited
-// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/samples/nextjs/quickstart/next.config.ts b/samples/nextjs/quickstart/next.config.ts
deleted file mode 100644
index e4f5738a..00000000
--- a/samples/nextjs/quickstart/next.config.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import type { NextConfig } from 'next'
-
-const nextConfig: NextConfig = {}
-
-export default nextConfig
diff --git a/samples/nextjs/quickstart/package.json b/samples/nextjs/quickstart/package.json
deleted file mode 100644
index 2f2379cd..00000000
--- a/samples/nextjs/quickstart/package.json
+++ /dev/null
@@ -1,24 +0,0 @@
-{
- "name": "@thunderid/nextjs-quickstart",
- "private": true,
- "version": "0.0.0",
- "scripts": {
- "prepare-dev": "node scripts/prepare-dev.cjs",
- "prepare-dev:redirect": "node scripts/prepare-dev.cjs --flow=redirect",
- "dev": "next dev",
- "build": "next build",
- "start": "next start"
- },
- "dependencies": {
- "@thunderid/nextjs": "workspace:*",
- "next": "^15.5.25",
- "react": "^19.2.3",
- "react-dom": "^19.2.3"
- },
- "devDependencies": {
- "@types/node": "^24.7.2",
- "@types/react": "^19.2.14",
- "@types/react-dom": "^19.2.3",
- "typescript": "^5.9.3"
- }
-}
diff --git a/samples/nextjs/quickstart/proxy.ts b/samples/nextjs/quickstart/proxy.ts
deleted file mode 100644
index bada0bec..00000000
--- a/samples/nextjs/quickstart/proxy.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import {thunderIDProxy, createRouteMatcher} from '@thunderid/nextjs/server'
-
-const isProtectedRoute = createRouteMatcher(['/dashboard(.*)'])
-
-export default thunderIDProxy(async (thunderid, request) => {
- if (isProtectedRoute(request)) {
- await thunderid.protectRoute()
- }
-})
-
-export const config = {
- matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
-}
diff --git a/samples/nextjs/quickstart/scripts/prepare-dev.cjs b/samples/nextjs/quickstart/scripts/prepare-dev.cjs
deleted file mode 100644
index 9811ec0d..00000000
--- a/samples/nextjs/quickstart/scripts/prepare-dev.cjs
+++ /dev/null
@@ -1,93 +0,0 @@
-// Copyright 2026 The ThunderID Authors
-// SPDX-License-Identifier: Apache-2.0
-
-const fs = require('node:fs');
-const path = require('node:path');
-
-const root = path.join(__dirname, '..');
-
-const PREFIX = 'NEXT_PUBLIC_THUNDERID_';
-const NATIVE_FLOW_VARS = [
- `${PREFIX}APPLICATION_ID`,
- `${PREFIX}SIGN_IN_URL`,
- `${PREFIX}SIGN_UP_URL`,
- 'THUNDERID_FLOW_SECRET',
-];
-const REDIRECT_FLOW_VARS = [`${PREFIX}CLIENT_ID`, 'THUNDERID_CLIENT_SECRET'];
-const REDIRECT_FLOW_PLACEHOLDERS = {
- [`${PREFIX}CLIENT_ID`]: 'your-client-id-here',
- THUNDERID_CLIENT_SECRET: 'your-client-secret-here',
-};
-
-const flowArg = process.argv.find((arg) => arg.startsWith('--flow='));
-const flowExplicitlyRequested = Boolean(flowArg);
-const flow = flowArg ? flowArg.slice('--flow='.length) : 'native';
-
-if (flowExplicitlyRequested && flow !== 'native' && flow !== 'redirect') {
- console.error(`Unknown --flow value "${flow}". Expected "native" or "redirect".`);
- process.exit(1);
-}
-
-/**
- * Toggles the leading `# ` on env var lines to match the selected flow, appending
- * any vars for the selected flow that aren't present in the source file yet (the
- * redirect-flow vars aren't checked into `.env.example`, so switching to `redirect`
- * from a fresh copy needs to add them rather than just uncomment them).
- */
-function applyFlow(envContent, selectedFlow) {
- const varsToEnable = selectedFlow === 'redirect' ? REDIRECT_FLOW_VARS : NATIVE_FLOW_VARS;
- const varsToDisable = selectedFlow === 'redirect' ? NATIVE_FLOW_VARS : REDIRECT_FLOW_VARS;
- const found = new Set();
-
- const lines = envContent.split('\n').map((line) => {
- const enable = varsToEnable.find((key) => line.replace(/^#\s*/, '').startsWith(`${key}=`));
- if (enable) {
- found.add(enable);
- return line.replace(/^#\s*/, '');
- }
-
- const disable = varsToDisable.find((key) => line.startsWith(`${key}=`));
- if (disable) return `# ${line}`;
-
- return line;
- });
-
- const missing = varsToEnable.filter((key) => !found.has(key));
- if (missing.length > 0) {
- lines.push('', `# ββ ${selectedFlow} flow βββββββββββββββββββββββββββββββββββββββββββββββββ`);
- for (const key of missing) lines.push(`${key}=${REDIRECT_FLOW_PLACEHOLDERS[key] ?? ''}`);
- }
-
- return lines.join('\n');
-}
-
-const envExample = path.join(root, '.env.example');
-const envTarget = path.join(root, '.env');
-
-// Only explicit `--flow=` invocations are allowed to (re)write an existing
-// .env β a plain `npm run prepare-dev` (e.g. from .stackblitzrc's
-// startCommand) stays a safe, idempotent copy-if-missing.
-if (fs.existsSync(envExample) && (flowExplicitlyRequested || !fs.existsSync(envTarget))) {
- const envSource = fs.existsSync(envTarget) ? envTarget : envExample;
- const envContent = applyFlow(fs.readFileSync(envSource, 'utf8'), flow)
- // Blank placeholder values (e.g. `your-client-id-here`, the sample base URL)
- // so the copied .env still trips the app's missing-env-var check until real
- // values are filled in.
- .replace(/^([A-Z0-9_]+)=(your-\S*|generate-with-\S*|https:\/\/localhost:8090)$/gm, '$1=');
- fs.writeFileSync(envTarget, envContent);
-}
-
-const pkgPath = path.join(root, 'package.json');
-const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
-
-for (const field of ['dependencies', 'devDependencies']) {
- const deps = pkg[field];
- if (!deps) continue;
- for (const [name, version] of Object.entries(deps)) {
- if (version.startsWith('workspace:')) {
- deps[name] = 'latest';
- }
- }
-}
-
-fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
diff --git a/samples/nextjs/quickstart/tsconfig.json b/samples/nextjs/quickstart/tsconfig.json
deleted file mode 100644
index fba2bf37..00000000
--- a/samples/nextjs/quickstart/tsconfig.json
+++ /dev/null
@@ -1,21 +0,0 @@
-{
- "compilerOptions": {
- "target": "ES2017",
- "lib": ["dom", "dom.iterable", "esnext"],
- "allowJs": true,
- "skipLibCheck": true,
- "strict": true,
- "noEmit": true,
- "esModuleInterop": true,
- "module": "esnext",
- "moduleResolution": "bundler",
- "resolveJsonModule": true,
- "isolatedModules": true,
- "jsx": "preserve",
- "incremental": true,
- "plugins": [{ "name": "next" }],
- "paths": { "@/*": ["./src/*"] }
- },
- "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
- "exclude": ["node_modules"]
-}
diff --git a/samples/node/quickstart/.env.example b/samples/node/quickstart/.env.example
deleted file mode 100644
index a45eda11..00000000
--- a/samples/node/quickstart/.env.example
+++ /dev/null
@@ -1,14 +0,0 @@
-# Base URL of your ThunderID deployment. ex: https://localhost:8090
-THUNDERID_BASE_URL=https://localhost:8090
-
-# OAuth2 Client ID for this service. ThunderID Console -> your application -> Overview.
-THUNDERID_CLIENT_ID=your-client-id
-# OAuth2 Client Secret for this service. Shown once when the application is created;
-# if lost, regenerate it from the app's Credentials tab (under Edit).
-THUNDERID_CLIENT_SECRET=your-client-secret
-
-# Optional: space-separated scopes to request for this service.
-THUNDERID_SCOPE=
-
-# DANGER: Disables ALL TLS verification. Only for local development with self-signed certs. NEVER use in production.
-NODE_TLS_REJECT_UNAUTHORIZED=0
diff --git a/samples/node/quickstart/.stackblitzrc b/samples/node/quickstart/.stackblitzrc
deleted file mode 100644
index 5f0a2000..00000000
--- a/samples/node/quickstart/.stackblitzrc
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "installDependencies": false,
- "startCommand": "npm run prepare-dev && npm install && npm run dev"
-}
diff --git a/samples/node/quickstart/README.md b/samples/node/quickstart/README.md
deleted file mode 100644
index 1aeb7ee0..00000000
--- a/samples/node/quickstart/README.md
+++ /dev/null
@@ -1,79 +0,0 @@
-# ThunderID Node.js Service Quickstart
-
-
-
-A minimal machine-to-machine (service-to-service) client, using the ThunderID Node.js SDK
-(`@thunderid/node`). Unlike the other quickstarts, there's no user and no browser sign-in: this
-service authenticates as **itself** with the OAuth 2.0 `client_credentials` grant, then uses the
-resulting access token to call another piece of business logic.
-
-This is the pattern to use for background jobs, cron tasks, or one backend service calling
-another, where there's no human in the loop to redirect through a login page.
-
-The sample is organized the way a real backend usually is, with authentication kept in its own
-service instead of scattered across business logic:
-
-```text
-node/quickstart/
-βββ index.mjs Wires the services together and prints the result
-βββ lib/
- βββ AuthService.mjs Acquires and caches this service's own access token
- βββ InventoryService.mjs Business logic; asks AuthService for a token before answering
- βββ ui.mjs Terminal output, built on boxen and picocolors
-```
-
-## How it works
-
-- `lib/AuthService.mjs` initializes `ThunderIDNodeClient` with `grantType: 'client_credentials'`.
- With that set, `getAccessToken()` authenticates as the service itself (no session, no sign-in)
- and transparently fetches, caches, and refreshes the token, the same method a user-facing
- quickstart would call to read an existing session's token, just authenticating as the service
- itself instead of a signed-in user.
-- `lib/InventoryService.mjs` is the business logic that needs a token before it can act. It asks
- `AuthService` for one; the rest of the app never handles a token or an `Authorization` header
- directly. In a real deployment, `InventoryService` would send that token to a separate inventory
- backend over HTTP, in place of another service calling `AuthService`'s owner the same way.
-
-## Prerequisites
-
-- Node.js 18+
-- A running ThunderID instance (default: `https://localhost:8090`)
-- A **Backend Service** application registered in ThunderID
-
-## Create an application
-
-1. Open the ThunderID Console (`https://localhost:8090/console`), navigate to **Applications**, and
- click **Add Application**.
-2. From the **Choose a type** page, select **Backend Service**.
-3. Enter a name (e.g. `node-service-quickstart`) and create the application. Backend Service apps
- are pre-configured for the `client_credentials` grant with `client_secret_basic` authentication,
- no extra setup needed.
-4. Copy the **Client ID** from the **General** tab, and the **Client Secret** from the window that
- pops up when the application is created (shown only once), you'll need them below.
-
-## Getting started
-
-1. Copy the environment file and fill in your application's credentials:
- ```sh
- cp .env.example .env
- ```
- ```
- THUNDERID_CLIENT_ID=
- THUNDERID_CLIENT_SECRET=
- THUNDERID_BASE_URL=https://localhost:8090
- ```
-
-2. Install dependencies and run it:
- ```sh
- npm install
- npm start
- ```
-
-The script authenticates once, then calls `InventoryService.getStock()` for a few SKUs and prints
-the result before exiting: a scope line, then an `Inventory` panel with one line per SKU, green
-for in-stock items, yellow for out-of-stock, red for a SKU that doesn't exist.
-
-## Learn more
-
-- [ThunderID Docs](https://thunderid.dev/docs)
-- [OAuth 2.0 Client Credentials Grant (RFC 6749 Β§4.4)](https://www.rfc-editor.org/rfc/rfc6749#section-4.4)
diff --git a/samples/node/quickstart/index.mjs b/samples/node/quickstart/index.mjs
deleted file mode 100644
index 8d01c870..00000000
--- a/samples/node/quickstart/index.mjs
+++ /dev/null
@@ -1,42 +0,0 @@
-import {AuthService} from './lib/AuthService.mjs';
-import {InventoryService} from './lib/InventoryService.mjs';
-import {printIntro, printConfigNeeded, printAuthenticated, printInventory} from './lib/ui.mjs';
-
-printIntro();
-
-const REQUIRED_ENV_VARS = ['THUNDERID_CLIENT_ID', 'THUNDERID_CLIENT_SECRET'];
-const missingEnvVars = REQUIRED_ENV_VARS.filter((key) => !process.env[key]);
-
-if (missingEnvVars.length > 0) {
- printConfigNeeded(missingEnvVars);
- process.exit(1);
-}
-
-const THUNDER_BASE_URL = process.env.THUNDERID_BASE_URL || 'https://localhost:8090';
-
-// AuthService authenticates as this service itself (grantType: 'client_credentials'),
-// no user and no browser sign-in, unlike the express/react/etc quickstarts, which
-// authenticate a human through a login page.
-const auth = new AuthService({
- baseUrl: THUNDER_BASE_URL,
- clientId: process.env.THUNDERID_CLIENT_ID,
- clientSecret: process.env.THUNDERID_CLIENT_SECRET,
- scopes: process.env.THUNDERID_SCOPE,
-});
-
-const inventory = new InventoryService({auth});
-
-const accessToken = await auth.getAccessToken();
-const {scope} = await auth.decodeAccessToken(accessToken);
-printAuthenticated({baseUrl: THUNDER_BASE_URL, clientId: process.env.THUNDERID_CLIENT_ID, scope});
-
-const rows = [];
-for (const sku of ['SKU-100', 'SKU-200', 'SKU-300', 'SKU-999']) {
- try {
- const item = await inventory.getStock(sku);
- rows.push({sku, name: item.name, stock: item.stock});
- } catch (error) {
- rows.push({sku, error: error.message});
- }
-}
-printInventory(rows);
diff --git a/samples/node/quickstart/lib/AuthService.mjs b/samples/node/quickstart/lib/AuthService.mjs
deleted file mode 100644
index e827c150..00000000
--- a/samples/node/quickstart/lib/AuthService.mjs
+++ /dev/null
@@ -1,33 +0,0 @@
-import {ThunderIDNodeClient} from '@thunderid/node';
-
-/**
- * Authenticates this service as itself with the OAuth 2.0 client_credentials
- * grant, no user, no browser sign-in. Other services in this process (or a
- * real deployment) ask AuthService for a token instead of talking to
- * ThunderIDNodeClient directly, so the authentication concern stays in one
- * place and out of business logic like InventoryService.
- */
-export class AuthService {
- #client = new ThunderIDNodeClient();
- #ready;
-
- constructor({baseUrl, clientId, clientSecret, scopes}) {
- this.#ready = this.#client.initialize({
- baseUrl,
- clientId,
- clientSecret,
- grantType: 'client_credentials',
- scopes,
- });
- }
-
- /** Fetches (or reuses the cached) access token for this service's own identity. */
- async getAccessToken() {
- await this.#ready;
- return this.#client.getAccessToken();
- }
-
- async decodeAccessToken(accessToken) {
- return this.#client.decodeJwtToken(accessToken);
- }
-}
diff --git a/samples/node/quickstart/lib/InventoryService.mjs b/samples/node/quickstart/lib/InventoryService.mjs
deleted file mode 100644
index 9897c796..00000000
--- a/samples/node/quickstart/lib/InventoryService.mjs
+++ /dev/null
@@ -1,29 +0,0 @@
-const CATALOG = {
- 'SKU-100': {sku: 'SKU-100', name: 'Wireless Mouse', stock: 42},
- 'SKU-200': {sku: 'SKU-200', name: 'Mechanical Keyboard', stock: 7},
- 'SKU-300': {sku: 'SKU-300', name: '4K Monitor', stock: 0},
-};
-
-/**
- * Business logic for a service that needs to call another backend. It asks
- * AuthService for a token before answering, so the rest of the app never
- * handles a token or an Authorization header directly. In a real deployment
- * this would send the token to a separate inventory service over HTTP
- * instead of just holding it in-process.
- */
-export class InventoryService {
- constructor({auth}) {
- this.auth = auth;
- }
-
- async getStock(sku) {
- // A real backend would attach this as `Authorization: Bearer ${accessToken}`
- // on the request to the inventory service; here it just proves the token
- // was obtained before answering.
- await this.auth.getAccessToken();
-
- const item = CATALOG[sku];
- if (!item) throw new Error(`No item with SKU ${sku}`);
- return item;
- }
-}
diff --git a/samples/node/quickstart/lib/ui.mjs b/samples/node/quickstart/lib/ui.mjs
deleted file mode 100644
index f234454d..00000000
--- a/samples/node/quickstart/lib/ui.mjs
+++ /dev/null
@@ -1,89 +0,0 @@
-import boxen from 'boxen';
-import pc from 'picocolors';
-
-const BRAND = '#3688FF';
-const ORANGE = '#e88b3a';
-
-function indent(text) {
- return text
- .split('\n')
- .map((line) => ` ${line}`)
- .join('\n');
-}
-
-function badge(label, colorFn) {
- const line = pc.dim('ββββββ');
- return `${line} ${pc.bold(colorFn(label.toUpperCase()))} ${line}`;
-}
-
-function box(content, {title, borderColor}) {
- return boxen(content, {
- title,
- titleAlignment: 'left',
- padding: {top: 0, bottom: 0, left: 1, right: 1},
- borderStyle: 'round',
- borderColor,
- });
-}
-
-export function printIntro() {
- console.log();
- console.log(indent(pc.bold('ThunderID Node Service Quickstart')));
- console.log(indent(pc.dim('Service-to-service auth via OAuth 2.0 client_credentials')));
- console.log();
-}
-
-export function printConfigNeeded(missingEnvVars) {
- console.log(indent(badge('Setup required', pc.yellow)));
- console.log();
- console.log(indent(pc.bold('Configuration needed')));
- console.log(
- indent(
- pc.dim(
- "This quickstart can't reach ThunderID yet. Set the following environment variable(s), then run again.",
- ),
- ),
- );
- console.log();
- console.log(indent(box(missingEnvVars.map((key) => pc.red(key)).join('\n'), {borderColor: ORANGE})));
- console.log();
- console.log(
- indent(
- pc.dim('Copy ') +
- pc.cyan('.env.example') +
- pc.dim(' to ') +
- pc.cyan('.env') +
- pc.dim(', fill in the client credentials for your application, then run ') +
- pc.cyan('npm start') +
- pc.dim(' again.'),
- ),
- );
- console.log();
- console.log(
- indent(
- pc.dim('Need more info? Take a look at the Node quickstart guide: ') +
- pc.cyan('https://thunderid.dev/docs/next/getting-started/connect-your-application/node/'),
- ),
- );
- console.log();
-}
-
-export function printAuthenticated({baseUrl, clientId, scope}) {
- console.log(indent(`${pc.bgGreen(pc.black(' β AUTHENTICATED '))} ${pc.dim('client_credentials grant')}`));
- console.log();
- console.log(indent(`${pc.dim('BASE URL')} ${baseUrl}`));
- console.log(indent(`${pc.dim('CLIENT ID')} ${clientId}`));
- console.log(indent(`${pc.dim('SCOPE')} ${scope || pc.dim('(none requested)')}`));
- console.log();
-}
-
-export function printInventory(rows) {
- const lines = rows.map(({sku, name, stock, error}) => {
- const label = pc.bold(sku.padEnd(9));
- if (error) return `${label} ${pc.red(error)}`;
- const stockText = stock > 0 ? pc.green(`${stock} in stock`) : pc.yellow('out of stock');
- return `${label} ${name.padEnd(20)} ${stockText}`;
- });
- console.log(indent(box(lines.join('\n'), {title: 'Inventory', borderColor: BRAND})));
- console.log();
-}
diff --git a/samples/node/quickstart/package.json b/samples/node/quickstart/package.json
deleted file mode 100644
index a69445fe..00000000
--- a/samples/node/quickstart/package.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
- "name": "@thunderid/node-quickstart",
- "private": true,
- "version": "0.0.0",
- "type": "module",
- "scripts": {
- "prepare-dev": "node scripts/prepare-dev.cjs",
- "start": "node --env-file-if-exists=.env index.mjs",
- "dev": "node --env-file-if-exists=.env --watch index.mjs"
- },
- "dependencies": {
- "@thunderid/node": "workspace:*",
- "boxen": "^8.0.1",
- "picocolors": "^1.1.1"
- }
-}
diff --git a/samples/node/quickstart/scripts/prepare-dev.cjs b/samples/node/quickstart/scripts/prepare-dev.cjs
deleted file mode 100644
index 8e20fa43..00000000
--- a/samples/node/quickstart/scripts/prepare-dev.cjs
+++ /dev/null
@@ -1,34 +0,0 @@
-// Copyright 2026 The ThunderID Authors
-// SPDX-License-Identifier: Apache-2.0
-
-const fs = require('node:fs');
-const path = require('node:path');
-
-const root = path.join(__dirname, '..');
-
-const envExample = path.join(root, '.env.example');
-const envTarget = path.join(root, '.env');
-if (fs.existsSync(envExample) && !fs.existsSync(envTarget)) {
- // Blank placeholder values (e.g. `your-client-id-here`, the sample base URL)
- // so the copied .env still trips the app's missing-env-var check until real
- // values are filled in.
- const envContent = fs
- .readFileSync(envExample, 'utf8')
- .replace(/^([A-Z0-9_]+)=(your-\S*|generate-with-\S*|https:\/\/localhost:8090)$/gm, '$1=');
- fs.writeFileSync(envTarget, envContent);
-}
-
-const pkgPath = path.join(root, 'package.json');
-const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
-
-for (const field of ['dependencies', 'devDependencies']) {
- const deps = pkg[field];
- if (!deps) continue;
- for (const [name, version] of Object.entries(deps)) {
- if (version.startsWith('workspace:')) {
- deps[name] = 'latest';
- }
- }
-}
-
-fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
diff --git a/samples/nuxt/quickstart/.env.example b/samples/nuxt/quickstart/.env.example
deleted file mode 100644
index fbeed8e6..00000000
--- a/samples/nuxt/quickstart/.env.example
+++ /dev/null
@@ -1,20 +0,0 @@
-# Base URL of your ThunderID deployment. ex: https://localhost:8090
-NUXT_PUBLIC_THUNDERID_BASE_URL=https://localhost:8090
-
-# Flow Secret for this app. Sent in the `Flow-Secret` header to authenticate this app when the
-# native (embedded) flow starts. Shown once when the application is created; if lost, regenerate it
-# from the app's Credentials tab (under Edit). Server-only, never expose to the browser.
-THUNDERID_FLOW_SECRET=your-flow-secret-here
-
-# Secret used to encrypt this app's session cookie. Generate locally, not from the console.
-THUNDERID_SESSION_SECRET=generate-with-openssl-rand-base64-32
-
-# Application ID (spId) for this app. ThunderID Console -> your application -> Overview.
-NUXT_PUBLIC_THUNDERID_APPLICATION_ID=your-application-id-here
-# Local app route that renders the sign-in page. Not from the console.
-NUXT_PUBLIC_THUNDERID_SIGN_IN_URL=/signin
-# Local app route that renders the sign-up page. Not from the console.
-NUXT_PUBLIC_THUNDERID_SIGN_UP_URL=/signup
-
-# DANGER: Disables ALL TLS verification. Only for local development with self-signed certs. NEVER use in production.
-NODE_TLS_REJECT_UNAUTHORIZED=0
diff --git a/samples/nuxt/quickstart/.stackblitzrc b/samples/nuxt/quickstart/.stackblitzrc
deleted file mode 100644
index 5f0a2000..00000000
--- a/samples/nuxt/quickstart/.stackblitzrc
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "installDependencies": false,
- "startCommand": "npm run prepare-dev && npm install && npm run dev"
-}
diff --git a/samples/nuxt/quickstart/README.md b/samples/nuxt/quickstart/README.md
deleted file mode 100644
index 2a610109..00000000
--- a/samples/nuxt/quickstart/README.md
+++ /dev/null
@@ -1,119 +0,0 @@
-# ThunderID Nuxt Quickstart
-
-
-
-A minimal Nuxt 3 application demonstrating ThunderID authentication with OAuth 2.0, PKCE, and JWT out of the box.
-
-## Prerequisites
-
-- Node.js 18+
-- pnpm
-- A ThunderID application
-
-## Getting started
-
-1. Copy the example environment file:
-
- ```bash
- cp .env.example .env
- ```
-
-2. Fill in your ThunderID credentials in `.env`. By default the file is set up for the native flow:
-
- ```dotenv
- NUXT_PUBLIC_THUNDERID_BASE_URL=https://localhost:8090
- NUXT_PUBLIC_THUNDERID_APPLICATION_ID=
- NUXT_PUBLIC_THUNDERID_SIGN_IN_URL=/signin
- NUXT_PUBLIC_THUNDERID_SIGN_UP_URL=/signup
- THUNDERID_FLOW_SECRET=
- THUNDERID_SESSION_SECRET=
- ```
-
-3. Start the development server:
-
- ```bash
- pnpm dev
- ```
-
- The app is now running at [http://localhost:3000](http://localhost:3000).
-
-
-Redirect-based flow
-
-By default this quickstart uses the native (embedded) flow, where sign-in/sign-up render inline on this
-app's own `/signin` and `/signup` routes with no redirect to ThunderID's hosted pages.
-
-To send the user to ThunderID's hosted sign-in page instead, switch to the redirect-based flow:
-
-1. Register a redirect URI for your application (see the app's config notice in the console for the
- exact value to use).
-2. Regenerate `.env` for the redirect flow:
-
- ```bash
- npm run prepare-dev:redirect
- ```
-
- This comments out the native-flow vars (`NUXT_PUBLIC_THUNDERID_APPLICATION_ID`,
- `NUXT_PUBLIC_THUNDERID_SIGN_IN_URL`, `NUXT_PUBLIC_THUNDERID_SIGN_UP_URL`) and adds:
-
- ```dotenv
- NUXT_PUBLIC_THUNDERID_CLIENT_ID=
- THUNDERID_CLIENT_SECRET=
- ```
-
- Both values come from the application's Credentials tab in the console. The redirect-based flow
- doesn't use `THUNDERID_FLOW_SECRET` (that's only sent when the native flow starts), so it can stay
- set in `.env` from step 2 above, unused. To switch back to the native flow, run
- `node scripts/prepare-dev.cjs --flow=native` (or manually re-enable the native-flow vars and comment
- out the two above).
-3. Fill in `NUXT_PUBLIC_THUNDERID_CLIENT_ID` and `THUNDERID_CLIENT_SECRET` in `.env`, then restart the
- dev server.
-
-
-
-## Back-channel logout
-
-ThunderID can end this app's session itself. When the user's ThunderID session ends somewhere else,
-for example by signing out of another application, ThunderID posts a logout token to this app. The
-module serves the route once it is turned on in `nuxt.config.ts`:
-
-```ts
-thunderid: {
- backchannelLogout: { enabled: true },
-},
-```
-
-The session lives in a cookie that only the browser can delete, so the SDK records the logout and
-treats every later request with that session as signed out.
-
-To try it, use the redirect-based flow above, since the logout token is addressed to the client ID:
-
-1. In the ThunderID Console, open this application, go to **Advanced Settings β OAuth2
- Configuration**, and set **Back-Channel Logout URI** to
- `http://localhost:3000/api/auth/backchannel-logout`.
-2. ThunderID refuses localhost and private network addresses by default. For local testing only,
- turn that off in the server's `deployment.yaml` and restart it:
- ```yaml
- oauth:
- logout:
- backchannel:
- reject_private_addresses: false
- ```
-3. Sign in at [http://localhost:3000](http://localhost:3000), then sign in to a second application
- in the same browser, so both share one ThunderID session.
-4. Sign out of the second application, then reload this app: you are signed out here too.
-
-Things to know before production:
-
-- The SDK records ended sessions in memory by default. If you run more than one instance, name a
- [Nitro storage](https://nitro.build/guide/storage) mount point that all of them share, since
- ThunderID's request reaches only one instance:
- ```ts
- thunderid: {
- backchannelLogout: { enabled: true, store: 'redis' },
- },
- nitro: {
- storage: { redis: { driver: 'redis', url: process.env.REDIS_URL } },
- },
- ```
-- An access token already handed to the browser or to another API stays valid until it expires.
diff --git a/samples/nuxt/quickstart/app/app.vue b/samples/nuxt/quickstart/app/app.vue
deleted file mode 100644
index d463ae9a..00000000
--- a/samples/nuxt/quickstart/app/app.vue
+++ /dev/null
@@ -1,28 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/samples/nuxt/quickstart/app/assets/styles.css b/samples/nuxt/quickstart/app/assets/styles.css
deleted file mode 100644
index 82719ff9..00000000
--- a/samples/nuxt/quickstart/app/assets/styles.css
+++ /dev/null
@@ -1,1397 +0,0 @@
-*,
-::before,
-::after {
- box-sizing: border-box;
-}
-
-/* ββ Design tokens ββ */
-.app {
- --color-bg: #f7f9fc;
- --color-card: #ffffff;
- --color-border: #dde3ec;
- --color-text: #05213f;
- --color-text-muted: #5a7085;
- --color-primary: #3688ff;
- --color-primary-hover: #1f72f0;
- --color-nav-bg: rgba(247, 249, 252, 0.92);
- --color-nav-border: #dde3ec;
- --shadow-card: 0 1px 3px 0 rgba(5, 33, 63, 0.06), 0 4px 12px 0 rgba(5, 33, 63, 0.08);
- --font-sans: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
- --radius-card: 12px;
- --radius-btn: 8px;
-
- font-family: var(--font-sans);
- background: var(--color-bg);
- color: var(--color-text);
- min-height: 100vh;
- display: flex;
- flex-direction: column;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
- transition: background 0.2s, color 0.2s;
-}
-
-/* ββ Dark mode ββ */
-.app.dark,
-.dark,
-html.dark {
- --color-bg: #080f1c;
- --color-card: #0f1a2d;
- --color-border: rgba(255, 255, 255, 0.09);
- --color-text: #E0EAFF;
- --color-text-muted: rgba(224, 234, 255, 0.48);
- --color-nav-bg: rgba(8, 15, 28, 0.92);
-
- /* ThunderID Design Tokens for Dark Mode */
- --thunderid-color-background-surface: #0f1a2d;
- --thunderid-color-background-body: #080f1c;
- --thunderid-color-background-disabled: rgba(255, 255, 255, 0.06);
- --thunderid-color-background-muted: rgba(255, 255, 255, 0.05);
- --thunderid-color-text-primary: #E0EAFF;
- --thunderid-color-text-secondary: rgba(224, 234, 255, 0.65);
- --thunderid-color-border: rgba(255, 255, 255, 0.09);
- --thunderid-color-border-focus: var(--color-primary);
- --thunderid-input-borderColor: rgba(255, 255, 255, 0.16);
- --thunderid-input-focusBorderColor: var(--color-primary);
- --thunderid-input-focusRing: 0 0 0 3px rgba(54, 136, 255, 0.25);
- --thunderid-card-borderColor: rgba(255, 255, 255, 0.09);
- --thunderid-card-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
- --thunderid-color-action-hover: rgba(255, 255, 255, 0.06);
-}
-
-/* ββ Navigation ββ */
-.nav {
- position: fixed;
- inset-block-start: 0;
- inset-inline: 0;
- height: 52px;
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 0 24px;
- background: var(--color-nav-bg);
- border-bottom: 1px solid var(--color-nav-border);
- z-index: 100;
- transition: background 0.2s, border-color 0.2s;
-}
-
-.nav-logo {
- display: flex;
- align-items: center;
- gap: 10px;
- text-decoration: none;
- color: var(--color-text);
-}
-
-.wordmark {
- display: flex;
- flex-direction: column;
- gap: 1px;
-}
-
-.wordmark-name {
- font-size: 14px;
- font-weight: 700;
- letter-spacing: -0.03em;
- color: var(--color-text);
- line-height: 1;
-}
-
-.wordmark-sub {
- font-size: 8.5px;
- font-weight: 700;
- letter-spacing: 0.1em;
- text-transform: uppercase;
- color: var(--color-primary);
- line-height: 1;
-}
-
-.dark-toggle {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 32px;
- height: 32px;
- padding: 0;
- background: transparent;
- border: none;
- border-radius: var(--radius-btn);
- color: var(--color-text-muted);
- cursor: pointer;
- transition: color 0.15s, background 0.15s;
-}
-
-.dark-toggle:hover {
- color: var(--color-text);
- background: rgba(54, 136, 255, 0.06);
-}
-
-.nav-actions {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-/* ββ Nav back button ββ */
-.nav-back-btn {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- height: 32px;
- padding: 0 10px;
- border-radius: 6px;
- border: none;
- background: transparent;
- color: var(--color-text-muted);
- font-size: 13px;
- font-family: var(--font-sans);
- letter-spacing: -0.01em;
- cursor: pointer;
- text-decoration: none;
- transition: background 0.2s, color 0.2s;
-}
-
-.nav-back-btn:hover {
- background: var(--color-border);
- color: var(--color-text);
-}
-
-/* ββ Buttons ββ */
-button {
- font-family: var(--font-sans);
- cursor: pointer;
- border: none;
- border-radius: var(--radius-btn);
- font-weight: 500;
- transition: background 0.15s, color 0.15s, border-color 0.15s, opacity 0.15s;
- line-height: 1;
- white-space: nowrap;
-}
-
-button:focus-visible {
- outline: 2px solid var(--color-primary);
- outline-offset: 2px;
-}
-
-.btn-primary {
- background: var(--color-primary);
- color: #ffffff;
- border: 1.5px solid var(--color-primary);
-}
-
-.btn-primary:hover {
- background: var(--color-primary-hover);
- border-color: var(--color-primary-hover);
-}
-
-.btn-outline {
- background: transparent;
- color: var(--color-text);
- border: 1.5px solid rgba(54, 136, 255, 0.4);
-}
-
-.btn-outline:hover {
- border-color: var(--color-primary);
- color: var(--color-primary);
-}
-
-.btn-ghost {
- background: transparent;
- color: var(--color-text-muted);
- border: none;
-}
-
-.btn-ghost:hover {
- color: var(--color-text);
- background: rgba(54, 136, 255, 0.06);
-}
-
-.btn-sm {
- padding: 6px 14px;
- font-size: 13px;
- height: 32px;
-}
-
-.btn-lg {
- padding: 0 24px;
- font-size: 15px;
- height: 46px;
-}
-
-/* ββ Hero section ββ */
-.hero {
- flex: 1;
- padding: 96px 32px 80px;
- min-height: 100vh;
-}
-
-.hero-inner {
- display: flex;
- flex-direction: column;
- align-items: center;
- text-align: center;
- gap: 20px;
- max-width: 560px;
- width: 100%;
- margin: 0 auto;
-}
-
-.hero-badge {
- display: inline-flex;
- align-items: center;
- gap: 8px;
- font-size: 10px;
- font-weight: 600;
- letter-spacing: 0.1em;
- text-transform: uppercase;
- color: var(--color-text-muted);
-}
-
-.hero-badge-line {
- display: block;
- width: 24px;
- height: 1px;
- background: var(--color-border);
- flex-shrink: 0;
-}
-
-.hero-title {
- font-size: 42px;
- font-weight: 700;
- letter-spacing: -0.05em;
- color: var(--color-text);
- line-height: 1.1;
- margin: 0;
-}
-
-.hero-subtitle {
- font-size: 16px;
- color: var(--color-text-muted);
- max-width: 420px;
- line-height: 1.8;
- letter-spacing: -0.01em;
-}
-
-.hero-ctas {
- display: flex;
- gap: 12px;
- flex-wrap: wrap;
- justify-content: center;
- margin-top: 4px;
-}
-
-.hero-divider {
- width: 100%;
- border: none;
- border-top: 1px solid var(--color-border);
- margin: 8px 0;
-}
-
-/* ββ Stats row ββ */
-.hero-stats {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- width: 100%;
- border: 1px solid var(--color-border);
- border-radius: var(--radius-card);
- overflow: hidden;
-}
-
-.hero-stat {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 4px;
- padding: 20px 16px;
- position: relative;
-}
-
-.hero-stat + .hero-stat::before {
- content: '';
- position: absolute;
- left: 0;
- top: 20%;
- height: 60%;
- width: 1px;
- background: var(--color-border);
-}
-
-.hero-stat-value {
- font-size: 15px;
- font-weight: 700;
- color: var(--color-text);
- letter-spacing: -0.02em;
-}
-
-.hero-stat-label {
- font-size: 12px;
- color: var(--color-text-muted);
-}
-
-/* ββ Config notice ββ */
-.config-badge {
- color: #e88b3a;
-}
-
-.config-list {
- width: 100%;
- max-width: 420px;
- text-align: left;
- list-style: none;
- background: var(--color-card);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-btn);
- padding: 0 16px;
- margin: 4px 0 28px;
-}
-
-.config-list-item {
- padding: 10px 0;
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
- font-size: 13px;
- color: var(--color-text);
-}
-
-.config-list-item:not(:last-child) {
- border-bottom: 1px solid var(--color-border);
-}
-
-.config-hint {
- font-size: 14px;
- color: var(--color-text-muted);
- line-height: 1.7;
- max-width: 420px;
- margin: 0 auto;
-}
-
-.config-hint code {
- background: rgba(54, 136, 255, 0.1);
- border-radius: 4px;
- padding: 2px 6px;
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
- font-size: 13px;
-}
-
-.config-step {
- width: 100%;
- max-width: 420px;
- margin: 0 auto 28px;
-}
-
-.config-step-label {
- font-size: 11px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.09em;
- color: var(--color-text-muted);
- margin-bottom: 10px;
- text-align: left;
-}
-
-.config-step .config-list,
-.config-step .config-hint {
- margin-left: 0;
- margin-right: 0;
-}
-
-.config-box {
- text-align: left;
- background: var(--color-card);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-card);
- padding: 16px;
-}
-
-.config-box-body {
- font-size: 13px;
- color: var(--color-text-muted);
- line-height: 1.6;
- margin-bottom: 14px;
-}
-
-.config-value-group {
- display: flex;
- flex-direction: column;
- gap: 12px;
-}
-
-.config-value-label {
- font-size: 11px;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.06em;
- color: var(--color-text-muted);
- margin-bottom: 4px;
-}
-
-.config-value {
- display: block;
- overflow-x: auto;
- white-space: nowrap;
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
- font-size: 12px;
- color: var(--color-text);
- background: rgba(54, 136, 255, 0.1);
- border-radius: 4px;
- padding: 8px 10px;
-}
-
-.config-docs-note {
- font-size: 13px;
- color: var(--color-text-muted);
- max-width: 420px;
- margin: 0 auto;
-}
-
-.config-docs-note a {
- color: var(--color-primary);
- font-weight: 600;
-}
-
-/* ββ Responsive ββ */
-@media (max-width: 480px) {
- .nav {
- padding: 0 16px;
- }
-
- .hero {
- padding: 80px 16px 32px;
- }
-
- .hero-title {
- font-size: 2rem;
- }
-
- .hero-stats {
- grid-template-columns: 1fr;
- }
-
- .hero-stat + .hero-stat::before {
- top: 0;
- left: 20%;
- width: 60%;
- height: 1px;
- }
-
- .hero-ctas {
- flex-direction: column;
- align-items: stretch;
- }
-}
-
-/* ββ Signed-in shell ββ */
-.home-shell {
- min-height: calc(100vh - 52px);
- margin-top: 52px;
-}
-
-/* ββ Signed-in home page ββ */
-.home-main {
- width: 100%;
- max-width: 900px;
- margin: 0 auto;
- padding: 52px 28px 80px;
-}
-
-/* Greeting */
-.home-greeting {
- display: flex;
- align-items: flex-start;
- gap: 16px;
- margin-bottom: 36px;
-}
-
-.home-greeting-text {
- min-width: 0;
-}
-
-.home-avatar {
- width: 52px;
- height: 52px;
- border-radius: 50%;
- flex-shrink: 0;
- object-fit: cover;
-}
-
-.home-avatar--fallback {
- display: flex;
- align-items: center;
- justify-content: center;
- background: rgba(54, 136, 255, 0.12);
- color: var(--color-primary);
- font-size: 18px;
- font-weight: 700;
- letter-spacing: -0.02em;
-}
-
-.home-greeting-name {
- margin: 0 0 6px;
- font-size: 26px;
- font-weight: 700;
- color: var(--color-text);
- letter-spacing: -0.04em;
-}
-
-.home-greeting-meta {
- display: flex;
- align-items: center;
- gap: 8px;
- font-size: 13px;
- color: var(--color-text-muted);
- letter-spacing: -0.01em;
- flex-wrap: wrap;
-}
-
-.home-dot {
- display: inline-block;
- width: 3px;
- height: 3px;
- border-radius: 50%;
- background: var(--color-border);
- flex-shrink: 0;
-}
-
-.home-session-active {
- display: inline-flex;
- align-items: center;
- gap: 5px;
- color: #2fbd6b;
-}
-
-.home-session-dot {
- display: inline-block;
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: #2fbd6b;
- flex-shrink: 0;
-}
-
-/* Session stats row */
-.home-stats {
- display: flex;
- gap: 0;
- padding: 20px 0;
- border-top: 1px solid var(--color-border);
- border-bottom: 1px solid var(--color-border);
- margin-bottom: 52px;
-}
-
-.home-stat {
- flex: 1;
- display: flex;
- align-items: center;
- gap: 10px;
- min-width: 0;
-}
-
-.home-stat--bordered {
- padding-left: 32px;
- border-left: 1px solid var(--color-border);
-}
-
-.home-stat-icon {
- width: 32px;
- height: 32px;
- border-radius: 8px;
- background: rgba(54, 136, 255, 0.1);
- display: flex;
- align-items: center;
- justify-content: center;
- flex-shrink: 0;
- color: #3688FF;
-}
-
-.home-stat-value {
- font-size: 14px;
- font-weight: 700;
- color: var(--color-text);
- letter-spacing: -0.02em;
-}
-
-.home-stat-value--mono {
- font-family: var(--font-mono, monospace);
- letter-spacing: 0.01em;
-}
-
-.home-stat-label {
- font-size: 11px;
- font-weight: 600;
- color: var(--color-text-muted);
- text-transform: uppercase;
- letter-spacing: 0.07em;
- margin-top: 2px;
-}
-
-/* What's next */
-.home-next-label {
- font-size: 11px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.09em;
- color: var(--color-text-muted);
- margin-bottom: 20px;
-}
-
-.home-next-list {
- display: flex;
- flex-direction: column;
-}
-
-.home-next-item {
- display: grid;
- grid-template-columns: 28px 1fr auto;
- gap: 16px;
- align-items: start;
- padding: 20px 0;
- border-top: 1px solid var(--color-border);
- text-decoration: none;
- color: inherit;
- cursor: pointer;
- transition: background 0.2s;
- border-radius: 4px;
-}
-
-.home-next-item:hover .home-next-cta {
- opacity: 1;
-}
-
-.home-next-n {
- font-size: 11px;
- font-weight: 700;
- color: #3688FF;
- letter-spacing: 0.05em;
- padding-top: 2px;
- font-variant-numeric: tabular-nums;
-}
-
-.home-next-body {
- min-width: 0;
-}
-
-.home-next-title {
- font-size: 14px;
- font-weight: 600;
- color: var(--color-text);
- letter-spacing: -0.02em;
- margin-bottom: 4px;
-}
-
-.home-next-desc {
- font-size: 13px;
- color: var(--color-text-muted);
- line-height: 1.6;
- letter-spacing: -0.01em;
-}
-
-.home-next-cta {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- font-size: 13px;
- font-weight: 600;
- color: #3688FF;
- white-space: nowrap;
- opacity: 0.7;
- transition: opacity 0.2s;
- padding-top: 2px;
-}
-
-/* ββ Sign in / Sign up pages ββ */
-.auth-main {
- width: 100%;
- min-height: calc(100vh - 52px);
- margin-top: 52px;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 40px 20px;
-}
-
-.auth-card {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 8px;
- background: var(--color-card);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-card);
- box-shadow: var(--shadow-card);
- padding: 40px 32px;
- max-width: 420px;
- width: 100%;
-}
-
-.auth-title {
- font-size: 1.5rem;
- font-weight: 700;
- letter-spacing: -0.02em;
- color: var(--color-text);
- text-align: center;
-}
-
-.auth-subtitle {
- margin: 0 0 16px;
- font-size: 13px;
- color: var(--color-text-muted);
- text-align: center;
-}
-
-.auth-switch {
- margin-top: 20px;
- font-size: 13px;
- color: var(--color-text-muted);
- text-align: center;
-}
-
-/* ββ Profile page ββ */
-.profile-main {
- width: 100%;
- max-width: 780px;
- margin: 0 auto;
- padding: 52px 28px 80px;
-}
-
-/* ββ Token debug page ββ */
-.token-main {
- width: 100%;
- max-width: 780px;
- margin: 0 auto;
- padding: 52px 28px 80px;
-}
-
-.token-header {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 16px;
- margin-bottom: 36px;
- flex-wrap: wrap;
-}
-
-.token-title {
- margin: 0 0 5px;
- font-size: 22px;
- font-weight: 700;
- letter-spacing: -0.04em;
- color: var(--color-text);
-}
-
-.token-subtitle {
- margin: 0;
- font-size: 13px;
- color: var(--color-text-muted);
- letter-spacing: -0.01em;
-}
-
-.token-badge {
- display: inline-flex;
- align-items: center;
- gap: 7px;
- padding: 6px 12px;
- border-radius: 6px;
- font-size: 12px;
- font-weight: 700;
- letter-spacing: -0.01em;
- flex-shrink: 0;
-}
-
-.token-badge--expired {
- background: rgba(220, 38, 38, 0.08);
- border: 1px solid rgba(220, 38, 38, 0.22);
- color: #dc2626;
-}
-
-.token-badge--valid {
- background: rgba(47, 189, 107, 0.08);
- border: 1px solid rgba(47, 189, 107, 0.22);
- color: #2fbd6b;
-}
-
-.token-badge-dot {
- display: inline-block;
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: currentColor;
- flex-shrink: 0;
-}
-
-.token-raw-section {
- margin-bottom: 20px;
-}
-
-.token-raw-label-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 8px;
-}
-
-.token-section-label {
- font-size: 11px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.09em;
- color: var(--color-text-muted);
-}
-
-.token-copy-btn {
- display: inline-flex;
- align-items: center;
- gap: 5px;
- padding: 4px 10px;
- border-radius: 5px;
- background: transparent;
- border: 1px solid var(--color-border);
- font-size: 11px;
- font-weight: 600;
- color: var(--color-text-muted);
- cursor: pointer;
- font-family: var(--font-sans);
- letter-spacing: -0.01em;
- transition: border-color 0.2s, color 0.2s;
-}
-
-.token-copy-btn:hover {
- border-color: #3688FF;
- color: #3688FF;
-}
-
-.token-raw {
- display: block;
- font-size: 11px;
- font-family: var(--font-mono, monospace);
- word-break: break-all;
- line-height: 1.8;
- background: #0b1120;
- padding: 14px 16px;
- border-radius: 8px;
- overflow-x: auto;
-}
-
-.token-raw--loading {
- color: rgba(255, 255, 255, 0.3);
-}
-
-.token-part--header { color: #ff7b72; }
-.token-part--payload { color: #79c0ff; }
-.token-part--signature { color: #3fb950; }
-
-.token-dot {
- color: rgba(255, 255, 255, 0.2);
-}
-
-.token-decoded-grid {
- display: grid;
- grid-template-columns: 1fr 1.7fr;
- gap: 12px;
- margin-bottom: 20px;
-}
-
-.token-code-box {
- background: #0b1120;
- border-radius: 10px;
- overflow: hidden;
-}
-
-.token-code-box-header {
- padding: 9px 14px;
- font-size: 10px;
- font-weight: 700;
- letter-spacing: 0.09em;
- text-transform: uppercase;
-}
-
-.token-code-box-header--header {
- color: #ff7b72;
- border-bottom: 1px solid rgba(255, 123, 114, 0.15);
-}
-
-.token-code-box-header--payload {
- color: #79c0ff;
- border-bottom: 1px solid rgba(121, 192, 255, 0.15);
-}
-
-.token-code-pre {
- margin: 0;
- padding: 14px;
- font-size: 11.5px;
- line-height: 1.8;
- font-family: var(--font-mono, monospace);
- color: #c9d1d9;
- overflow-x: auto;
- max-height: 220px;
-}
-
-.token-meta-row {
- display: flex;
- gap: 32px;
- padding-top: 20px;
- border-top: 1px solid var(--color-border);
- flex-wrap: wrap;
-}
-
-.token-meta-label {
- font-size: 10px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.09em;
- color: var(--color-text-muted);
- margin-bottom: 5px;
-}
-
-.token-meta-value {
- font-size: 12px;
- font-family: var(--font-mono, monospace);
- color: var(--color-text);
- letter-spacing: -0.01em;
-}
-
-/* βββ Account page (Google Account-style left-nav layout) βββββββββββ */
-.account-page {
- display: flex;
- align-items: flex-start;
- min-height: calc(100vh - 52px);
- margin-top: 52px;
-}
-
-.account-sidebar {
- width: 280px;
- flex-shrink: 0;
- padding: 40px 24px;
- position: sticky;
- top: 52px;
-}
-
-.account-sidebar-title {
- font-size: 22px;
- font-weight: 700;
- letter-spacing: -0.03em;
- color: var(--color-text);
- margin: 0 0 24px 16px;
-}
-
-.account-nav {
- display: flex;
- flex-direction: column;
- gap: 2px;
-}
-
-.account-nav-item {
- display: flex;
- align-items: center;
- gap: 14px;
- width: 100%;
- padding: 11px 16px;
- border: none;
- border-radius: 20px;
- background: transparent;
- color: var(--color-text-muted);
- font-size: 14px;
- font-weight: 500;
- font-family: inherit;
- text-align: left;
- cursor: pointer;
- white-space: nowrap;
- transition: background 0.2s, color 0.2s;
-}
-
-.account-nav-item:hover {
- background: var(--color-border);
- color: var(--color-text);
-}
-
-.account-nav-item--active,
-.account-nav-item--active:hover {
- background: rgba(54, 136, 255, 0.12);
- color: var(--color-primary);
-}
-
-.account-content {
- flex: 1;
- min-width: 0;
- display: flex;
- justify-content: center;
- padding: 40px 32px 80px;
- border-left: 1px solid var(--color-border);
-}
-
-.account-content-inner {
- width: 100%;
- max-width: 720px;
-}
-
-/* UserProfile's `min-width: 600px` (a floor meant to keep the field grid from getting
- cramped in other contexts) fights this page's own responsive layout, refusing to shrink
- below 600px even when the viewport is narrower. The Security/Home boxes have no such floor
- and already shrink fluidly with their container, so match that here. */
-.account-content .thunderid-user-profile {
- min-width: 0;
- background: var(--color-card) !important;
- border: 1px solid var(--color-border) !important;
- border-radius: var(--radius-card) !important;
- box-shadow: var(--shadow-card);
- color: var(--color-text);
- box-sizing: border-box;
-}
-
-.account-content .thunderid-user-profile .thunderid-user-profile__hero {
- border-bottom: 1px solid var(--color-border);
-}
-
-.account-content .thunderid-user-profile .thunderid-user-profile__hero-name {
- color: var(--color-text);
-}
-
-.account-content .thunderid-user-profile .thunderid-user-profile__hero-subtitle {
- color: var(--color-text-muted);
-}
-
-.account-content .thunderid-user-profile .thunderid-user-profile__field {
- border-bottom: 1px solid var(--color-border);
-}
-
-.account-content .thunderid-user-profile .thunderid-user-profile__field-label {
- color: var(--color-text-muted);
-}
-
-.account-content .thunderid-user-profile .thunderid-user-profile__field-value {
- color: var(--color-text);
-}
-
-.account-content .thunderid-user-profile .thunderid-user-profile__field-placeholder {
- color: var(--color-text-muted);
-}
-
-.account-content .thunderid-user-profile .thunderid-user-profile__field-edit-btn {
- color: var(--color-text-muted);
-}
-
-.account-content .thunderid-user-profile .thunderid-user-profile__field-edit-btn:hover {
- color: var(--color-primary);
-}
-
-.account-content .thunderid-user-profile input,
-.account-content .thunderid-user-profile select,
-.account-content .thunderid-user-profile textarea {
- background: rgba(255, 255, 255, 0.05);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-btn);
- color: var(--color-text);
- padding: 6px 10px;
-}
-
-.account-content-title {
- font-size: 26px;
- font-weight: 700;
- letter-spacing: -0.03em;
- color: var(--color-text);
- margin: 0 0 6px;
-}
-
-.account-content-subtitle {
- font-size: 14px;
- color: var(--color-text-muted);
- margin: 0 0 32px;
-}
-
-.account-tiles {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
- gap: 16px;
- margin-top: 8px;
-}
-
-.account-tile {
- text-align: left;
- background: var(--color-card);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-card);
- padding: 22px;
- cursor: pointer;
- font-family: inherit;
- display: flex;
- flex-direction: column;
- transition: border-color 0.2s;
-}
-
-.account-tile:hover {
- border-color: rgba(54, 136, 255, 0.35);
-}
-
-.account-tile-icon {
- width: 36px;
- height: 36px;
- border-radius: 10px;
- display: flex;
- align-items: center;
- justify-content: center;
- margin-bottom: 14px;
-}
-
-.account-tile-icon--blue {
- background: rgba(54, 136, 255, 0.12);
- color: var(--color-primary);
-}
-
-.account-tile-icon--green {
- background: rgba(16, 185, 129, 0.12);
- color: #10b981;
-}
-
-.account-tile-title {
- font-size: 15px;
- font-weight: 600;
- color: var(--color-text);
- margin-bottom: 4px;
-}
-
-.account-tile-desc {
- font-size: 12.5px;
- color: var(--color-text-muted);
- line-height: 1.5;
-}
-
-.account-security-list {
- display: flex;
- flex-direction: column;
- gap: 16px;
-}
-
-.account-security-card {
- background: var(--color-card);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-card);
- padding: 20px 22px;
-}
-
-.account-security-card-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 16px;
-}
-
-.account-security-card-title {
- font-size: 14px;
- font-weight: 600;
- color: var(--color-text);
- margin-bottom: 2px;
-}
-
-.account-security-card-desc {
- font-size: 12.5px;
- color: var(--color-text-muted);
-}
-
-.account-security-card-cta {
- flex-shrink: 0;
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 0;
- background: none;
- border: none;
- color: var(--color-primary);
- font-size: 13px;
- font-weight: 500;
- font-family: inherit;
- cursor: pointer;
- transition: opacity 0.2s;
-}
-
-.account-security-card-cta:hover {
- opacity: 0.8;
-}
-
-.account-security-card-chevron {
- display: inline-flex;
- transition: transform 0.2s;
-}
-
-.account-security-card-chevron--open {
- transform: rotate(180deg);
-}
-
-.account-security-card-form {
- margin-top: 18px;
- padding-top: 18px;
- border-top: 1px solid var(--color-border);
-}
-
-@media (max-width: 760px) {
- .account-page {
- flex-direction: column;
- }
- .account-sidebar {
- width: 100%;
- position: static;
- padding: 20px 16px 8px;
- }
- .account-nav {
- flex-direction: row;
- overflow-x: auto;
- }
- .account-content {
- max-width: 100%;
- border-left: none;
- border-top: 1px solid var(--color-border);
- padding: 24px 20px 60px;
- }
-}
-
-/* ββ ThunderID inputs & forms dark mode ββ */
-.dark .thunderid-password-field__wrapper,
-html.dark .thunderid-password-field__wrapper,
-.app.dark .thunderid-password-field__wrapper,
-.dark .thunderid-text-field__wrapper,
-html.dark .thunderid-text-field__wrapper,
-.app.dark .thunderid-text-field__wrapper,
-.dark .thunderid-text-field__input,
-html.dark .thunderid-text-field__input,
-.app.dark .thunderid-text-field__input {
- background-color: rgba(255, 255, 255, 0.05) !important;
- border-color: rgba(255, 255, 255, 0.16) !important;
- color: var(--color-text) !important;
-}
-
-.dark .thunderid-password-field__label,
-html.dark .thunderid-password-field__label,
-.app.dark .thunderid-password-field__label,
-.dark .thunderid-text-field__label,
-html.dark .thunderid-text-field__label,
-.app.dark .thunderid-text-field__label,
-.dark .thunderid-input-label,
-html.dark .thunderid-input-label,
-.app.dark .thunderid-input-label {
- color: var(--color-text) !important;
-}
-
-.dark .thunderid-password-field__input,
-html.dark .thunderid-password-field__input,
-.app.dark .thunderid-password-field__input {
- background: transparent !important;
- color: var(--color-text) !important;
-}
-
-.dark .thunderid-password-field__input::placeholder,
-html.dark .thunderid-password-field__input::placeholder,
-.app.dark .thunderid-password-field__input::placeholder,
-.dark .thunderid-text-field__input::placeholder,
-html.dark .thunderid-text-field__input::placeholder,
-.app.dark .thunderid-text-field__input::placeholder {
- color: var(--color-text-muted) !important;
-}
-
-.dark .thunderid-password-field__toggle,
-html.dark .thunderid-password-field__toggle,
-.app.dark .thunderid-password-field__toggle {
- color: var(--color-text-muted) !important;
- background: transparent !important;
-}
-
-.dark .thunderid-password-field__toggle:hover,
-html.dark .thunderid-password-field__toggle:hover,
-.app.dark .thunderid-password-field__toggle:hover {
- color: var(--color-text) !important;
-}
-
-.dark .thunderid-change-credential__requirements-heading,
-html.dark .thunderid-change-credential__requirements-heading,
-.app.dark .thunderid-change-credential__requirements-heading,
-.dark .thunderid-change-credential__requirement,
-html.dark .thunderid-change-credential__requirement,
-.app.dark .thunderid-change-credential__requirement {
- color: var(--color-text-muted) !important;
-}
-
-.dark .thunderid-change-credential__requirement--passed,
-html.dark .thunderid-change-credential__requirement--passed,
-.app.dark .thunderid-change-credential__requirement--passed {
- color: #38c172 !important;
-}
-
-/* ββ ThunderID UserDropdown dark mode ββ */
-.dark .thunderid-user-dropdown__menu,
-html.dark .thunderid-user-dropdown__menu,
-.app.dark .thunderid-user-dropdown__menu,
-.dark .thunderid-user-dropdown__content,
-html.dark .thunderid-user-dropdown__content,
-.app.dark .thunderid-user-dropdown__content {
- background-color: var(--color-card) !important;
- border: 1px solid var(--color-border) !important;
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45) !important;
- color: var(--color-text) !important;
-}
-
-.dark .thunderid-user-dropdown__menu-header,
-html.dark .thunderid-user-dropdown__menu-header,
-.app.dark .thunderid-user-dropdown__menu-header,
-.dark .thunderid-user-dropdown__header,
-html.dark .thunderid-user-dropdown__header,
-.app.dark .thunderid-user-dropdown__header {
- border-bottom: 1px solid var(--color-border) !important;
-}
-
-.dark .thunderid-user-dropdown__menu-header-name,
-html.dark .thunderid-user-dropdown__menu-header-name,
-.app.dark .thunderid-user-dropdown__menu-header-name,
-.dark .thunderid-user-dropdown__header-name,
-html.dark .thunderid-user-dropdown__header-name,
-.app.dark .thunderid-user-dropdown__header-name {
- color: var(--color-text) !important;
-}
-
-.dark .thunderid-user-dropdown__menu-header-subtitle,
-html.dark .thunderid-user-dropdown__menu-header-subtitle,
-.app.dark .thunderid-user-dropdown__menu-header-subtitle,
-.dark .thunderid-user-dropdown__header-subtitle,
-html.dark .thunderid-user-dropdown__header-subtitle,
-.app.dark .thunderid-user-dropdown__header-subtitle {
- color: var(--color-text-muted) !important;
-}
-
-.dark .thunderid-user-dropdown__menu-divider,
-html.dark .thunderid-user-dropdown__menu-divider,
-.app.dark .thunderid-user-dropdown__menu-divider,
-.dark .thunderid-divider,
-html.dark .thunderid-divider,
-.app.dark .thunderid-divider {
- background-color: var(--color-border) !important;
- border-color: var(--color-border) !important;
-}
-
-.dark .thunderid-user-dropdown__item,
-html.dark .thunderid-user-dropdown__item,
-.app.dark .thunderid-user-dropdown__item {
- color: var(--color-text) !important;
-}
-
-.dark .thunderid-user-dropdown__item:hover,
-html.dark .thunderid-user-dropdown__item:hover,
-.app.dark .thunderid-user-dropdown__item:hover {
- background-color: rgba(255, 255, 255, 0.06) !important;
- color: var(--color-text) !important;
-}
-
-.dark .thunderid-user-dropdown__item--danger,
-html.dark .thunderid-user-dropdown__item--danger,
-.app.dark .thunderid-user-dropdown__item--danger {
- color: #f87171 !important;
-}
-
-.dark .thunderid-user-dropdown__item--danger:hover,
-html.dark .thunderid-user-dropdown__item--danger:hover,
-.app.dark .thunderid-user-dropdown__item--danger:hover {
- background-color: rgba(239, 68, 68, 0.12) !important;
-}
-
-.dark .thunderid-card,
-html.dark .thunderid-card,
-.app.dark .thunderid-card {
- background-color: var(--color-card) !important;
- border-color: var(--color-border) !important;
- color: var(--color-text) !important;
-}
-
diff --git a/samples/nuxt/quickstart/app/components/AppNav.vue b/samples/nuxt/quickstart/app/components/AppNav.vue
deleted file mode 100644
index 91ac758c..00000000
--- a/samples/nuxt/quickstart/app/components/AppNav.vue
+++ /dev/null
@@ -1,82 +0,0 @@
-
-
-
-
-
-
-
-
- Quickstart
-
-
-
-
-
-
-
- Home
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ isLoading ? 'Signing inβ¦' : 'Sign in' }}
-
-
-
-
-
diff --git a/samples/nuxt/quickstart/app/components/ConfigNotice.vue b/samples/nuxt/quickstart/app/components/ConfigNotice.vue
deleted file mode 100644
index 52d595fa..00000000
--- a/samples/nuxt/quickstart/app/components/ConfigNotice.vue
+++ /dev/null
@@ -1,112 +0,0 @@
-
-
-
-
-
-
-
-
-
- Quickstart
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Setup required
-
-
-
-
Configuration needed
-
-
- This quickstart can't reach ThunderID yet. Follow the steps below,
- then restart the dev server.
-
-
-
-
Step 1 · Set environment variables
-
-
-
-
- Copy .env.example to .env, fill in the
- values from your ThunderID application, then run npm run dev again.
-
-
-
-
-
Step 2 · Register redirect URIs
-
-
-
- Sign-in and sign-out are handled by this app's server, so no CORS
- configuration is needed. In the ThunderID Console ,
- open this application and go to Advanced Settings →
- OAuth2 Configuration , then add the exact URIs below.
-
-
-
-
-
Authorized redirect URI
-
http://localhost:3000/api/auth/callback
-
-
-
Post-Logout Redirect URI
-
http://localhost:3000/
-
-
-
-
-
-
- Need more info? Take a look at the
- Nuxt quickstart guide.
-
-
-
-
-
diff --git a/samples/nuxt/quickstart/app/pages/account.vue b/samples/nuxt/quickstart/app/pages/account.vue
deleted file mode 100644
index addd18bd..00000000
--- a/samples/nuxt/quickstart/app/pages/account.vue
+++ /dev/null
@@ -1,162 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Hi, {{ user?.givenName || user?.given_name || user?.displayName || user?.username || 'there' }}
-
- Manage your info and security across ThunderID apps.
-
-
-
-
-
-
-
-
-
-
- {{ tile.title }}
- {{ tile.description }}
-
-
-
-
-
-
-
- Personal info
- Manage the basic profile info others may see.
-
-
-
-
- Security
- Manage your password and other credentials.
-
-
-
-
-
{{ credential.title }}
-
{{ credential.description }}
-
-
- {{ credential.cta }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Sign in to manage your account.
-
Sign in
-
-
-
-
-
diff --git a/samples/nuxt/quickstart/app/pages/index.vue b/samples/nuxt/quickstart/app/pages/index.vue
deleted file mode 100644
index fe0b7abb..00000000
--- a/samples/nuxt/quickstart/app/pages/index.vue
+++ /dev/null
@@ -1,234 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Open source
-
-
-
-
Auth for Modern Apps and Agents
-
-
- ThunderID gives you OAuth 2.0, PKCE, MFA, and JWT out of
- the box. Clone the Quickstart and ship auth before lunch.
-
-
-
-
- {{ isLoading ? 'Signing inβ¦' : 'Sign in' }}
-
-
-
-
-
-
-
- OAuth 2.0
- Authorization standard
-
-
- < 5 min
- Integration time
-
-
- Apache 2.0
- License
-
-
-
-
-
-
-
-
-
-
-
-
{{ initials(user) }}
-
-
- {{ greeting(user?.givenName || user?.given_name || user?.displayName || user?.username || 'there') }}
-
-
- {{ user.email }}
-
-
-
- Session active
-
-
-
-
-
-
-
-
-
-
{{ formatTime(authTime) }}
-
Signed in at
-
-
-
-
-
-
- {{ countdown ? countdown.text : 'β' }}
-
-
Session expires in
-
-
-
-
-
-
{{ organizationHandle || user?.ouName || user?.org || 'Default' }}
-
Organisation
-
-
-
-
- What's next
-
-
-
-
-
-
diff --git a/samples/nuxt/quickstart/app/pages/signin.vue b/samples/nuxt/quickstart/app/pages/signin.vue
deleted file mode 100644
index adcb418c..00000000
--- a/samples/nuxt/quickstart/app/pages/signin.vue
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
-
-
-
-
-
-
-
-
You're already signed in.
-
Go home
-
-
-
-
-
-
-
-
-
-
-
diff --git a/samples/nuxt/quickstart/app/pages/signup.vue b/samples/nuxt/quickstart/app/pages/signup.vue
deleted file mode 100644
index 6e8405e1..00000000
--- a/samples/nuxt/quickstart/app/pages/signup.vue
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
-
-
-
-
-
-
-
-
You're already signed in.
-
Go home
-
-
-
-
-
-
-
-
-
-
-
diff --git a/samples/nuxt/quickstart/app/pages/token.vue b/samples/nuxt/quickstart/app/pages/token.vue
deleted file mode 100644
index b4505049..00000000
--- a/samples/nuxt/quickstart/app/pages/token.vue
+++ /dev/null
@@ -1,125 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
Access token
-
-
-
-
-
- {{ copied ? 'Copied!' : 'Copy' }}
-
-
-
-
- .
- {{ tokenParts[1] }}
- .
- {{ tokenParts[2] }}
-
-
Loadingβ¦
-
-
-
-
-
-
{{ jwtHeader ? JSON.stringify(jwtHeader, null, 2) : 'β¦' }}
-
-
-
-
{{ jwtPayload ? JSON.stringify(jwtPayload, null, 2) : 'β¦' }}
-
-
-
-
-
-
-
-
diff --git a/samples/nuxt/quickstart/nuxt.config.ts b/samples/nuxt/quickstart/nuxt.config.ts
deleted file mode 100644
index 229cee98..00000000
--- a/samples/nuxt/quickstart/nuxt.config.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-// https://nuxt.com/docs/api/configuration/nuxt-config
-export default defineNuxtConfig({
- compatibilityDate: '2025-07-15',
- devtools: { enabled: true },
- modules: ['@thunderid/nuxt'],
- thunderid: {
- // Serves POST /api/auth/backchannel-logout, so signing out elsewhere ends the session here.
- backchannelLogout: { enabled: true },
- },
- css: ['~/assets/styles.css'],
-})
diff --git a/samples/nuxt/quickstart/package.json b/samples/nuxt/quickstart/package.json
deleted file mode 100644
index c5b52b1e..00000000
--- a/samples/nuxt/quickstart/package.json
+++ /dev/null
@@ -1,18 +0,0 @@
-{
- "name": "@thunderid/nuxt-quickstart",
- "private": true,
- "version": "0.0.0",
- "scripts": {
- "prepare-dev": "node scripts/prepare-dev.cjs",
- "prepare-dev:redirect": "node scripts/prepare-dev.cjs --flow=redirect",
- "dev": "TMPDIR=/tmp/nuxt-tmp/ nuxt dev",
- "build": "nuxt build",
- "generate": "nuxt generate",
- "preview": "nuxt preview"
- },
- "dependencies": {
- "@thunderid/nuxt": "workspace:*",
- "nuxt": "^4.5.2",
- "vue": "^3.5.13"
- }
-}
diff --git a/samples/nuxt/quickstart/scripts/prepare-dev.cjs b/samples/nuxt/quickstart/scripts/prepare-dev.cjs
deleted file mode 100644
index 1121a5a2..00000000
--- a/samples/nuxt/quickstart/scripts/prepare-dev.cjs
+++ /dev/null
@@ -1,88 +0,0 @@
-// Copyright 2026 The ThunderID Authors
-// SPDX-License-Identifier: Apache-2.0
-
-const fs = require('node:fs');
-const path = require('node:path');
-
-const root = path.join(__dirname, '..');
-
-const PREFIX = 'NUXT_PUBLIC_THUNDERID_';
-const NATIVE_FLOW_VARS = [`${PREFIX}APPLICATION_ID`, `${PREFIX}SIGN_IN_URL`, `${PREFIX}SIGN_UP_URL`];
-const REDIRECT_FLOW_VARS = [`${PREFIX}CLIENT_ID`, 'THUNDERID_CLIENT_SECRET'];
-const REDIRECT_FLOW_PLACEHOLDERS = {
- [`${PREFIX}CLIENT_ID`]: 'your-client-id-here',
- THUNDERID_CLIENT_SECRET: 'your-client-secret-here',
-};
-
-const flowArg = process.argv.find((arg) => arg.startsWith('--flow='));
-const flowExplicitlyRequested = Boolean(flowArg);
-const flow = flowArg ? flowArg.slice('--flow='.length) : 'native';
-
-if (flowExplicitlyRequested && flow !== 'native' && flow !== 'redirect') {
- console.error(`Unknown --flow value "${flow}". Expected "native" or "redirect".`);
- process.exit(1);
-}
-
-/**
- * Toggles the leading `# ` on env var lines to match the selected flow, appending
- * any vars for the selected flow that aren't present in the source file yet (the
- * redirect-flow vars aren't checked into `.env.example`, so switching to `redirect`
- * from a fresh copy needs to add them rather than just uncomment them).
- */
-function applyFlow(envContent, selectedFlow) {
- const varsToEnable = selectedFlow === 'redirect' ? REDIRECT_FLOW_VARS : NATIVE_FLOW_VARS;
- const varsToDisable = selectedFlow === 'redirect' ? NATIVE_FLOW_VARS : REDIRECT_FLOW_VARS;
- const found = new Set();
-
- const lines = envContent.split('\n').map((line) => {
- const enable = varsToEnable.find((key) => line.replace(/^#\s*/, '').startsWith(`${key}=`));
- if (enable) {
- found.add(enable);
- return line.replace(/^#\s*/, '');
- }
-
- const disable = varsToDisable.find((key) => line.startsWith(`${key}=`));
- if (disable) return `# ${line}`;
-
- return line;
- });
-
- const missing = varsToEnable.filter((key) => !found.has(key));
- if (missing.length > 0) {
- lines.push('', `# ββ ${selectedFlow} flow βββββββββββββββββββββββββββββββββββββββββββββββββ`);
- for (const key of missing) lines.push(`${key}=${REDIRECT_FLOW_PLACEHOLDERS[key] ?? ''}`);
- }
-
- return lines.join('\n');
-}
-
-const envExample = path.join(root, '.env.example');
-const envTarget = path.join(root, '.env');
-
-// Only explicit `--flow=` invocations are allowed to (re)write an existing
-// .env β a plain `npm run prepare-dev` (e.g. from .stackblitzrc's
-// startCommand) stays a safe, idempotent copy-if-missing.
-if (fs.existsSync(envExample) && (flowExplicitlyRequested || !fs.existsSync(envTarget))) {
- const envSource = fs.existsSync(envTarget) ? envTarget : envExample;
- const envContent = applyFlow(fs.readFileSync(envSource, 'utf8'), flow)
- // Blank placeholder values (e.g. `your-client-id-here`, the sample base URL)
- // so the copied .env still trips the app's missing-env-var check until real
- // values are filled in.
- .replace(/^([A-Z0-9_]+)=(your-\S*|generate-with-\S*|https:\/\/localhost:8090)$/gm, '$1=');
- fs.writeFileSync(envTarget, envContent);
-}
-
-const pkgPath = path.join(root, 'package.json');
-const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
-
-for (const field of ['dependencies', 'devDependencies']) {
- const deps = pkg[field];
- if (!deps) continue;
- for (const [name, version] of Object.entries(deps)) {
- if (version.startsWith('workspace:')) {
- deps[name] = 'latest';
- }
- }
-}
-
-fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
diff --git a/samples/react/quickstart/.env.example b/samples/react/quickstart/.env.example
deleted file mode 100644
index e38f4022..00000000
--- a/samples/react/quickstart/.env.example
+++ /dev/null
@@ -1,5 +0,0 @@
-# Base URL of your ThunderID deployment. ex: https://localhost:8090
-VITE_THUNDERID_BASE_URL=https://localhost:8090
-
-# OAuth2 Client ID for this app. ThunderID Console -> your application -> Overview.
-VITE_THUNDERID_CLIENT_ID=your-client-id-here
diff --git a/samples/react/quickstart/.stackblitzrc b/samples/react/quickstart/.stackblitzrc
deleted file mode 100644
index 5f0a2000..00000000
--- a/samples/react/quickstart/.stackblitzrc
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "installDependencies": false,
- "startCommand": "npm run prepare-dev && npm install && npm run dev"
-}
diff --git a/samples/react/quickstart/README.md b/samples/react/quickstart/README.md
deleted file mode 100644
index 47e77e45..00000000
--- a/samples/react/quickstart/README.md
+++ /dev/null
@@ -1,36 +0,0 @@
-# ThunderID React Quickstart
-
-
-
-A minimal React + Vite application demonstrating ThunderID authentication with OAuth 2.0, PKCE, and JWT support using the `@thunderid/react` SDK.
-
-## Prerequisites
-
-- Node.js 18+
-- pnpm
-- A ThunderID application
-
-## Setup
-
-1. Copy the environment template:
- ```sh
- cp .env.example .env
- ```
-
-2. Fill in your ThunderID credentials in `.env`:
- ```
- VITE_THUNDERID_CLIENT_ID=
- VITE_THUNDERID_BASE_URL=https://your-thunderid-instance
- ```
-
-3. Install dependencies and start the dev server:
- ```sh
- pnpm install
- pnpm dev
- ```
-
-The app will be available at `http://localhost:5173`.
-
-## Docs
-
-Full SDK reference: [thunderid.dev/docs](https://thunderid.dev/docs)
diff --git a/samples/react/quickstart/index.html b/samples/react/quickstart/index.html
deleted file mode 100644
index 562e0aa8..00000000
--- a/samples/react/quickstart/index.html
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
- ThunderID React Quickstart
-
-
-
-
-
-
diff --git a/samples/react/quickstart/package.json b/samples/react/quickstart/package.json
deleted file mode 100644
index 675c73b2..00000000
--- a/samples/react/quickstart/package.json
+++ /dev/null
@@ -1,23 +0,0 @@
-{
- "name": "@thunderid/react-quickstart",
- "private": true,
- "version": "0.0.0",
- "type": "module",
- "scripts": {
- "prepare-dev": "node scripts/prepare-dev.cjs",
- "dev": "vite",
- "build": "vite build",
- "preview": "vite preview"
- },
- "dependencies": {
- "@thunderid/react": "workspace:*",
- "@thunderid/react-router": "workspace:*",
- "react": "^19.2.3",
- "react-dom": "^19.2.3",
- "react-router": "^7.18.2"
- },
- "devDependencies": {
- "@vitejs/plugin-react": "^4.5.2",
- "vite": "^6.3.5"
- }
-}
diff --git a/samples/react/quickstart/scripts/prepare-dev.cjs b/samples/react/quickstart/scripts/prepare-dev.cjs
deleted file mode 100644
index 8e20fa43..00000000
--- a/samples/react/quickstart/scripts/prepare-dev.cjs
+++ /dev/null
@@ -1,34 +0,0 @@
-// Copyright 2026 The ThunderID Authors
-// SPDX-License-Identifier: Apache-2.0
-
-const fs = require('node:fs');
-const path = require('node:path');
-
-const root = path.join(__dirname, '..');
-
-const envExample = path.join(root, '.env.example');
-const envTarget = path.join(root, '.env');
-if (fs.existsSync(envExample) && !fs.existsSync(envTarget)) {
- // Blank placeholder values (e.g. `your-client-id-here`, the sample base URL)
- // so the copied .env still trips the app's missing-env-var check until real
- // values are filled in.
- const envContent = fs
- .readFileSync(envExample, 'utf8')
- .replace(/^([A-Z0-9_]+)=(your-\S*|generate-with-\S*|https:\/\/localhost:8090)$/gm, '$1=');
- fs.writeFileSync(envTarget, envContent);
-}
-
-const pkgPath = path.join(root, 'package.json');
-const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
-
-for (const field of ['dependencies', 'devDependencies']) {
- const deps = pkg[field];
- if (!deps) continue;
- for (const [name, version] of Object.entries(deps)) {
- if (version.startsWith('workspace:')) {
- deps[name] = 'latest';
- }
- }
-}
-
-fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
diff --git a/samples/react/quickstart/src/App.css b/samples/react/quickstart/src/App.css
deleted file mode 100644
index 741c085a..00000000
--- a/samples/react/quickstart/src/App.css
+++ /dev/null
@@ -1,1069 +0,0 @@
-/* βββ Design tokens βββββββββββββββββββββββββββββββββββββββββββββββ */
-:root {
- --color-primary: #3688FF;
- --color-navy: #05213F;
- --color-bg: #F7F9FC;
- --color-card: #ffffff;
- --color-border: #DDE3EC;
- --color-muted: #5A7085;
- --color-text: #05213F;
- --nav-bg: rgba(247, 249, 252, 0.92);
- --nav-height: 52px;
- --radius-card: 12px;
- --radius-btn: 8px;
- --transition: 0.2s ease;
-}
-
-[data-theme='dark'] {
- --color-bg: #080f1c;
- --color-card: #0f1a2d;
- --color-border: rgba(255, 255, 255, 0.09);
- --color-text: #E0EAFF;
- --color-muted: rgba(224, 234, 255, 0.48);
- --color-navy: #E0EAFF;
- --nav-bg: rgba(8, 15, 28, 0.92);
-}
-
-/* βββ App shell βββββββββββββββββββββββββββββββββββββββββββββββββββ */
-.app {
- min-height: 100vh;
- background: var(--color-bg);
- color: var(--color-text);
- transition: background var(--transition), color var(--transition);
-}
-
-/* βββ Nav βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
-.nav {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- height: var(--nav-height);
- display: flex;
- align-items: center;
- padding: 0 28px;
- background: var(--nav-bg);
- backdrop-filter: blur(12px);
- -webkit-backdrop-filter: blur(12px);
- border-bottom: 1px solid var(--color-border);
- z-index: 100;
- gap: 12px;
-}
-
-.nav-logo {
- display: flex;
- align-items: center;
- gap: 10px;
- cursor: pointer;
- text-decoration: none;
- color: inherit;
- flex: 1;
-}
-
-.wordmark {
- display: flex;
- flex-direction: column;
- gap: 0;
-}
-.wordmark-name {
- font-size: 14px;
- font-weight: 700;
- letter-spacing: -0.03em;
- line-height: 1.1;
- color: var(--color-text);
-}
-.wordmark-sub {
- font-size: 8.5px;
- font-weight: 700;
- letter-spacing: 0.1em;
- text-transform: uppercase;
- color: #3688FF;
- line-height: 1;
- margin-top: 1.5px;
-}
-
-.nav-actions {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-/* βββ Dark-mode toggle ββββββββββββββββββββββββββββββββββββββββββββ */
-.dark-toggle {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 34px;
- height: 34px;
- border-radius: 8px;
- border: 1px solid var(--color-border);
- background: transparent;
- color: var(--color-muted);
- cursor: pointer;
- font-size: 15px;
- transition: border-color var(--transition), color var(--transition), background var(--transition);
- flex-shrink: 0;
-}
-
-.dark-toggle:hover {
- background: var(--color-border);
- color: var(--color-text);
-}
-
-/* βββ Buttons βββββββββββββββββββββββββββββββββββββββββββββββββββββ */
-.btn-primary,
-.btn-outline,
-.btn-ghost {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- height: 46px;
- padding: 0 24px;
- border-radius: var(--radius-btn);
- font-size: 15px;
- font-weight: 600;
- cursor: pointer;
- transition: opacity var(--transition), background var(--transition), border-color var(--transition);
- white-space: nowrap;
- text-decoration: none;
- font-family: inherit;
-}
-
-.btn-primary {
- background: var(--color-primary);
- color: #fff;
- border: 1.5px solid var(--color-primary);
-}
-
-.btn-primary:hover {
- opacity: 0.88;
-}
-
-.btn-outline {
- background: transparent;
- color: var(--color-primary);
- border: 1.5px solid rgba(54, 136, 255, 0.4);
-}
-
-.btn-outline:hover {
- background: rgba(54, 136, 255, 0.08);
-}
-
-.btn-ghost {
- background: transparent;
- color: var(--color-muted);
- border: none;
-}
-.btn-ghost:hover {
- background: rgba(0, 0, 0, 0.04);
-}
-
-/* Nav-sized buttons (slightly shorter) */
-.nav .btn-primary,
-.nav .btn-outline,
-.nav .btn-ghost {
- height: 36px;
- padding: 0 16px;
- font-size: 14px;
-}
-
-/* βββ Loading βββββββββββββββββββββββββββββββββββββββββββββββββββββ */
-.loading-screen {
- min-height: calc(100vh - var(--nav-height));
- margin-top: var(--nav-height);
- display: flex;
- align-items: center;
- justify-content: center;
- color: var(--color-muted);
- font-size: 15px;
-}
-
-/* βββ Hero ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
-.hero {
- min-height: calc(100vh - var(--nav-height));
- margin-top: var(--nav-height);
- padding: 96px 32px 80px;
-}
-
-.home-shell {
- min-height: calc(100vh - var(--nav-height));
- margin-top: var(--nav-height);
-}
-
-.hero-inner {
- width: 100%;
- max-width: 560px;
- margin: 0 auto;
- display: flex;
- flex-direction: column;
- align-items: center;
- text-align: center;
- gap: 0;
-}
-
-.hero-mark { display: flex; justify-content: center; margin-bottom: 32px; }
-
-.hero-badge {
- display: inline-flex;
- align-items: center;
- gap: 8px;
- font-size: 11px;
- font-weight: 700;
- letter-spacing: 0.1em;
- text-transform: uppercase;
- color: var(--color-muted);
- margin-bottom: 28px;
-}
-.hero-badge-line {
- display: block;
- height: 1px;
- width: 24px;
- background: var(--color-border);
- flex-shrink: 0;
-}
-
-.hero-title {
- font-size: 42px;
- font-weight: 700;
- letter-spacing: -0.05em;
- line-height: 1.1;
- color: var(--color-text);
- margin: 0 0 18px;
-}
-
-.hero-subtitle {
- font-size: 16px;
- color: var(--color-muted);
- max-width: 420px;
- line-height: 1.8;
- letter-spacing: -0.01em;
- margin: 0 auto 36px;
-}
-
-.hero-ctas {
- display: flex;
- justify-content: center;
- gap: 10px;
- margin-bottom: 52px;
-}
-
-.hero-divider {
- width: 100%;
- border: none;
- border-top: 1px solid var(--color-border);
- margin: 4px 0;
-}
-
-/* βββ Stats row βββββββββββββββββββββββββββββββββββββββββββββββββββ */
-.hero-stats {
- width: 100%;
- display: grid;
- grid-template-columns: repeat(3, 1fr);
-}
-
-.stat {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 4px;
- padding: 16px 8px;
-}
-
-.stat:not(:last-child) {
- border-right: 1px solid var(--color-border);
-}
-
-.stat-value {
- font-size: 15px;
- font-weight: 700;
- color: var(--color-text);
-}
-
-.stat-label {
- font-size: 12px;
- color: var(--color-muted);
-}
-
-/* βββ Config notice βββββββββββββββββββββββββββββββββββββββββββββββββββ */
-.config-badge {
- color: #e88b3a;
-}
-
-.config-list {
- width: 100%;
- max-width: 420px;
- text-align: left;
- list-style: none;
- background: var(--color-card);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-btn);
- padding: 0 16px;
- margin: 4px 0 28px;
-}
-
-.config-list-item {
- padding: 10px 0;
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
- font-size: 13px;
- color: var(--color-text);
-}
-
-.config-list-item:not(:last-child) {
- border-bottom: 1px solid var(--color-border);
-}
-
-.config-hint {
- font-size: 14px;
- color: var(--color-muted);
- line-height: 1.7;
- max-width: 420px;
- margin: 0 auto;
-}
-
-.config-hint code {
- background: rgba(54, 136, 255, 0.08);
- border-radius: 4px;
- padding: 2px 6px;
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
- font-size: 13px;
-}
-
-.config-step {
- width: 100%;
- max-width: 420px;
- margin: 0 auto 28px;
-}
-
-.config-step-label {
- font-size: 11px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.09em;
- color: var(--color-muted);
- margin-bottom: 10px;
- text-align: left;
-}
-
-.config-step .config-list,
-.config-step .config-hint {
- margin-left: 0;
- margin-right: 0;
-}
-
-.config-box {
- text-align: left;
- background: var(--color-card);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-card);
- padding: 16px;
-}
-
-.config-box-body {
- font-size: 13px;
- color: var(--color-muted);
- line-height: 1.6;
- margin-bottom: 14px;
-}
-
-.config-value-row {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.config-value {
- flex: 1;
- min-width: 0;
- overflow-x: auto;
- white-space: nowrap;
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
- font-size: 12px;
- color: var(--color-text);
- background: rgba(54, 136, 255, 0.08);
- border-radius: 4px;
- padding: 8px 10px;
-}
-
-.config-value-label {
- font-size: 11px;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.06em;
- color: var(--color-muted);
- margin-bottom: 4px;
-}
-
-.config-value-group {
- display: flex;
- flex-direction: column;
- gap: 12px;
-}
-
-.config-docs-note {
- font-size: 13px;
- color: var(--color-muted);
- max-width: 420px;
- margin: 0 auto;
-}
-
-.config-docs-note a {
- color: var(--color-primary);
- font-weight: 600;
-}
-
-/* βββ Signed-in home page βββββββββββββββββββββββββββββββββββββββββββββ */
-.home-main {
- width: 100%;
- max-width: 900px;
- margin: 0 auto;
- padding: 52px 28px 80px;
-}
-
-/* Greeting */
-.home-greeting {
- display: flex;
- align-items: flex-start;
- gap: 16px;
- margin-bottom: 36px;
-}
-
-.home-greeting-text {
- min-width: 0;
-}
-
-.home-greeting-name {
- margin: 0 0 6px;
- font-size: 26px;
- font-weight: 700;
- color: var(--color-text);
- letter-spacing: -0.04em;
-}
-
-.home-greeting-meta {
- display: flex;
- align-items: center;
- gap: 8px;
- font-size: 13px;
- color: var(--color-muted);
- letter-spacing: -0.01em;
- flex-wrap: wrap;
-}
-
-.home-dot {
- display: inline-block;
- width: 3px;
- height: 3px;
- border-radius: 50%;
- background: var(--color-border);
- flex-shrink: 0;
-}
-
-.home-session-active {
- display: inline-flex;
- align-items: center;
- gap: 5px;
- color: #2fbd6b;
-}
-
-.home-session-dot {
- display: inline-block;
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: #2fbd6b;
- flex-shrink: 0;
-}
-
-/* Session stats row */
-.home-stats {
- display: flex;
- gap: 0;
- padding: 20px 0;
- border-top: 1px solid var(--color-border);
- border-bottom: 1px solid var(--color-border);
- margin-bottom: 52px;
-}
-
-.home-stat {
- flex: 1;
- display: flex;
- align-items: center;
- gap: 10px;
- min-width: 0;
-}
-
-.home-stat--bordered {
- padding-left: 32px;
- border-left: 1px solid var(--color-border);
-}
-
-.home-stat-icon {
- width: 32px;
- height: 32px;
- border-radius: 8px;
- background: rgba(54, 136, 255, 0.1);
- display: flex;
- align-items: center;
- justify-content: center;
- flex-shrink: 0;
- color: #3688FF;
-}
-
-.home-stat-value {
- font-size: 14px;
- font-weight: 700;
- color: var(--color-text);
- letter-spacing: -0.02em;
-}
-
-.home-stat-value--mono {
- font-family: var(--font-mono, monospace);
- letter-spacing: 0.01em;
-}
-
-.home-stat-label {
- font-size: 11px;
- font-weight: 600;
- color: var(--color-muted);
- text-transform: uppercase;
- letter-spacing: 0.07em;
- margin-top: 2px;
-}
-
-/* What's next */
-.home-next-label {
- font-size: 11px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.09em;
- color: var(--color-muted);
- margin-bottom: 20px;
-}
-
-.home-next-list {
- display: flex;
- flex-direction: column;
-}
-
-.home-next-item {
- display: grid;
- grid-template-columns: 28px 1fr auto;
- gap: 16px;
- align-items: start;
- padding: 20px 0;
- border-top: 1px solid var(--color-border);
- text-decoration: none;
- color: inherit;
- cursor: pointer;
- transition: background var(--transition);
- border-radius: 4px;
-}
-
-.home-next-item:hover .home-next-cta {
- opacity: 1;
-}
-
-.home-next-n {
- font-size: 11px;
- font-weight: 700;
- color: #3688FF;
- letter-spacing: 0.05em;
- padding-top: 2px;
- font-variant-numeric: tabular-nums;
-}
-
-.home-next-body {
- min-width: 0;
-}
-
-.home-next-title {
- font-size: 14px;
- font-weight: 600;
- color: var(--color-text);
- letter-spacing: -0.02em;
- margin-bottom: 4px;
-}
-
-.home-next-desc {
- font-size: 13px;
- color: var(--color-muted);
- line-height: 1.6;
- letter-spacing: -0.01em;
-}
-
-.home-next-cta {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- font-size: 13px;
- font-weight: 600;
- color: #3688FF;
- white-space: nowrap;
- opacity: 0.7;
- transition: opacity var(--transition);
- padding-top: 2px;
-}
-
-/* βββ Nav back button βββββββββββββββββββββββββββββββββββββββββββββ */
-.nav-back-btn {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- height: 32px;
- padding: 0 10px;
- border-radius: 6px;
- border: none;
- background: transparent;
- color: var(--color-muted);
- font-size: 13px;
- font-family: inherit;
- letter-spacing: -0.01em;
- cursor: pointer;
- transition: background var(--transition), color var(--transition);
-}
-
-.nav-back-btn:hover {
- background: var(--color-border);
- color: var(--color-text);
-}
-
-/* βββ Account page (Google Account-style left-nav layout) βββββββββββ */
-.account-page {
- display: flex;
- align-items: flex-start;
- min-height: calc(100vh - var(--nav-height));
- margin-top: var(--nav-height);
-}
-
-.account-sidebar {
- width: 280px;
- flex-shrink: 0;
- padding: 40px 24px;
- position: sticky;
- top: var(--nav-height);
-}
-
-.account-sidebar-title {
- font-size: 22px;
- font-weight: 700;
- letter-spacing: -0.03em;
- color: var(--color-text);
- margin: 0 0 24px 16px;
-}
-
-.account-nav {
- display: flex;
- flex-direction: column;
- gap: 2px;
-}
-
-.account-nav-item {
- display: flex;
- align-items: center;
- gap: 14px;
- width: 100%;
- padding: 11px 16px;
- border: none;
- border-radius: 20px;
- background: transparent;
- color: var(--color-muted);
- font-size: 14px;
- font-weight: 500;
- font-family: inherit;
- text-align: left;
- cursor: pointer;
- white-space: nowrap;
- transition: background var(--transition), color var(--transition);
-}
-
-.account-nav-item:hover {
- background: var(--color-border);
- color: var(--color-text);
-}
-
-.account-nav-item--active,
-.account-nav-item--active:hover {
- background: rgba(54, 136, 255, 0.12);
- color: var(--color-primary);
-}
-
-.account-content {
- flex: 1;
- min-width: 0;
- display: flex;
- justify-content: center;
- padding: 40px 32px 80px;
- border-left: 1px solid var(--color-border);
-}
-
-.account-content-inner {
- width: 100%;
- max-width: 720px;
-}
-
-/* UserProfile's `min-width: 600px` (a floor meant to keep the field grid from getting
- cramped in other contexts) fights this page's own responsive layout, refusing to shrink
- below 600px even when the viewport is narrower. The Security/Home boxes have no such floor
- and already shrink fluidly with their container, so match that here. */
-.account-content .thunderid-user-profile {
- min-width: 0;
-}
-
-.account-content-title {
- font-size: 26px;
- font-weight: 700;
- letter-spacing: -0.03em;
- color: var(--color-text);
- margin: 0 0 6px;
-}
-
-.account-content-subtitle {
- font-size: 14px;
- color: var(--color-muted);
- margin: 0 0 32px;
-}
-
-.account-tiles {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
- gap: 16px;
- margin-top: 8px;
-}
-
-.account-tile {
- text-align: left;
- background: var(--color-card);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-card);
- padding: 22px;
- cursor: pointer;
- font-family: inherit;
- display: flex;
- flex-direction: column;
- transition: border-color var(--transition);
-}
-
-.account-tile:hover {
- border-color: rgba(54, 136, 255, 0.35);
-}
-
-.account-tile-icon {
- width: 36px;
- height: 36px;
- border-radius: 10px;
- display: flex;
- align-items: center;
- justify-content: center;
- margin-bottom: 14px;
-}
-
-.account-tile-icon--blue {
- background: rgba(54, 136, 255, 0.12);
- color: var(--color-primary);
-}
-
-.account-tile-icon--green {
- background: rgba(16, 185, 129, 0.12);
- color: #10b981;
-}
-
-.account-tile-title {
- font-size: 15px;
- font-weight: 600;
- color: var(--color-text);
- margin-bottom: 4px;
-}
-
-.account-tile-desc {
- font-size: 12.5px;
- color: var(--color-muted);
- line-height: 1.5;
-}
-
-.account-security-list {
- display: flex;
- flex-direction: column;
- gap: 16px;
-}
-
-.account-security-card {
- background: var(--color-card);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-card);
- padding: 20px 22px;
-}
-
-.account-security-card-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 16px;
-}
-
-.account-security-card-title {
- font-size: 14px;
- font-weight: 600;
- color: var(--color-text);
- margin-bottom: 2px;
-}
-
-.account-security-card-desc {
- font-size: 12.5px;
- color: var(--color-muted);
-}
-
-.account-security-card-cta {
- flex-shrink: 0;
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 0;
- background: none;
- border: none;
- color: var(--color-primary);
- font-size: 13px;
- font-weight: 500;
- font-family: inherit;
- cursor: pointer;
- transition: opacity var(--transition);
-}
-
-.account-security-card-cta:hover {
- opacity: 0.8;
-}
-
-.account-security-card-chevron {
- display: inline-flex;
- transition: transform 0.2s;
-}
-
-.account-security-card-chevron--open {
- transform: rotate(180deg);
-}
-
-.account-security-card-form {
- margin-top: 18px;
- padding-top: 18px;
- border-top: 1px solid var(--color-border);
-}
-
-@media (max-width: 760px) {
- .account-page {
- flex-direction: column;
- }
- .account-sidebar {
- width: 100%;
- position: static;
- padding: 20px 16px 8px;
- }
- .account-nav {
- flex-direction: row;
- overflow-x: auto;
- }
- .account-content {
- max-width: 100%;
- border-left: none;
- border-top: 1px solid var(--color-border);
- padding: 24px 20px 60px;
- }
-}
-
-/* βββ Token debug page ββββββββββββββββββββββββββββββββββββββββββββ */
-.token-main {
- width: 100%;
- max-width: 780px;
- margin: 0 auto;
- margin-top: var(--nav-height);
- padding: 52px 28px 80px;
-}
-
-.token-header {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 16px;
- margin-bottom: 36px;
- flex-wrap: wrap;
-}
-
-.token-title {
- margin: 0 0 5px;
- font-size: 22px;
- font-weight: 700;
- letter-spacing: -0.04em;
- color: var(--color-text);
-}
-
-.token-subtitle {
- margin: 0;
- font-size: 13px;
- color: var(--color-muted);
- letter-spacing: -0.01em;
-}
-
-.token-badge {
- display: inline-flex;
- align-items: center;
- gap: 7px;
- padding: 6px 12px;
- border-radius: 6px;
- font-size: 12px;
- font-weight: 700;
- letter-spacing: -0.01em;
- flex-shrink: 0;
-}
-
-.token-badge--expired {
- background: rgba(220, 38, 38, 0.08);
- border: 1px solid rgba(220, 38, 38, 0.22);
- color: #dc2626;
-}
-
-.token-badge--valid {
- background: rgba(47, 189, 107, 0.08);
- border: 1px solid rgba(47, 189, 107, 0.22);
- color: #2fbd6b;
-}
-
-.token-badge-dot {
- display: inline-block;
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: currentColor;
- flex-shrink: 0;
-}
-
-/* Raw token section */
-.token-raw-section {
- margin-bottom: 20px;
-}
-
-.token-raw-label-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 8px;
-}
-
-.token-section-label {
- font-size: 11px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.09em;
- color: var(--color-muted);
-}
-
-.token-copy-btn {
- display: inline-flex;
- align-items: center;
- gap: 5px;
- padding: 4px 10px;
- border-radius: 5px;
- background: transparent;
- border: 1px solid var(--color-border);
- font-size: 11px;
- font-weight: 600;
- color: var(--color-muted);
- cursor: pointer;
- font-family: inherit;
- letter-spacing: -0.01em;
- transition: border-color var(--transition), color var(--transition);
-}
-
-.token-copy-btn:hover {
- border-color: #3688FF;
- color: #3688FF;
-}
-
-.token-raw {
- display: block;
- font-size: 11px;
- font-family: var(--font-mono, monospace);
- word-break: break-all;
- line-height: 1.8;
- background: #0b1120;
- padding: 14px 16px;
- border-radius: 8px;
- overflow-x: auto;
-}
-
-.token-raw--loading {
- color: rgba(255, 255, 255, 0.3);
-}
-
-.token-part--header { color: #ff7b72; }
-.token-part--payload { color: #79c0ff; }
-.token-part--signature { color: #3fb950; }
-
-.token-dot {
- color: rgba(255, 255, 255, 0.2);
-}
-
-/* Decoded grid */
-.token-decoded-grid {
- display: grid;
- grid-template-columns: 1fr 1.7fr;
- gap: 12px;
- margin-bottom: 20px;
-}
-
-.token-code-box {
- background: #0b1120;
- border-radius: 10px;
- overflow: hidden;
-}
-
-.token-code-box-header {
- padding: 9px 14px;
- font-size: 10px;
- font-weight: 700;
- letter-spacing: 0.09em;
- text-transform: uppercase;
-}
-
-.token-code-box-header--header {
- color: #ff7b72;
- border-bottom: 1px solid rgba(255, 123, 114, 0.15);
-}
-
-.token-code-box-header--payload {
- color: #79c0ff;
- border-bottom: 1px solid rgba(121, 192, 255, 0.15);
-}
-
-.token-code-pre {
- margin: 0;
- padding: 14px;
- font-size: 11.5px;
- line-height: 1.8;
- font-family: var(--font-mono, monospace);
- color: #c9d1d9;
- overflow-x: auto;
- max-height: 220px;
-}
-
-/* Meta row */
-.token-meta-row {
- display: flex;
- gap: 32px;
- padding-top: 20px;
- border-top: 1px solid var(--color-border);
- flex-wrap: wrap;
-}
-
-.token-meta-label {
- font-size: 10px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.09em;
- color: var(--color-muted);
- margin-bottom: 5px;
-}
-
-.token-meta-value {
- font-size: 12px;
- font-family: var(--font-mono, monospace);
- color: var(--color-text);
- letter-spacing: -0.01em;
-}
diff --git a/samples/react/quickstart/src/App.jsx b/samples/react/quickstart/src/App.jsx
deleted file mode 100644
index e9b27ab8..00000000
--- a/samples/react/quickstart/src/App.jsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import { createBrowserRouter, RouterProvider } from 'react-router'
-import { ProtectedRoute } from '@thunderid/react-router'
-import Nav from './components/Nav'
-import HomePage from './pages/HomePage'
-import TokenDebugPage from './pages/TokenDebugPage'
-import AccountPage from './pages/AccountPage'
-import './App.css'
-
-const router = createBrowserRouter([
- {
- element: ,
- children: [
- { path: '/', element: },
- { path: '/token', element: },
- { path: '/account', element: },
- ],
- },
-])
-
-export default function App() {
- return
-}
diff --git a/samples/react/quickstart/src/components/ConfigNotice.jsx b/samples/react/quickstart/src/components/ConfigNotice.jsx
deleted file mode 100644
index 320a87ff..00000000
--- a/samples/react/quickstart/src/components/ConfigNotice.jsx
+++ /dev/null
@@ -1,164 +0,0 @@
-import { useState } from 'react'
-import ThunderMark from './ThunderMark'
-import ReactLogo from './icons/ReactLogo'
-
-function MoonIcon() {
- return (
-
-
-
- )
-}
-
-function SunIcon() {
- return (
-
-
-
-
-
-
-
- )
-}
-
-export default function ConfigNotice({ missing }) {
- const [dark, setDark] = useState(false)
- const [copiedField, setCopiedField] = useState(null)
- const origin = typeof window !== 'undefined' ? window.location.origin : ''
-
- const toggleDark = () => {
- const next = !dark
- setDark(next)
- document.documentElement.setAttribute('data-theme', next ? 'dark' : '')
- }
-
- const handleCopy = async (field) => {
- try {
- await navigator.clipboard.writeText(origin)
- setCopiedField(field)
- setTimeout(() => setCopiedField(null), 1500)
- } catch {
- // Clipboard API unavailable (e.g. insecure context) β ignore.
- }
- }
-
- return (
-
-
-
-
- Quickstart
-
-
-
- {dark ? : }
-
-
-
-
-
-
-
-
-
-
-
-
- Setup required
-
-
-
-
Configuration needed
-
-
- This quickstart can't reach ThunderID yet. Follow the steps below,
- then restart the dev server.
-
-
-
-
Step 1 · Set environment variables
-
-
- {missing.map((key) => (
- {key}
- ))}
-
-
-
- Copy .env.example to .env, fill in the
- values from your ThunderID application, then run npm run dev again.
-
-
-
-
-
Step 2 · Allow this origin for CORS
-
-
-
- Sign-in requests from this origin will be blocked by the browser
- until it's added to your ThunderID deployment's allowed CORS
- origins. In the ThunderID Console , go to
- Settings → CORS → Allowed origins and
- add it.
-
-
-
- {origin}
- handleCopy('cors')}>
- {copiedField === 'cors' ? 'Copied!' : 'Copy'}
-
-
-
-
-
-
-
Step 3 · Register redirect URIs
-
-
-
- This origin also doubles as this app's Authorized redirect URI
- and Post-Logout Redirect URI. In the ThunderID
- Console , open this application and go to
- Advanced Settings → OAuth2 Configuration ,
- then add it to both fields below.
-
-
-
-
-
Authorized redirect URI
-
- {origin}
- handleCopy('redirect')}>
- {copiedField === 'redirect' ? 'Copied!' : 'Copy'}
-
-
-
-
-
Post-Logout Redirect URI
-
- {origin}
- handleCopy('logout')}>
- {copiedField === 'logout' ? 'Copied!' : 'Copy'}
-
-
-
-
-
-
-
-
- Need more info? Take a look at the{' '}
-
- React quickstart guide.
-
-
-
-
-
- )
-}
diff --git a/samples/react/quickstart/src/components/HeroCtas.jsx b/samples/react/quickstart/src/components/HeroCtas.jsx
deleted file mode 100644
index 72427021..00000000
--- a/samples/react/quickstart/src/components/HeroCtas.jsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import { SignInButton } from '@thunderid/react'
-
-export default function HeroCtas() {
- return (
-
-
- {({ signIn, isLoading }) => (
-
- {isLoading ? 'Signing inβ¦' : 'Sign in'}
-
- )}
-
-
- )
-}
diff --git a/samples/react/quickstart/src/components/Nav.jsx b/samples/react/quickstart/src/components/Nav.jsx
deleted file mode 100644
index 55955be0..00000000
--- a/samples/react/quickstart/src/components/Nav.jsx
+++ /dev/null
@@ -1,111 +0,0 @@
-import { useState } from 'react'
-import { Link, Outlet, useLocation, useNavigate } from 'react-router'
-import { SignedIn, SignedOut, SignInButton, UserDropdown, Loading, useTheme } from '@thunderid/react'
-import ReactLogo from './icons/ReactLogo'
-
-function MoonIcon() {
- return (
-
-
-
- )
-}
-
-function SunIcon() {
- return (
-
-
-
-
-
-
-
- )
-}
-
-function ChevronLeftIcon() {
- return (
-
-
-
- )
-}
-
-function KeyIcon() {
- return (
-
-
-
-
-
- )
-}
-
-export default function Nav() {
- const [dark, setDark] = useState(false)
- const location = useLocation()
- const navigate = useNavigate()
- const isHome = location.pathname === '/'
- const { toggleTheme } = useTheme()
-
- const toggleDark = () => {
- const next = !dark
- setDark(next)
- document.documentElement.setAttribute('data-theme', next ? 'dark' : '')
- toggleTheme()
- }
-
- const tokenDebugMenuItem = {
- label: Token debug ,
- href: '/token',
- }
-
- return (
-
-
-
-
- Quickstart
-
-
- {!isHome && (
-
-
- Home
-
- )}
-
- {dark ? : }
-
-
- navigate('/account')}
- />
-
-
-
- {({ signIn, isLoading }) => (
-
- {isLoading ? 'Signing inβ¦' : 'Sign in'}
-
- )}
-
-
-
-
-
-
- Loadingβ¦
-
-
-
-
- )
-}
diff --git a/samples/react/quickstart/src/components/ThunderMark.jsx b/samples/react/quickstart/src/components/ThunderMark.jsx
deleted file mode 100644
index 4fe9bbf8..00000000
--- a/samples/react/quickstart/src/components/ThunderMark.jsx
+++ /dev/null
@@ -1,10 +0,0 @@
-export default function ThunderMark({ height = 40, dark = false }) {
- const w = Math.round(height * (207 / 257))
- return (
-
-
-
-
-
- )
-}
diff --git a/samples/react/quickstart/src/components/icons/ReactLogo.jsx b/samples/react/quickstart/src/components/icons/ReactLogo.jsx
deleted file mode 100644
index 91ac76c7..00000000
--- a/samples/react/quickstart/src/components/icons/ReactLogo.jsx
+++ /dev/null
@@ -1,16 +0,0 @@
-export default function ReactLogo({ size = 16 }) {
- return (
-
-
-
-
- )
-}
diff --git a/samples/react/quickstart/src/index.css b/samples/react/quickstart/src/index.css
deleted file mode 100644
index 077b2d2e..00000000
--- a/samples/react/quickstart/src/index.css
+++ /dev/null
@@ -1,9 +0,0 @@
-*, *::before, *::after {
- box-sizing: border-box;
- margin: 0;
- padding: 0;
-}
-
-body {
- font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
-}
diff --git a/samples/react/quickstart/src/main.jsx b/samples/react/quickstart/src/main.jsx
deleted file mode 100644
index aa4ab1fa..00000000
--- a/samples/react/quickstart/src/main.jsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import { StrictMode } from 'react'
-import { createRoot } from 'react-dom/client'
-import { ThunderIDProvider } from '@thunderid/react'
-import App from './App.jsx'
-import ConfigNotice from './components/ConfigNotice.jsx'
-import './index.css'
-
-const REQUIRED_ENV_VARS = ['VITE_THUNDERID_CLIENT_ID', 'VITE_THUNDERID_BASE_URL']
-const missingEnvVars = REQUIRED_ENV_VARS.filter((key) => !import.meta.env[key])
-
-createRoot(document.getElementById('root')).render(
-
- {missingEnvVars.length > 0 ? (
-
- ) : (
-
-
-
- )}
-
-)
diff --git a/samples/react/quickstart/src/pages/AccountPage.jsx b/samples/react/quickstart/src/pages/AccountPage.jsx
deleted file mode 100644
index 917ae57a..00000000
--- a/samples/react/quickstart/src/pages/AccountPage.jsx
+++ /dev/null
@@ -1,187 +0,0 @@
-import { ChangeCredential, User, UserProfile } from '@thunderid/react'
-import { useState } from 'react'
-import { useSearchParams } from 'react-router'
-
-function HomeIcon() {
- return (
-
-
-
-
- )
-}
-
-function PersonIcon() {
- return (
-
-
-
-
- )
-}
-
-function ShieldIcon() {
- return (
-
-
-
- )
-}
-
-const TABS = [
- { id: 'home', label: 'Home', icon: HomeIcon },
- { id: 'personal', label: 'Personal info', icon: PersonIcon },
- { id: 'security', label: 'Security', icon: ShieldIcon },
-]
-
-const CREDENTIALS = [
- { attribute: 'password', title: 'Password', description: 'Used to sign in to your account.', cta: 'Change password' },
-]
-
-function ChevronIcon() {
- return (
-
-
-
- )
-}
-
-function CredentialCard({ attribute, title, description, cta }) {
- const [open, setOpen] = useState(false)
- const close = () => setOpen(false)
-
- return (
-
-
-
-
{title}
-
{description}
-
-
(open ? close() : setOpen(true))}
- >
- {cta}
-
-
-
-
-
- {open && (
-
-
-
- )}
-
- )
-}
-
-const TILES = [
- {
- id: 'personal',
- icon: PersonIcon,
- tone: 'blue',
- title: 'Personal info',
- description: 'Name, email, phone, and profile photo',
- },
- {
- id: 'security',
- icon: ShieldIcon,
- tone: 'green',
- title: 'Security',
- description: 'Password and other credentials',
- },
-]
-
-// Google Account-style layout: a persistent left nav switching between tabs of
-// content on the right, rather than a single scrolling page or a modal.
-export default function AccountPage() {
- const [searchParams, setSearchParams] = useSearchParams()
- const requestedTab = searchParams.get('tab')
- const [tab, setTab] = useState(TABS.some((t) => t.id === requestedTab) ? requestedTab : 'home')
-
- const selectTab = (id) => {
- setTab(id)
- setSearchParams(id === 'home' ? {} : { tab: id })
- }
-
- return (
-
-
- Account
-
- {TABS.map(({ id, label, icon: Icon }) => (
- selectTab(id)}
- >
-
- {label}
-
- ))}
-
-
-
-
-
- {tab === 'home' && (
-
- {(user) => {
- const givenName = user?.givenName || user?.given_name || user?.displayName || user?.username || 'there'
-
- return (
- <>
- Hi, {givenName}
-
- Manage your info and security across ThunderID apps.
-
-
- {TILES.map(({ id, icon: Icon, tone, title, description }) => (
- selectTab(id)}
- >
-
-
-
- {title}
- {description}
-
- ))}
-
- >
- )
- }}
-
- )}
-
- {tab === 'personal' && (
- <>
-
Personal info
-
Manage the basic profile info others may see.
-
- >
- )}
-
- {tab === 'security' && (
- <>
-
Security
-
Manage your password and other credentials.
-
- {CREDENTIALS.map((credential) => (
-
- ))}
-
- >
- )}
-
-
-
- )
-}
diff --git a/samples/react/quickstart/src/pages/HomePage.jsx b/samples/react/quickstart/src/pages/HomePage.jsx
deleted file mode 100644
index e1c7b919..00000000
--- a/samples/react/quickstart/src/pages/HomePage.jsx
+++ /dev/null
@@ -1,254 +0,0 @@
-import { useState, useEffect } from 'react'
-import { useOutletContext } from 'react-router'
-import { SignedIn, SignedOut, User, UserAvatar, useThunderID } from '@thunderid/react'
-import ThunderMark from '../components/ThunderMark'
-import HeroCtas from '../components/HeroCtas'
-
-function greeting(name) {
- const h = new Date().getHours()
- const tod = h < 12 ? 'morning' : h < 17 ? 'afternoon' : 'evening'
- return `Good ${tod}, ${name}.`
-}
-
-function formatTime(unixSeconds) {
- if (!unixSeconds) return 'β'
- return new Date(unixSeconds * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
-}
-
-function formatCountdown(secsLeft) {
- if (secsLeft <= 0) return { text: 'Expired', color: '#d95757' }
- if (secsLeft < 300) {
- const m = Math.floor(secsLeft / 60), s = secsLeft % 60
- return { text: `${m}m ${s}s`, color: '#e88b3a' }
- }
- if (secsLeft < 3600) {
- const m = Math.floor(secsLeft / 60), s = secsLeft % 60
- return { text: `${m}m ${s}s`, color: '#2fbd6b' }
- }
- const hr = Math.floor(secsLeft / 3600), m = Math.floor((secsLeft % 3600) / 60)
- return { text: `${hr}h ${m}m`, color: '#2fbd6b' }
-}
-
-const NEXT_STEPS = [
- {
- n: '01',
- title: 'Explore use cases',
- body: 'See what you can build β auth flows for web, mobile, APIs, and agents.',
- cta: 'Browse use cases',
- href: 'https://thunderid.dev/docs/next/use-cases/overview/',
- },
- {
- n: '02',
- title: 'Learn about flows',
- body: 'Understand how authorization code, PKCE, client credentials, and device flows work.',
- cta: 'Read guide',
- href: 'https://thunderid.dev/docs/next/guides/flows/what-are-flows/',
- },
- {
- n: '03',
- title: 'Style your experience',
- body: 'Customize the login UI, branding, and email templates to match your product.',
- cta: 'Design guide',
- href: 'https://thunderid.dev/docs/next/guides/design/overview/',
- },
- {
- n: '04',
- title: 'Explore SDK APIs',
- body: 'Full React SDK reference β hooks, components, and configuration options.',
- cta: 'SDK reference',
- href: 'https://thunderid.dev/docs/next/sdks/react/overview/',
- },
-]
-
-function ClockIcon() {
- return (
-
-
-
- )
-}
-
-function TimerIcon() {
- return (
-
-
-
-
-
- )
-}
-
-function BuildingIcon() {
- return (
-
-
-
-
- )
-}
-
-function ArrowIcon() {
- return (
-
-
-
- )
-}
-
-function HomeContent() {
- const { getDecodedIdToken, organization } = useThunderID()
- const [token, setToken] = useState(null)
- const [now, setNow] = useState(Math.floor(Date.now() / 1000))
-
- useEffect(() => {
- getDecodedIdToken().then(setToken).catch(() => {})
- }, [getDecodedIdToken])
-
- useEffect(() => {
- const id = setInterval(() => setNow(Math.floor(Date.now() / 1000)), 1000)
- return () => clearInterval(id)
- }, [])
-
- const authTime = token?.auth_time
- const exp = token?.exp
- const secsLeft = exp ? Math.max(0, exp - now) : null
- const countdown = secsLeft !== null ? formatCountdown(secsLeft) : null
- const orgName = organization?.displayName || organization?.name
-
- return (
-
- {(user) => {
- const givenName = user?.givenName || user?.given_name || user?.displayName || user?.username || 'there'
- const email = user?.email
- const ouName = orgName || user?.ouName || user?.org || 'Default'
-
- return (
-
-
-
-
-
{greeting(givenName)}
-
- {email && {email} }
- {email && }
-
-
- Session active
-
-
-
-
-
-
-
-
-
-
{formatTime(authTime)}
-
Signed in at
-
-
-
-
-
-
- {countdown ? countdown.text : 'β'}
-
-
Session expires in
-
-
-
-
-
-
{ouName}
-
Organisation
-
-
-
-
- What's next
-
-
- )
- }}
-
- )
-}
-
-export default function HomePage() {
- const { dark } = useOutletContext()
-
- return (
- <>
-
-
-
-
-
-
-
-
-
- Open source
-
-
-
-
Auth for Modern Apps and Agents
-
-
- ThunderID gives you OAuth 2.0, PKCE, MFA, and JWT out of the box.
- Clone the Quickstart and ship auth before lunch.
-
-
-
-
-
-
-
-
- OAuth 2.0
- Authorization standard
-
-
- < 5 min
- Integration time
-
-
- Apache 2.0
- License
-
-
-
-
-
-
-
-
-
-
-
- >
- )
-}
diff --git a/samples/react/quickstart/src/pages/TokenDebugPage.jsx b/samples/react/quickstart/src/pages/TokenDebugPage.jsx
deleted file mode 100644
index fad3dda9..00000000
--- a/samples/react/quickstart/src/pages/TokenDebugPage.jsx
+++ /dev/null
@@ -1,139 +0,0 @@
-import { useState, useEffect } from 'react'
-import { useThunderID } from '@thunderid/react'
-
-function decodeJwtPart(part) {
- try {
- const base64 = part.replace(/-/g, '+').replace(/_/g, '/')
- const padded = base64.padEnd(base64.length + (4 - (base64.length % 4)) % 4, '=')
- return JSON.parse(atob(padded))
- } catch {
- return null
- }
-}
-
-function CopyIcon() {
- return (
-
-
-
-
- )
-}
-
-export default function TokenDebugPage() {
- const { getAccessToken } = useThunderID()
- const [rawToken, setRawToken] = useState(null)
- const [copied, setCopied] = useState(false)
- const [now, setNow] = useState(Math.floor(Date.now() / 1000))
-
- useEffect(() => {
- getAccessToken().then(setRawToken).catch(() => {})
- }, [getAccessToken])
-
- useEffect(() => {
- const id = setInterval(() => setNow(Math.floor(Date.now() / 1000)), 1000)
- return () => clearInterval(id)
- }, [])
-
- const parts = rawToken ? rawToken.split('.') : []
- const jwtHeader = parts[0] ? decodeJwtPart(parts[0]) : null
- const jwtPayload = parts[1] ? decodeJwtPart(parts[1]) : null
-
- const exp = jwtPayload?.exp
- const expiresIn = exp ? exp - now : null
- const expired = expiresIn !== null && expiresIn <= 0
- const minsLeft = expiresIn !== null ? Math.abs(Math.floor(expiresIn / 60)) : null
-
- const handleCopy = () => {
- if (rawToken && navigator.clipboard) {
- navigator.clipboard.writeText(rawToken)
- setCopied(true)
- setTimeout(() => setCopied(false), 1500)
- }
- }
-
- const issuer = jwtPayload?.iss
- const audience = Array.isArray(jwtPayload?.aud) ? jwtPayload.aud.join(', ') : jwtPayload?.aud
- const scopes = jwtPayload?.scope
-
- return (
-
-
-
-
Token debug
-
Inspect your access token and decoded claims.
-
- {expiresIn !== null && (
-
-
- {expired ? 'Expired' : `Valid Β· expires in ${minsLeft} min`}
-
- )}
-
-
- {rawToken ? (
-
-
- Access token
-
-
- {copied ? 'Copied!' : 'Copy'}
-
-
-
- {parts[0]}
- .
- {parts[1]}
- .
- {parts[2]}
-
-
- ) : (
-
-
- Access token
-
-
Loadingβ¦
-
- )}
-
-
-
-
JWT Header
-
- {jwtHeader ? JSON.stringify(jwtHeader, null, 2) : 'β¦'}
-
-
-
-
JWT Payload
-
- {jwtPayload ? JSON.stringify(jwtPayload, null, 2) : 'β¦'}
-
-
-
-
- {(issuer || audience || scopes) && (
-
- {issuer && (
-
- )}
- {audience && (
-
-
Audience
-
{audience}
-
- )}
- {scopes && (
-
- )}
-
- )}
-
- )
-}
diff --git a/samples/react/quickstart/vite.config.js b/samples/react/quickstart/vite.config.js
deleted file mode 100644
index 9ffcc675..00000000
--- a/samples/react/quickstart/vite.config.js
+++ /dev/null
@@ -1,6 +0,0 @@
-import { defineConfig } from 'vite'
-import react from '@vitejs/plugin-react'
-
-export default defineConfig({
- plugins: [react()],
-})
diff --git a/samples/vue/quickstart/.env.example b/samples/vue/quickstart/.env.example
deleted file mode 100644
index e38f4022..00000000
--- a/samples/vue/quickstart/.env.example
+++ /dev/null
@@ -1,5 +0,0 @@
-# Base URL of your ThunderID deployment. ex: https://localhost:8090
-VITE_THUNDERID_BASE_URL=https://localhost:8090
-
-# OAuth2 Client ID for this app. ThunderID Console -> your application -> Overview.
-VITE_THUNDERID_CLIENT_ID=your-client-id-here
diff --git a/samples/vue/quickstart/.stackblitzrc b/samples/vue/quickstart/.stackblitzrc
deleted file mode 100644
index 5f0a2000..00000000
--- a/samples/vue/quickstart/.stackblitzrc
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "installDependencies": false,
- "startCommand": "npm run prepare-dev && npm install && npm run dev"
-}
diff --git a/samples/vue/quickstart/README.md b/samples/vue/quickstart/README.md
deleted file mode 100644
index a43b42ea..00000000
--- a/samples/vue/quickstart/README.md
+++ /dev/null
@@ -1,36 +0,0 @@
-# ThunderID Vue Quickstart
-
-
-
-A minimal Vue 3 + Vite application demonstrating ThunderID authentication with OAuth 2.0, PKCE, and JWT support using the `@thunderid/vue` SDK.
-
-## Prerequisites
-
-- Node.js 18+
-- pnpm
-- A ThunderID application
-
-## Setup
-
-1. Copy the environment template:
- ```sh
- cp .env.example .env
- ```
-
-2. Fill in your ThunderID credentials in `.env`:
- ```
- VITE_THUNDERID_CLIENT_ID=
- VITE_THUNDERID_BASE_URL=https://your-thunderid-instance
- ```
-
-3. Install dependencies and start the dev server:
- ```sh
- pnpm install
- pnpm dev
- ```
-
-The app will be available at `http://localhost:5173`.
-
-## Docs
-
-Full SDK reference: [thunderid.dev/docs](https://thunderid.dev/docs)
diff --git a/samples/vue/quickstart/index.html b/samples/vue/quickstart/index.html
deleted file mode 100644
index 01e5e851..00000000
--- a/samples/vue/quickstart/index.html
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
- ThunderID Vue Quickstart
-
-
-
-
-
-
diff --git a/samples/vue/quickstart/package.json b/samples/vue/quickstart/package.json
deleted file mode 100644
index aea34d69..00000000
--- a/samples/vue/quickstart/package.json
+++ /dev/null
@@ -1,20 +0,0 @@
-{
- "name": "@thunderid/vue-quickstart",
- "private": true,
- "version": "0.0.0",
- "type": "module",
- "scripts": {
- "prepare-dev": "node scripts/prepare-dev.cjs",
- "dev": "vite",
- "build": "vite build",
- "preview": "vite preview"
- },
- "dependencies": {
- "@thunderid/vue": "workspace:*",
- "vue": "^3.5.13"
- },
- "devDependencies": {
- "@vitejs/plugin-vue": "^5.2.3",
- "vite": "^6.3.5"
- }
-}
diff --git a/samples/vue/quickstart/scripts/prepare-dev.cjs b/samples/vue/quickstart/scripts/prepare-dev.cjs
deleted file mode 100644
index 8e20fa43..00000000
--- a/samples/vue/quickstart/scripts/prepare-dev.cjs
+++ /dev/null
@@ -1,34 +0,0 @@
-// Copyright 2026 The ThunderID Authors
-// SPDX-License-Identifier: Apache-2.0
-
-const fs = require('node:fs');
-const path = require('node:path');
-
-const root = path.join(__dirname, '..');
-
-const envExample = path.join(root, '.env.example');
-const envTarget = path.join(root, '.env');
-if (fs.existsSync(envExample) && !fs.existsSync(envTarget)) {
- // Blank placeholder values (e.g. `your-client-id-here`, the sample base URL)
- // so the copied .env still trips the app's missing-env-var check until real
- // values are filled in.
- const envContent = fs
- .readFileSync(envExample, 'utf8')
- .replace(/^([A-Z0-9_]+)=(your-\S*|generate-with-\S*|https:\/\/localhost:8090)$/gm, '$1=');
- fs.writeFileSync(envTarget, envContent);
-}
-
-const pkgPath = path.join(root, 'package.json');
-const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
-
-for (const field of ['dependencies', 'devDependencies']) {
- const deps = pkg[field];
- if (!deps) continue;
- for (const [name, version] of Object.entries(deps)) {
- if (version.startsWith('workspace:')) {
- deps[name] = 'latest';
- }
- }
-}
-
-fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
diff --git a/samples/vue/quickstart/src/App.vue b/samples/vue/quickstart/src/App.vue
deleted file mode 100644
index c1dcf670..00000000
--- a/samples/vue/quickstart/src/App.vue
+++ /dev/null
@@ -1,40 +0,0 @@
-
-
-
-
-
-
-
-
-
- Loadingβ¦
-
-
-
-
-
-
-
-
diff --git a/samples/vue/quickstart/src/components/ConfigNotice.vue b/samples/vue/quickstart/src/components/ConfigNotice.vue
deleted file mode 100644
index 9c2c35ee..00000000
--- a/samples/vue/quickstart/src/components/ConfigNotice.vue
+++ /dev/null
@@ -1,157 +0,0 @@
-
-
-
-
-
-
-
-
-
-
- Quickstart
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Setup required
-
-
-
-
Configuration needed
-
-
- This quickstart can't reach ThunderID yet. Follow the steps below,
- then restart the dev server.
-
-
-
-
Step 1 · Set environment variables
-
-
-
-
- Copy .env.example to .env, fill in the
- values from your ThunderID application, then run npm run dev again.
-
-
-
-
-
Step 2 · Allow this origin for CORS
-
-
-
- Sign-in requests from this origin will be blocked by the browser
- until it's added to your ThunderID deployment's allowed CORS
- origins. In the ThunderID Console , go to
- Settings → CORS → Allowed origins and
- add it.
-
-
-
- {{ origin }}
-
- {{ copiedField === 'cors' ? 'Copied!' : 'Copy' }}
-
-
-
-
-
-
-
Step 3 · Register redirect URIs
-
-
-
- This origin also doubles as this app's Authorized redirect URI
- and Post-Logout Redirect URI. In the ThunderID
- Console , open this application and go to
- Advanced Settings → OAuth2 Configuration ,
- then add it to both fields below.
-
-
-
-
-
Authorized redirect URI
-
- {{ origin }}
-
- {{ copiedField === 'redirect' ? 'Copied!' : 'Copy' }}
-
-
-
-
-
Post-Logout Redirect URI
-
- {{ origin }}
-
- {{ copiedField === 'logout' ? 'Copied!' : 'Copy' }}
-
-
-
-
-
-
-
-
- Need more info? Take a look at the
- Vue quickstart guide.
-
-
-
-
-
diff --git a/samples/vue/quickstart/src/components/Nav.vue b/samples/vue/quickstart/src/components/Nav.vue
deleted file mode 100644
index a4426a2c..00000000
--- a/samples/vue/quickstart/src/components/Nav.vue
+++ /dev/null
@@ -1,85 +0,0 @@
-
-
-
-
-
-
-
-
-
- Quickstart
-
-
-
-
-
-
-
- Home
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ isLoading ? 'Signing inβ¦' : 'Sign in' }}
-
-
-
-
-
-
-
diff --git a/samples/vue/quickstart/src/main.js b/samples/vue/quickstart/src/main.js
deleted file mode 100644
index f49fe524..00000000
--- a/samples/vue/quickstart/src/main.js
+++ /dev/null
@@ -1,8 +0,0 @@
-import { createApp } from 'vue'
-import { ThunderIDPlugin } from '@thunderid/vue'
-import App from './App.vue'
-import './style.css'
-
-const app = createApp(App)
-app.use(ThunderIDPlugin)
-app.mount('#app')
diff --git a/samples/vue/quickstart/src/pages/AccountPage.vue b/samples/vue/quickstart/src/pages/AccountPage.vue
deleted file mode 100644
index eec1b9ae..00000000
--- a/samples/vue/quickstart/src/pages/AccountPage.vue
+++ /dev/null
@@ -1,146 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
- Hi, {{ user?.givenName || user?.given_name || user?.displayName || user?.username || 'there' }}
-
- Manage your info and security across ThunderID apps.
-
-
-
-
-
-
-
-
-
-
- {{ tile.title }}
- {{ tile.description }}
-
-
-
-
-
-
-
- Personal info
- Manage the basic profile info others may see.
-
-
-
-
- Security
- Manage your password and other credentials.
-
-
-
-
-
{{ credential.title }}
-
{{ credential.description }}
-
-
- {{ credential.cta }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/samples/vue/quickstart/src/pages/HomePage.vue b/samples/vue/quickstart/src/pages/HomePage.vue
deleted file mode 100644
index 192cbca9..00000000
--- a/samples/vue/quickstart/src/pages/HomePage.vue
+++ /dev/null
@@ -1,249 +0,0 @@
-
-
-
-
-
-
-
-
-
-
- Open source
-
-
-
-
Auth for Modern Apps and Agents
-
-
- ThunderID gives you OAuth 2.0, PKCE, MFA, and JWT out of the box.
- Clone the Quickstart and ship auth before lunch.
-
-
-
-
-
-
- {{ isLoading ? 'Signing inβ¦' : 'Sign in' }}
-
-
-
-
-
-
-
-
-
- OAuth 2.0
- Authorization standard
-
-
- < 5 min
- Integration time
-
-
- Apache 2.0
- License
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ (user?.givenName || user?.given_name || user?.displayName || user?.username || '?')[0].toUpperCase() }}
-
-
-
-
- {{ greeting(user?.givenName || user?.given_name || user?.displayName || user?.username || 'there') }}
-
-
- {{ user.email }}
-
-
-
- Session active
-
-
-
-
-
-
-
-
-
-
{{ formatTime(authTime) }}
-
Signed in at
-
-
-
-
-
-
- {{ countdown ? countdown.text : 'β' }}
-
-
Session expires in
-
-
-
-
-
-
- {{ organizationHandle || user?.ouName || user?.org || 'Default' }}
-
-
Organisation
-
-
-
-
- What's next
-
-
-
-
-
-
-
diff --git a/samples/vue/quickstart/src/pages/TokenDebugPage.vue b/samples/vue/quickstart/src/pages/TokenDebugPage.vue
deleted file mode 100644
index 403ddb8f..00000000
--- a/samples/vue/quickstart/src/pages/TokenDebugPage.vue
+++ /dev/null
@@ -1,118 +0,0 @@
-
-
-
-
-
-
-
-
-
Access token
-
-
-
-
-
- {{ copied ? 'Copied!' : 'Copy' }}
-
-
-
-
- .
- {{ tokenParts[1] }}
- .
- {{ tokenParts[2] }}
-
-
Loadingβ¦
-
-
-
-
-
-
{{ jwtHeader ? JSON.stringify(jwtHeader, null, 2) : 'β¦' }}
-
-
-
-
{{ jwtPayload ? JSON.stringify(jwtPayload, null, 2) : 'β¦' }}
-
-
-
-
-
-
diff --git a/samples/vue/quickstart/src/style.css b/samples/vue/quickstart/src/style.css
deleted file mode 100644
index a8e73a24..00000000
--- a/samples/vue/quickstart/src/style.css
+++ /dev/null
@@ -1,1103 +0,0 @@
-/* βββ Reset βββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
-*, *::before, *::after {
- box-sizing: border-box;
- margin: 0;
- padding: 0;
-}
-
-body {
- font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
-}
-
-/* βββ Design tokens βββββββββββββββββββββββββββββββββββββββββββββββ */
-:root {
- --color-primary: #3688FF;
- --color-navy: #05213F;
- --color-bg: #F7F9FC;
- --color-card: #ffffff;
- --color-border: #DDE3EC;
- --color-muted: #5A7085;
- --color-text: #05213F;
- --nav-height: 52px;
- --radius-card: 12px;
- --radius-btn: 8px;
- --transition: 0.2s ease;
- --nav-bg: rgba(247, 249, 252, 0.92);
-}
-
-/* βββ App shell βββββββββββββββββββββββββββββββββββββββββββββββββββ */
-.app {
- min-height: 100vh;
- background: var(--color-bg);
- color: var(--color-text);
- transition: background var(--transition), color var(--transition);
-}
-
-.app.dark {
- --color-bg: #080f1c;
- --color-card: #0f1a2d;
- --color-border: rgba(255, 255, 255, 0.09);
- --color-text: #E0EAFF;
- --color-muted: rgba(224, 234, 255, 0.48);
- --color-navy: #E0EAFF;
- --nav-bg: rgba(8, 15, 28, 0.92);
-}
-
-/* βββ Nav βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
-.nav {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- height: var(--nav-height);
- display: flex;
- align-items: center;
- padding: 0 28px;
- background: var(--nav-bg);
- backdrop-filter: blur(12px);
- -webkit-backdrop-filter: blur(12px);
- border-bottom: 1px solid var(--color-border);
- z-index: 100;
- gap: 12px;
-}
-
-.nav-logo {
- display: flex;
- align-items: center;
- gap: 10px;
- cursor: pointer;
- text-decoration: none;
- color: inherit;
- flex: 1;
-}
-
-.wordmark {
- display: flex;
- flex-direction: column;
- gap: 0;
-}
-.wordmark-name {
- font-size: 14px;
- font-weight: 700;
- letter-spacing: -0.03em;
- line-height: 1.1;
- color: var(--color-text);
-}
-.wordmark-sub {
- font-size: 8.5px;
- font-weight: 700;
- letter-spacing: 0.1em;
- text-transform: uppercase;
- color: #3688FF;
- line-height: 1;
- margin-top: 1.5px;
-}
-
-.nav-actions {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-/* βββ Dark-mode toggle ββββββββββββββββββββββββββββββββββββββββββββ */
-.dark-toggle {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 34px;
- height: 34px;
- border-radius: 8px;
- border: 1px solid var(--color-border);
- background: transparent;
- color: var(--color-muted);
- cursor: pointer;
- font-size: 15px;
- transition: border-color var(--transition), color var(--transition), background var(--transition);
- flex-shrink: 0;
-}
-
-.dark-toggle:hover {
- background: var(--color-border);
- color: var(--color-text);
-}
-
-/* βββ Buttons βββββββββββββββββββββββββββββββββββββββββββββββββββββ */
-.btn-primary,
-.btn-outline,
-.btn-ghost {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- height: 46px;
- padding: 0 24px;
- border-radius: var(--radius-btn);
- font-size: 15px;
- font-weight: 600;
- cursor: pointer;
- transition: opacity var(--transition), background var(--transition), border-color var(--transition);
- white-space: nowrap;
- text-decoration: none;
- font-family: inherit;
-}
-
-.btn-primary {
- background: var(--color-primary);
- color: #fff;
- border: 1.5px solid var(--color-primary);
-}
-
-.btn-primary:hover {
- opacity: 0.88;
-}
-
-.btn-outline {
- background: transparent;
- color: var(--color-primary);
- border: 1.5px solid rgba(54, 136, 255, 0.4);
-}
-
-.btn-outline:hover {
- background: color-mix(in srgb, var(--color-primary) 8%, transparent);
-}
-
-.btn-ghost {
- background: transparent;
- color: var(--color-muted);
- border: none;
-}
-.btn-ghost:hover {
- background: rgba(0, 0, 0, 0.04);
-}
-
-/* Nav-sized buttons (slightly shorter) */
-.nav .btn-primary,
-.nav .btn-outline,
-.nav .btn-ghost {
- height: 36px;
- padding: 0 16px;
- font-size: 14px;
-}
-
-/* βββ Loading βββββββββββββββββββββββββββββββββββββββββββββββββββββ */
-.loading-screen {
- min-height: calc(100vh - var(--nav-height));
- margin-top: var(--nav-height);
- display: flex;
- align-items: center;
- justify-content: center;
- color: var(--color-muted);
- font-size: 15px;
-}
-
-/* βββ Hero ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
-.hero {
- min-height: calc(100vh - var(--nav-height));
- margin-top: var(--nav-height);
- padding: 96px 32px 80px;
-}
-
-.hero-inner {
- width: 100%;
- max-width: 560px;
- margin: 0 auto;
- display: flex;
- flex-direction: column;
- align-items: center;
- text-align: center;
-}
-
-.hero-mark { display: flex; justify-content: center; margin-bottom: 32px; }
-.hero-badge {
- display: inline-flex;
- align-items: center;
- gap: 8px;
- font-size: 11px;
- font-weight: 700;
- letter-spacing: 0.1em;
- text-transform: uppercase;
- color: var(--color-muted);
- margin-bottom: 28px;
-}
-.hero-badge-line {
- display: block;
- height: 1px;
- width: 24px;
- background: var(--color-border);
- flex-shrink: 0;
-}
-
-.hero-title {
- font-size: 42px;
- font-weight: 700;
- letter-spacing: -0.05em;
- line-height: 1.1;
- color: var(--color-text);
- margin: 0 0 18px;
-}
-
-.hero-subtitle {
- font-size: 16px;
- color: var(--color-muted);
- max-width: 420px;
- line-height: 1.8;
- letter-spacing: -0.01em;
- margin: 0 auto 36px;
-}
-
-.hero-ctas {
- display: flex;
- justify-content: center;
- gap: 10px;
- margin-bottom: 52px;
-}
-
-.hero-divider {
- width: 100%;
- border: none;
- border-top: 1px solid var(--color-border);
- margin: 4px 0;
-}
-
-/* βββ Stats row βββββββββββββββββββββββββββββββββββββββββββββββββββ */
-.hero-stats {
- width: 100%;
- display: grid;
- grid-template-columns: repeat(3, 1fr);
-}
-
-.stat {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 4px;
- padding: 16px 8px;
-}
-
-.stat:not(:last-child) {
- border-right: 1px solid var(--color-border);
-}
-
-.stat-value {
- font-size: 15px;
- font-weight: 700;
- color: var(--color-text);
-}
-
-.stat-label {
- font-size: 12px;
- color: var(--color-muted);
-}
-
-/* βββ Config notice βββββββββββββββββββββββββββββββββββββββββββββββββββ */
-.config-badge {
- color: #e88b3a;
-}
-
-.config-list {
- width: 100%;
- max-width: 420px;
- text-align: left;
- list-style: none;
- background: var(--color-card);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-btn);
- padding: 0 16px;
- margin: 4px 0 28px;
-}
-
-.config-list-item {
- padding: 10px 0;
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
- font-size: 13px;
- color: var(--color-text);
-}
-
-.config-list-item:not(:last-child) {
- border-bottom: 1px solid var(--color-border);
-}
-
-.config-hint {
- font-size: 14px;
- color: var(--color-muted);
- line-height: 1.7;
- max-width: 420px;
- margin: 0 auto;
-}
-
-.config-hint code {
- background: rgba(54, 136, 255, 0.08);
- border-radius: 4px;
- padding: 2px 6px;
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
- font-size: 13px;
-}
-
-.config-step {
- width: 100%;
- max-width: 420px;
- margin: 0 auto 28px;
-}
-
-.config-step-label {
- font-size: 11px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.09em;
- color: var(--color-muted);
- margin-bottom: 10px;
- text-align: left;
-}
-
-.config-step .config-list,
-.config-step .config-hint {
- margin-left: 0;
- margin-right: 0;
-}
-
-.config-box {
- text-align: left;
- background: var(--color-card);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-card);
- padding: 16px;
-}
-
-.config-box-body {
- font-size: 13px;
- color: var(--color-muted);
- line-height: 1.6;
- margin-bottom: 14px;
-}
-
-.config-value-row {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.config-value {
- flex: 1;
- min-width: 0;
- overflow-x: auto;
- white-space: nowrap;
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
- font-size: 12px;
- color: var(--color-text);
- background: rgba(54, 136, 255, 0.08);
- border-radius: 4px;
- padding: 8px 10px;
-}
-
-.config-value-label {
- font-size: 11px;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.06em;
- color: var(--color-muted);
- margin-bottom: 4px;
-}
-
-.config-value-group {
- display: flex;
- flex-direction: column;
- gap: 12px;
-}
-
-.config-docs-note {
- font-size: 13px;
- color: var(--color-muted);
- max-width: 420px;
- margin: 0 auto;
-}
-
-.config-docs-note a {
- color: var(--color-primary);
- font-weight: 600;
-}
-
-/* βββ Signed-in home page βββββββββββββββββββββββββββββββββββββββββββββ */
-.home-shell {
- min-height: calc(100vh - var(--nav-height));
- margin-top: var(--nav-height);
-}
-
-.home-main {
- width: 100%;
- max-width: 900px;
- margin: 0 auto;
- padding: 52px 28px 80px;
-}
-
-/* Greeting */
-.home-greeting {
- display: flex;
- align-items: flex-start;
- gap: 16px;
- margin-bottom: 36px;
-}
-
-.home-avatar {
- width: 52px;
- height: 52px;
- border-radius: 50%;
- background: var(--color-primary);
- color: #fff;
- font-size: 18px;
- font-weight: 700;
- display: flex;
- align-items: center;
- justify-content: center;
- flex-shrink: 0;
- overflow: hidden;
-}
-
-.home-avatar-img {
- width: 100%;
- height: 100%;
- object-fit: cover;
-}
-
-.home-avatar-initials {
- line-height: 1;
-}
-
-.home-greeting-text {
- min-width: 0;
-}
-
-.home-greeting-name {
- margin: 0 0 6px;
- font-size: 26px;
- font-weight: 700;
- color: var(--color-text);
- letter-spacing: -0.04em;
-}
-
-.home-greeting-meta {
- display: flex;
- align-items: center;
- gap: 8px;
- font-size: 13px;
- color: var(--color-muted);
- letter-spacing: -0.01em;
- flex-wrap: wrap;
-}
-
-.home-dot {
- display: inline-block;
- width: 3px;
- height: 3px;
- border-radius: 50%;
- background: var(--color-border);
- flex-shrink: 0;
-}
-
-.home-session-active {
- display: inline-flex;
- align-items: center;
- gap: 5px;
- color: #2fbd6b;
-}
-
-.home-session-dot {
- display: inline-block;
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: #2fbd6b;
- flex-shrink: 0;
-}
-
-/* Session stats row */
-.home-stats {
- display: flex;
- gap: 0;
- padding: 20px 0;
- border-top: 1px solid var(--color-border);
- border-bottom: 1px solid var(--color-border);
- margin-bottom: 52px;
-}
-
-.home-stat {
- flex: 1;
- display: flex;
- align-items: center;
- gap: 10px;
- min-width: 0;
-}
-
-.home-stat--bordered {
- padding-left: 32px;
- border-left: 1px solid var(--color-border);
-}
-
-.home-stat-icon {
- width: 32px;
- height: 32px;
- border-radius: 8px;
- background: rgba(54, 136, 255, 0.1);
- display: flex;
- align-items: center;
- justify-content: center;
- flex-shrink: 0;
- color: #3688FF;
-}
-
-.home-stat-value {
- font-size: 14px;
- font-weight: 700;
- color: var(--color-text);
- letter-spacing: -0.02em;
-}
-
-.home-stat-value--mono {
- font-family: var(--font-mono, monospace);
- letter-spacing: 0.01em;
-}
-
-.home-stat-label {
- font-size: 11px;
- font-weight: 600;
- color: var(--color-muted);
- text-transform: uppercase;
- letter-spacing: 0.07em;
- margin-top: 2px;
-}
-
-/* What's next */
-.home-next-label {
- font-size: 11px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.09em;
- color: var(--color-muted);
- margin-bottom: 20px;
-}
-
-.home-next-list {
- display: flex;
- flex-direction: column;
-}
-
-.home-next-item {
- display: grid;
- grid-template-columns: 28px 1fr auto;
- gap: 16px;
- align-items: start;
- padding: 20px 0;
- border-top: 1px solid var(--color-border);
- text-decoration: none;
- color: inherit;
- cursor: pointer;
- transition: background var(--transition);
- border-radius: 4px;
-}
-
-.home-next-item:hover .home-next-cta {
- opacity: 1;
-}
-
-.home-next-n {
- font-size: 11px;
- font-weight: 700;
- color: #3688FF;
- letter-spacing: 0.05em;
- padding-top: 2px;
- font-variant-numeric: tabular-nums;
-}
-
-.home-next-body {
- min-width: 0;
-}
-
-.home-next-title {
- font-size: 14px;
- font-weight: 600;
- color: var(--color-text);
- letter-spacing: -0.02em;
- margin-bottom: 4px;
-}
-
-.home-next-desc {
- font-size: 13px;
- color: var(--color-muted);
- line-height: 1.6;
- letter-spacing: -0.01em;
-}
-
-.home-next-cta {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- font-size: 13px;
- font-weight: 600;
- color: #3688FF;
- white-space: nowrap;
- opacity: 0.7;
- transition: opacity var(--transition);
- padding-top: 2px;
-}
-
-/* βββ Nav back button βββββββββββββββββββββββββββββββββββββββββββββ */
-.nav-back-btn {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- height: 32px;
- padding: 0 10px;
- border-radius: 6px;
- border: none;
- background: transparent;
- color: var(--color-muted);
- font-size: 13px;
- font-family: inherit;
- letter-spacing: -0.01em;
- cursor: pointer;
- transition: background var(--transition), color var(--transition);
-}
-
-.nav-back-btn:hover {
- background: var(--color-border);
- color: var(--color-text);
-}
-
-/* βββ Token debug page ββββββββββββββββββββββββββββββββββββββββββββ */
-.token-main {
- width: 100%;
- max-width: 780px;
- margin: 0 auto;
- margin-top: var(--nav-height);
- padding: 52px 28px 80px;
-}
-
-.token-header {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 16px;
- margin-bottom: 36px;
- flex-wrap: wrap;
-}
-
-.token-title {
- margin: 0 0 5px;
- font-size: 22px;
- font-weight: 700;
- letter-spacing: -0.04em;
- color: var(--color-text);
-}
-
-.token-subtitle {
- margin: 0;
- font-size: 13px;
- color: var(--color-muted);
- letter-spacing: -0.01em;
-}
-
-.token-badge {
- display: inline-flex;
- align-items: center;
- gap: 7px;
- padding: 6px 12px;
- border-radius: 6px;
- font-size: 12px;
- font-weight: 700;
- letter-spacing: -0.01em;
- flex-shrink: 0;
-}
-
-.token-badge--expired {
- background: rgba(220, 38, 38, 0.08);
- border: 1px solid rgba(220, 38, 38, 0.22);
- color: #dc2626;
-}
-
-.token-badge--valid {
- background: rgba(47, 189, 107, 0.08);
- border: 1px solid rgba(47, 189, 107, 0.22);
- color: #2fbd6b;
-}
-
-.token-badge-dot {
- display: inline-block;
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: currentColor;
- flex-shrink: 0;
-}
-
-/* Raw token section */
-.token-raw-section {
- margin-bottom: 20px;
-}
-
-.token-raw-label-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 8px;
-}
-
-.token-section-label {
- font-size: 11px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.09em;
- color: var(--color-muted);
-}
-
-.token-copy-btn {
- display: inline-flex;
- align-items: center;
- gap: 5px;
- padding: 4px 10px;
- border-radius: 5px;
- background: transparent;
- border: 1px solid var(--color-border);
- font-size: 11px;
- font-weight: 600;
- color: var(--color-muted);
- cursor: pointer;
- font-family: inherit;
- letter-spacing: -0.01em;
- transition: border-color var(--transition), color var(--transition);
-}
-
-.token-copy-btn:hover {
- border-color: #3688FF;
- color: #3688FF;
-}
-
-.token-raw {
- display: block;
- font-size: 11px;
- font-family: var(--font-mono, monospace);
- word-break: break-all;
- line-height: 1.8;
- background: #0b1120;
- padding: 14px 16px;
- border-radius: 8px;
- overflow-x: auto;
-}
-
-.token-raw--loading {
- color: rgba(255, 255, 255, 0.3);
-}
-
-.token-part--header { color: #ff7b72; }
-.token-part--payload { color: #79c0ff; }
-.token-part--signature { color: #3fb950; }
-
-.token-dot {
- color: rgba(255, 255, 255, 0.2);
-}
-
-/* Decoded grid */
-.token-decoded-grid {
- display: grid;
- grid-template-columns: 1fr 1.7fr;
- gap: 12px;
- margin-bottom: 20px;
-}
-
-.token-code-box {
- background: #0b1120;
- border-radius: 10px;
- overflow: hidden;
-}
-
-.token-code-box-header {
- padding: 9px 14px;
- font-size: 10px;
- font-weight: 700;
- letter-spacing: 0.09em;
- text-transform: uppercase;
-}
-
-.token-code-box-header--header {
- color: #ff7b72;
- border-bottom: 1px solid rgba(255, 123, 114, 0.15);
-}
-
-.token-code-box-header--payload {
- color: #79c0ff;
- border-bottom: 1px solid rgba(121, 192, 255, 0.15);
-}
-
-.token-code-pre {
- margin: 0;
- padding: 14px;
- font-size: 11.5px;
- line-height: 1.8;
- font-family: var(--font-mono, monospace);
- color: #c9d1d9;
- overflow-x: auto;
- max-height: 220px;
-}
-
-/* Meta row */
-.token-meta-row {
- display: flex;
- gap: 32px;
- padding-top: 20px;
- border-top: 1px solid var(--color-border);
- flex-wrap: wrap;
-}
-
-.token-meta-label {
- font-size: 10px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.09em;
- color: var(--color-muted);
- margin-bottom: 5px;
-}
-
-.token-meta-value {
- font-size: 12px;
- font-family: var(--font-mono, monospace);
- color: var(--color-text);
- letter-spacing: -0.01em;
-}
-
-/* βββ Account page (Google Account-style left-nav layout) βββββββββββ */
-.account-page {
- display: flex;
- align-items: flex-start;
- min-height: calc(100vh - var(--nav-height));
- margin-top: var(--nav-height);
-}
-
-.account-sidebar {
- width: 280px;
- flex-shrink: 0;
- padding: 40px 24px;
- position: sticky;
- top: var(--nav-height);
-}
-
-.account-sidebar-title {
- font-size: 22px;
- font-weight: 700;
- letter-spacing: -0.03em;
- color: var(--color-text);
- margin: 0 0 24px 16px;
-}
-
-.account-nav {
- display: flex;
- flex-direction: column;
- gap: 2px;
-}
-
-.account-nav-item {
- display: flex;
- align-items: center;
- gap: 14px;
- width: 100%;
- padding: 11px 16px;
- border: none;
- border-radius: 20px;
- background: transparent;
- color: var(--color-muted);
- font-size: 14px;
- font-weight: 500;
- font-family: inherit;
- text-align: left;
- cursor: pointer;
- white-space: nowrap;
- transition: background var(--transition), color var(--transition);
-}
-
-.account-nav-item:hover {
- background: var(--color-border);
- color: var(--color-text);
-}
-
-.account-nav-item--active,
-.account-nav-item--active:hover {
- background: rgba(54, 136, 255, 0.12);
- color: var(--color-primary);
-}
-
-.account-content {
- flex: 1;
- min-width: 0;
- display: flex;
- justify-content: center;
- padding: 40px 32px 80px;
- border-left: 1px solid var(--color-border);
-}
-
-.account-content-inner {
- width: 100%;
- max-width: 720px;
-}
-
-/* UserProfile's `min-width: 600px` (a floor meant to keep the field grid from getting
- cramped in other contexts) fights this page's own responsive layout, refusing to shrink
- below 600px even when the viewport is narrower. The Security/Home boxes have no such floor
- and already shrink fluidly with their container, so match that here. */
-.account-content .thunderid-user-profile {
- min-width: 0;
-}
-
-.account-content-title {
- font-size: 26px;
- font-weight: 700;
- letter-spacing: -0.03em;
- color: var(--color-text);
- margin: 0 0 6px;
-}
-
-.account-content-subtitle {
- font-size: 14px;
- color: var(--color-muted);
- margin: 0 0 32px;
-}
-
-.account-tiles {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
- gap: 16px;
- margin-top: 8px;
-}
-
-.account-tile {
- text-align: left;
- background: var(--color-card);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-card);
- padding: 22px;
- cursor: pointer;
- font-family: inherit;
- display: flex;
- flex-direction: column;
- transition: border-color var(--transition);
-}
-
-.account-tile:hover {
- border-color: rgba(54, 136, 255, 0.35);
-}
-
-.account-tile-icon {
- width: 36px;
- height: 36px;
- border-radius: 10px;
- display: flex;
- align-items: center;
- justify-content: center;
- margin-bottom: 14px;
-}
-
-.account-tile-icon--blue {
- background: rgba(54, 136, 255, 0.12);
- color: var(--color-primary);
-}
-
-.account-tile-icon--green {
- background: rgba(16, 185, 129, 0.12);
- color: #10b981;
-}
-
-.account-tile-title {
- font-size: 15px;
- font-weight: 600;
- color: var(--color-text);
- margin-bottom: 4px;
-}
-
-.account-tile-desc {
- font-size: 12.5px;
- color: var(--color-muted);
- line-height: 1.5;
-}
-
-.account-security-list {
- display: flex;
- flex-direction: column;
- gap: 16px;
-}
-
-.account-security-card {
- background: var(--color-card);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-card);
- padding: 20px 22px;
-}
-
-.account-security-card-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 16px;
-}
-
-.account-security-card-title {
- font-size: 14px;
- font-weight: 600;
- color: var(--color-text);
- margin-bottom: 2px;
-}
-
-.account-security-card-desc {
- font-size: 12.5px;
- color: var(--color-muted);
-}
-
-.account-security-card-cta {
- flex-shrink: 0;
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 0;
- background: none;
- border: none;
- color: var(--color-primary);
- font-size: 13px;
- font-weight: 500;
- font-family: inherit;
- cursor: pointer;
- transition: opacity var(--transition);
-}
-
-.account-security-card-cta:hover {
- opacity: 0.8;
-}
-
-.account-security-card-chevron {
- display: inline-flex;
- transition: transform 0.2s;
-}
-
-.account-security-card-chevron--open {
- transform: rotate(180deg);
-}
-
-.account-security-card-form {
- margin-top: 18px;
- padding-top: 18px;
- border-top: 1px solid var(--color-border);
-}
-
-@media (max-width: 760px) {
- .account-page {
- flex-direction: column;
- }
- .account-sidebar {
- width: 100%;
- position: static;
- padding: 20px 16px 8px;
- }
- .account-nav {
- flex-direction: row;
- overflow-x: auto;
- }
- .account-content {
- max-width: 100%;
- border-left: none;
- border-top: 1px solid var(--color-border);
- padding: 24px 20px 60px;
- }
-}
diff --git a/samples/vue/quickstart/vite.config.js b/samples/vue/quickstart/vite.config.js
deleted file mode 100644
index c40aa3c3..00000000
--- a/samples/vue/quickstart/vite.config.js
+++ /dev/null
@@ -1,6 +0,0 @@
-import { defineConfig } from 'vite'
-import vue from '@vitejs/plugin-vue'
-
-export default defineConfig({
- plugins: [vue()],
-})
diff --git a/tests/e2e/.gitignore b/tests/e2e/.gitignore
index 961cd677..6fe98e76 100644
--- a/tests/e2e/.gitignore
+++ b/tests/e2e/.gitignore
@@ -2,6 +2,9 @@
thunderid/
.npx-thunderid-home/
+# Quickstarts fetched from thunder-id/samples by scripts/prepare-samples.ts.
+.samples/
+
# Playwright output
playwright-report/
test-results/
diff --git a/tests/e2e/.prettierignore b/tests/e2e/.prettierignore
index 14fc5e19..ca8f87b9 100644
--- a/tests/e2e/.prettierignore
+++ b/tests/e2e/.prettierignore
@@ -1,3 +1,6 @@
# Go-template + YAML hybrids (ThunderID's declarative /import format) β not valid YAML on their
# own, only once the {{ }} placeholders are rendered server-side.
thunderid-config/**/*.yaml
+
+# Quickstarts fetched from thunder-id/samples, formatted by that repository.
+.samples/
diff --git a/tests/e2e/constants/sample-apps.ts b/tests/e2e/constants/sample-apps.ts
index 4a83d056..d1b9bc46 100644
--- a/tests/e2e/constants/sample-apps.ts
+++ b/tests/e2e/constants/sample-apps.ts
@@ -4,58 +4,58 @@
/**
* Sample App Registry
*
- * One entry per browser-testable quickstart app under `samples/`, each with a dedicated port (so
+ * One entry per browser-testable quickstart app fetched into `.samples/` from thunder-id/samples, each with a dedicated port (so
* all apps can run concurrently against a single backend) and a dedicated OAuth2 client (declared
* with matching redirectUris in `thunderid-config/sample-apps.yaml`) so one app's E2E run never
- * collides with another's. `node/quickstart` is deliberately absent from this registry β it has
+ * collides with another's. `quickstarts/node` is deliberately absent from this registry β it has
* no browser UI, so no page object or port β but it still has its own OAuth2 client, see
* `NodeQuickstart` below and `tests/node-quickstart/client-credentials.spec.ts`.
*
- * Ports match each dev server's own default where possible (browser/quickstart keeps Vite's 5173,
- * express/quickstart keeps its hardcoded 3000) and are staggered elsewhere via each tool's own
+ * Ports match each dev server's own default where possible (quickstarts/browser keeps Vite's 5173,
+ * quickstarts/express keeps its hardcoded 3000) and are staggered elsewhere via each tool's own
* `--port`/`-p` flag β no sample app source changes required.
*/
export const SampleApps = {
BROWSER: {
clientId: 'JS_SDK_E2E_BROWSER',
- dir: 'samples/browser/quickstart',
+ dir: '.samples/quickstarts/browser',
envVar: 'BROWSER_APP_URL',
- name: 'browser/quickstart',
+ name: 'quickstarts/browser',
port: 5173,
},
EXPRESS: {
clientId: 'JS_SDK_E2E_EXPRESS',
- dir: 'samples/express/quickstart',
+ dir: '.samples/quickstarts/express',
envVar: 'EXPRESS_APP_URL',
- name: 'express/quickstart',
+ name: 'quickstarts/express',
port: 3000,
},
NEXTJS: {
clientId: 'JS_SDK_E2E_NEXTJS',
- dir: 'samples/nextjs/quickstart',
+ dir: '.samples/quickstarts/nextjs',
envVar: 'NEXTJS_APP_URL',
- name: 'nextjs/quickstart',
+ name: 'quickstarts/nextjs',
port: 3001,
},
NUXT: {
clientId: 'JS_SDK_E2E_NUXT',
- dir: 'samples/nuxt/quickstart',
+ dir: '.samples/quickstarts/nuxt',
envVar: 'NUXT_APP_URL',
- name: 'nuxt/quickstart',
+ name: 'quickstarts/nuxt',
port: 3002,
},
REACT: {
clientId: 'JS_SDK_E2E_REACT',
- dir: 'samples/react/quickstart',
+ dir: '.samples/quickstarts/react',
envVar: 'REACT_APP_URL',
- name: 'react/quickstart',
+ name: 'quickstarts/react',
port: 5174,
},
VUE: {
clientId: 'JS_SDK_E2E_VUE',
- dir: 'samples/vue/quickstart',
+ dir: '.samples/quickstarts/vue',
envVar: 'VUE_APP_URL',
- name: 'vue/quickstart',
+ name: 'quickstarts/vue',
port: 5175,
},
} as const;
@@ -68,8 +68,8 @@ export type SampleAppKey = keyof typeof SampleApps;
*/
export const NodeQuickstart = {
clientId: 'JS_SDK_E2E_NODE',
- dir: 'samples/node/quickstart',
- name: 'node/quickstart',
+ dir: '.samples/quickstarts/node',
+ name: 'quickstarts/node',
} as const;
/** Resolves an app's base URL from its env var, falling back to `http://localhost:`. */
diff --git a/tests/e2e/pages/browser-quickstart.page.ts b/tests/e2e/pages/browser-quickstart.page.ts
index 04b22b6c..6660e660 100644
--- a/tests/e2e/pages/browser-quickstart.page.ts
+++ b/tests/e2e/pages/browser-quickstart.page.ts
@@ -2,9 +2,9 @@
// SPDX-License-Identifier: Apache-2.0
/**
- * browser/quickstart Page Object β vanilla JS + @thunderid/browser, redirect flow.
+ * quickstarts/browser Page Object β vanilla JS + @thunderid/browser, redirect flow.
* Custom hand-built markup (not the shared component library), so its own selectors: see
- * samples/browser/quickstart/src/components/nav.js and src/pages/home.js.
+ * thunder-id/samples quickstarts/browser/src/components/nav.js and src/pages/home.js.
*/
import {Page, expect} from '@playwright/test';
@@ -72,7 +72,7 @@ export class BrowserQuickstartPage extends GateLoginPage {
/** Opens the "Manage Account" page (src/pages/account.js) via the nav dropdown, then switches
* to its Personal info tab β the account page lands on the Home tab first, same as
- * react/vue/quickstart's own Account page. Replaces the old "Manage Profile" popup; the field
+ * react/quickstarts/vue's own Account page. Replaces the old "Manage Profile" popup; the field
* rendering/editing logic that used to back that popup (src/components/profileDialog.js) was
* kept and repurposed to feed this page's Personal info tab instead (renamed to
* profileFields.js). */
diff --git a/tests/e2e/pages/express-quickstart.page.ts b/tests/e2e/pages/express-quickstart.page.ts
index 72458012..478fc10c 100644
--- a/tests/e2e/pages/express-quickstart.page.ts
+++ b/tests/e2e/pages/express-quickstart.page.ts
@@ -2,9 +2,9 @@
// SPDX-License-Identifier: Apache-2.0
/**
- * express/quickstart Page Object β server-rendered Express app + @thunderid/express, cookie
+ * quickstarts/express Page Object β server-rendered Express app + @thunderid/express, cookie
* session. No client SDK, plain server-rendered markup (a native dropdown), so its own
- * selectors: see samples/express/quickstart/lib/layout.mjs.
+ * selectors: see thunder-id/samples quickstarts/express/lib/layout.mjs.
*/
import {Page, expect} from '@playwright/test';
@@ -55,7 +55,7 @@ export class ExpressQuickstartPage extends GateLoginPage {
}
/** Reads the raw access token JWT rendered across the three .token-part--* spans β see
- * samples/express/quickstart/index.mjs's /token handler. */
+ * thunder-id/samples quickstarts/express/index.mjs's /token handler. */
async getDisplayedAccessToken(): Promise {
const raw = this.page.locator('.token-raw');
await raw.waitFor({state: 'visible', timeout: Timeouts.ELEMENT_VISIBILITY});
diff --git a/tests/e2e/run-e2e.sh b/tests/e2e/run-e2e.sh
index 0261cb2b..db79d9b4 100755
--- a/tests/e2e/run-e2e.sh
+++ b/tests/e2e/run-e2e.sh
@@ -6,14 +6,18 @@
#
# Downloads the latest ThunderID release distribution (this repo can't build the backend from
# source β that lives in thunder-id/thunderid), bootstraps it, imports each sample app's OAuth2
-# client declaratively, builds the SDK packages under test, starts all six sample apps on their
-# assigned ports, and runs the Playwright suite. Mirrors thunder-id/thunderid/tests/e2e/run-e2e.sh
+# client declaratively, builds the SDK packages under test, fetches the quickstarts from
+# thunder-id/samples and wires them to those packages (scripts/prepare-samples.ts), starts all six
+# sample apps on their assigned ports, and runs the Playwright suite. Mirrors thunder-id/thunderid/tests/e2e/run-e2e.sh
# as closely as a cross-repo setup allows β same setup.sh/start.sh/health-check/import sequence.
#
# Usage:
# ./run-e2e.sh [playwright-args...]
#
-# Requirements: curl, jq, unzip, pnpm, lsof
+# Requirements: curl, jq, unzip, pnpm, lsof, git
+#
+# The quickstarts come from thunder-id/samples main by default. Set THUNDERID_SAMPLES_REF to test
+# against another branch or tag, or THUNDERID_SAMPLES_DIR to use a local samples checkout.
set -euo pipefail
@@ -26,7 +30,7 @@ SERVER_PORT=8090
ADMIN_USER="${ADMIN_USERNAME:-admin}"
ADMIN_PASS="${ADMIN_PASSWORD:-admin}"
ADMIN_TOKEN=""
-APP_ENVS_WRITTEN=0
+SAMPLES_DIR="$SCRIPT_DIR/.samples"
kill_port() {
lsof -ti tcp:"$1" | xargs kill -9 2>/dev/null || true
@@ -56,48 +60,10 @@ cleanup() {
kill_port 3000
kill_port 3001
kill_port 3002
- # restore_app_envs falls back to deleting each app's .env when no .e2e-backup marker exists β
- # correct once write_app_envs has actually run (it backs up first), wrong on any earlier exit
- # (e.g. the already-running-server check below), which would otherwise wipe a developer's own
- # untouched .env files that this script never wrote in the first place.
- if [ "$APP_ENVS_WRITTEN" = 1 ]; then
- restore_app_envs
- fi
rm -rf "$SCRIPT_DIR/thunderid" "$SCRIPT_DIR/.npx-thunderid-home" "$SCRIPT_DIR/distribution"
}
trap cleanup EXIT
-# Sample app dirs whose .env this script owns for the run (see write_app_envs/restore_app_envs).
-APP_ENV_DIRS=(
- "samples/browser/quickstart"
- "samples/react/quickstart"
- "samples/vue/quickstart"
- "samples/nextjs/quickstart"
- "samples/nuxt/quickstart"
- "samples/express/quickstart"
-)
-
-# Backs up any .env a developer already has in a sample app dir (from their own manual testing)
-# before this script overwrites it, so cleanup can restore it rather than leaving a real E2E
-# client secret sitting in a dev's working tree.
-backup_app_env() {
- local dir="$PROJECT_ROOT/$1/.env"
- [ -f "$dir" ] && [ ! -f "$dir.e2e-backup" ] && mv "$dir" "$dir.e2e-backup"
- return 0
-}
-
-restore_app_envs() {
- local app_dir env_file
- for app_dir in "${APP_ENV_DIRS[@]}"; do
- env_file="$PROJECT_ROOT/$app_dir/.env"
- if [ -f "$env_file.e2e-backup" ]; then
- mv "$env_file.e2e-backup" "$env_file"
- else
- rm -f "$env_file"
- fi
- done
-}
-
# Resolves the release asset name for the current platform, e.g. thunderid-1.0.0-beta2-macos-arm64.
# Only used by the THUNDERID_VERSION debug-pin fallback below: npx thunderid detects the platform
# itself for the normal, always-latest path.
@@ -289,47 +255,42 @@ import_sample_apps_config() {
# Writes each sample app's own .env with the real OAuth2 client it was just imported with (see
# import_sample_apps_config). The apps' own `prepare-dev.cjs --flow=redirect` helper looks like
-# the natural fit here, but it isn't: it rewrites the sample's checked-in package.json
-# (`workspace:*` -> `latest`, defeating the whole point of testing the PR's own SDK changes) and
-# blanks out any value that looks like one of its own placeholders β including the literal string
-# `https://localhost:8090`, which happens to be our *real* server URL too. Writing each .env
-# directly, with the exact variable names those scripts use for "redirect flow", sidesteps both.
+# the natural fit here, but it isn't: it blanks out any value that looks like one of its own
+# placeholders β including the literal string `https://localhost:8090`, which happens to be our
+# *real* server URL too. Writing each .env directly, with the exact variable names those scripts
+# use for "redirect flow", sidesteps that. The apps are fresh copies under .samples/, so there is
+# no developer .env to preserve.
write_app_envs() {
echo "Writing sample app .env files..."
- APP_ENVS_WRITTEN=1
local nextjs_secret nuxt_secret
nextjs_secret=$(openssl rand -base64 32)
nuxt_secret=$(openssl rand -base64 32)
- for app_dir in "${APP_ENV_DIRS[@]}"; do
- backup_app_env "$app_dir"
- done
-
- cat > "$PROJECT_ROOT/samples/browser/quickstart/.env" < "$SAMPLES_DIR/quickstarts/browser/.env" < "$PROJECT_ROOT/samples/react/quickstart/.env" < "$SAMPLES_DIR/quickstarts/react/.env" < "$PROJECT_ROOT/samples/vue/quickstart/.env" < "$SAMPLES_DIR/quickstarts/vue/.env" < "$PROJECT_ROOT/samples/express/quickstart/.env" < "$SAMPLES_DIR/quickstarts/express/.env" < "$PROJECT_ROOT/samples/nextjs/quickstart/.env" < "$SAMPLES_DIR/quickstarts/nextjs/.env" < "$PROJECT_ROOT/samples/nuxt/quickstart/.env" < "$SAMPLES_DIR/quickstarts/nuxt/.env" < void; dir: string} {
+ if (process.env.THUNDERID_SAMPLES_DIR) {
+ return {dir: path.resolve(process.env.THUNDERID_SAMPLES_DIR)};
+ }
+
+ const repo = env('THUNDERID_SAMPLES_REPO', 'https://github.com/thunder-id/samples.git');
+ const ref = env('THUNDERID_SAMPLES_REF', 'main');
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'thunderid-samples-'));
+ console.log(`Cloning ${repo} (${ref})...`);
+ run('git', ['clone', '--depth', '1', '--branch', ref, repo, dir]);
+
+ return {cleanup: () => fs.rmSync(dir, {force: true, recursive: true}), dir};
+}
+
+function packPackages(): Record {
+ fs.mkdirSync(packsDir, {recursive: true});
+ const tarballs: Record = {};
+
+ for (const entry of fs.readdirSync(path.join(projectRoot, 'packages'))) {
+ const pkgDir = path.join(projectRoot, 'packages', entry);
+ const manifest = path.join(pkgDir, 'package.json');
+ if (!fs.existsSync(manifest)) continue;
+
+ const pkg = JSON.parse(fs.readFileSync(manifest, 'utf8')) as {name: string; private?: boolean};
+ if (pkg.private) continue;
+
+ const before = new Set(fs.readdirSync(packsDir));
+ run('pnpm', ['pack', '--pack-destination', packsDir], pkgDir);
+ const tarball = fs.readdirSync(packsDir).find((file) => file.endsWith('.tgz') && !before.has(file));
+ if (!tarball) throw new Error(`pnpm pack produced no tarball for ${pkg.name}`);
+
+ tarballs[pkg.name] = `file:${path.join(packsDir, tarball)}`;
+ }
+
+ return tarballs;
+}
+
+/** Adds an `overrides` entry per packed package to the quickstart's own pnpm-workspace.yaml. */
+function overrideWithTarballs(sampleDir: string, tarballs: Record): void {
+ const workspaceFile = path.join(sampleDir, 'pnpm-workspace.yaml');
+ const lines = fs.existsSync(workspaceFile) ? fs.readFileSync(workspaceFile, 'utf8').split('\n') : [];
+ const entries = Object.entries(tarballs).map(([name, spec]) => ` '${name}': '${spec}'`);
+
+ const overridesAt = lines.findIndex((line) => line.trimEnd() === 'overrides:');
+ if (overridesAt === -1) {
+ lines.push('', 'overrides:', ...entries, '');
+ } else {
+ lines.splice(overridesAt + 1, 0, ...entries);
+ }
+
+ fs.writeFileSync(workspaceFile, lines.join('\n'));
+}
+
+const source = resolveSource();
+
+try {
+ fs.rmSync(samplesHome, {force: true, recursive: true});
+ const tarballs = packPackages();
+
+ for (const name of QUICKSTARTS) {
+ const from = path.join(source.dir, 'quickstarts', name);
+ if (!fs.existsSync(from)) throw new Error(`quickstarts/${name} not found in ${source.dir}`);
+
+ const to = path.join(samplesHome, 'quickstarts', name);
+ fs.cpSync(from, to, {
+ filter: (src) => !['node_modules', '.env', 'dist', '.next', '.nuxt', '.output'].includes(path.basename(src)),
+ recursive: true,
+ });
+
+ overrideWithTarballs(to, tarballs);
+ console.log(`Installing quickstarts/${name} against the local SDK packages...`);
+ // The samples repo's lockfile pins the published releases, which the overrides replace.
+ run('pnpm', ['install', '--no-frozen-lockfile'], to);
+ }
+} finally {
+ source.cleanup?.();
+}
diff --git a/tests/e2e/tests/node-quickstart/client-credentials.spec.ts b/tests/e2e/tests/node-quickstart/client-credentials.spec.ts
index 92adee5a..05c073da 100644
--- a/tests/e2e/tests/node-quickstart/client-credentials.spec.ts
+++ b/tests/e2e/tests/node-quickstart/client-credentials.spec.ts
@@ -2,8 +2,8 @@
// SPDX-License-Identifier: Apache-2.0
/**
- * node/quickstart β a service-to-service CLI, not a browser app: it authenticates via the OAuth2
- * `client_credentials` grant (samples/node/quickstart/lib/AuthService.mjs) and prints the result.
+ * quickstarts/node β a service-to-service CLI, not a browser app: it authenticates via the OAuth2
+ * `client_credentials` grant (quickstarts/node/lib/AuthService.mjs in thunder-id/samples) and prints the result.
* No page to drive, so this spawns the CLI directly and asserts it exits 0 having actually
* obtained a token, rather than being skipped from E2E coverage entirely.
*/
@@ -13,9 +13,9 @@ import path from 'node:path';
import {test, expect} from '@playwright/test';
import {Timeouts} from '../../constants/timeouts';
-const APP_DIR = path.resolve(import.meta.dirname, '../../../../samples/node/quickstart');
+const APP_DIR = path.resolve(import.meta.dirname, '../../.samples/quickstarts/node');
-test.describe('node/quickstart - client_credentials', () => {
+test.describe('quickstarts/node - client_credentials', () => {
test('TC001: obtains an access token and exits cleanly', async () => {
let child: ChildProcess | undefined;
diff --git a/tests/e2e/tsconfig.json b/tests/e2e/tsconfig.json
index 6cee8c97..035b7e77 100644
--- a/tests/e2e/tsconfig.json
+++ b/tests/e2e/tsconfig.json
@@ -14,5 +14,5 @@
"noEmit": true
},
"include": ["**/*.ts"],
- "exclude": ["node_modules", "playwright-report", "test-results", "blob-report"]
+ "exclude": ["node_modules", ".samples", "playwright-report", "test-results", "blob-report"]
}
diff --git a/tests/e2e/utils/jwt/index.ts b/tests/e2e/utils/jwt/index.ts
index c2c939f1..13549b05 100644
--- a/tests/e2e/utils/jwt/index.ts
+++ b/tests/e2e/utils/jwt/index.ts
@@ -4,7 +4,7 @@
/**
* Decodes an access token's JWT payload for assertion purposes only (no signature
* verification β the token debug pages under test do the exact same client-side decode, see
- * e.g. samples/react/quickstart/src/pages/TokenDebugPage.jsx's decodeJwtPart()).
+ * e.g. thunder-id/samples quickstarts/react/src/pages/TokenDebugPage.jsx's decodeJwtPart()).
*/
export function decodeJwtPayload(token: string): Record {
const parts = token.split('.');