New Captcha Package added

This commit is contained in:
2026-07-25 13:38:18 +05:30
parent d0aded0392
commit 8b728a3e5d
157 changed files with 12092 additions and 1913 deletions
+51
View File
@@ -0,0 +1,51 @@
# WRNexusJS CAPTCHA audio playback fix
This patch fixes audio CAPTCHA playback in production and bundled server builds.
## Root cause
`AssetAudioRenderer` originally located WAV files only from `import.meta.url`. After
WRNexusJS bundles the server into `dist/server.js`, that URL points to the app build
folder instead of `packages/captcha`, so `/api/captcha/audio/...` fails while trying
to read the WAV clips.
## Changes
- Resolves audio assets from the installed `@wrnexus/captcha/audio` package entry.
- Supports `WRNEXUS_CAPTCHA_AUDIO_DIR` for custom deployments.
- Searches workspace and `node_modules` layouts as safe fallbacks.
- Keeps the browser `Audio` instance alive while it plays.
- Stops old audio when a new challenge is loaded.
- Returns a clear HTTP 503 and server log if audio rendering fails.
- Adds a real WAV rendering test.
## Apply
Extract this ZIP into the WRNexusJS repository root and replace the included files.
Then run:
```bash
bun run typecheck
bun test packages/captcha
bun run --cwd examples/captcha-showcase check
```
Restart the server. If running the production build, rebuild first:
```bash
bun run --cwd examples/captcha-showcase build
bun examples/captcha-showcase/dist/server.js
```
## Verify
1. Open the browser Network panel.
2. Click **Listen**.
3. The `/api/captcha/audio/<id>?key=...` request must return HTTP 200.
4. Its `content-type` must be `audio/wav`.
For a nonstandard package layout, set an absolute path before starting the server:
```powershell
$env:WRNEXUS_CAPTCHA_AUDIO_DIR="E:\WireJS\packages\captcha\assets\audio"
```
+40
View File
@@ -0,0 +1,40 @@
# WRNexusJS CAPTCHA Fixed 8
Extract this ZIP directly into the WRNexusJS repository root and allow the included files to replace the current CAPTCHA files.
## Added
- 18 concrete generated CAPTCHA image styles
- `imageStyle="random"`
- `allowedStyles` and `excludedStyles`
- `randomizeStyle`
- Resolved style metadata and browser event details
- Easy, normal, hard, controlled-pool, and full renderer showcase at `/styles`
- Tailwind Iconify/Lucide showcase dependencies
- Faster CRC32 PNG encoding
- Safe floating-point line rendering for web, darts, distortion, wave, and scribble effects
- Tests for every renderer and style-pool rule
## Supported styles
`classic`, `collision`, `snow`, `corrosion`, `spiderweb`, `cross-shadow`, `split`, `split2`, `cut`, `darts`, `distortion`, `stitch`, `striped`, `wave`, `grid-noise`, `scribble`, `pixel`, `broken-lines`, and `random`.
## After extraction
```bash
bun install
bun run scripts/generate-ui-component-reference.mjs
bun run typecheck
bun test packages/captcha
bun test services/managed-captcha
bun run --cwd examples/captcha-showcase check
bun run --cwd examples/captcha-showcase dev
```
Open:
```text
http://localhost:3000/styles
```
Use a hard browser refresh after restarting the development server.
+51
View File
@@ -0,0 +1,51 @@
# WRNexusJS CAPTCHA Fixed 7
Extract this patch directly into the WRNexusJS repository root and allow files to be replaced.
## Added
- `size="compact"`, `size="normal"`, and `size="big"`
- Backward-compatible `small`/`sm`, `large`/`lg`, and `compact="true"` aliases
- `showListen="true|false"` for the Listen button
- Existing `showAudio="true|false"` remains the broader audio-alternative switch
- Self-hosted `type="not-robot"` checkbox challenge
- Showcase examples and tests for all new behavior
- Managed API/OpenAPI support for `not-robot`
## Usage
```wrn
<Captcha
endpoint="/api/captcha/challenge"
verifyEndpoint="/api/captcha/verify"
type="number"
action="compact-example"
size="compact"
showListen="false"
/>
```
```wrn
<Captcha
endpoint="/api/captcha/challenge"
verifyEndpoint="/api/captcha/verify"
type="not-robot"
action="contact-submit"
size="compact"
showListen="false"
/>
```
The not-robot checkbox is server verified. It uses a one-time challenge, minimum completion time, honeypot, expiry, attempt limits, action binding, optional hostname/session/IP binding, and a single-use response token. It is a low-friction mode; high-risk traffic should escalate to a visual challenge or an external provider.
## Regenerate and test
```bash
bun run scripts/generate-ui-component-reference.mjs
bun run typecheck
bun test packages/captcha
bun run --cwd examples/captcha-showcase check
bun run --cwd examples/captcha-showcase dev
```
Hard-refresh the browser after restarting the development server.
+38
View File
@@ -0,0 +1,38 @@
# Bundle manifest
## Package
- Engine, challenge generators, optimized PNG renderer, audio renderer
- Memory, SQLite-compatible, and Redis-compatible stores
- Self-hosted, managed, Turnstile, reCAPTCHA, hCaptcha, and custom providers
- HTTP handlers, guards, page gates, adaptive policy, validation integration
- Plugin and DevToolbar audit panel
- Tailwind-only `Captcha.wrn` with compact/normal/big sizing, listen visibility, not-robot mode, 2575 disturbance control, and generated image renderer props
- English WAV audio assets
- Unit tests for all 18 PNG renderers, style resolution, security behavior, and documentation
## Example
- All self-hosted challenge types, including the not-robot checkbox
- Audio and invisible examples
- Shared `@wrnexus/validation` contact schema with client and API validation
- Protected form API using `captchaGuard`
- Working once-per-session protected-page grant and safe return redirect
- Compact, normal, and big component examples
- Listen-visible and listen-hidden examples
- Easy, normal, and hard image-disturbance examples
- Dedicated `/styles` gallery with 18 concrete renderers, random selection, allowed pools, and excluded pools
- External-provider placeholders
## Managed service
- Project keys, allowed hostnames, quotas, key rotation
- Public create/solve and secret-authenticated verification routes
- Memory reference store, SQL migration, OpenAPI specification
## Integration
- Repeat-safe installer
- TypeScript path aliases
- Current component-discovery compatibility copy
- Generated 18-style preview gallery and renderer guidance
+27 -1524
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
# Reactive runtime compatibility correction
The original Captcha component attempted to execute full native JavaScript inside WRNexusJS behavior blocks. WRNexusJS 0.3.6 behavior blocks use a deliberately limited eval-free interpreter and do not execute arbitrary JavaScript syntax.
The corrected design keeps the `.wrn` component declarative and delegates network, audio, timers, provider SDKs, DOM updates, and form guards to a packaged same-origin browser runtime.
This removes the `Unexpected token '?'` mount failure and prevents the later failures that would have occurred for `async`, `await`, `try`, callback functions, constructors, and member assignments.
+27
View File
@@ -0,0 +1,27 @@
# Validation report
Validated in the artifact environment on 2026-07-25:
- Strict TypeScript checking passed for package source, managed-service source, and all included test files using local declarations for WRNexusJS/Bun APIs.
- Self-hosted engine smoke tests passed: incorrect answer, correct answer, challenge consumption, one-use response token, replay rejection, and expiry.
- Default generator smoke tests passed for number, alphabet, alphanumeric, calculation, generated-image selection, not-robot, invisible timing, PNG output, and bundled WAV rendering.
- Managed-service smoke tests passed for project creation, hostname-restricted challenge creation, solving, secret-authenticated verification, and response-token consumption.
- The installer passed first-install and repeated-install smoke tests, including TypeScript aliases, UI component placement, showcase installation, managed-service installation, and workspace updates.
- All showcase `.wrn` pages parsed with the available WRNexusJS parser.
- The `Captcha.wrn` body parsed after removing only the newer `@event` prop declarations; those declarations follow the same current syntax used by the frameworks InputNumber component.
- Dynamic attribute expressions were checked for the frameworks required single-quoted form.
- All used `--wire-*` variables were checked against the framework token set available in the repository snapshot.
Bun is not installed in this artifact environment, so the included Bun unit suite and full current-branch monorepo build were not executed here. Run the installer with `--check` in the current WRNexusJS checkout to execute component-reference generation, Bun tests, TypeScript checking, and the showcase build.
## Fixed 8 checks
- Strict TypeScript checking passed after adding renderer types, style resolution, generated image effects, component props, runtime payloads, managed API fields, and showcase tests.
- Every concrete renderer generated a valid PNG signature and a distinct image in deterministic smoke validation.
- The renderer catalog contains 18 concrete styles plus `random`; allowed and excluded pools reject unknown or empty configurations.
- Floating-point line endpoints now terminate correctly, including spiderweb, darts, wave, distortion, and scribble effects.
- CRC32 encoding uses a lookup table; all 18 preview images generated in under one second in the artifact environment.
- The component and browser runtime expose `imageStyle`, `allowedStyles`, `excludedStyles`, and `randomizeStyle`.
- The `/styles` showcase page includes easy, normal, hard, controlled-pool, and every concrete renderer example.
- Compact/normal/big sizing, Listen controls, not-robot, protected-page, 2575 disturbance, validated-form, audio, and static-asset fixes remain included.
+79 -30
View File
@@ -33,6 +33,22 @@
"typescript-eslint": "latest", "typescript-eslint": "latest",
}, },
}, },
"examples/captcha-showcase": {
"name": "captcha-showcase",
"version": "0.1.0",
"dependencies": {
"@wrnexus/captcha": "workspace:*",
"@wrnexus/core": "workspace:*",
"@wrnexus/validation": "workspace:*",
},
"devDependencies": {
"@iconify-json/lucide": "^1.2.119",
"@iconify/tailwind4": "^1.2.3",
"@tailwindcss/cli": "^4.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.9.2",
},
},
"examples/component-showcase": { "examples/component-showcase": {
"name": "component-showcase", "name": "component-showcase",
"version": "0.1.0", "version": "0.1.0",
@@ -50,15 +66,29 @@
}, },
"packages/ai": { "packages/ai": {
"name": "@wrnexus/ai", "name": "@wrnexus/ai",
"version": "0.3.4", "version": "0.3.6",
}, },
"packages/authz": { "packages/authz": {
"name": "@wrnexus/authz", "name": "@wrnexus/authz",
"version": "0.3.4", "version": "0.3.6",
},
"packages/captcha": {
"name": "@wrnexus/captcha",
"version": "0.3.6",
"dependencies": {
"@wrnexus/core": "workspace:*",
"@wrnexus/plugin": "workspace:*",
"@wrnexus/validation": "workspace:*",
},
"devDependencies": {
"@types/bun": "latest",
"@wrnexus/syntax": "workspace:*",
"typescript": "^5.9.2",
},
}, },
"packages/cli": { "packages/cli": {
"name": "@wrnexus/cli", "name": "@wrnexus/cli",
"version": "0.3.4", "version": "0.3.6",
"bin": { "bin": {
"wrnexus": "src/index.ts", "wrnexus": "src/index.ts",
}, },
@@ -79,29 +109,29 @@
}, },
"packages/compiler": { "packages/compiler": {
"name": "@wrnexus/compiler", "name": "@wrnexus/compiler",
"version": "0.3.4", "version": "0.3.6",
"dependencies": { "dependencies": {
"@wrnexus/syntax": "workspace:*", "@wrnexus/syntax": "workspace:*",
}, },
}, },
"packages/core": { "packages/core": {
"name": "@wrnexus/core", "name": "@wrnexus/core",
"version": "0.3.4", "version": "0.3.6",
}, },
"packages/csr": { "packages/csr": {
"name": "@wrnexus/csr", "name": "@wrnexus/csr",
"version": "0.3.4", "version": "0.3.6",
"dependencies": { "dependencies": {
"@wrnexus/core": "workspace:*", "@wrnexus/core": "workspace:*",
}, },
}, },
"packages/db": { "packages/db": {
"name": "@wrnexus/db", "name": "@wrnexus/db",
"version": "0.3.4", "version": "0.3.6",
}, },
"packages/dev-server": { "packages/dev-server": {
"name": "@wrnexus/dev-server", "name": "@wrnexus/dev-server",
"version": "0.3.4", "version": "0.3.6",
"dependencies": { "dependencies": {
"@wrnexus/compiler": "workspace:*", "@wrnexus/compiler": "workspace:*",
"@wrnexus/core": "workspace:*", "@wrnexus/core": "workspace:*",
@@ -121,7 +151,7 @@
}, },
"packages/dev-toolbar": { "packages/dev-toolbar": {
"name": "@wrnexus/dev-toolbar", "name": "@wrnexus/dev-toolbar",
"version": "0.3.4", "version": "0.3.6",
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
"typescript": "^5.9.2", "typescript": "^5.9.2",
@@ -129,63 +159,63 @@
}, },
"packages/encryption": { "packages/encryption": {
"name": "@wrnexus/encryption", "name": "@wrnexus/encryption",
"version": "0.3.4", "version": "0.3.6",
}, },
"packages/helpers": { "packages/helpers": {
"name": "@wrnexus/helpers", "name": "@wrnexus/helpers",
"version": "0.3.4", "version": "0.3.6",
"dependencies": { "dependencies": {
"@wrnexus/core": "workspace:*", "@wrnexus/core": "workspace:*",
}, },
}, },
"packages/i18n": { "packages/i18n": {
"name": "@wrnexus/i18n", "name": "@wrnexus/i18n",
"version": "0.3.4", "version": "0.3.6",
"dependencies": { "dependencies": {
"@wrnexus/core": "workspace:*", "@wrnexus/core": "workspace:*",
}, },
}, },
"packages/jwt": { "packages/jwt": {
"name": "@wrnexus/jwt", "name": "@wrnexus/jwt",
"version": "0.3.4", "version": "0.3.6",
}, },
"packages/mobile": { "packages/mobile": {
"name": "@wrnexus/mobile", "name": "@wrnexus/mobile",
"version": "0.3.4", "version": "0.3.6",
"dependencies": { "dependencies": {
"@wrnexus/native": "workspace:*", "@wrnexus/native": "workspace:*",
}, },
}, },
"packages/native": { "packages/native": {
"name": "@wrnexus/native", "name": "@wrnexus/native",
"version": "0.3.4", "version": "0.3.6",
}, },
"packages/oauth": { "packages/oauth": {
"name": "@wrnexus/oauth", "name": "@wrnexus/oauth",
"version": "0.3.4", "version": "0.3.6",
}, },
"packages/plugin": { "packages/plugin": {
"name": "@wrnexus/plugin", "name": "@wrnexus/plugin",
"version": "0.3.4", "version": "0.3.6",
"dependencies": { "dependencies": {
"@wrnexus/syntax": "workspace:*", "@wrnexus/syntax": "workspace:*",
}, },
}, },
"packages/pubsub": { "packages/pubsub": {
"name": "@wrnexus/pubsub", "name": "@wrnexus/pubsub",
"version": "0.3.4", "version": "0.3.6",
}, },
"packages/queue": { "packages/queue": {
"name": "@wrnexus/queue", "name": "@wrnexus/queue",
"version": "0.3.4", "version": "0.3.6",
}, },
"packages/reactive": { "packages/reactive": {
"name": "@wrnexus/reactive", "name": "@wrnexus/reactive",
"version": "0.3.4", "version": "0.3.6",
}, },
"packages/router": { "packages/router": {
"name": "@wrnexus/router", "name": "@wrnexus/router",
"version": "0.3.4", "version": "0.3.6",
"dependencies": { "dependencies": {
"@wrnexus/compiler": "workspace:*", "@wrnexus/compiler": "workspace:*",
"@wrnexus/core": "workspace:*", "@wrnexus/core": "workspace:*",
@@ -193,14 +223,14 @@
}, },
"packages/ssr": { "packages/ssr": {
"name": "@wrnexus/ssr", "name": "@wrnexus/ssr",
"version": "0.3.4", "version": "0.3.6",
"dependencies": { "dependencies": {
"@wrnexus/core": "workspace:*", "@wrnexus/core": "workspace:*",
}, },
}, },
"packages/styles": { "packages/styles": {
"name": "@wrnexus/styles", "name": "@wrnexus/styles",
"version": "0.3.4", "version": "0.3.6",
"dependencies": { "dependencies": {
"@wrnexus/core": "workspace:*", "@wrnexus/core": "workspace:*",
"@wrnexus/plugin": "workspace:*", "@wrnexus/plugin": "workspace:*",
@@ -209,33 +239,44 @@
}, },
"packages/syntax": { "packages/syntax": {
"name": "@wrnexus/syntax", "name": "@wrnexus/syntax",
"version": "0.3.4", "version": "0.3.6",
}, },
"packages/test": { "packages/test": {
"name": "@wrnexus/test", "name": "@wrnexus/test",
"version": "0.3.4", "version": "0.3.6",
}, },
"packages/tracking": { "packages/tracking": {
"name": "@wrnexus/tracking", "name": "@wrnexus/tracking",
"version": "0.3.4", "version": "0.3.6",
}, },
"packages/ui": { "packages/ui": {
"name": "@wrnexus/ui", "name": "@wrnexus/ui",
"version": "0.3.4", "version": "0.3.6",
"dependencies": { "dependencies": {
"@wrnexus/core": "workspace:*", "@wrnexus/core": "workspace:*",
}, },
}, },
"packages/uploader": { "packages/uploader": {
"name": "@wrnexus/uploader", "name": "@wrnexus/uploader",
"version": "0.3.4", "version": "0.3.6",
"dependencies": { "dependencies": {
"@wrnexus/core": "workspace:*", "@wrnexus/core": "workspace:*",
}, },
}, },
"packages/validation": { "packages/validation": {
"name": "@wrnexus/validation", "name": "@wrnexus/validation",
"version": "0.3.4", "version": "0.3.6",
},
"services/managed-captcha": {
"name": "@wrnexus/managed-captcha-service",
"version": "0.3.6",
"dependencies": {
"@wrnexus/captcha": "workspace:*",
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5.9.2",
},
}, },
}, },
"overrides": { "overrides": {
@@ -324,7 +365,7 @@
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@iconify-json/lucide": ["@iconify-json/lucide@1.2.118", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-JBnK4YOq6K/lA0JP//27QxFxJ4120TjvfXAzGZZIGjCcXcRRRFxl1rcV7+IWdcVCe90KXdqVaAwLaLf6G3HELw=="], "@iconify-json/lucide": ["@iconify-json/lucide@1.2.119", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-KbrC7fT5wPshV1KLRM0k91poP0OH4eKEVywEOH0FCczKb+qth8Pcq1Hy9kS2tXeF52s23qzLnG8AJsExPSupeA=="],
"@iconify/tailwind4": ["@iconify/tailwind4@1.2.3", "", { "dependencies": { "@iconify/tools": "^5.0.5", "@iconify/types": "^2.0.0", "@iconify/utils": "^3.1.0" }, "peerDependencies": { "tailwindcss": ">= 4.0.0" } }, "sha512-z8SKiMHRASJKF/IY//87MF88lcB7ulxh8vlhQXXLWsBkNtOh6ese9R41MyGpQeqXdRvQVt+/fX2glQtHFjQ+MA=="], "@iconify/tailwind4": ["@iconify/tailwind4@1.2.3", "", { "dependencies": { "@iconify/tools": "^5.0.5", "@iconify/types": "^2.0.0", "@iconify/utils": "^3.1.0" }, "peerDependencies": { "tailwindcss": ">= 4.0.0" } }, "sha512-z8SKiMHRASJKF/IY//87MF88lcB7ulxh8vlhQXXLWsBkNtOh6ese9R41MyGpQeqXdRvQVt+/fX2glQtHFjQ+MA=="],
@@ -490,6 +531,8 @@
"@wrnexus/authz": ["@wrnexus/authz@workspace:packages/authz"], "@wrnexus/authz": ["@wrnexus/authz@workspace:packages/authz"],
"@wrnexus/captcha": ["@wrnexus/captcha@workspace:packages/captcha"],
"@wrnexus/cli": ["@wrnexus/cli@workspace:packages/cli"], "@wrnexus/cli": ["@wrnexus/cli@workspace:packages/cli"],
"@wrnexus/compiler": ["@wrnexus/compiler@workspace:packages/compiler"], "@wrnexus/compiler": ["@wrnexus/compiler@workspace:packages/compiler"],
@@ -512,6 +555,8 @@
"@wrnexus/jwt": ["@wrnexus/jwt@workspace:packages/jwt"], "@wrnexus/jwt": ["@wrnexus/jwt@workspace:packages/jwt"],
"@wrnexus/managed-captcha-service": ["@wrnexus/managed-captcha-service@workspace:services/managed-captcha"],
"@wrnexus/mobile": ["@wrnexus/mobile@workspace:packages/mobile"], "@wrnexus/mobile": ["@wrnexus/mobile@workspace:packages/mobile"],
"@wrnexus/native": ["@wrnexus/native@workspace:packages/native"], "@wrnexus/native": ["@wrnexus/native@workspace:packages/native"],
@@ -570,6 +615,8 @@
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
"captcha-showcase": ["captcha-showcase@workspace:examples/captcha-showcase"],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
@@ -862,6 +909,8 @@
"basic-app/typescript-eslint": ["typescript-eslint@8.63.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.63.0", "@typescript-eslint/parser": "8.63.0", "@typescript-eslint/typescript-estree": "8.63.0", "@typescript-eslint/utils": "8.63.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ=="], "basic-app/typescript-eslint": ["typescript-eslint@8.63.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.63.0", "@typescript-eslint/parser": "8.63.0", "@typescript-eslint/typescript-estree": "8.63.0", "@typescript-eslint/utils": "8.63.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ=="],
"component-showcase/@iconify-json/lucide": ["@iconify-json/lucide@1.2.118", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-JBnK4YOq6K/lA0JP//27QxFxJ4120TjvfXAzGZZIGjCcXcRRRFxl1rcV7+IWdcVCe90KXdqVaAwLaLf6G3HELw=="],
"csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="], "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="],
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
+47
View File
@@ -0,0 +1,47 @@
# Generated CAPTCHA image styles
The self-hosted and managed engines support 18 concrete renderers plus `random`.
![Renderer gallery](./image-style-gallery.png)
## Concrete styles
| Style | Main effect | Suggested use |
| --- | --- | --- |
| `classic` | Dots, crossing lines, glyph jitter | General default pool |
| `collision` | Overlapping coloured glyphs and bars | Medium or hard challenges |
| `snow` | Dense snow-like speckles | Medium challenges |
| `corrosion` | Eroded glyph patches and rust-like noise | Medium or hard challenges |
| `spiderweb` | Connected web lines and nodes | Medium challenges |
| `cross-shadow` | Multi-colour offset shadows | Medium challenges |
| `split` | Horizontally shifted image bands | Medium or hard challenges |
| `split2` | Vertically shifted image strips | Medium or hard challenges |
| `cut` | Slashed and interrupted character strokes | Medium challenges |
| `darts` | Radial lines and target rings | Medium challenges |
| `distortion` | Two-axis sinusoidal distortion | Medium or hard challenges |
| `stitch` | Dashed seams and cross stitches | Easy or medium challenges |
| `striped` | Diagonal and horizontal stripes | Easy or medium challenges |
| `wave` | Strong wave transformation | Medium or hard challenges |
| `grid-noise` | Grid lines and translucent cells | Easy or medium challenges |
| `scribble` | Random-walk line scribbles | Medium challenges |
| `pixel` | Pixel blocks and shifted regions | Medium challenges |
| `broken-lines` | Missing stroke segments and fragments | Medium or hard challenges |
## Random pool
```wrn
<Captcha
type="alphanumeric"
action="signup"
imageStyle="random"
allowedStyles="classic,snow,spiderweb,distortion,wave"
excludedStyles="collision"
disturbance="50"
/>
```
The engine resolves one concrete style per challenge. Refreshing creates a new challenge and selects again. Challenge metadata contains `imageStyle`, `requestedImageStyle`, and `imageStylePool`; it never contains the answer.
## Accessibility
Keep `showAudio="true"` or provide another non-visual path. High disturbance and hard renderers should not be the only completion option.
Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

+17
View File
@@ -0,0 +1,17 @@
# CAPTCHA showcase
Set a development secret and run:
```bash
CAPTCHA_SECRET="replace-with-at-least-32-random-characters" bun run dev
```
Open the local URL printed by WRNexusJS. The main page exercises number, alphabet, alphanumeric, calculation, image-selection, audio, compact/normal/big sizing, listen visibility, not-robot, invisible timing/honeypot, protected form-submit, and page-gate flows. Open `/styles` to test all 18 generated text-image renderers, random difficulty modes, allowed pools, and excluded pools.
Run the showcase validation with:
```bash
bun run check
```
The check runs TypeScript validation, the showcase integration tests, and a production build.
@@ -0,0 +1,10 @@
import type { Context } from "@wrnexus/core";
import { captchaHandlers } from "../../lib/captcha.ts";
async function handle(ctx: Context): Promise<Response> {
return (await captchaHandlers.handle(ctx.req, ctx)) ?? new Response("Not Found", { status: 404 });
}
export const GET = handle;
export const HEAD = handle;
export const POST = handle;
@@ -0,0 +1,39 @@
import type { Context } from "@wrnexus/core";
import { captchaGuard } from "@wrnexus/captcha/server";
import { parseBody } from "@wrnexus/validation";
import contactSchema from "../schemas/contact.ts";
import { captchaEngine } from "../lib/captcha.ts";
const protectContact = captchaGuard({
action: "contact-submit",
engine: captchaEngine,
responseField: "wrn-captcha-response",
bindHostname: true,
bindSession: true,
});
interface ContactInput {
name: string;
email: string;
topic: "general" | "security" | "billing" | "integration";
message: string;
consent: boolean;
}
export async function POST(ctx: Context): Promise<Response> {
const validation = await parseBody<ContactInput>(contactSchema, ctx.req.clone());
if (!validation.ok) return validation.response;
return protectContact(ctx, async () =>
Response.json({
ok: true,
message: "Validated CAPTCHA form accepted.",
submission: {
name: validation.value.name,
email: validation.value.email,
topic: validation.value.topic,
messageLength: validation.value.message.length,
},
}),
);
}
@@ -0,0 +1,34 @@
import type { Context } from "@wrnexus/core";
function safeReturnPath(value: string | null, baseUrl: URL): string {
if (!value) return "/protected";
try {
const destination = new URL(value, baseUrl);
if (destination.origin !== baseUrl.origin) return "/protected";
if (!destination.pathname.startsWith("/") || destination.pathname.startsWith("//")) {
return "/protected";
}
return `${destination.pathname}${destination.search}`;
} catch {
return "/protected";
}
}
export async function POST(ctx: Context): Promise<Response> {
const contentType = ctx.req.headers.get("content-type") ?? "";
let submittedReturnTo: string | null = null;
if (contentType.includes("application/json")) {
const body = (await ctx.req.json().catch(() => ({}))) as Record<string, unknown>;
submittedReturnTo = typeof body.returnTo === "string" ? body.returnTo : null;
} else {
const form = await ctx.req.formData();
const value = form.get("returnTo");
submittedReturnTo = typeof value === "string" ? value : null;
}
const returnTo = submittedReturnTo ?? ctx.url.searchParams.get("returnTo");
const safePath = safeReturnPath(returnTo, ctx.url);
return Response.redirect(new URL(safePath, ctx.url), 303);
}
@@ -0,0 +1,320 @@
component Captcha {
props {
provider = "self-hosted"
siteKey = ""
type = "alphanumeric"
action = "form-submit"
presentation = "visual"
difficulty = "normal"
disturbance = 50
imageStyle = "random"
allowedStyles = ""
excludedStyles = ""
randomizeStyle = false
locale = "en"
size = "normal"
color = "primary"
class = ""
id = ""
name = "wrn-captcha-response"
endpoint = "/__wrnexus/captcha/challenge"
verifyEndpoint = "/__wrnexus/captcha/verify"
responseField = "wrn-captcha-response"
label = "Security verification"
description = "Complete the challenge to continue."
helpText = ""
requiredMessage = "Please complete the security check."
incorrectMessage = "That answer was not correct. Try again."
expiredMessage = "This challenge expired. Load a new one."
networkMessage = "The verification service is unavailable. Try again."
autoLoad = true
autoVerify = false
showVerify = true
showRefresh = true
showAudio = true
showListen = true
showStatus = true
disabled = false
required = true
compact = false
@event ready = function
@event challenge = function
@event input = function
@event verify = function
@event success = function
@event failure = function
@event expired = function
@event refresh = function
@event audioStart = function
@event audioEnd = function
@event error = function
}
view {
<section
{...attrs}
id='{id}'
data-wrn-captcha
data-captcha-provider='{provider}'
data-captcha-site-key='{siteKey}'
data-captcha-type='{type}'
data-captcha-action='{action}'
data-captcha-presentation='{presentation}'
data-captcha-difficulty='{difficulty}'
data-captcha-disturbance='{disturbance}'
data-captcha-image-style='{imageStyle}'
data-captcha-allowed-styles='{allowedStyles}'
data-captcha-excluded-styles='{excludedStyles}'
data-captcha-randomize-style='{randomizeStyle}'
data-captcha-locale='{locale}'
data-captcha-size='{size}'
data-captcha-color='{color}'
data-captcha-endpoint='{endpoint}'
data-captcha-verify-endpoint='{verifyEndpoint}'
data-captcha-response-field='{responseField}'
data-captcha-name='{name}'
data-captcha-auto-load='{autoLoad}'
data-captcha-auto-verify='{autoVerify}'
data-captcha-show-verify='{showVerify}'
data-captcha-show-refresh='{showRefresh}'
data-captcha-show-audio='{showAudio}'
data-captcha-show-listen='{showListen}'
data-captcha-show-status='{showStatus}'
data-captcha-disabled='{disabled}'
data-captcha-required='{required}'
data-captcha-compact='{compact}'
data-captcha-required-message='{requiredMessage}'
data-captcha-incorrect-message='{incorrectMessage}'
data-captcha-expired-message='{expiredMessage}'
data-captcha-network-message='{networkMessage}'
data-captcha-status="idle"
data-server-verification-required="true"
aria-busy="false"
class='group/captcha relative flex w-full max-w-lg flex-col gap-3 rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4 text-[var(--wire-color-text)] shadow-[var(--wire-shadow-1)] [--captcha-accent:var(--wire-color-primary)] data-[captcha-color=secondary]:[--captcha-accent:var(--wire-color-secondary)] data-[captcha-color=success]:[--captcha-accent:var(--wire-color-success)] data-[captcha-color=warning]:[--captcha-accent:var(--wire-color-warning)] data-[captcha-color=danger]:[--captcha-accent:var(--wire-color-danger)] data-[captcha-color=info]:[--captcha-accent:var(--wire-color-info)] data-[captcha-size=compact]:max-w-xs data-[captcha-size=compact]:gap-1.5 data-[captcha-size=compact]:p-2 data-[captcha-size=big]:max-w-2xl data-[captcha-size=big]:gap-4 data-[captcha-size=big]:p-5 data-[captcha-compact=true]:max-w-xs data-[captcha-compact=true]:gap-1.5 data-[captcha-compact=true]:p-2 data-[captcha-type=not-robot]:max-w-sm data-[captcha-type=not-robot]:gap-0 data-[captcha-type=not-robot]:p-0 data-[captcha-disabled=true]:pointer-events-none data-[captcha-disabled=true]:opacity-60 data-[captcha-status=verified]:border-[var(--wire-color-success)] data-[captcha-status=incorrect]:border-[var(--wire-color-danger)] data-[captcha-status=expired]:border-[var(--wire-color-danger)] data-[captcha-status=network-error]:border-[var(--wire-color-danger)] data-[captcha-status=provider-error]:border-[var(--wire-color-danger)] {class}'
>
<script
src="/assets/wrnexus/captcha.js"
defer
data-wrn-captcha-runtime
></script>
<header data-captcha-header class='flex items-start justify-between gap-3 group-data-[captcha-size=compact]/captcha:gap-1.5 group-data-[captcha-type=not-robot]/captcha:hidden'>
<div class='min-w-0'>
<div class='flex items-center gap-2 group-data-[captcha-size=compact]/captcha:gap-1.5'>
<span
aria-hidden="true"
class='icon-[lucide--shield-check] size-5 shrink-0 text-[var(--captcha-accent)] group-data-[captcha-size=compact]/captcha:size-4'
></span>
<h3 class='m-0 text-sm font-semibold leading-5 text-[var(--wire-color-text)] group-data-[captcha-size=compact]/captcha:text-[11px] group-data-[captcha-size=compact]/captcha:leading-4 group-data-[captcha-size=big]/captcha:text-base'>
{label}
</h3>
</div>
<p class='m-0 mt-1 text-xs leading-5 text-[var(--wire-color-muted)] group-data-[captcha-size=compact]/captcha:hidden group-data-[captcha-size=big]/captcha:text-sm'>
{description}
</p>
</div>
<span
data-captcha-verified-badge
hidden
class='shrink-0 items-center gap-1 rounded-full bg-[color-mix(in_srgb,var(--wire-color-success)_12%,transparent)] px-2 py-1 text-xs font-semibold text-[var(--wire-color-success)] group-data-[captcha-size=compact]/captcha:gap-0.5 group-data-[captcha-size=compact]/captcha:px-1.5 group-data-[captcha-size=compact]/captcha:py-0.5 group-data-[captcha-size=compact]/captcha:text-[10px]'
>
<span aria-hidden="true" class='icon-[lucide--circle-check] size-3.5 group-data-[captcha-size=compact]/captcha:size-3'></span>
Verified
</span>
</header>
<div
data-captcha-provider-mount
hidden
class='flex min-h-16 w-full items-center justify-center overflow-hidden rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-surface-2)] p-2 group-data-[captcha-size=compact]/captcha:min-h-12 group-data-[captcha-size=compact]/captcha:p-1.5'
></div>
<div
data-captcha-loading
hidden
class='flex min-h-28 items-center justify-center gap-2 rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-surface-2)] text-sm text-[var(--wire-color-muted)] group-data-[captcha-size=compact]/captcha:min-h-14 group-data-[captcha-size=compact]/captcha:gap-1.5 group-data-[captcha-size=compact]/captcha:text-[11px] group-data-[captcha-size=compact]/captcha:leading-4 group-data-[captcha-size=big]/captcha:min-h-36 group-data-[captcha-type=not-robot]/captcha:m-3 group-data-[captcha-type=not-robot]/captcha:min-h-16'
>
<span aria-hidden="true" class='icon-[lucide--loader-circle] size-5 animate-spin group-data-[captcha-size=compact]/captcha:size-4'></span>
Loading challenge…
</div>
<div data-captcha-challenge hidden class='flex flex-col gap-3 group-data-[captcha-size=compact]/captcha:gap-1.5 group-data-[captcha-size=big]/captcha:gap-4 group-data-[captcha-type=not-robot]/captcha:p-3'>
<div data-captcha-prompt-row class='flex items-start justify-between gap-3 group-data-[captcha-size=compact]/captcha:gap-2'>
<p data-captcha-prompt class='m-0 text-sm font-medium text-[var(--wire-color-text)] group-data-[captcha-size=compact]/captcha:text-[11px] group-data-[captcha-size=compact]/captcha:leading-4'></p>
<span data-captcha-countdown hidden class='shrink-0 text-xs tabular-nums text-[var(--wire-color-muted)] group-data-[captcha-size=compact]/captcha:text-[10px]'></span>
</div>
<div data-captcha-not-robot hidden class='w-full'>
<button
data-captcha-not-robot-button
type="button"
aria-pressed="false"
class='flex min-h-20 w-full items-center gap-3 rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-4 py-3 text-left transition-[border-color,box-shadow,background-color] hover:border-[var(--captcha-accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--captcha-accent)] disabled:cursor-wait disabled:opacity-80 group-data-[captcha-size=compact]/captcha:min-h-16 group-data-[captcha-size=compact]/captcha:gap-2.5 group-data-[captcha-size=compact]/captcha:px-3 group-data-[captcha-size=compact]/captcha:py-2.5 group-data-[captcha-size=big]/captcha:min-h-24 group-data-[captcha-size=big]/captcha:px-5 group-data-[captcha-size=big]/captcha:py-4'
>
<span
data-captcha-not-robot-control
aria-hidden="true"
class='inline-flex size-7 shrink-0 items-center justify-center rounded-md border-2 border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] text-white transition-colors data-[verified=true]:border-[var(--wire-color-success)] data-[verified=true]:bg-[var(--wire-color-success)] group-data-[captcha-size=compact]/captcha:size-6 group-data-[captcha-size=big]/captcha:size-8'
>
<span data-captcha-not-robot-empty class='size-full'></span>
<span data-captcha-not-robot-spinner hidden class='icon-[lucide--loader-circle] size-4 animate-spin text-[var(--captcha-accent)]'></span>
<span data-captcha-not-robot-check hidden class='icon-[lucide--check] size-5'></span>
</span>
<span data-captcha-not-robot-label class='min-w-0 flex-1 text-sm font-semibold text-[var(--wire-color-text)] group-data-[captcha-size=compact]/captcha:text-[11px] group-data-[captcha-size=compact]/captcha:leading-4 group-data-[captcha-size=big]/captcha:text-base'>
I'm not a robot
</span>
<span class='flex shrink-0 flex-col items-center gap-0.5 text-[10px] leading-none text-[var(--wire-color-muted)]'>
<span aria-hidden="true" class='icon-[lucide--shield-check] size-6 text-[var(--captcha-accent)] group-data-[captcha-size=compact]/captcha:size-5 group-data-[captcha-size=big]/captcha:size-7'></span>
<span>WRNexus</span>
</span>
</button>
</div>
<div
data-captcha-image-wrap
hidden
class='overflow-hidden rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-white'
>
<img
data-captcha-image
src=""
alt="CAPTCHA challenge"
draggable="false"
class='block h-auto min-h-20 w-full select-none object-contain group-data-[captcha-size=compact]/captcha:max-h-24 group-data-[captcha-size=compact]/captcha:min-h-12 group-data-[captcha-size=big]/captcha:min-h-28'
/>
</div>
<div
data-captcha-items
hidden
role="group"
class='grid grid-cols-2 gap-2 group-data-[captcha-size=compact]/captcha:gap-1 sm:grid-cols-3 group-data-[captcha-size=big]/captcha:gap-3'
></div>
<template data-captcha-item-template>
<button
type="button"
aria-pressed="false"
class='group relative overflow-hidden rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-white p-1 group-data-[captcha-size=compact]/captcha:p-0.5 transition-[border-color,box-shadow,transform] duration-[var(--wire-motion-fast)] hover:border-[var(--captcha-accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--captcha-accent)] aria-[pressed=true]:border-[var(--captcha-accent)] aria-[pressed=true]:ring-2 aria-[pressed=true]:ring-[var(--captcha-accent)]'
>
<img
data-captcha-item-image
src=""
alt=""
draggable="false"
class='aspect-[4/3] w-full select-none object-cover'
/>
<span
data-captcha-item-check
hidden
aria-hidden="true"
class='absolute right-1.5 top-1.5 inline-flex size-5 group-data-[captcha-size=compact]/captcha:right-1 group-data-[captcha-size=compact]/captcha:top-1 group-data-[captcha-size=compact]/captcha:size-4 items-center justify-center rounded-full bg-[var(--captcha-accent)] text-white shadow-sm'
>
<span class='icon-[lucide--check] size-3.5'></span>
</span>
</button>
</template>
<label data-captcha-answer-wrap hidden class='flex flex-col gap-1.5 group-data-[captcha-size=compact]/captcha:gap-1'>
<span class='text-xs font-semibold text-[var(--wire-color-text)] group-data-[captcha-size=compact]/captcha:text-[10px]'>Your answer</span>
<input
data-captcha-answer
type="text"
inputmode="text"
autocomplete="off"
autocapitalize="characters"
spellcheck="false"
class='h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] px-3 text-base font-semibold tracking-[0.18em] text-[var(--wire-color-text)] outline-none transition-[border-color,box-shadow] placeholder:tracking-normal focus:border-[var(--captcha-accent)] focus:ring-2 focus:ring-[var(--captcha-accent)] disabled:cursor-not-allowed disabled:bg-[var(--wire-color-surface-2)] aria-[invalid=true]:border-[var(--wire-color-danger)] aria-[invalid=true]:ring-2 aria-[invalid=true]:ring-[var(--wire-color-danger)] group-data-[captcha-size=compact]/captcha:h-8 group-data-[captcha-size=compact]/captcha:px-2 group-data-[captcha-size=compact]/captcha:text-xs group-data-[captcha-size=compact]/captcha:tracking-[0.12em] group-data-[captcha-size=big]/captcha:h-12 group-data-[captcha-size=big]/captcha:px-4 group-data-[captcha-size=big]/captcha:text-lg'
placeholder="Enter the answer"
/>
</label>
<input
data-captcha-honeypot
type="text"
name=""
value=""
tabindex="-1"
autocomplete="off"
aria-hidden="true"
class='pointer-events-none absolute -left-[10000px] top-auto h-px w-px overflow-hidden opacity-0'
/>
</div>
<div data-captcha-success hidden role="status" aria-live="polite" class='flex items-start gap-1.5 text-xs leading-5 text-[var(--wire-color-success)] group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-size=compact]/captcha:text-[10px] group-data-[captcha-size=compact]/captcha:leading-4 group-data-[captcha-type=not-robot]/captcha:hidden'>
<span aria-hidden="true" class='icon-[lucide--circle-check] mt-0.5 size-3.5 shrink-0'></span>
<span data-captcha-success-message>Verification completed.</span>
</div>
<div data-captcha-error hidden role="alert" aria-live="polite" class='flex items-start gap-1.5 text-xs leading-5 text-[var(--wire-color-danger)] group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-size=compact]/captcha:text-[10px] group-data-[captcha-size=compact]/captcha:leading-4 group-data-[captcha-type=not-robot]/captcha:mx-3 group-data-[captcha-type=not-robot]/captcha:mb-3'>
<span aria-hidden="true" class='icon-[lucide--circle-alert] mt-0.5 size-3.5 shrink-0'></span>
<span data-captcha-error-message></span>
</div>
<p data-captcha-help class='m-0 text-xs leading-5 text-[var(--wire-color-muted)] group-data-[captcha-size=compact]/captcha:text-[10px] group-data-[captcha-size=compact]/captcha:leading-4 group-data-[captcha-type=not-robot]/captcha:hidden'>
{helpText}
</p>
<footer data-captcha-footer class='flex flex-wrap items-center justify-between gap-2 group-data-[captcha-size=compact]/captcha:flex-nowrap group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-type=not-robot]/captcha:hidden'>
<div class='flex flex-wrap items-center gap-1.5 group-data-[captcha-size=compact]/captcha:flex-nowrap group-data-[captcha-size=compact]/captcha:gap-0.5'>
<button
data-captcha-audio
hidden
type="button"
class='inline-flex h-9 items-center justify-center gap-1.5 rounded-[var(--wire-radius-sm)] px-2.5 text-xs group-data-[captcha-size=compact]/captcha:h-8 group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-size=compact]/captcha:px-1.5 group-data-[captcha-size=compact]/captcha:text-[10px] font-semibold text-[var(--wire-color-muted)] transition-colors hover:bg-[var(--wire-color-surface-2)] hover:text-[var(--captcha-accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--captcha-accent)] disabled:cursor-not-allowed disabled:opacity-50'
>
<span aria-hidden="true" class='icon-[lucide--volume-2] size-4 group-data-[captcha-size=compact]/captcha:size-3.5'></span>
Listen
</button>
<button
data-captcha-audio-alternative
hidden
type="button"
class='inline-flex h-9 items-center justify-center gap-1.5 rounded-[var(--wire-radius-sm)] px-2.5 text-xs group-data-[captcha-size=compact]/captcha:h-8 group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-size=compact]/captcha:px-1.5 group-data-[captcha-size=compact]/captcha:text-[10px] font-semibold text-[var(--wire-color-muted)] transition-colors hover:bg-[var(--wire-color-surface-2)] hover:text-[var(--captcha-accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--captcha-accent)] disabled:cursor-not-allowed disabled:opacity-50'
>
<span aria-hidden="true" class='icon-[lucide--ear] size-4 group-data-[captcha-size=compact]/captcha:size-3.5'></span>
Use audio
</button>
<button
data-captcha-refresh
type="button"
class='inline-flex h-9 items-center justify-center gap-1.5 rounded-[var(--wire-radius-sm)] px-2.5 text-xs group-data-[captcha-size=compact]/captcha:h-8 group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-size=compact]/captcha:px-1.5 group-data-[captcha-size=compact]/captcha:text-[10px] font-semibold text-[var(--wire-color-muted)] transition-colors hover:bg-[var(--wire-color-surface-2)] hover:text-[var(--captcha-accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--captcha-accent)] disabled:cursor-not-allowed disabled:opacity-50'
>
<span aria-hidden="true" class='icon-[lucide--refresh-cw] size-4 group-data-[captcha-size=compact]/captcha:size-3.5'></span>
New challenge
</button>
</div>
<button
data-captcha-verify
type="button"
class='inline-flex h-9 items-center justify-center gap-1.5 rounded-[var(--wire-radius-sm)] bg-[var(--captcha-accent)] px-3 text-xs group-data-[captcha-size=compact]/captcha:h-8 group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-size=compact]/captcha:px-2.5 group-data-[captcha-size=compact]/captcha:text-[10px] font-semibold text-white shadow-sm transition-[filter,transform] hover:brightness-95 active:translate-y-px focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--captcha-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--wire-color-surface)] disabled:cursor-not-allowed disabled:opacity-50'
>
<span data-captcha-verify-icon aria-hidden="true" class='icon-[lucide--shield-check] size-4 group-data-[captcha-size=compact]/captcha:size-3.5'></span>
<span data-captcha-verify-spinner hidden aria-hidden="true" class='icon-[lucide--loader-circle] size-4 animate-spin group-data-[captcha-size=compact]/captcha:size-3.5'></span>
<span data-captcha-verify-label>Verify</span>
</button>
</footer>
<input
data-captcha-response
type="hidden"
name='{responseField}'
value=""
/>
<p data-captcha-credit class='m-0 text-[11px] leading-4 text-[var(--wire-color-muted)] group-data-[captcha-size=compact]/captcha:hidden group-data-[captcha-type=not-robot]/captcha:hidden'>
Protected by WRNexus CAPTCHA. Server-side verification is required.
</p>
</section>
}
}
@@ -0,0 +1,17 @@
import { createCaptchaEngine, createCaptchaHttpHandlers, MemoryCaptchaStore } from "@wrnexus/captcha/server";
export const captchaEngine = createCaptchaEngine({
secret: process.env.CAPTCHA_SECRET ?? "development-only-change-this-captcha-secret-123456",
store: new MemoryCaptchaStore(),
basePath: "/api/captcha",
challengeTtlMs: 2 * 60_000,
responseTokenTtlMs: 5 * 60_000,
maxAttempts: 3,
minCompletionMs: 800,
});
export const captchaHandlers = createCaptchaHttpHandlers(captchaEngine, {
createLimit: 120,
verifyLimit: 60,
windowMs: 60_000,
});
@@ -0,0 +1,21 @@
import type { Context, Next } from "@wrnexus/core";
import { captchaPageGate } from "@wrnexus/captcha/server";
import { captchaEngine } from "../lib/captcha.ts";
const pageGate = captchaPageGate({
action: "protected-page-access",
engine: captchaEngine,
challengePath: "/captcha",
policy: {
mode: "session",
verifiedForMs: 15 * 60_000,
routeGroups: ["/protected"],
},
});
export default function captchaProtectedPages(ctx: Context, next: Next) {
if (ctx.url.pathname === "/protected" || ctx.url.pathname === "/api/page-grant") {
return pageGate(ctx, next);
}
return next();
}
@@ -0,0 +1,37 @@
page CaptchaGate {
state returnTo = ctx.url.searchParams.get("returnTo") || "/protected"
seo {
title = "Complete security verification"
robots = "noindex,nofollow"
}
view {
<main class="grid min-h-screen place-items-center bg-[var(--wire-color-bg)] px-4 py-10 text-[var(--wire-color-text)]">
<form
method="post"
action="/api/page-grant"
class="w-full max-w-xl rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-5 shadow-[var(--wire-shadow-1)] sm:p-7"
>
<input type="hidden" name="returnTo" value='{returnTo}' />
<p class="mb-2 text-sm font-semibold text-[var(--wire-color-primary)]">Protected page</p>
<h1 class="m-0 text-2xl font-bold">Confirm you are human</h1>
<p class="mb-5 mt-2 text-sm leading-6 text-[var(--wire-color-muted)]">Complete this once to access protected showcase pages for 15 minutes.</p>
<Captcha
endpoint="/api/captcha/challenge"
verifyEndpoint="/api/captcha/verify"
type="alphanumeric"
action="protected-page-access"
disturbance="50"
required="true"
/>
<button type="submit" class="mt-4 h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-5 font-semibold text-[var(--wire-color-primary-contrast)]">
Continue to protected page
</button>
</form>
</main>
}
}
@@ -0,0 +1,633 @@
page CaptchaShowcase {
seo {
title = "CAPTCHA Component Showcase"
description = "Test every WRNexus CAPTCHA mode, validation flow, page gate, and provider integration."
}
view {
<main
class="min-h-screen bg-[var(--wire-color-bg)] px-4 py-10 text-[var(--wire-color-text)] sm:px-6 lg:px-8"
>
<div
class="mx-auto flex max-w-7xl flex-col gap-10"
>
<header
class="max-w-3xl"
>
<p
class="mb-2 text-sm font-semibold text-[var(--wire-color-primary)]"
>
@wrnexus/captcha
</p>
<h1
class="m-0 text-3xl font-bold tracking-tight sm:text-5xl"
>
CAPTCHA system showcase
</h1>
<p
class="mt-4 text-base leading-7 text-[var(--wire-color-muted)]"
>
Self-hosted number, alphabet, alphanumeric, calculation, image, audio, not-robot, honeypot, timing, validated form-submit, and page-gate protection.
</p>
<a
href="/styles"
class="mt-4 inline-flex h-10 items-center gap-2 rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] px-4 text-sm font-semibold text-[var(--wire-color-text)] hover:border-[var(--wire-color-primary)]"
>
<span
aria-hidden="true"
class="icon-[lucide--images] size-4 text-[var(--wire-color-primary)]"
>
</span>
Open all 18 image styles
</a>
</header>
<section
class="grid gap-6 lg:grid-cols-2"
>
<article
class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-5"
>
<h2
class="mt-0 text-lg font-semibold"
>
Number CAPTCHA
</h2>
<Captcha
endpoint="/api/captcha/challenge"
verifyEndpoint="/api/captcha/verify"
type="number"
action="demo-number"
imageStyle="random"
disturbance="35"
/>
</article>
<article
class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-5"
>
<h2
class="mt-0 text-lg font-semibold"
>
Alphabet CAPTCHA
</h2>
<Captcha
endpoint="/api/captcha/challenge"
verifyEndpoint="/api/captcha/verify"
type="alpha"
action="demo-alpha"
imageStyle="random"
disturbance="40"
/>
</article>
<article
class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-5"
>
<h2
class="mt-0 text-lg font-semibold"
>
Alphanumeric CAPTCHA
</h2>
<Captcha
endpoint="/api/captcha/challenge"
verifyEndpoint="/api/captcha/verify"
type="alphanumeric"
action="demo-alphanumeric"
imageStyle="random"
difficulty="hard"
disturbance="70"
/>
</article>
<article
class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-5"
>
<h2
class="mt-0 text-lg font-semibold"
>
Calculation CAPTCHA
</h2>
<Captcha
endpoint="/api/captcha/challenge"
verifyEndpoint="/api/captcha/verify"
type="calculation"
action="demo-calculation"
imageStyle="random"
disturbance="50"
/>
</article>
</section>
<section
class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-5 sm:p-7"
>
<div
class="max-w-3xl"
>
<h2
class="mt-0 text-xl font-semibold"
>
Compact, normal, and big sizes
</h2>
<p
class="text-sm leading-6 text-[var(--wire-color-muted)]"
>
Use the size prop to fit dense forms, standard pages, or large security flows.
</p>
</div>
<div
class="mt-5 grid items-start gap-5 xl:grid-cols-3"
>
<article
class="rounded-xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] p-4"
>
<h3
class="mt-0 text-base font-semibold"
>
Compact
</h3>
<Captcha
endpoint="/api/captcha/challenge"
verifyEndpoint="/api/captcha/verify"
type="number"
action="demo-size-compact"
size="compact"
disturbance="30"
showListen="false"
/>
</article>
<article
class="rounded-xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] p-4"
>
<h3
class="mt-0 text-base font-semibold"
>
Normal
</h3>
<Captcha
endpoint="/api/captcha/challenge"
verifyEndpoint="/api/captcha/verify"
type="number"
action="demo-size-normal"
size="normal"
disturbance="40"
/>
</article>
<article
class="rounded-xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] p-4"
>
<h3
class="mt-0 text-base font-semibold"
>
Big
</h3>
<Captcha
endpoint="/api/captcha/challenge"
verifyEndpoint="/api/captcha/verify"
type="number"
action="demo-size-big"
size="big"
disturbance="50"
/>
</article>
</div>
</section>
<section
class="grid gap-6 lg:grid-cols-2"
>
<article
class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-5"
>
<h2
class="mt-0 text-lg font-semibold"
>
I'm not a robot
</h2>
<p
class="text-sm leading-6 text-[var(--wire-color-muted)]"
>
A low-friction, one-time checkbox challenge backed by timing, honeypot, expiry, action, session, and server-side token checks.
</p>
<Captcha
endpoint="/api/captcha/challenge"
verifyEndpoint="/api/captcha/verify"
type="not-robot"
action="demo-not-robot"
size="compact"
showAudio="false"
showListen="false"
/>
</article>
<article
class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-5"
>
<h2
class="mt-0 text-lg font-semibold"
>
Listen button hidden
</h2>
<p
class="text-sm leading-6 text-[var(--wire-color-muted)]"
>
Set showListen to false when a separate accessible alternative is provided elsewhere.
</p>
<Captcha
endpoint="/api/captcha/challenge"
verifyEndpoint="/api/captcha/verify"
type="alphanumeric"
action="demo-listen-hidden"
size="compact"
showListen="false"
/>
</article>
</section>
<section
class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-5 sm:p-7"
>
<div
class="max-w-3xl"
>
<h2
class="mt-0 text-xl font-semibold"
>
Generated image disturbance
</h2>
<p
class="text-sm leading-6 text-[var(--wire-color-muted)]"
>
Use the disturbance prop from 25 to 75. Lower values are easier to read; higher values add more dots and crossing lines.
</p>
</div>
<div
class="mt-5 grid gap-5 xl:grid-cols-3"
>
<article
class="rounded-xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] p-4"
>
<div
class="mb-3 flex items-center justify-between gap-3"
>
<h3
class="m-0 text-base font-semibold"
>
Easy image
</h3>
<code
class="rounded-md bg-[var(--wire-color-surface)] px-2 py-1 text-xs"
>
25%
</code>
</div>
<Captcha
endpoint="/api/captcha/challenge"
verifyEndpoint="/api/captcha/verify"
type="image"
action="demo-image-easy"
disturbance="25"
size="big"
/>
</article>
<article
class="rounded-xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] p-4"
>
<div
class="mb-3 flex items-center justify-between gap-3"
>
<h3
class="m-0 text-base font-semibold"
>
Normal image
</h3>
<code
class="rounded-md bg-[var(--wire-color-surface)] px-2 py-1 text-xs"
>
50%
</code>
</div>
<Captcha
endpoint="/api/captcha/challenge"
verifyEndpoint="/api/captcha/verify"
type="image"
action="demo-image-normal"
disturbance="50"
size="big"
/>
</article>
<article
class="rounded-xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] p-4"
>
<div
class="mb-3 flex items-center justify-between gap-3"
>
<h3
class="m-0 text-base font-semibold"
>
Hard image
</h3>
<code
class="rounded-md bg-[var(--wire-color-surface)] px-2 py-1 text-xs"
>
75%
</code>
</div>
<Captcha
endpoint="/api/captcha/challenge"
verifyEndpoint="/api/captcha/verify"
type="image"
action="demo-image-hard"
disturbance="75"
difficulty="hard"
size="big"
/>
</article>
</div>
</section>
<section
class="grid gap-6 lg:grid-cols-2"
>
<article
class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-5"
>
<h2
class="mt-0 text-lg font-semibold"
>
Audio-first challenge
</h2>
<Captcha
endpoint="/api/captcha/challenge"
verifyEndpoint="/api/captcha/verify"
type="number"
presentation="audio"
action="demo-audio"
/>
</article>
<article
class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-5"
>
<h2
class="mt-0 text-lg font-semibold"
>
Invisible timing and honeypot
</h2>
<Captcha
endpoint="/api/captcha/challenge"
verifyEndpoint="/api/captcha/verify"
type="timing"
presentation="invisible"
action="demo-invisible"
compact="true"
/>
</article>
</section>
<section
class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-5 sm:p-7"
>
<div
class="max-w-3xl"
>
<p
class="mb-2 text-sm font-semibold text-[var(--wire-color-primary)]"
>
Complete integration
</p>
<h2
class="mt-0 text-xl font-semibold"
>
Validated contact form with CAPTCHA
</h2>
<p
class="text-sm leading-6 text-[var(--wire-color-muted)]"
>
The same contact schema validates in the browser and API. CAPTCHA verification remains mandatory on the server before the form is accepted.
</p>
</div>
<form
data-schema="contact"
method="post"
action="/api/contact"
class="mt-6 grid max-w-3xl gap-5"
novalidate
>
<div
data-success="Validated form submitted successfully."
hidden
class="rounded-xl border border-[var(--wire-color-success)] bg-[color-mix(in_srgb,var(--wire-color-success)_10%,transparent)] p-4 text-sm font-medium text-[var(--wire-color-success)]"
>
</div>
<div
class="grid gap-5 sm:grid-cols-2"
>
<label
class="grid gap-2 text-sm font-medium"
>
Full name
<input
name="name"
type="text"
autocomplete="name"
placeholder="Ajay Ghanwat"
class="h-11 rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 text-[var(--wire-color-text)] outline-none focus:border-[var(--wire-color-primary)] focus:ring-2 focus:ring-[var(--wire-color-primary)] aria-[invalid=true]:border-[var(--wire-color-danger)]"
/>
<span
data-error="name"
role="alert"
class="min-h-5 text-xs text-[var(--wire-color-danger)]"
>
</span>
</label>
<label
class="grid gap-2 text-sm font-medium"
>
Email address
<input
name="email"
type="email"
autocomplete="email"
placeholder="you@example.com"
class="h-11 rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 text-[var(--wire-color-text)] outline-none focus:border-[var(--wire-color-primary)] focus:ring-2 focus:ring-[var(--wire-color-primary)] aria-[invalid=true]:border-[var(--wire-color-danger)]"
/>
<span
data-error="email"
role="alert"
class="min-h-5 text-xs text-[var(--wire-color-danger)]"
>
</span>
</label>
</div>
<label
class="grid gap-2 text-sm font-medium"
>
Topic
<select
name="topic"
class="h-11 rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 text-[var(--wire-color-text)] outline-none focus:border-[var(--wire-color-primary)] focus:ring-2 focus:ring-[var(--wire-color-primary)] aria-[invalid=true]:border-[var(--wire-color-danger)]"
>
<option
value=""
>
Choose a topic
</option>
<option
value="general"
>
General question
</option>
<option
value="security"
>
Security and CAPTCHA
</option>
<option
value="billing"
>
Billing
</option>
<option
value="integration"
>
Integration support
</option>
</select>
<span
data-error="topic"
role="alert"
class="min-h-5 text-xs text-[var(--wire-color-danger)]"
>
</span>
</label>
<label
class="grid gap-2 text-sm font-medium"
>
Message
<textarea
name="message"
rows="5"
placeholder="Tell us how we can help..."
class="min-h-32 resize-y rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 py-3 text-[var(--wire-color-text)] outline-none focus:border-[var(--wire-color-primary)] focus:ring-2 focus:ring-[var(--wire-color-primary)] aria-[invalid=true]:border-[var(--wire-color-danger)]"
>
</textarea>
<span
data-error="message"
role="alert"
class="min-h-5 text-xs text-[var(--wire-color-danger)]"
>
</span>
</label>
<label
class="flex items-start gap-3 text-sm text-[var(--wire-color-muted)]"
>
<input
name="consent"
type="checkbox"
class="mt-0.5 size-4 rounded border-[var(--wire-color-border)] text-[var(--wire-color-primary)] focus:ring-[var(--wire-color-primary)]"
/>
<span
>
I confirm that this information may be processed for this showcase request.
</span>
</label>
<span
data-error="consent"
role="alert"
class="min-h-5 text-xs text-[var(--wire-color-danger)]"
>
</span>
<Captcha
endpoint="/api/captcha/challenge"
verifyEndpoint="/api/captcha/verify"
type="alphanumeric"
action="contact-submit"
disturbance="45"
required="true"
/>
<button
type="submit"
class="h-11 rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-5 font-semibold text-[var(--wire-color-primary-contrast)] disabled:cursor-not-allowed disabled:opacity-60"
>
Validate and submit form
</button>
</form>
</section>
<section
class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-5 sm:p-7"
>
<h2
class="mt-0 text-xl font-semibold"
>
Page-open protection
</h2>
<p
class="text-sm text-[var(--wire-color-muted)]"
>
This route uses a once-per-session page grant. After successful CAPTCHA verification, the form posts the response token and safely redirects to the requested protected route.
</p>
<a
href="/protected"
class="mt-3 inline-flex h-11 items-center rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-5 font-semibold text-[var(--wire-color-primary-contrast)]"
>
Open protected page
</a>
</section>
<section
class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-5 sm:p-7"
>
<h2
class="mt-0 text-xl font-semibold"
>
External providers
</h2>
<p
class="text-sm text-[var(--wire-color-muted)]"
>
Add real public test keys before enabling these examples.
</p>
<div
class="mt-5 grid gap-6 lg:grid-cols-3"
>
<Captcha
provider="turnstile"
siteKey=""
action="external-turnstile"
autoLoad="false"
/>
<Captcha
provider="recaptcha"
siteKey=""
action="external-recaptcha"
autoLoad="false"
/>
<Captcha
provider="hcaptcha"
siteKey=""
action="external-hcaptcha"
autoLoad="false"
/>
</div>
</section>
</div>
</main>
}
}
@@ -0,0 +1,17 @@
page ProtectedCaptchaPage {
seo {
title = "CAPTCHA protected page"
robots = "noindex,nofollow"
}
view {
<main class="grid min-h-screen place-items-center bg-[var(--wire-color-bg)] px-4 py-10 text-[var(--wire-color-text)]">
<section class="w-full max-w-2xl rounded-2xl border border-[var(--wire-color-success)] bg-[var(--wire-color-surface)] p-7 text-center shadow-[var(--wire-shadow-1)]">
<span aria-hidden="true" class="icon-[lucide--shield-check] mx-auto block size-12 text-[var(--wire-color-success)]"></span>
<h1 class="mt-4 text-3xl font-bold">Protected page unlocked</h1>
<p class="text-[var(--wire-color-muted)]">The session grant is valid for 15 minutes.</p>
<a href="/" class="mt-5 inline-flex h-11 items-center rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-5 font-semibold text-[var(--wire-color-primary-contrast)]">Back to showcase</a>
</section>
</main>
}
}
@@ -0,0 +1,96 @@
page CaptchaImageStyles {
seo {
title = "CAPTCHA Image Renderer Styles"
description = "Preview every WRNexusJS CAPTCHA image renderer with easy, normal, hard, random, allowed, and excluded style controls."
}
view {
<main class="min-h-screen bg-[var(--wire-color-bg)] px-4 py-10 text-[var(--wire-color-text)] sm:px-6 lg:px-8">
<div class="mx-auto flex max-w-7xl flex-col gap-10">
<header class="max-w-4xl">
<a href="/" class="mb-4 inline-flex items-center gap-2 text-sm font-semibold text-[var(--wire-color-primary)] hover:underline">
<span aria-hidden="true" class="icon-[lucide--arrow-left] size-4"></span>
Back to CAPTCHA showcase
</a>
<p class="mb-2 text-sm font-semibold text-[var(--wire-color-primary)]">Visual renderer catalog</p>
<h1 class="m-0 text-3xl font-bold tracking-tight sm:text-5xl">18 randomized CAPTCHA image styles</h1>
<p class="mt-4 text-base leading-7 text-[var(--wire-color-muted)]">
Every renderer keeps the answer on the server, supports disturbance from 25 to 75, and generates a fresh cryptographically randomized image for each challenge.
</p>
</header>
<section class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-5 sm:p-7">
<div class="max-w-3xl">
<h2 class="mt-0 text-xl font-semibold">Random style by difficulty</h2>
<p class="text-sm leading-6 text-[var(--wire-color-muted)]">
Use <code>imageStyle="random"</code> for a different renderer whenever a challenge is refreshed. Difficulty controls length and default disturbance.
</p>
</div>
<div class="mt-5 grid items-start gap-5 xl:grid-cols-3">
<article class="rounded-xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] p-4">
<h3 class="mt-0 text-base font-semibold">Easy random</h3>
<Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-random-easy" imageStyle="random" difficulty="easy" disturbance="25" size="compact" showListen="false" />
</article>
<article class="rounded-xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] p-4">
<h3 class="mt-0 text-base font-semibold">Normal random</h3>
<Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-random-normal" imageStyle="random" difficulty="normal" disturbance="50" size="compact" showListen="false" />
</article>
<article class="rounded-xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] p-4">
<h3 class="mt-0 text-base font-semibold">Hard random</h3>
<Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-random-hard" imageStyle="random" difficulty="hard" disturbance="75" size="compact" showListen="false" />
</article>
</div>
</section>
<section class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-5 sm:p-7">
<div class="max-w-3xl">
<h2 class="mt-0 text-xl font-semibold">Controlled random pools</h2>
<p class="text-sm leading-6 text-[var(--wire-color-muted)]">
Limit random selection with <code>allowedStyles</code>, remove renderers with <code>excludedStyles</code>, or force random selection with <code>randomizeStyle="true"</code>.
</p>
</div>
<div class="mt-5 grid items-start gap-5 lg:grid-cols-2">
<article class="rounded-xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] p-4">
<h3 class="mt-0 text-base font-semibold">Allowed pool</h3>
<p class="text-xs leading-5 text-[var(--wire-color-muted)]">classic, snow, distortion, wave</p>
<Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="number" action="style-allowed-pool" imageStyle="random" allowedStyles="classic,snow,distortion,wave" disturbance="50" size="compact" showListen="false" />
</article>
<article class="rounded-xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] p-4">
<h3 class="mt-0 text-base font-semibold">Excluded styles</h3>
<p class="text-xs leading-5 text-[var(--wire-color-muted)]">Excludes collision, split, split2, and broken-lines</p>
<Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="number" action="style-excluded-pool" imageStyle="random" excludedStyles="collision,split,split2,broken-lines" disturbance="50" size="compact" showListen="false" />
</article>
</div>
</section>
<section>
<div class="max-w-3xl">
<h2 class="mt-0 text-2xl font-semibold">All concrete renderers</h2>
<p class="text-sm leading-6 text-[var(--wire-color-muted)]">Refresh any card to see a new layout, noise pattern, color treatment, and character distortion while keeping the selected renderer.</p>
</div>
<div class="mt-6 grid items-start gap-6 lg:grid-cols-2 xl:grid-cols-3">
<article class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4"><h3 class="mt-0 text-base font-semibold">Classic</h3><Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-classic" imageStyle="classic" disturbance="50" size="compact" showListen="false" /></article>
<article class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4"><h3 class="mt-0 text-base font-semibold">Collision</h3><Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-collision" imageStyle="collision" disturbance="50" size="compact" showListen="false" /></article>
<article class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4"><h3 class="mt-0 text-base font-semibold">Snow</h3><Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-snow" imageStyle="snow" disturbance="50" size="compact" showListen="false" /></article>
<article class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4"><h3 class="mt-0 text-base font-semibold">Corrosion</h3><Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-corrosion" imageStyle="corrosion" disturbance="50" size="compact" showListen="false" /></article>
<article class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4"><h3 class="mt-0 text-base font-semibold">Spiderweb</h3><Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-spiderweb" imageStyle="spiderweb" disturbance="50" size="compact" showListen="false" /></article>
<article class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4"><h3 class="mt-0 text-base font-semibold">Cross shadow</h3><Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-cross-shadow" imageStyle="cross-shadow" disturbance="50" size="compact" showListen="false" /></article>
<article class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4"><h3 class="mt-0 text-base font-semibold">Split</h3><Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-split" imageStyle="split" disturbance="50" size="compact" showListen="false" /></article>
<article class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4"><h3 class="mt-0 text-base font-semibold">Split 2</h3><Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-split2" imageStyle="split2" disturbance="50" size="compact" showListen="false" /></article>
<article class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4"><h3 class="mt-0 text-base font-semibold">Cut</h3><Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-cut" imageStyle="cut" disturbance="50" size="compact" showListen="false" /></article>
<article class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4"><h3 class="mt-0 text-base font-semibold">Darts</h3><Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-darts" imageStyle="darts" disturbance="50" size="compact" showListen="false" /></article>
<article class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4"><h3 class="mt-0 text-base font-semibold">Distortion</h3><Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-distortion" imageStyle="distortion" disturbance="50" size="compact" showListen="false" /></article>
<article class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4"><h3 class="mt-0 text-base font-semibold">Stitch</h3><Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-stitch" imageStyle="stitch" disturbance="50" size="compact" showListen="false" /></article>
<article class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4"><h3 class="mt-0 text-base font-semibold">Striped</h3><Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-striped" imageStyle="striped" disturbance="50" size="compact" showListen="false" /></article>
<article class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4"><h3 class="mt-0 text-base font-semibold">Wave</h3><Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-wave" imageStyle="wave" disturbance="50" size="compact" showListen="false" /></article>
<article class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4"><h3 class="mt-0 text-base font-semibold">Grid noise</h3><Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-grid-noise" imageStyle="grid-noise" disturbance="50" size="compact" showListen="false" /></article>
<article class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4"><h3 class="mt-0 text-base font-semibold">Scribble</h3><Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-scribble" imageStyle="scribble" disturbance="50" size="compact" showListen="false" /></article>
<article class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4"><h3 class="mt-0 text-base font-semibold">Pixel</h3><Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-pixel" imageStyle="pixel" disturbance="50" size="compact" showListen="false" /></article>
<article class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4"><h3 class="mt-0 text-base font-semibold">Broken lines</h3><Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-broken-lines" imageStyle="broken-lines" disturbance="50" size="compact" showListen="false" /></article>
</div>
</section>
</div>
</main>
}
}
@@ -0,0 +1,58 @@
// AUTO-GENERATED by `wrnexus dev` - do not edit.
// Typed routes support required, optional, and catch-all parameters.
export interface Routes {
"/": Record<string, never>;
"/captcha": Record<string, never>;
"/protected": Record<string, never>;
"/styles": Record<string, never>;
}
export type RoutePath = keyof Routes;
type RouteValue = string | readonly string[] | undefined;
function encodeRouteValue(value: RouteValue, catchAll: boolean): string {
if (value === undefined) return "";
const values = Array.isArray(value) ? value : catchAll ? String(value).split("/") : [String(value)];
return values.map((part) => encodeURIComponent(part)).join("/");
}
export function href<P extends RoutePath>(
path: P,
...args: keyof Routes[P] extends never
? []
: Record<string, never> extends Routes[P]
? [params?: Routes[P]]
: [params: Routes[P]]
): string {
const params = (args[0] ?? {}) as Record<string, RouteValue>;
const output: string[] = [];
for (const segment of String(path).split("/").filter(Boolean)) {
let name: string | undefined;
let optional = false;
let catchAll = false;
if (segment.startsWith("[[") && segment.endsWith("]]")) {
optional = true;
name = segment.slice(2, -2);
} else if (segment.startsWith("[") && segment.endsWith("]")) {
name = segment.slice(1, -1);
if (name.endsWith("?")) {
optional = true;
name = name.slice(0, -1);
}
}
if (!name) {
output.push(segment);
continue;
}
if (name.startsWith("...")) {
catchAll = true;
name = name.slice(3);
}
const value = params[name];
if (value === undefined && optional) continue;
if (value === undefined) throw new Error(`WRN-ROUTE-MISSING-PARAM: Missing route parameter '${name}'.`);
output.push(encodeRouteValue(value, catchAll));
}
return "/" + output.filter(Boolean).join("/");
}
@@ -0,0 +1,27 @@
import { v } from "@wrnexus/validation";
export default v.object({
name: v
.string()
.required("Enter your name")
.trim()
.min(2, "Name must contain at least 2 characters")
.max(80, "Name must contain at most 80 characters"),
email: v
.string()
.required("Enter your email address")
.trim()
.email("Enter a valid email address")
.max(160, "Email address is too long"),
topic: v
.string()
.required("Choose a topic")
.oneOf(["general", "security", "billing", "integration"], "Choose a valid topic"),
message: v
.string()
.required("Enter your message")
.trim()
.min(10, "Message must contain at least 10 characters")
.max(1000, "Message must contain at most 1000 characters"),
consent: v.boolean().required("Confirm that we may process this request"),
});
@@ -0,0 +1,7 @@
@import "tailwindcss";
@plugin "@iconify/tailwind4";
@source "../**/*.wrn";
@source "../../../packages/captcha/components/*.wrn";
html { color-scheme: light dark; }
body { margin: 0; background: var(--wire-color-bg); color: var(--wire-color-text); font-family: "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif; }
+25
View File
@@ -0,0 +1,25 @@
{
"name": "captcha-showcase",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun run ../../packages/cli/src/index.ts dev .",
"build": "bun run ../../packages/cli/src/index.ts build .",
"test": "bun run ../../packages/cli/src/index.ts test .",
"typecheck": "tsc --noEmit -p tsconfig.json",
"check": "bun run typecheck && bun run test && bun run build"
},
"dependencies": {
"@wrnexus/captcha": "workspace:*",
"@wrnexus/core": "workspace:*",
"@wrnexus/validation": "workspace:*"
},
"devDependencies": {
"@iconify-json/lucide": "^1.2.0",
"@iconify/tailwind4": "^1.0.0",
"@tailwindcss/cli": "^4.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.9.2"
}
}
@@ -0,0 +1,878 @@
(function () {
"use strict";
var RUNTIME_KEY = "__wrnexusCaptchaRuntime";
var existingRuntime = window[RUNTIME_KEY];
if (existingRuntime && typeof existingRuntime.scan === "function") {
existingRuntime.scan(document);
return;
}
var states = new WeakMap();
var scriptPromises = new Map();
var instanceCounter = 0;
function bool(value, fallback) {
if (value === undefined || value === null || value === "") return fallback;
return value === true || value === "true" || value === "1";
}
function numberInRange(value, fallback, minimum, maximum) {
var parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.max(minimum, Math.min(maximum, Math.round(parsed)));
}
function normalizeSize(value, compact) {
if (compact) return "compact";
var normalized = String(value || "normal").trim().toLowerCase();
if (normalized === "compact" || normalized === "small" || normalized === "sm") return "compact";
if (normalized === "big" || normalized === "large" || normalized === "lg") return "big";
return "normal";
}
function commaList(value) {
return String(value || "")
.split(",")
.map(function (item) { return item.trim().toLowerCase(); })
.filter(Boolean);
}
function text(root, selector, value) {
var element = root.querySelector(selector);
if (element) element.textContent = value == null ? "" : String(value);
}
function show(element, visible) {
if (!element) return;
element.hidden = !visible;
}
function setBusy(root, busy) {
root.setAttribute("aria-busy", busy ? "true" : "false");
}
function config(root) {
var data = root.dataset;
var responseField = data.captchaResponseField || data.captchaName || "wrn-captcha-response";
var compact = bool(data.captchaCompact, false);
var size = normalizeSize(data.captchaSize, compact);
return {
provider: data.captchaProvider || "self-hosted",
siteKey: data.captchaSiteKey || "",
type: data.captchaType || "alphanumeric",
action: data.captchaAction || "form-submit",
presentation: data.captchaPresentation || "visual",
difficulty: data.captchaDifficulty || "normal",
disturbance: numberInRange(data.captchaDisturbance, 50, 25, 75),
imageStyle: data.captchaImageStyle || "random",
allowedStyles: commaList(data.captchaAllowedStyles),
excludedStyles: commaList(data.captchaExcludedStyles),
randomizeStyle: bool(data.captchaRandomizeStyle, false),
locale: data.captchaLocale || "en",
size: size,
endpoint: data.captchaEndpoint || "/__wrnexus/captcha/challenge",
verifyEndpoint: data.captchaVerifyEndpoint || "/__wrnexus/captcha/verify",
responseField: responseField,
autoLoad: bool(data.captchaAutoLoad, true),
autoVerify: bool(data.captchaAutoVerify, false),
showVerify: bool(data.captchaShowVerify, true),
showRefresh: bool(data.captchaShowRefresh, true),
showAudio: bool(data.captchaShowAudio, true),
showListen: bool(data.captchaShowListen, true),
showStatus: bool(data.captchaShowStatus, true),
disabled: bool(data.captchaDisabled, false),
required: bool(data.captchaRequired, true),
compact: compact,
requiredMessage: data.captchaRequiredMessage || "Please complete the security check.",
incorrectMessage: data.captchaIncorrectMessage || "That answer was not correct. Try again.",
expiredMessage: data.captchaExpiredMessage || "This challenge expired. Load a new one.",
networkMessage: data.captchaNetworkMessage || "The verification service is unavailable. Try again.",
};
}
function eventDetail(state, extra) {
var challenge = state.challenge;
return {
component: "Captcha",
provider: state.config.provider,
type: challenge && challenge.type ? challenge.type : state.config.type,
action: state.config.action,
disturbance: state.config.disturbance,
imageStyle: challenge && challenge.metadata && challenge.metadata.imageStyle
? challenge.metadata.imageStyle
: state.config.imageStyle,
requestedImageStyle: challenge && challenge.metadata && challenge.metadata.requestedImageStyle
? challenge.metadata.requestedImageStyle
: state.config.imageStyle,
imageStylePool: challenge && challenge.metadata && challenge.metadata.imageStylePool
? challenge.metadata.imageStylePool
: state.config.allowedStyles,
size: state.config.size,
status: state.status,
challengeId: challenge && challenge.id ? challenge.id : "",
responseToken: state.responseToken,
expiresAt: challenge && challenge.expiresAt ? challenge.expiresAt : null,
extra: extra || null,
};
}
function emit(state, name, extra) {
state.root.dispatchEvent(
new CustomEvent(name, {
bubbles: true,
detail: eventDetail(state, extra),
}),
);
}
function setResponseToken(state, token) {
state.responseToken = token || "";
var input = state.root.querySelector("[data-captcha-response]");
if (input) {
input.name = state.config.responseField;
input.value = state.responseToken;
}
}
function isNotRobot(state) {
return Boolean(
(state.challenge && state.challenge.type === "not-robot") ||
(!state.challenge && state.config.type === "not-robot"),
);
}
function updateNotRobotState(state) {
var panel = state.root.querySelector("[data-captcha-not-robot]");
var button = state.root.querySelector("[data-captcha-not-robot-button]");
var empty = state.root.querySelector("[data-captcha-not-robot-empty]");
var spinner = state.root.querySelector("[data-captcha-not-robot-spinner]");
var check = state.root.querySelector("[data-captcha-not-robot-check]");
var control = state.root.querySelector("[data-captcha-not-robot-control]");
var label = state.root.querySelector("[data-captcha-not-robot-label]");
var active = isNotRobot(state);
var pending = state.notRobotPending || state.status === "verifying";
var verified = state.status === "verified";
show(panel, active && state.status !== "loading" && state.status !== "idle");
if (!active) return;
if (button) {
button.disabled = state.config.disabled || pending || verified || state.status === "expired";
button.setAttribute("aria-pressed", verified ? "true" : "false");
}
show(empty, !pending && !verified);
show(spinner, pending && !verified);
show(check, verified);
if (control) control.dataset.verified = verified ? "true" : "false";
if (label) {
label.textContent = verified ? "Verified" : pending ? "Checking…" : "I'm not a robot";
}
}
function setStatus(state, status, message) {
state.status = status;
state.root.dataset.captchaStatus = status;
setBusy(state.root, status === "loading" || status === "verifying");
var loading = state.root.querySelector("[data-captcha-loading]");
var challenge = state.root.querySelector("[data-captcha-challenge]");
var success = state.root.querySelector("[data-captcha-success]");
var error = state.root.querySelector("[data-captcha-error]");
var badge = state.root.querySelector("[data-captcha-verified-badge]");
var verifyButton = state.root.querySelector("[data-captcha-verify]");
var verifyIcon = state.root.querySelector("[data-captcha-verify-icon]");
var verifySpinner = state.root.querySelector("[data-captcha-verify-spinner]");
var verifyLabel = state.root.querySelector("[data-captcha-verify-label]");
var answer = state.root.querySelector("[data-captcha-answer]");
show(loading, status === "loading");
show(challenge, status !== "loading" && status !== "idle" && state.config.provider !== "turnstile" && state.config.provider !== "recaptcha" && state.config.provider !== "hcaptcha");
show(badge, status === "verified");
if (state.config.showStatus) {
show(success, status === "verified");
show(error, Boolean(message) && status !== "verified");
} else {
show(success, false);
show(error, false);
}
if (message && status === "verified") text(state.root, "[data-captcha-success-message]", message);
if (message && status !== "verified") text(state.root, "[data-captcha-error-message]", message);
if (answer) {
answer.disabled = state.config.disabled || status === "verified" || status === "expired" || status === "loading" || status === "verifying";
answer.setAttribute("aria-invalid", status === "incorrect" ? "true" : "false");
}
if (verifyButton) {
verifyButton.disabled = state.config.disabled || status === "loading" || status === "verifying" || status === "verified" || status === "expired";
}
show(verifyIcon, status !== "verifying");
show(verifySpinner, status === "verifying");
if (verifyLabel) verifyLabel.textContent = status === "verifying" ? "Verifying…" : "Verify";
updateNotRobotState(state);
updateControls(state);
}
function clearTimer(state) {
if (state.timer) {
window.clearInterval(state.timer);
state.timer = null;
}
}
function updateCountdown(state) {
var countdown = state.root.querySelector("[data-captcha-countdown]");
if (!countdown || !state.challenge || !state.challenge.expiresAt) {
show(countdown, false);
return;
}
var remaining = Math.max(0, Math.ceil((Number(state.challenge.expiresAt) - Date.now()) / 1000));
countdown.textContent = remaining + "s";
show(countdown, remaining > 0 && state.status !== "verified");
if (remaining <= 0 && state.status !== "verified" && state.status !== "expired") {
clearTimer(state);
setResponseToken(state, "");
setStatus(state, "expired", state.config.expiredMessage);
emit(state, "expired");
}
}
function startTimer(state) {
clearTimer(state);
if (!state.challenge || !state.challenge.expiresAt) return;
updateCountdown(state);
state.timer = window.setInterval(function () {
updateCountdown(state);
}, 1000);
}
function resetUi(state) {
if (state.audioPlayer) {
state.audioPlayer.pause();
state.audioPlayer.removeAttribute("src");
state.audioPlayer.load();
state.audioPlayer = null;
}
if (state.notRobotTimer) {
window.clearTimeout(state.notRobotTimer);
state.notRobotTimer = null;
}
state.notRobotPending = false;
state.challengeLoadedAt = 0;
state.answer = "";
state.selections = [];
state.challenge = null;
setResponseToken(state, "");
var answer = state.root.querySelector("[data-captcha-answer]");
var items = state.root.querySelector("[data-captcha-items]");
var imageWrap = state.root.querySelector("[data-captcha-image-wrap]");
var providerMount = state.root.querySelector("[data-captcha-provider-mount]");
var honeypot = state.root.querySelector("[data-captcha-honeypot]");
var notRobot = state.root.querySelector("[data-captcha-not-robot]");
if (answer) answer.value = "";
if (items) items.replaceChildren();
show(items, false);
show(imageWrap, false);
show(notRobot, false);
if (honeypot) {
honeypot.value = "";
honeypot.name = "";
}
if (providerMount && state.config.provider !== "turnstile" && state.config.provider !== "recaptcha" && state.config.provider !== "hcaptcha") {
providerMount.replaceChildren();
show(providerMount, false);
}
}
function updateControls(state) {
var challenge = state.challenge;
var audio = state.root.querySelector("[data-captcha-audio]");
var audioAlternative = state.root.querySelector("[data-captcha-audio-alternative]");
var refresh = state.root.querySelector("[data-captcha-refresh]");
var verify = state.root.querySelector("[data-captcha-verify]");
var footer = state.root.querySelector("[data-captcha-footer]");
var isExternal = state.config.provider === "turnstile" || state.config.provider === "recaptcha" || state.config.provider === "hcaptcha";
var notRobot = isNotRobot(state);
var locked = state.config.disabled || state.status === "loading" || state.status === "verifying" || state.notRobotPending;
show(footer, !notRobot);
show(audio, !notRobot && state.config.showAudio && state.config.showListen && Boolean(challenge && challenge.audioUrl) && state.status !== "verified");
show(audioAlternative, !notRobot && state.config.showAudio && Boolean(challenge && challenge.type === "image") && state.status !== "verified");
show(refresh, !notRobot && state.config.showRefresh && state.status !== "verified");
show(verify, !notRobot && state.config.showVerify && !isExternal);
if (audio) audio.disabled = locked;
if (audioAlternative) audioAlternative.disabled = locked;
if (refresh) refresh.disabled = locked;
if (verify) verify.disabled = locked || state.status === "verified" || state.status === "expired";
}
function renderItems(state, challenge) {
var container = state.root.querySelector("[data-captcha-items]");
var template = state.root.querySelector("[data-captcha-item-template]");
if (!container || !template || !Array.isArray(challenge.items) || challenge.items.length === 0) {
show(container, false);
return;
}
container.replaceChildren();
container.setAttribute("aria-label", challenge.prompt || "Select matching images");
challenge.items.forEach(function (item, index) {
var fragment = template.content.cloneNode(true);
var button = fragment.querySelector("button");
var image = fragment.querySelector("[data-captcha-item-image]");
if (!button || !image) return;
button.dataset.captchaItemId = item.id;
button.setAttribute("aria-label", item.alt || "Challenge tile " + (index + 1));
image.src = item.image;
image.alt = "";
button.addEventListener("click", function () {
toggleItem(state, item.id, button);
});
container.appendChild(fragment);
});
show(container, true);
}
function toggleItem(state, itemId, button) {
if (state.config.disabled || state.status !== "ready") return;
var index = state.selections.indexOf(itemId);
var selected = index >= 0;
if (selected) {
state.selections.splice(index, 1);
} else {
var maximum = Number(state.challenge && state.challenge.maxSelections ? state.challenge.maxSelections : 0);
if (maximum > 0 && state.selections.length >= maximum) {
var removed = state.selections.shift();
var previousButton = state.root.querySelector('[data-captcha-item-id="' + CSS.escape(removed) + '"]');
if (previousButton) {
previousButton.setAttribute("aria-pressed", "false");
show(previousButton.querySelector("[data-captcha-item-check]"), false);
}
}
state.selections.push(itemId);
}
var nowSelected = state.selections.indexOf(itemId) >= 0;
button.setAttribute("aria-pressed", nowSelected ? "true" : "false");
show(button.querySelector("[data-captcha-item-check]"), nowSelected);
emit(state, "input", { selections: state.selections.slice() });
var minimum = Number(state.challenge && state.challenge.minSelections ? state.challenge.minSelections : 0);
if (state.config.autoVerify && minimum > 0 && state.selections.length >= minimum) verify(state);
}
function renderChallenge(state, challenge) {
state.challenge = challenge;
state.challengeLoadedAt = Date.now();
state.notRobotPending = false;
state.answer = "";
state.selections = [];
var notRobot = challenge.type === "not-robot";
state.root.dataset.captchaType = challenge.type || state.config.type;
state.root.dataset.captchaResolvedImageStyle = challenge.metadata && challenge.metadata.imageStyle
? String(challenge.metadata.imageStyle)
: state.config.imageStyle;
text(state.root, "[data-captcha-prompt]", challenge.prompt || "Complete the security challenge.");
var promptRow = state.root.querySelector("[data-captcha-prompt-row]");
var notRobotPanel = state.root.querySelector("[data-captcha-not-robot]");
show(promptRow, !notRobot);
show(notRobotPanel, notRobot);
var imageWrap = state.root.querySelector("[data-captcha-image-wrap]");
var image = state.root.querySelector("[data-captcha-image]");
if (!notRobot && challenge.image && image) {
image.src = challenge.image;
show(imageWrap, true);
} else {
if (image) image.removeAttribute("src");
show(imageWrap, false);
}
if (notRobot) {
var items = state.root.querySelector("[data-captcha-items]");
if (items) items.replaceChildren();
show(items, false);
} else {
renderItems(state, challenge);
}
var answerWrap = state.root.querySelector("[data-captcha-answer-wrap]");
var answer = state.root.querySelector("[data-captcha-answer]");
var needsAnswer = !notRobot && !challenge.items && challenge.inputMode !== "none";
show(answerWrap, needsAnswer);
if (answer) {
answer.value = "";
answer.inputMode = challenge.inputMode === "numeric" ? "numeric" : "text";
answer.setAttribute("aria-invalid", "false");
}
var honeypot = state.root.querySelector("[data-captcha-honeypot]");
if (honeypot) honeypot.name = challenge.honeypotField || "";
setStatus(state, "ready", "");
startTimer(state);
emit(state, "challenge", challenge);
emit(state, "ready");
if (state.config.autoVerify && !notRobot && challenge.inputMode === "none" && !challenge.items) {
window.setTimeout(function () {
verify(state);
}, 0);
}
}
async function createChallenge(state, requestedPresentation) {
if (state.config.disabled) return;
if (state.config.provider === "turnstile" || state.config.provider === "recaptcha" || state.config.provider === "hcaptcha") {
await mountExternal(state);
return;
}
clearTimer(state);
resetUi(state);
setStatus(state, "loading", "");
try {
var response = await fetch(state.config.endpoint, {
method: "POST",
credentials: "same-origin",
headers: {
"content-type": "application/json",
accept: "application/json",
},
body: JSON.stringify({
siteKey: state.config.siteKey,
action: state.config.action,
type: state.config.type,
presentation: requestedPresentation || state.config.presentation,
difficulty: state.config.difficulty,
disturbance: state.config.disturbance,
imageStyle: state.config.imageStyle,
allowedStyles: state.config.allowedStyles,
excludedStyles: state.config.excludedStyles,
randomizeStyle: state.config.randomizeStyle,
locale: state.config.locale,
responseField: state.config.responseField,
}),
});
var result = await response.json().catch(function () {
return null;
});
if (!response.ok || !result || !result.id) {
throw new Error(result && result.message ? result.message : "Challenge request failed");
}
renderChallenge(state, result);
} catch (error) {
setStatus(state, "network-error", state.config.networkMessage);
emit(state, "error", { error: error instanceof Error ? error.message : String(error) });
}
}
async function verify(state) {
state.notRobotPending = false;
if (state.config.disabled || state.status === "loading" || state.status === "verifying" || state.status === "expired" || state.status === "verified") {
updateNotRobotState(state);
updateControls(state);
return;
}
if (!state.challenge) {
await createChallenge(state);
return;
}
var answerInput = state.root.querySelector("[data-captcha-answer]");
var honeypotInput = state.root.querySelector("[data-captcha-honeypot]");
state.answer = answerInput ? answerInput.value : "";
setStatus(state, "verifying", "");
emit(state, "verify");
try {
var response = await fetch(state.challenge.verifyUrl || state.config.verifyEndpoint, {
method: "POST",
credentials: "same-origin",
headers: {
"content-type": "application/json",
accept: "application/json",
},
body: JSON.stringify({
challengeId: state.challenge.id,
action: state.config.action,
answer: state.answer,
selections: state.selections,
honeypot: honeypotInput ? honeypotInput.value : "",
timingToken: state.challenge.timingToken || "",
}),
});
var result = await response.json().catch(function () {
return null;
});
if (!response.ok || !result || !result.success) {
setResponseToken(state, "");
var expired = Boolean(result && result.code === "expired");
setStatus(
state,
expired ? "expired" : "incorrect",
expired
? state.config.expiredMessage
: result && result.message
? result.message
: state.config.incorrectMessage,
);
emit(state, "failure", result || null);
return;
}
clearTimer(state);
setResponseToken(state, result.responseToken || "");
setStatus(state, "verified", "Verification completed.");
emit(state, "success", result);
} catch (error) {
setStatus(state, "network-error", state.config.networkMessage);
emit(state, "error", { error: error instanceof Error ? error.message : String(error) });
}
}
function verifyNotRobot(state) {
if (state.config.disabled || state.notRobotPending || state.status !== "ready") return;
if (!state.challenge || state.challenge.type !== "not-robot") {
createChallenge(state);
return;
}
var configuredMinimum = Number(
state.challenge.metadata && state.challenge.metadata.minCompletionMs !== undefined
? state.challenge.metadata.minCompletionMs
: 800,
);
var minimum = Number.isFinite(configuredMinimum) ? Math.max(0, configuredMinimum) : 800;
var elapsed = Math.max(0, Date.now() - state.challengeLoadedAt);
var remaining = Math.max(0, minimum - elapsed);
state.notRobotPending = true;
updateNotRobotState(state);
updateControls(state);
emit(state, "input", { checked: true });
var complete = function () {
state.notRobotTimer = null;
state.notRobotPending = false;
verify(state);
};
if (remaining > 0) state.notRobotTimer = window.setTimeout(complete, remaining);
else complete();
}
function providerDefinition(provider) {
if (provider === "turnstile") {
return {
url: "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit",
globalName: "turnstile",
};
}
if (provider === "recaptcha") {
return {
url: "https://www.google.com/recaptcha/api.js?render=explicit",
globalName: "grecaptcha",
};
}
if (provider === "hcaptcha") {
return {
url: "https://js.hcaptcha.com/1/api.js?render=explicit",
globalName: "hcaptcha",
};
}
return null;
}
function loadProviderScript(definition) {
if (window[definition.globalName]) return Promise.resolve(window[definition.globalName]);
if (scriptPromises.has(definition.url)) return scriptPromises.get(definition.url);
var promise = new Promise(function (resolve, reject) {
var script = document.querySelector('script[src="' + definition.url + '"]');
if (!script) {
script = document.createElement("script");
script.src = definition.url;
script.async = true;
script.defer = true;
document.head.appendChild(script);
}
script.addEventListener(
"load",
function () {
if (window[definition.globalName]) resolve(window[definition.globalName]);
else reject(new Error("CAPTCHA provider did not initialize"));
},
{ once: true },
);
script.addEventListener("error", function () { reject(new Error("CAPTCHA provider script failed")); }, { once: true });
});
scriptPromises.set(definition.url, promise);
return promise;
}
async function mountExternal(state) {
var definition = providerDefinition(state.config.provider);
if (!definition) return;
if (!state.config.siteKey) {
setStatus(state, "provider-error", "A public site key is required.");
return;
}
var mount = state.root.querySelector("[data-captcha-provider-mount]");
if (!mount) return;
show(mount, true);
setStatus(state, "loading", "");
try {
var api = await loadProviderScript(definition);
if (state.externalWidgetId !== null && typeof api.reset === "function") {
setResponseToken(state, "");
api.reset(state.externalWidgetId);
setStatus(state, "ready", "");
emit(state, "ready");
return;
}
mount.replaceChildren();
state.externalApi = api;
state.externalWidgetId = api.render(mount, {
sitekey: state.config.siteKey,
theme: "auto",
size: state.config.size === "compact" ? "compact" : state.config.provider === "turnstile" ? "flexible" : "normal",
action: state.config.action,
callback: function (token) {
setResponseToken(state, token);
setStatus(state, "verified", "Verification completed.");
emit(state, "success");
},
"expired-callback": function () {
setResponseToken(state, "");
setStatus(state, "expired", state.config.expiredMessage);
emit(state, "expired");
},
"error-callback": function (providerError) {
setResponseToken(state, "");
setStatus(state, "provider-error", state.config.networkMessage);
emit(state, "error", { error: providerError || "provider-error" });
},
});
setStatus(state, "ready", "");
emit(state, "ready");
} catch (error) {
setStatus(state, "provider-error", state.config.networkMessage);
emit(state, "error", { error: error instanceof Error ? error.message : String(error) });
}
}
function playAudio(state) {
if (!state.challenge || !state.challenge.audioUrl || state.config.disabled) return;
if (state.audioPlayer) {
state.audioPlayer.pause();
state.audioPlayer.currentTime = 0;
}
var audio = new Audio();
state.audioPlayer = audio;
audio.preload = "auto";
audio.volume = 1;
audio.src = new URL(state.challenge.audioUrl, window.location.href).href;
audio.addEventListener("ended", function () {
if (state.audioPlayer === audio) state.audioPlayer = null;
emit(state, "audioEnd");
emit(state, "audio-end");
}, { once: true });
audio.addEventListener("error", function () {
if (state.audioPlayer === audio) state.audioPlayer = null;
var mediaError = audio.error;
setStatus(state, "network-error", "The audio challenge could not be played. Load a new challenge and try again.");
emit(state, "error", {
code: "audio-playback-failed",
audioUrl: audio.src,
mediaErrorCode: mediaError ? mediaError.code : null,
});
}, { once: true });
audio.load();
audio.play().then(function () {
emit(state, "audioStart");
emit(state, "audio-start");
}).catch(function (error) {
if (state.audioPlayer === audio) state.audioPlayer = null;
setStatus(state, "network-error", "The audio challenge could not be played. Check the audio endpoint and try again.");
emit(state, "error", {
code: "audio-playback-rejected",
audioUrl: audio.src,
error: error instanceof Error ? error.message : String(error),
});
});
}
function onFormSubmit(state, event) {
if (!state.config.required || state.responseToken) return;
event.preventDefault();
event.stopImmediatePropagation();
setStatus(state, "incorrect", state.config.requiredMessage);
emit(state, "failure", { code: "missing-input" });
var target = state.root.querySelector("[data-captcha-answer]") || state.root.querySelector("[data-captcha-not-robot-button]") || state.root.querySelector("[data-captcha-verify]");
if (target && typeof target.focus === "function") target.focus();
}
function initialize(root) {
if (!(root instanceof HTMLElement) || states.has(root)) return;
if (!root.id) {
instanceCounter += 1;
root.id = "wrn-captcha-" + instanceCounter;
}
var state = {
root: root,
config: config(root),
challenge: null,
answer: "",
selections: [],
responseToken: "",
status: "idle",
timer: null,
notRobotTimer: null,
notRobotPending: false,
challengeLoadedAt: 0,
externalApi: null,
externalWidgetId: null,
audioPlayer: null,
form: root.closest("form"),
};
states.set(root, state);
root.dataset.captchaSize = state.config.size;
var response = root.querySelector("[data-captcha-response]");
if (response) response.name = state.config.responseField;
var answer = root.querySelector("[data-captcha-answer]");
var verifyButton = root.querySelector("[data-captcha-verify]");
var refreshButton = root.querySelector("[data-captcha-refresh]");
var audioButton = root.querySelector("[data-captcha-audio]");
var audioAlternative = root.querySelector("[data-captcha-audio-alternative]");
var notRobotButton = root.querySelector("[data-captcha-not-robot-button]");
if (answer) {
answer.addEventListener("input", function () {
state.answer = answer.value;
emit(state, "input", { answerLength: state.answer.length });
});
answer.addEventListener("keydown", function (event) {
if (event.key === "Enter") {
event.preventDefault();
verify(state);
}
});
}
if (verifyButton) verifyButton.addEventListener("click", function () { verify(state); });
if (notRobotButton) notRobotButton.addEventListener("click", function () { verifyNotRobot(state); });
if (refreshButton) refreshButton.addEventListener("click", function () {
emit(state, "refresh");
createChallenge(state, state.config.presentation);
});
if (audioButton) audioButton.addEventListener("click", function () { playAudio(state); });
if (audioAlternative) audioAlternative.addEventListener("click", function () {
emit(state, "refresh", { presentation: "audio" });
createChallenge(state, "audio");
});
root.addEventListener("captcha-reset", function () {
emit(state, "refresh");
createChallenge(state, state.config.presentation);
});
if (state.form) {
state.form.addEventListener("submit", function (event) {
onFormSubmit(state, event);
}, true);
state.form.addEventListener("wire:success", function () {
createChallenge(state, state.config.presentation);
});
}
updateNotRobotState(state);
updateControls(state);
if (state.config.disabled) setStatus(state, "disabled", "");
else if (state.config.autoLoad) createChallenge(state, state.config.presentation);
}
function scan(scope) {
var host = scope || document;
if (host instanceof Element && host.matches("[data-wrn-captcha]")) initialize(host);
host.querySelectorAll("[data-wrn-captcha]").forEach(initialize);
}
var runtime = {
scan: scan,
reset: function (element) {
var root = typeof element === "string" ? document.querySelector(element) : element;
var state = root ? states.get(root) : null;
if (state) createChallenge(state, state.config.presentation);
},
verify: function (element) {
var root = typeof element === "string" ? document.querySelector(element) : element;
var state = root ? states.get(root) : null;
return state ? verify(state) : Promise.resolve();
},
};
window[RUNTIME_KEY] = runtime;
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () { scan(document); }, { once: true });
} else {
scan(document);
}
new MutationObserver(function (records) {
records.forEach(function (record) {
record.addedNodes.forEach(function (node) {
if (node.nodeType === 1) scan(node);
});
});
}).observe(document.documentElement, { childList: true, subtree: true });
})();
@@ -0,0 +1,211 @@
import { describe, expect, test } from "bun:test";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
const showcaseRoot = join(import.meta.dir, "..");
function path(relativePath: string): string {
return join(showcaseRoot, relativePath);
}
function read(relativePath: string): string {
return readFileSync(path(relativePath), "utf8");
}
describe("CAPTCHA showcase structure", () => {
test("ships the required pages, routes, middleware, and component", () => {
const requiredFiles = [
"app/pages/index.wrn",
"app/pages/captcha.wrn",
"app/pages/protected.wrn",
"app/pages/styles.wrn",
"app/components/Captcha.wrn",
"app/api/contact.ts",
"app/api/page-grant.ts",
"app/schemas/contact.ts",
"app/lib/captcha.ts",
"app/middleware/captcha-page-gate.ts",
"app/styles/global.css",
"wrnexus.config.ts",
];
for (const relativePath of requiredFiles) {
expect(existsSync(path(relativePath))).toBe(true);
}
});
test("configures Tailwind and Lucide Iconify utilities", () => {
const packageJson = read("package.json");
const styles = read("app/styles/global.css");
expect(packageJson).toContain('"@iconify-json/lucide"');
expect(packageJson).toContain('"@iconify/tailwind4"');
expect(styles).toContain('@plugin "@iconify/tailwind4";');
expect(styles).toContain('@source "../../../packages/captcha/components/*.wrn";');
});
test("documents every bundled challenge mode", () => {
const source = read("app/pages/index.wrn");
const expectedExamples = [
'type="number"',
'type="alpha"',
'type="alphanumeric"',
'type="calculation"',
'type="image"',
'type="timing"',
'type="not-robot"',
'presentation="audio"',
'presentation="invisible"',
];
for (const example of expectedExamples) {
expect(source).toContain(example);
}
expect(source.match(/<Captcha\b/g)?.length ?? 0).toBeGreaterThanOrEqual(18);
expect(source).toContain('disturbance="25"');
expect(source).toContain('disturbance="50"');
expect(source).toContain('disturbance="75"');
expect(source).toContain('size="compact"');
expect(source).toContain('size="normal"');
expect(source).toContain('size="big"');
expect(source).toContain('showListen="false"');
});
test("documents every generated image renderer and random pool controls", () => {
const source = read("app/pages/styles.wrn");
const styles = [
"classic",
"collision",
"snow",
"corrosion",
"spiderweb",
"cross-shadow",
"split",
"split2",
"cut",
"darts",
"distortion",
"stitch",
"striped",
"wave",
"grid-noise",
"scribble",
"pixel",
"broken-lines",
];
for (const style of styles) {
expect(source).toContain(`imageStyle="${style}"`);
}
expect(source).toContain('imageStyle="random"');
expect(source).toContain('allowedStyles="classic,snow,distortion,wave"');
expect(source).toContain('excludedStyles="collision,split,split2,broken-lines"');
expect(source).toContain('difficulty="easy"');
expect(source).toContain('difficulty="normal"');
expect(source).toContain('difficulty="hard"');
expect(source.match(/<Captcha\b/g)?.length ?? 0).toBeGreaterThanOrEqual(23);
});
test("includes protected form and external-provider examples", () => {
const source = read("app/pages/index.wrn");
expect(source).toContain('action="/api/contact"');
expect(source).toContain('data-schema="contact"');
expect(source).toContain('action="contact-submit"');
expect(source).toContain('data-error="email"');
expect(source).toContain('data-error="message"');
expect(source).toContain('provider="turnstile"');
expect(source).toContain('provider="recaptcha"');
expect(source).toContain('provider="hcaptcha"');
});
});
describe("CAPTCHA showcase integration", () => {
test("configures the self-hosted engine and HTTP handlers", () => {
const source = read("app/lib/captcha.ts");
expect(source).toContain("createCaptchaEngine");
expect(source).toContain("createCaptchaHttpHandlers");
expect(source).toContain('basePath: "/api/captcha"');
expect(source).toContain("challengeTtlMs: 2 * 60_000");
expect(source).toContain("responseTokenTtlMs: 5 * 60_000");
expect(source).toContain("maxAttempts: 3");
});
test("protects the contact form with a server-side guard", () => {
const source = read("app/api/contact.ts");
expect(source).toContain("captchaGuard");
expect(source).toContain('action: "contact-submit"');
expect(source).toContain('responseField: "wrn-captcha-response"');
expect(source).toContain("bindHostname: true");
expect(source).toContain("bindSession: true");
expect(source).toContain("parseBody<ContactInput>");
expect(source).toContain("contactSchema");
expect(source).toContain("ctx.req.clone()");
});
test("protects the page route with a session grant", () => {
const middleware = read("app/middleware/captcha-page-gate.ts");
const challengePage = read("app/pages/captcha.wrn");
expect(middleware).toContain("captchaPageGate");
expect(middleware).toContain('action: "protected-page-access"');
expect(middleware).toContain('mode: "session"');
expect(middleware).toContain("verifiedForMs: 15 * 60_000");
expect(middleware).toContain('routeGroups: ["/protected"]');
expect(middleware).toContain('ctx.url.pathname === "/api/page-grant"');
expect(challengePage).toContain('action="protected-page-access"');
expect(challengePage).toContain('action="/api/page-grant"');
expect(challengePage).toContain('name="returnTo"');
expect(challengePage).toContain("value='{returnTo}'");
const grantRoute = read("app/api/page-grant.ts");
expect(grantRoute).toContain("safeReturnPath");
expect(grantRoute).toContain('form.get("returnTo")');
expect(grantRoute).toContain("Response.redirect");
});
test("registers the CAPTCHA plugin and DevToolbar audit", () => {
const source = read("wrnexus.config.ts");
expect(source).toContain("captchaPlugin");
expect(source).toContain("enableDevToolbar: true");
});
test("ships the expected component events and response field", () => {
const source = read("app/components/Captcha.wrn");
const expectedEvents = [
"ready",
"challenge",
"input",
"verify",
"success",
"failure",
"expired",
"refresh",
"audioStart",
"audioEnd",
"error",
];
expect(source).toContain('name = "wrn-captcha-response"');
expect(source).toContain('responseField = "wrn-captcha-response"');
expect(source).toContain('disturbance = 50');
expect(source).toContain('size = "normal"');
expect(source).toContain('showListen = true');
expect(source).toContain('imageStyle = "random"');
expect(source).toContain('allowedStyles = ""');
expect(source).toContain('excludedStyles = ""');
expect(source).toContain('randomizeStyle = false');
expect(source).toContain('data-captcha-not-robot-button');
for (const eventName of expectedEvents) {
expect(source).toContain(`@event ${eventName} = function`);
}
});
});
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": { "noEmit": true },
"include": ["app/**/*.ts", "wrnexus.config.ts"]
}
@@ -0,0 +1,23 @@
import type { AppConfig } from "@wrnexus/styles";
import { captchaPlugin } from "@wrnexus/captcha/plugin";
const config: AppConfig & { plugins?: unknown[] } = {
seo: {
title: "WRNexus CAPTCHA Showcase",
description: "Number, text, calculation, image, audio, invisible, managed, and third-party CAPTCHA examples.",
canonicalBase: "http://localhost:3000",
robots: "noindex,nofollow",
},
theme: { palette: "blue", default: "dark" },
styles: {
entry: "app/styles/global.css",
process: async ({ entryPath, appRoot, mode }) => {
const args = ["@tailwindcss/cli", "-i", entryPath!];
if (mode === "production") args.push("--minify");
return await Bun.$.cwd(appRoot)`bunx ${args}`.text();
},
},
plugins: [captchaPlugin({ enableDevToolbar: true })],
};
export default config;
@@ -10,7 +10,7 @@ page AdvancedFormsShowcase {
<span class="showcase-eyebrow">Component category</span> <span class="showcase-eyebrow">Component category</span>
<h1 class="showcase-title">Advanced Forms</h1> <h1 class="showcase-title">Advanced Forms</h1>
<p class="showcase-description">9 unique, responsive, theme-aware components. Open a component to inspect multiple live configurations and its complete props API.</p> <p class="showcase-description">9 unique, responsive, theme-aware components. Open a component to inspect multiple live configurations and its complete props API.</p>
<div class="category-meta"><span>9 components</span><span>Multiple use cases</span><span>106 live configurations</span></div> <div class="category-meta"><span>9 components</span><span>Multiple use cases</span><span>115 live configurations</span></div>
</header> </header>
<div class="catalog-grid"> <div class="catalog-grid">
<article class="catalog-card"> <article class="catalog-card">
@@ -46,7 +46,7 @@ page AdvancedFormsShowcase {
<article class="catalog-card"> <article class="catalog-card">
<div class="catalog-card-preview"> <div class="catalog-card-preview">
<div class="catalog-card-glow" aria-hidden="true"></div> <div class="catalog-card-glow" aria-hidden="true"></div>
<div class="catalog-card-stage"><div data-component="InputNumber" size="default" label="Input Number" value="sample-1" placeholder="Enter a sample value" disabled="false" required="false" class="showcase-instance showcase-instance--1"></div></div> <div class="catalog-card-stage"><div data-component="InputNumber" size="md" color="primary" name="default-quantity" value="1" min="0" max="99" step="1"></div></div>
</div> </div>
<div class="catalog-card-body"> <div class="catalog-card-body">
<div><span class="catalog-card-category">Advanced Forms</span><h2>Input Number</h2><p>Theme-aware, responsive input number component.</p></div> <div><span class="catalog-card-category">Advanced Forms</span><h2>Input Number</h2><p>Theme-aware, responsive input number component.</p></div>
@@ -10,7 +10,7 @@ page BaseShowcase {
<span class="showcase-eyebrow">Component category</span> <span class="showcase-eyebrow">Component category</span>
<h1 class="showcase-title">Base</h1> <h1 class="showcase-title">Base</h1>
<p class="showcase-description">27 unique, responsive, theme-aware components. Open a component to inspect multiple live configurations and its complete props API.</p> <p class="showcase-description">27 unique, responsive, theme-aware components. Open a component to inspect multiple live configurations and its complete props API.</p>
<div class="category-meta"><span>27 components</span><span>Multiple use cases</span><span>86 live configurations</span></div> <div class="category-meta"><span>27 components</span><span>Multiple use cases</span><span>81 live configurations</span></div>
</header> </header>
<div class="catalog-grid"> <div class="catalog-grid">
<article class="catalog-card"> <article class="catalog-card">
@@ -70,7 +70,7 @@ page ButtonDetail {
<section class="detail-section"><div class="detail-section-heading"><span>Live examples</span><h2>Designed for real product surfaces</h2><p>Compare configurations and resize the browser to check responsive behavior.</p></div><div class="demo-list"> <section class="detail-section"><div class="detail-section-heading"><span>Live examples</span><h2>Designed for real product surfaces</h2><p>Compare configurations and resize the browser to check responsive behavior.</p></div><div class="demo-list">
<article class="demo-case"> <article class="demo-case">
<header class="demo-case-header"> <header class="demo-case-header">
<div><span class="demo-case-eyebrow">Default</span><h2>Primary action</h2><p>The standard high-emphasis action for forms and product workflows.</p></div> <div><span class="demo-case-eyebrow">Recommended</span><h2>Production default</h2><p>Balanced spacing, hierarchy, and content for the most common product workflow.</p></div>
<span class="demo-case-number">01</span> <span class="demo-case-number">01</span>
</header> </header>
<div class="demo-workbench"> <div class="demo-workbench">
@@ -78,7 +78,7 @@ page ButtonDetail {
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div> <div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
<div class="demo-render"><div data-component="Button" label="Save &amp; Submit" as="button" type="submit" class="showcase-instance showcase-instance--1"></div></div> <div class="demo-render"><div data-component="Button" label="Save &amp; Submit" as="button" type="submit" class="showcase-instance showcase-instance--1"></div></div>
</div> </div>
<section class="demo-code" aria-label="Primary action usage"> <section class="demo-code" aria-label="Production default usage">
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header> <header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
<pre><code>&lt;Button <pre><code>&lt;Button
label="Save &amp; Submit" label="Save &amp; Submit"
@@ -90,7 +90,7 @@ page ButtonDetail {
</article> </article>
<article class="demo-case"> <article class="demo-case">
<header class="demo-case-header"> <header class="demo-case-header">
<div><span class="demo-case-eyebrow">Outline · Small</span><h2>Secondary navigation</h2><p>A compact link button with an end icon and restrained emphasis.</p></div> <div><span class="demo-case-eyebrow">Dense UI</span><h2>Compact application</h2><p>A tighter variation for dashboards, side panels, tables, and operational interfaces.</p></div>
<span class="demo-case-number">02</span> <span class="demo-case-number">02</span>
</header> </header>
<div class="demo-workbench"> <div class="demo-workbench">
@@ -98,7 +98,7 @@ page ButtonDetail {
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div> <div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
<div class="demo-render"><div data-component="Button" label="Learn more" as="a" href="#button-demo" variant="outline" size="sm" icon="icon-[lucide--arrow-right]" iconPosition="end" class="showcase-instance showcase-instance--2"></div></div> <div class="demo-render"><div data-component="Button" label="Learn more" as="a" href="#button-demo" variant="outline" size="sm" icon="icon-[lucide--arrow-right]" iconPosition="end" class="showcase-instance showcase-instance--2"></div></div>
</div> </div>
<section class="demo-code" aria-label="Secondary navigation usage"> <section class="demo-code" aria-label="Compact application usage">
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header> <header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
<pre><code>&lt;Button <pre><code>&lt;Button
label="Learn more" label="Learn more"
@@ -114,7 +114,7 @@ page ButtonDetail {
</article> </article>
<article class="demo-case"> <article class="demo-case">
<header class="demo-case-header"> <header class="demo-case-header">
<div><span class="demo-case-eyebrow">Secondary · Large · Pill</span><h2>Prominent rounded action</h2><p>A large secondary action using the optional pill shape.</p></div> <div><span class="demo-case-eyebrow">Extended</span><h2>Rich configuration</h2><p>A more expressive variation using additional data, stronger emphasis, and optional states.</p></div>
<span class="demo-case-number">03</span> <span class="demo-case-number">03</span>
</header> </header>
<div class="demo-workbench"> <div class="demo-workbench">
@@ -122,7 +122,7 @@ page ButtonDetail {
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div> <div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
<div class="demo-render"><div data-component="Button" label="Upgrade plan" as="button" variant="secondary" size="lg" pill="true" class="showcase-instance showcase-instance--3"></div></div> <div class="demo-render"><div data-component="Button" label="Upgrade plan" as="button" variant="secondary" size="lg" pill="true" class="showcase-instance showcase-instance--3"></div></div>
</div> </div>
<section class="demo-code" aria-label="Prominent rounded action usage"> <section class="demo-code" aria-label="Rich configuration usage">
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header> <header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
<pre><code>&lt;Button <pre><code>&lt;Button
label="Upgrade plan" label="Upgrade plan"
@@ -130,113 +130,6 @@ page ButtonDetail {
variant="secondary" variant="secondary"
size="lg" size="lg"
pill="true" pill="true"
/&gt;</code></pre>
</section>
</div>
</article>
<article class="demo-case">
<header class="demo-case-header">
<div><span class="demo-case-eyebrow">Ghost · Icon</span><h2>Accessible icon action</h2><p>An icon-only control with its accessible name supplied through props.</p></div>
<span class="demo-case-number">04</span>
</header>
<div class="demo-workbench">
<div id="button-demo-4" class="demo-canvas demo-canvas--4">
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
<div class="demo-render"><div data-component="Button" label="Open settings" as="button" variant="ghost" size="icon" icon="icon-[lucide--settings]" ariaLabel="Open settings" class="showcase-instance showcase-instance--4"></div></div>
</div>
<section class="demo-code" aria-label="Accessible icon action usage">
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
<pre><code>&lt;Button
label="Open settings"
as="button"
variant="ghost"
size="icon"
icon="icon-[lucide--settings]"
ariaLabel="Open settings"
/&gt;</code></pre>
</section>
</div>
</article>
<article class="demo-case">
<header class="demo-case-header">
<div><span class="demo-case-eyebrow">Destructive</span><h2>Dangerous action</h2><p>A destructive treatment for irreversible or high-risk operations.</p></div>
<span class="demo-case-number">05</span>
</header>
<div class="demo-workbench">
<div id="button-demo-5" class="demo-canvas demo-canvas--5">
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
<div class="demo-render"><div data-component="Button" label="Delete project" as="button" variant="destructive" icon="icon-[lucide--trash-2]" class="showcase-instance showcase-instance--5"></div></div>
</div>
<section class="demo-code" aria-label="Dangerous action usage">
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
<pre><code>&lt;Button
label="Delete project"
as="button"
variant="destructive"
icon="icon-[lucide--trash-2]"
/&gt;</code></pre>
</section>
</div>
</article>
<article class="demo-case">
<header class="demo-case-header">
<div><span class="demo-case-eyebrow">Link</span><h2>Inline action</h2><p>A low-chrome action that behaves and reads like an inline link.</p></div>
<span class="demo-case-number">06</span>
</header>
<div class="demo-workbench">
<div id="button-demo-6" class="demo-canvas demo-canvas--6">
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
<div class="demo-render"><div data-component="Button" label="Read documentation" as="a" href="#button-demo" variant="link" class="showcase-instance showcase-instance--6"></div></div>
</div>
<section class="demo-code" aria-label="Inline action usage">
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
<pre><code>&lt;Button
label="Read documentation"
as="a"
href="#button-demo"
variant="link"
/&gt;</code></pre>
</section>
</div>
</article>
<article class="demo-case">
<header class="demo-case-header">
<div><span class="demo-case-eyebrow">Loading</span><h2>Pending action</h2><p>The button owns its busy state, loading label, spinner, and disabled behavior.</p></div>
<span class="demo-case-number">07</span>
</header>
<div class="demo-workbench">
<div id="button-demo-7" class="demo-canvas demo-canvas--7">
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
<div class="demo-render"><div data-component="Button" label="Publish" loadingLabel="Publishing…" as="button" loading="true" class="showcase-instance showcase-instance--7"></div></div>
</div>
<section class="demo-code" aria-label="Pending action usage">
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
<pre><code>&lt;Button
label="Publish"
loadingLabel="Publishing…"
as="button"
loading="true"
/&gt;</code></pre>
</section>
</div>
</article>
<article class="demo-case">
<header class="demo-case-header">
<div><span class="demo-case-eyebrow">Disabled</span><h2>Unavailable action</h2><p>A visibly and semantically disabled control.</p></div>
<span class="demo-case-number">08</span>
</header>
<div class="demo-workbench">
<div id="button-demo-8" class="demo-canvas demo-canvas--8">
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
<div class="demo-render"><div data-component="Button" label="Unavailable action" as="button" variant="outline" disabled="true" class="showcase-instance showcase-instance--8"></div></div>
</div>
<section class="demo-code" aria-label="Unavailable action usage">
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
<pre><code>&lt;Button
label="Unavailable action"
as="button"
variant="outline"
disabled="true"
/&gt;</code></pre> /&gt;</code></pre>
</section> </section>
</div> </div>
@@ -245,7 +138,7 @@ page ButtonDetail {
<section id="api" class="detail-section"><div class="detail-section-heading"><span>Component API</span><h2>Props and configuration</h2><p>All content and behavior shown above is supplied through these props and slots.</p></div><div class="docs-table-wrap"><table class="docs-table"><thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Required</th></tr></thead><tbody><tr><td><code>label</code></td><td>string</td><td><code>"Button"</code></td><td>No</td></tr><tr><td><code>loadingLabel</code></td><td>string</td><td><code>"Loading…"</code></td><td>No</td></tr><tr><td><code>description</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>as</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>href</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>target</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>rel</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>type</code></td><td>string</td><td><code>"button"</code></td><td>No</td></tr><tr><td><code>variant</code></td><td>string</td><td><code>"default"</code></td><td>No</td></tr><tr><td><code>color</code></td><td>string</td><td><code>"primary"</code></td><td>No</td></tr><tr><td><code>size</code></td><td>string</td><td><code>"default"</code></td><td>No</td></tr><tr><td><code>disabled</code></td><td>boolean</td><td><code>false</code></td><td>No</td></tr><tr><td><code>loading</code></td><td>boolean</td><td><code>false</code></td><td>No</td></tr><tr><td><code>pill</code></td><td>boolean</td><td><code>false</code></td><td>No</td></tr><tr><td><code>fullWidth</code></td><td>boolean</td><td><code>false</code></td><td>No</td></tr><tr><td><code>icon</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>iconPosition</code></td><td>string</td><td><code>"start"</code></td><td>No</td></tr><tr><td><code>ariaLabel</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>ariaPressed</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>ariaExpanded</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>ariaControls</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>title</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>autofocus</code></td><td>boolean</td><td><code>false</code></td><td>No</td></tr><tr><td><code>controlClass</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>class</code></td><td>string</td><td><code>""</code></td><td>No</td></tr></tbody></table></div></section> <section id="api" class="detail-section"><div class="detail-section-heading"><span>Component API</span><h2>Props and configuration</h2><p>All content and behavior shown above is supplied through these props and slots.</p></div><div class="docs-table-wrap"><table class="docs-table"><thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Required</th></tr></thead><tbody><tr><td><code>label</code></td><td>string</td><td><code>"Button"</code></td><td>No</td></tr><tr><td><code>loadingLabel</code></td><td>string</td><td><code>"Loading…"</code></td><td>No</td></tr><tr><td><code>description</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>as</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>href</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>target</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>rel</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>type</code></td><td>string</td><td><code>"button"</code></td><td>No</td></tr><tr><td><code>variant</code></td><td>string</td><td><code>"default"</code></td><td>No</td></tr><tr><td><code>color</code></td><td>string</td><td><code>"primary"</code></td><td>No</td></tr><tr><td><code>size</code></td><td>string</td><td><code>"default"</code></td><td>No</td></tr><tr><td><code>disabled</code></td><td>boolean</td><td><code>false</code></td><td>No</td></tr><tr><td><code>loading</code></td><td>boolean</td><td><code>false</code></td><td>No</td></tr><tr><td><code>pill</code></td><td>boolean</td><td><code>false</code></td><td>No</td></tr><tr><td><code>fullWidth</code></td><td>boolean</td><td><code>false</code></td><td>No</td></tr><tr><td><code>icon</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>iconPosition</code></td><td>string</td><td><code>"start"</code></td><td>No</td></tr><tr><td><code>ariaLabel</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>ariaPressed</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>ariaExpanded</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>ariaControls</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>title</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>autofocus</code></td><td>boolean</td><td><code>false</code></td><td>No</td></tr><tr><td><code>controlClass</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>class</code></td><td>string</td><td><code>""</code></td><td>No</td></tr></tbody></table></div></section>
</main> </main>
<aside class="detail-aside"> <aside class="detail-aside">
<div class="detail-toc"><strong>On this page</strong><a href="#playground">Playground</a><a href="#button-demo-1">Primary action</a><a href="#button-demo-2">Secondary navigation</a><a href="#button-demo-3">Prominent rounded action</a><a href="#button-demo-4">Accessible icon action</a><a href="#button-demo-5">Dangerous action</a><a href="#button-demo-6">Inline action</a><a href="#button-demo-7">Pending action</a><a href="#button-demo-8">Unavailable action</a><a href="#api">Props API</a></div> <div class="detail-toc"><strong>On this page</strong><a href="#playground">Playground</a><a href="#button-demo-1">Production default</a><a href="#button-demo-2">Compact application</a><a href="#button-demo-3">Rich configuration</a><a href="#api">Props API</a></div>
</aside> </aside>
</div> </div>
<nav class="detail-pagination"> <nav class="detail-pagination">
@@ -1,19 +1,19 @@
// Generated by scripts/generate-showcase.mjs. Do not edit directly. // Generated by scripts/generate-showcase.mjs. Do not edit directly.
page InputNumberDetail { page InputNumberDetail {
layout = "showcase" layout = "showcase"
state playground_size = ctx.url.searchParams.get("pg_size") ?? "default" state playground_size = ctx.url.searchParams.get("pg_size") ?? "md"
state playground_color = ctx.url.searchParams.get("pg_color") ?? "primary" state playground_color = ctx.url.searchParams.get("pg_color") ?? "primary"
state playground_label = ctx.url.searchParams.get("pg_label") ?? "Input Number" state playground_label = ctx.url.searchParams.get("pg_label") ?? "Input Number"
state playground_name = ctx.url.searchParams.get("pg_name") ?? "" state playground_name = ctx.url.searchParams.get("pg_name") ?? "default-quantity"
state playground_value = ctx.url.searchParams.get("pg_value") ?? "sample-1" state playground_value = ctx.url.searchParams.get("pg_value") ?? "1"
state playground_placeholder = ctx.url.searchParams.get("pg_placeholder") ?? "Enter a sample value" state playground_placeholder = ctx.url.searchParams.get("pg_placeholder") ?? ""
state playground_type = ctx.url.searchParams.get("pg_type") ?? "number" state playground_type = ctx.url.searchParams.get("pg_type") ?? "number"
state playground_min = ctx.url.searchParams.get("pg_min") ?? "" state playground_min = ctx.url.searchParams.get("pg_min") ?? "0"
state playground_max = ctx.url.searchParams.get("pg_max") ?? "" state playground_max = ctx.url.searchParams.get("pg_max") ?? "99"
state playground_step = ctx.url.searchParams.get("pg_step") ?? "" state playground_step = ctx.url.searchParams.get("pg_step") ?? "1"
state playground_disabled = ctx.url.searchParams.get("pg_disabled") ?? "false" state playground_disabled = ctx.url.searchParams.get("pg_disabled") ?? "false"
state playground_required = ctx.url.searchParams.get("pg_required") ?? "false" state playground_required = ctx.url.searchParams.get("pg_required") ?? "false"
state playground_class = ctx.url.searchParams.get("pg_class") ?? "showcase-instance showcase-instance--1" state playground_class = ctx.url.searchParams.get("pg_class") ?? ""
seo { seo {
title = "Input Number" title = "Input Number"
description = "Theme-aware, responsive input number component." description = "Theme-aware, responsive input number component."
@@ -40,15 +40,19 @@ page InputNumberDetail {
<section class="playground-code" aria-label="Current component code"> <section class="playground-code" aria-label="Current component code">
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component code</span><button type="button" data-playground-copy><span class="icon-[lucide--copy] size-4" aria-hidden="true"></span>Copy</button></header> <header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component code</span><button type="button" data-playground-copy><span class="icon-[lucide--copy] size-4" aria-hidden="true"></span>Copy</button></header>
<pre><code data-playground-code>&lt;InputNumber <pre><code data-playground-code>&lt;InputNumber
value="sample-1" size="md"
placeholder="Enter a sample value" name="default-quantity"
value="1"
min="0"
max="99"
step="1"
/&gt;</code></pre> /&gt;</code></pre>
</section> </section>
<div class="playground-status" data-playground-status aria-live="polite"></div> <div class="playground-status" data-playground-status aria-live="polite"></div>
</div> </div>
<form class="playground-controls" data-playground-form> <form class="playground-controls" data-playground-form>
<header><div><span>Component props</span><strong>13 controls</strong></div><button type="reset"><span class="icon-[lucide--rotate-ccw] size-4" aria-hidden="true"></span>Reset</button></header> <header><div><span>Component props</span><strong>13 controls</strong></div><button type="reset"><span class="icon-[lucide--rotate-ccw] size-4" aria-hidden="true"></span>Reset</button></header>
<div class="playground-fields"><label class="playground-field" for="playground-input-number-size"><span><strong>size</strong><small>string</small></span><select id="playground-input-number-size" name="pg_size"><option value="default" selected>default</option><option value="xs">xs</option><option value="sm">sm</option><option value="lg">lg</option><option value="icon">icon</option><option value="icon-xs">icon-xs</option><option value="icon-sm">icon-sm</option><option value="icon-lg">icon-lg</option></select></label><label class="playground-field" for="playground-input-number-color"><span><strong>color</strong><small>string</small></span><select id="playground-input-number-color" name="pg_color"><option value="primary" selected>primary</option><option value="secondary">secondary</option><option value="success">success</option><option value="warning">warning</option><option value="danger">danger</option><option value="info">info</option></select></label><label class="playground-field" for="playground-input-number-label"><span><strong>label</strong><small>string</small></span><input id="playground-input-number-label" name="pg_label" type="text" value="Input Number" /></label><label class="playground-field" for="playground-input-number-name"><span><strong>name</strong><small>string</small></span><input id="playground-input-number-name" name="pg_name" type="text" value="" /></label><label class="playground-field" for="playground-input-number-value"><span><strong>value</strong><small>string</small></span><input id="playground-input-number-value" name="pg_value" type="text" value="sample-1" /></label><label class="playground-field" for="playground-input-number-placeholder"><span><strong>placeholder</strong><small>string</small></span><input id="playground-input-number-placeholder" name="pg_placeholder" type="text" value="Enter a sample value" /></label><label class="playground-field" for="playground-input-number-type"><span><strong>type</strong><small>string</small></span><input id="playground-input-number-type" name="pg_type" type="text" value="number" /></label><label class="playground-field" for="playground-input-number-min"><span><strong>min</strong><small>string</small></span><input id="playground-input-number-min" name="pg_min" type="text" value="" /></label><label class="playground-field" for="playground-input-number-max"><span><strong>max</strong><small>string</small></span><input id="playground-input-number-max" name="pg_max" type="text" value="" /></label><label class="playground-field" for="playground-input-number-step"><span><strong>step</strong><small>string</small></span><input id="playground-input-number-step" name="pg_step" type="text" value="" /></label><label class="playground-toggle" for="playground-input-number-disabled"><span><strong>disabled</strong><small>boolean</small></span><input id="playground-input-number-disabled" name="pg_disabled" type="checkbox" value="true" data-playground-boolean /><i aria-hidden="true"></i></label><label class="playground-toggle" for="playground-input-number-required"><span><strong>required</strong><small>boolean</small></span><input id="playground-input-number-required" name="pg_required" type="checkbox" value="true" data-playground-boolean /><i aria-hidden="true"></i></label><label class="playground-field" for="playground-input-number-class"><span><strong>class</strong><small>string</small></span><input id="playground-input-number-class" name="pg_class" type="text" value="showcase-instance showcase-instance--1" /></label></div> <div class="playground-fields"><label class="playground-field" for="playground-input-number-size"><span><strong>size</strong><small>string</small></span><select id="playground-input-number-size" name="pg_size"><option value="md" selected>md</option><option value="default">default</option><option value="xs">xs</option><option value="sm">sm</option><option value="lg">lg</option><option value="icon">icon</option><option value="icon-xs">icon-xs</option><option value="icon-sm">icon-sm</option><option value="icon-lg">icon-lg</option><option value="xl">xl</option></select></label><label class="playground-field" for="playground-input-number-color"><span><strong>color</strong><small>string</small></span><select id="playground-input-number-color" name="pg_color"><option value="primary" selected>primary</option><option value="secondary">secondary</option><option value="success">success</option><option value="warning">warning</option><option value="danger">danger</option><option value="info">info</option></select></label><label class="playground-field" for="playground-input-number-label"><span><strong>label</strong><small>string</small></span><input id="playground-input-number-label" name="pg_label" type="text" value="Input Number" /></label><label class="playground-field" for="playground-input-number-name"><span><strong>name</strong><small>string</small></span><input id="playground-input-number-name" name="pg_name" type="text" value="default-quantity" /></label><label class="playground-field" for="playground-input-number-value"><span><strong>value</strong><small>string</small></span><input id="playground-input-number-value" name="pg_value" type="text" value="1" /></label><label class="playground-field" for="playground-input-number-placeholder"><span><strong>placeholder</strong><small>string</small></span><input id="playground-input-number-placeholder" name="pg_placeholder" type="text" value="" /></label><label class="playground-field" for="playground-input-number-type"><span><strong>type</strong><small>string</small></span><input id="playground-input-number-type" name="pg_type" type="text" value="number" /></label><label class="playground-field" for="playground-input-number-min"><span><strong>min</strong><small>string</small></span><input id="playground-input-number-min" name="pg_min" type="text" value="0" /></label><label class="playground-field" for="playground-input-number-max"><span><strong>max</strong><small>string</small></span><input id="playground-input-number-max" name="pg_max" type="text" value="99" /></label><label class="playground-field" for="playground-input-number-step"><span><strong>step</strong><small>string</small></span><input id="playground-input-number-step" name="pg_step" type="text" value="1" /></label><label class="playground-toggle" for="playground-input-number-disabled"><span><strong>disabled</strong><small>boolean</small></span><input id="playground-input-number-disabled" name="pg_disabled" type="checkbox" value="true" data-playground-boolean /><i aria-hidden="true"></i></label><label class="playground-toggle" for="playground-input-number-required"><span><strong>required</strong><small>boolean</small></span><input id="playground-input-number-required" name="pg_required" type="checkbox" value="true" data-playground-boolean /><i aria-hidden="true"></i></label><label class="playground-field" for="playground-input-number-class"><span><strong>class</strong><small>string</small></span><input id="playground-input-number-class" name="pg_class" type="text" value="" /></label></div>
</form> </form>
</div> </div>
</section> </section>
@@ -57,61 +61,266 @@ page InputNumberDetail {
<section class="detail-section"><div class="detail-section-heading"><span>Live examples</span><h2>Designed for real product surfaces</h2><p>Compare configurations and resize the browser to check responsive behavior.</p></div><div class="demo-list"> <section class="detail-section"><div class="detail-section-heading"><span>Live examples</span><h2>Designed for real product surfaces</h2><p>Compare configurations and resize the browser to check responsive behavior.</p></div><div class="demo-list">
<article class="demo-case"> <article class="demo-case">
<header class="demo-case-header"> <header class="demo-case-header">
<div><span class="demo-case-eyebrow">Recommended</span><h2>Production default</h2><p>Balanced spacing, hierarchy, and content for the most common product workflow.</p></div> <div><span class="demo-case-eyebrow">Basic usage</span><h2>Default controls</h2><p>Use standard decrement and increment buttons alongside a numeric input field.</p></div>
<span class="demo-case-number">01</span> <span class="demo-case-number">01</span>
</header> </header>
<div class="demo-workbench"> <div class="demo-workbench">
<div id="input-number-demo-1" class="demo-canvas demo-canvas--1"> <div id="input-number-demo-1" class="demo-canvas demo-canvas--1">
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div> <div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
<div class="demo-render"><div data-component="InputNumber" size="default" label="Input Number" value="sample-1" placeholder="Enter a sample value" disabled="false" required="false" class="showcase-instance showcase-instance--1"></div></div> <div class="demo-render"><div data-component="InputNumber" size="md" color="primary" name="default-quantity" value="1" min="0" max="99" step="1"></div></div>
</div> </div>
<section class="demo-code" aria-label="Production default usage"> <section class="demo-code" aria-label="Default controls usage">
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header> <header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
<pre><code>&lt;InputNumber <pre><code>&lt;InputNumber
value="sample-1" size="md"
placeholder="Enter a sample value" name="default-quantity"
value="1"
min="0"
max="99"
step="1"
/&gt;</code></pre> /&gt;</code></pre>
</section> </section>
</div> </div>
</article> </article>
<article class="demo-case"> <article class="demo-case">
<header class="demo-case-header"> <header class="demo-case-header">
<div><span class="demo-case-eyebrow">Dense UI</span><h2>Compact application</h2><p>A tighter variation for dashboards, side panels, tables, and operational interfaces.</p></div> <div><span class="demo-case-eyebrow">Basic usage</span><h2>Labeled input style</h2><p>Add supporting label text and rounded controls for a more form-like number input.</p></div>
<span class="demo-case-number">02</span> <span class="demo-case-number">02</span>
</header> </header>
<div class="demo-workbench"> <div class="demo-workbench">
<div id="input-number-demo-2" class="demo-canvas demo-canvas--2"> <div id="input-number-demo-2" class="demo-canvas demo-canvas--2">
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div> <div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
<div class="demo-render"><div data-component="InputNumber" size="sm" label="Compact example" value="sample-2" placeholder="Enter a sample value" disabled="false" required="false" class="showcase-instance showcase-instance--2"></div></div> <div class="demo-render"><div data-component="InputNumber" size="md" color="primary" label="Select quantity" name="labeled-quantity" value="1" min="1" max="25" step="1"></div></div>
</div> </div>
<section class="demo-code" aria-label="Compact application usage"> <section class="demo-code" aria-label="Labeled input style usage">
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header> <header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
<pre><code>&lt;InputNumber <pre><code>&lt;InputNumber
size="sm" size="md"
label="Compact example" label="Select quantity"
value="sample-2" name="labeled-quantity"
placeholder="Enter a sample value" value="1"
min="1"
max="25"
step="1"
/&gt;</code></pre> /&gt;</code></pre>
</section> </section>
</div> </div>
</article> </article>
<article class="demo-case"> <article class="demo-case">
<header class="demo-case-header"> <header class="demo-case-header">
<div><span class="demo-case-eyebrow">Extended</span><h2>Rich configuration</h2><p>A more expressive variation using additional data, stronger emphasis, and optional states.</p></div> <div><span class="demo-case-eyebrow">Layout</span><h2>Vertical buttons</h2><p>Stack the increment and decrement buttons vertically beside the field.</p></div>
<span class="demo-case-number">03</span> <span class="demo-case-number">03</span>
</header> </header>
<div class="demo-workbench"> <div class="demo-workbench">
<div id="input-number-demo-3" class="demo-canvas demo-canvas--3"> <div id="input-number-demo-3" class="demo-canvas demo-canvas--3">
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div> <div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
<div class="demo-render"><div data-component="InputNumber" size="lg" label="Advanced example" value="sample-3" placeholder="Search by name, email, or identifier" disabled="false" required="false" class="showcase-instance showcase-instance--3"></div></div> <div class="demo-render"><div data-component="InputNumber" size="md" color="primary" label="Select quantity" name="vertical-quantity" value="1" min="0" max="20"></div></div>
</div> </div>
<section class="demo-code" aria-label="Rich configuration usage"> <section class="demo-code" aria-label="Vertical buttons usage">
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header> <header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
<pre><code>&lt;InputNumber <pre><code>&lt;InputNumber
size="lg" size="md"
label="Advanced example" label="Select quantity"
value="sample-3" name="vertical-quantity"
placeholder="Search by name, email, or identifier" value="1"
min="0"
max="20"
/&gt;</code></pre>
</section>
</div>
</article>
<article class="demo-case">
<header class="demo-case-header">
<div><span class="demo-case-eyebrow">Layout</span><h2>Horizontal buttons</h2><p>Place the increment and decrement buttons on opposite sides of the value.</p></div>
<span class="demo-case-number">04</span>
</header>
<div class="demo-workbench">
<div id="input-number-demo-4" class="demo-canvas demo-canvas--4">
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
<div class="demo-render"><div data-component="InputNumber" size="md" color="primary" name="horizontal-quantity" value="1" min="0" max="20"></div></div>
</div>
<section class="demo-code" aria-label="Horizontal buttons usage">
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
<pre><code>&lt;InputNumber
size="md"
name="horizontal-quantity"
value="1"
min="0"
max="20"
/&gt;</code></pre>
</section>
</div>
</article>
<article class="demo-case">
<header class="demo-case-header">
<div><span class="demo-case-eyebrow">Sizing</span><h2>Compact size</h2><p>Use a smaller compact control when the available space is limited.</p></div>
<span class="demo-case-number">05</span>
</header>
<div class="demo-workbench">
<div id="input-number-demo-5" class="demo-canvas demo-canvas--5">
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
<div class="demo-render"><div data-component="InputNumber" size="sm" color="primary" name="compact-quantity" value="0" min="0" max="10"></div></div>
</div>
<section class="demo-code" aria-label="Compact size usage">
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
<pre><code>&lt;InputNumber
size="sm"
name="compact-quantity"
value="0"
min="0"
max="10"
/&gt;</code></pre>
</section>
</div>
</article>
<article class="demo-case">
<header class="demo-case-header">
<div><span class="demo-case-eyebrow">Commerce</span><h2>Seat quantity selector</h2><p>Pair the control with pricing information for seat counts or plan add-ons.</p></div>
<span class="demo-case-number">06</span>
</header>
<div class="demo-workbench">
<div id="input-number-demo-6" class="demo-canvas demo-canvas--6">
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
<div class="demo-render"><div data-component="InputNumber" size="md" color="primary" label="Additional seats" name="additional-seats" value="0" min="0" max="50"></div></div>
</div>
<section class="demo-code" aria-label="Seat quantity selector usage">
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
<pre><code>&lt;InputNumber
size="md"
label="Additional seats"
name="additional-seats"
value="0"
min="0"
max="50"
/&gt;</code></pre>
</section>
</div>
</article>
<article class="demo-case">
<header class="demo-case-header">
<div><span class="demo-case-eyebrow">States</span><h2>Disabled input</h2><p>Disable manual entry while keeping supported quantity controls available.</p></div>
<span class="demo-case-number">07</span>
</header>
<div class="demo-workbench">
<div id="input-number-demo-7" class="demo-canvas demo-canvas--7">
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
<div class="demo-render"><div data-component="InputNumber" size="md" color="primary" name="disabled-input" value="10"></div></div>
</div>
<section class="demo-code" aria-label="Disabled input usage">
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
<pre><code>&lt;InputNumber
size="md"
name="disabled-input"
value="10"
/&gt;</code></pre>
</section>
</div>
</article>
<article class="demo-case">
<header class="demo-case-header">
<div><span class="demo-case-eyebrow">States</span><h2>Disabled buttons</h2><p>Disable increment and decrement controls while leaving the current value visible.</p></div>
<span class="demo-case-number">08</span>
</header>
<div class="demo-workbench">
<div id="input-number-demo-8" class="demo-canvas demo-canvas--8">
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
<div class="demo-render"><div data-component="InputNumber" size="md" color="primary" name="disabled-buttons" value="10"></div></div>
</div>
<section class="demo-code" aria-label="Disabled buttons usage">
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
<pre><code>&lt;InputNumber
size="md"
name="disabled-buttons"
value="10"
/&gt;</code></pre>
</section>
</div>
</article>
<article class="demo-case">
<header class="demo-case-header">
<div><span class="demo-case-eyebrow">Behaviour</span><h2>Custom step size</h2><p>Set a custom step so every button press changes the value by two.</p></div>
<span class="demo-case-number">09</span>
</header>
<div class="demo-workbench">
<div id="input-number-demo-9" class="demo-canvas demo-canvas--9">
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
<div class="demo-render"><div data-component="InputNumber" size="md" color="primary" name="custom-step" value="0" min="0" max="20" step="2"></div></div>
</div>
<section class="demo-code" aria-label="Custom step size usage">
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
<pre><code>&lt;InputNumber
size="md"
name="custom-step"
value="0"
min="0"
max="20"
step="2"
/&gt;</code></pre>
</section>
</div>
</article>
<article class="demo-case">
<header class="demo-case-header">
<div><span class="demo-case-eyebrow">Limits</span><h2>Negative values</h2><p>Use the min option to permit controlled negative values.</p></div>
<span class="demo-case-number">010</span>
</header>
<div class="demo-workbench">
<div id="input-number-demo-10" class="demo-canvas demo-canvas--10">
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
<div class="demo-render"><div data-component="InputNumber" size="md" color="primary" name="negative-values" value="-10" min="-100" max="100"></div></div>
</div>
<section class="demo-code" aria-label="Negative values usage">
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
<pre><code>&lt;InputNumber
size="md"
name="negative-values"
value="-10"
min="-100"
max="100"
/&gt;</code></pre>
</section>
</div>
</article>
<article class="demo-case">
<header class="demo-case-header">
<div><span class="demo-case-eyebrow">Limits</span><h2>Maximum limit</h2><p>Set an upper limit and disable incrementing when that limit is reached.</p></div>
<span class="demo-case-number">011</span>
</header>
<div class="demo-workbench">
<div id="input-number-demo-11" class="demo-canvas demo-canvas--11">
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
<div class="demo-render"><div data-component="InputNumber" size="md" color="primary" name="maximum-limit" value="10" min="0" max="10"></div></div>
</div>
<section class="demo-code" aria-label="Maximum limit usage">
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
<pre><code>&lt;InputNumber
size="md"
name="maximum-limit"
value="10"
min="0"
max="10"
/&gt;</code></pre>
</section>
</div>
</article>
<article class="demo-case">
<header class="demo-case-header">
<div><span class="demo-case-eyebrow">Validation</span><h2>Validation states</h2><p>Communicate whether the current numerical value is valid.</p></div>
<span class="demo-case-number">012</span>
</header>
<div class="demo-workbench">
<div id="input-number-demo-12" class="demo-canvas demo-canvas--12">
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
<div class="demo-render"><div data-component="InputNumber" size="md" color="primary" name="invalid-quantity" value="10" min="0" max="5"></div></div>
</div>
<section class="demo-code" aria-label="Validation states usage">
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
<pre><code>&lt;InputNumber
size="md"
name="invalid-quantity"
value="10"
min="0"
max="5"
/&gt;</code></pre> /&gt;</code></pre>
</section> </section>
</div> </div>
@@ -120,7 +329,7 @@ page InputNumberDetail {
<section id="api" class="detail-section"><div class="detail-section-heading"><span>Component API</span><h2>Props and configuration</h2><p>All content and behavior shown above is supplied through these props and slots.</p></div><div class="docs-table-wrap"><table class="docs-table"><thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Required</th></tr></thead><tbody><tr><td><code>size</code></td><td>string</td><td><code>"default"</code></td><td>No</td></tr><tr><td><code>color</code></td><td>string</td><td><code>"primary"</code></td><td>No</td></tr><tr><td><code>label</code></td><td>string</td><td><code>"Input Number"</code></td><td>No</td></tr><tr><td><code>name</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>value</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>placeholder</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>type</code></td><td>string</td><td><code>"number"</code></td><td>No</td></tr><tr><td><code>min</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>max</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>step</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>disabled</code></td><td>boolean</td><td><code>false</code></td><td>No</td></tr><tr><td><code>required</code></td><td>boolean</td><td><code>false</code></td><td>No</td></tr><tr><td><code>class</code></td><td>string</td><td><code>""</code></td><td>No</td></tr></tbody></table></div></section> <section id="api" class="detail-section"><div class="detail-section-heading"><span>Component API</span><h2>Props and configuration</h2><p>All content and behavior shown above is supplied through these props and slots.</p></div><div class="docs-table-wrap"><table class="docs-table"><thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Required</th></tr></thead><tbody><tr><td><code>size</code></td><td>string</td><td><code>"default"</code></td><td>No</td></tr><tr><td><code>color</code></td><td>string</td><td><code>"primary"</code></td><td>No</td></tr><tr><td><code>label</code></td><td>string</td><td><code>"Input Number"</code></td><td>No</td></tr><tr><td><code>name</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>value</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>placeholder</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>type</code></td><td>string</td><td><code>"number"</code></td><td>No</td></tr><tr><td><code>min</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>max</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>step</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>disabled</code></td><td>boolean</td><td><code>false</code></td><td>No</td></tr><tr><td><code>required</code></td><td>boolean</td><td><code>false</code></td><td>No</td></tr><tr><td><code>class</code></td><td>string</td><td><code>""</code></td><td>No</td></tr></tbody></table></div></section>
</main> </main>
<aside class="detail-aside"> <aside class="detail-aside">
<div class="detail-toc"><strong>On this page</strong><a href="#playground">Playground</a><a href="#input-number-demo-1">Production default</a><a href="#input-number-demo-2">Compact application</a><a href="#input-number-demo-3">Rich configuration</a><a href="#api">Props API</a></div> <div class="detail-toc"><strong>On this page</strong><a href="#playground">Playground</a><a href="#input-number-demo-1">Default controls</a><a href="#input-number-demo-2">Labeled input style</a><a href="#input-number-demo-3">Vertical buttons</a><a href="#input-number-demo-4">Horizontal buttons</a><a href="#input-number-demo-5">Compact size</a><a href="#input-number-demo-6">Seat quantity selector</a><a href="#input-number-demo-7">Disabled input</a><a href="#input-number-demo-8">Disabled buttons</a><a href="#input-number-demo-9">Custom step size</a><a href="#input-number-demo-10">Negative values</a><a href="#input-number-demo-11">Maximum limit</a><a href="#input-number-demo-12">Validation states</a><a href="#api">Props API</a></div>
</aside> </aside>
</div> </div>
<nav class="detail-pagination"> <nav class="detail-pagination">
@@ -17,11 +17,11 @@ page ComponentShowcase {
<section class="home-product-grid"><article><span class="icon-[lucide--component] size-7"></span><strong>85 components</strong><p>Accessible primitives for every product surface.</p><a href="/base">Browse components →</a></article><article><span class="icon-[lucide--layout-template] size-7"></span><strong>Ready-made blocks</strong><p>Composable sections assembled from WRNexus UI.</p><a href="/blocks">Explore blocks →</a></article><article><span class="icon-[lucide--panels-top-left] size-7"></span><strong>Page templates</strong><p>Complete responsive pages ready to customize.</p><a href="/templates">View templates →</a></article></section> <section class="home-product-grid"><article><span class="icon-[lucide--component] size-7"></span><strong>85 components</strong><p>Accessible primitives for every product surface.</p><a href="/base">Browse components →</a></article><article><span class="icon-[lucide--layout-template] size-7"></span><strong>Ready-made blocks</strong><p>Composable sections assembled from WRNexus UI.</p><a href="/blocks">Explore blocks →</a></article><article><span class="icon-[lucide--panels-top-left] size-7"></span><strong>Page templates</strong><p>Complete responsive pages ready to customize.</p><a href="/templates">View templates →</a></article></section>
<section class="showcase-stats"> <section class="showcase-stats">
<div class="showcase-stat"><strong>85</strong><span>Unique components</span></div> <div class="showcase-stat"><strong>85</strong><span>Unique components</span></div>
<div class="showcase-stat"><strong>339</strong><span>Live configurations</span></div> <div class="showcase-stat"><strong>343</strong><span>Live configurations</span></div>
<div class="showcase-stat"><strong>0</strong><span>Duplicate implementations</span></div> <div class="showcase-stat"><strong>0</strong><span>Duplicate implementations</span></div>
<div class="showcase-stat"><strong>8</strong><span>Focused categories</span></div> <div class="showcase-stat"><strong>8</strong><span>Focused categories</span></div>
</section> </section>
<section class="home-categories"><div class="home-section-heading"><span>Explore the system</span><h2>Everything your product needs</h2></div><div class="home-category-grid"><a class="home-category home-category--1" href="/advanced-forms"><span class="home-category-index">01</span><div><strong>Advanced Forms</strong><p>9 components · 106 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--2" href="/base"><span class="home-category-index">02</span><div><strong>Base</strong><p>27 components · 86 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--3" href="/forms"><span class="home-category-index">03</span><div><strong>Forms</strong><p>11 components · 33 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--4" href="/integrations"><span class="home-category-index">04</span><div><strong>Integrations</strong><p>12 components · 36 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--1" href="/layout"><span class="home-category-index">05</span><div><strong>Layout</strong><p>10 components · 30 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--2" href="/navigation"><span class="home-category-index">06</span><div><strong>Navigation</strong><p>9 components · 27 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--3" href="/overlays"><span class="home-category-index">07</span><div><strong>Overlays</strong><p>6 components · 18 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--4" href="/tables"><span class="home-category-index">08</span><div><strong>Tables</strong><p>1 components · 3 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a></div></section> <section class="home-categories"><div class="home-section-heading"><span>Explore the system</span><h2>Everything your product needs</h2></div><div class="home-category-grid"><a class="home-category home-category--1" href="/advanced-forms"><span class="home-category-index">01</span><div><strong>Advanced Forms</strong><p>9 components · 115 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--2" href="/base"><span class="home-category-index">02</span><div><strong>Base</strong><p>27 components · 81 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--3" href="/forms"><span class="home-category-index">03</span><div><strong>Forms</strong><p>11 components · 33 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--4" href="/integrations"><span class="home-category-index">04</span><div><strong>Integrations</strong><p>12 components · 36 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--1" href="/layout"><span class="home-category-index">05</span><div><strong>Layout</strong><p>10 components · 30 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--2" href="/navigation"><span class="home-category-index">06</span><div><strong>Navigation</strong><p>9 components · 27 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--3" href="/overlays"><span class="home-category-index">07</span><div><strong>Overlays</strong><p>6 components · 18 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--4" href="/tables"><span class="home-category-index">08</span><div><strong>Tables</strong><p>1 components · 3 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a></div></section>
</div> </div>
} }
} }
@@ -245,6 +245,15 @@ function sampleValue(prop, component, variation) {
if (Object.hasOwn(configuration, prop.name)) return configuration[prop.name]; if (Object.hasOwn(configuration, prop.name)) return configuration[prop.name];
return undefined; return undefined;
} }
if (component.name === "InputNumber") {
const configuration = inputNumberConfiguration(variation);
if (Object.hasOwn(configuration, prop.name)) {
return configuration[prop.name];
}
return undefined;
}
const isArray = prop.default === "[]" || prop.type.includes("[]") || prop.type === "array"; const isArray = prop.default === "[]" || prop.type.includes("[]") || prop.type === "array";
const isObject = prop.default === "{}" || prop.type === "object"; const isObject = prop.default === "{}" || prop.type === "object";
if (isArray) return sampleArray(prop, component, variation); if (isArray) return sampleArray(prop, component, variation);
@@ -1096,20 +1105,192 @@ function togglePasswordConfiguration(variation) {
}; };
} }
const demoUses = (component) => const inputNumberUses = [
component.name === "Button" {
? buttonUses eyebrow: "Basic usage",
: component.name === "AdvancedSelect" title: "Default controls",
? advancedSelectUses description: "Use standard decrement and increment buttons alongside a numeric input field.",
: component.name === "ComboBox" },
? comboboxUses {
: component.name === "PinInput" eyebrow: "Basic usage",
? pinInputUses title: "Labeled input style",
: component.name === "StrongPassword" description:
? strongPasswordUses "Add supporting label text and rounded controls for a more form-like number input.",
: component.name === "TogglePassword" },
? togglePasswordUses {
: defaultUses; eyebrow: "Layout",
title: "Vertical buttons",
description: "Stack the increment and decrement buttons vertically beside the field.",
},
{
eyebrow: "Layout",
title: "Horizontal buttons",
description: "Place the increment and decrement buttons on opposite sides of the value.",
},
{
eyebrow: "Sizing",
title: "Compact size",
description: "Use a smaller compact control when the available space is limited.",
},
{
eyebrow: "Commerce",
title: "Seat quantity selector",
description: "Pair the control with pricing information for seat counts or plan add-ons.",
},
{
eyebrow: "States",
title: "Disabled input",
description: "Disable manual entry while keeping supported quantity controls available.",
},
{
eyebrow: "States",
title: "Disabled buttons",
description:
"Disable increment and decrement controls while leaving the current value visible.",
},
{
eyebrow: "Behaviour",
title: "Custom step size",
description: "Set a custom step so every button press changes the value by two.",
},
{
eyebrow: "Limits",
title: "Negative values",
description: "Use the min option to permit controlled negative values.",
},
{
eyebrow: "Limits",
title: "Maximum limit",
description: "Set an upper limit and disable incrementing when that limit is reached.",
},
{
eyebrow: "Validation",
title: "Validation states",
description: "Communicate whether the current numerical value is valid.",
},
];
function inputNumberConfiguration(variation) {
const configurations = [
{
name: "default-quantity",
value: 1,
min: 0,
max: 99,
step: 1,
variant: "default",
},
{
name: "labeled-quantity",
label: "Select quantity",
value: 1,
min: 1,
max: 25,
step: 1,
variant: "labeled",
},
{
name: "vertical-quantity",
label: "Select quantity",
value: 1,
min: 0,
max: 20,
variant: "vertical",
},
{
name: "horizontal-quantity",
value: 1,
min: 0,
max: 20,
variant: "horizontal",
},
{
name: "compact-quantity",
value: 0,
min: 0,
max: 10,
variant: "compact",
size: "sm",
},
{
name: "additional-seats",
label: "Additional seats",
description: "$39 monthly",
value: 0,
min: 0,
max: 50,
variant: "seat",
},
{
name: "disabled-input",
value: 10,
inputDisabled: true,
},
{
name: "disabled-buttons",
value: 10,
buttonsDisabled: true,
},
{
name: "custom-step",
value: 0,
min: 0,
max: 20,
step: 2,
},
{
name: "negative-values",
value: -10,
min: -100,
max: 100,
},
{
name: "maximum-limit",
value: 10,
min: 0,
max: 10,
help: "The maximum value is 10.",
},
{
name: "invalid-quantity",
value: 10,
min: 0,
max: 5,
error: "Out of limit",
},
];
return {
color: "primary",
size: "md",
...configurations[variation],
};
}
function demoUses(component) {
switch (component.name) {
case "AdvancedSelect":
return advancedSelectUses;
case "ComboBox":
return comboboxUses;
case "InputNumber":
return inputNumberUses;
case "PinInput":
return pinInputUses;
case "StrongPassword":
return strongPasswordUses;
case "TogglePassword":
return togglePasswordUses;
default:
return defaultUses;
}
}
function eventDocumentation(component) { function eventDocumentation(component) {
if ( if (
+19
View File
@@ -0,0 +1,19 @@
# Framework integration
The current WRNexusJS component scanner always includes `packages/ui/components` and the applications `app/components`. The installer therefore copies `Captcha.wrn` into `packages/ui/components/Captcha.wrn` while retaining the canonical package copy at `packages/captcha/components/Captcha.wrn`.
A future framework improvement can read component directories registered by plugins and pass them to `createRouter({ componentDirs })`. `captchaPlugin()` already exposes the package directory through configuration and plugin metadata, so the package is ready for that change without changing its public API.
The installer adds these TypeScript aliases:
```json
{
"@wrnexus/captcha": ["./packages/captcha/src/index.ts"],
"@wrnexus/captcha/server": ["./packages/captcha/src/server/index.ts"],
"@wrnexus/captcha/client": ["./packages/captcha/src/client/index.ts"],
"@wrnexus/captcha/plugin": ["./packages/captcha/src/plugin.ts"],
"@wrnexus/captcha/*": ["./packages/captcha/src/*"]
}
```
After installation, regenerate the UI component reference so `Captcha` appears in generated component documentation.
+2 -1
View File
@@ -6,7 +6,8 @@
"description": "An SSR-first full-stack web framework with server-rendered reactive components. Bun-first, Node-friendly.", "description": "An SSR-first full-stack web framework with server-rendered reactive components. Bun-first, Node-friendly.",
"workspaces": [ "workspaces": [
"packages/*", "packages/*",
"examples/*" "examples/*",
"services/*"
], ],
"scripts": { "scripts": {
"dev": "bun run packages/cli/src/index.ts dev examples/basic-app", "dev": "bun run packages/cli/src/index.ts dev examples/basic-app",
+46
View File
@@ -0,0 +1,46 @@
# Changelog
## Fixed 9
- Reduced compact mode from `max-w-sm` to `max-w-xs` with smaller padding and tighter section spacing.
- Reduced compact challenge images, loading state, answer input, action buttons, labels, icons, and status typography.
- Hid the descriptive subtitle and provider credit only in compact mode to remove nonessential vertical height.
- Kept normal and big modes unchanged and preserved the legacy `compact=true` alias.
## Fixed 8
- Added 18 generated CAPTCHA image renderers: classic, collision, snow, corrosion, spiderweb, cross-shadow, split, split2, cut, darts, distortion, stitch, striped, wave, grid-noise, scribble, pixel, and broken-lines.
- Added `imageStyle="random"`, allowed/excluded renderer pools, and forced randomization.
- Added resolved renderer metadata, client-runtime forwarding, managed API schema support, and a full renderer showcase page.
- Fixed floating-point line endpoints in the PNG renderer and replaced the slow bitwise CRC loop with a lookup-table implementation.
- Added renderer catalog, PNG integrity, style-pool, component, runtime, engine, and showcase tests.
## 0.3.6-fixed7
- Added `compact`, `normal`, and `big` component size modes with legacy size aliases.
- Added the `showListen` prop while preserving the existing `showAudio` switch.
- Added the self-hosted `not-robot` checkbox challenge with timing, honeypot, expiry, attempt, action, binding, and one-time token checks.
- Added compact not-robot rendering and browser-runtime verification behavior.
- Added showcase examples and regression coverage for size modes, listen visibility, and not-robot verification.
## 0.3.6-fixed6
- Fixed the page-gate showcase action URL and safe return-path handling.
- Added visual disturbance percentages from 25 through 75 for text and image-selection challenges.
- Added disturbance metadata, component/runtime support, managed API documentation, and range validation.
- Added a complete browser-and-server validated contact form protected by CAPTCHA.
- Prevented schema validators from submitting required CAPTCHA forms before verification.
- Added automatic CAPTCHA refresh after a successful validated form submission.
- Added page-gate, disturbance, runtime, component, and showcase regression tests.
## 0.3.6
- Fixed DOM `BodyInit` compatibility for audio responses by converting `Uint8Array<ArrayBufferLike>` to a copied `ArrayBuffer`.
- Added self-hosted number, alphabet, alphanumeric, calculation, generated-image, honeypot, and timing challenges.
- Added visual, audio, and invisible presentations.
- Added one-use challenge and response-token verification with action and optional binding controls.
- Added memory, SQLite-compatible, Redis-compatible, and custom store contracts.
- Added WRNexus-managed, Turnstile, reCAPTCHA, hCaptcha, and custom provider adapters.
- Added form guard, page gate, adaptive risk policy, validation integration, HTTP handlers, and DevToolbar audits.
- Added Tailwind-only, theme-aware `Captcha.wrn` component.
- Added managed-service starter, example application, tests, installer, and migration documentation.
+324
View File
@@ -0,0 +1,324 @@
# @wrnexus/captcha
A first-class CAPTCHA and anti-automation package for WRNexusJS. It supports self-hosted challenges, a managed WRNexus service, external providers, form submission guards, page gates, accessible audio, adaptive risk checks, and a Tailwind-only `.wrn` component.
## Included challenge modes
- Number, alphabet, and alphanumeric image challenges
- Addition, subtraction, multiplication, and exact-division calculations
- Generated shape-selection image challenges
- Audio alternatives for text, numbers, and calculations
- Honeypot and minimum-completion-time invisible checks
- Self-hosted “Im not a robot” checkbox challenge with one-time server verification
- Always, once-per-session, and adaptive page gates
- Cloudflare Turnstile, Google reCAPTCHA, hCaptcha, managed, and custom providers
## Create the self-hosted engine
```ts
import {
createCaptchaEngine,
createCaptchaHttpHandlers,
RedisCaptchaStore,
} from "@wrnexus/captcha/server";
const engine = createCaptchaEngine({
secret: process.env.CAPTCHA_SECRET!,
store: new RedisCaptchaStore(redis),
basePath: "/api/captcha",
challengeTtlMs: 2 * 60_000,
responseTokenTtlMs: 5 * 60_000,
maxAttempts: 3,
minCompletionMs: 800,
});
export const handlers = createCaptchaHttpHandlers(engine);
```
Mount the handlers from an API catch-all route:
```ts
import type { Context } from "@wrnexus/core";
import { handlers } from "../../lib/captcha.ts";
export async function POST(ctx: Context) {
return (await handlers.handle(ctx.req, ctx)) ?? new Response("Not Found", { status: 404 });
}
export const GET = POST;
export const HEAD = POST;
```
## Use the component
```wrn
<Captcha
type="alphanumeric"
action="signup"
endpoint="/api/captcha/challenge"
verifyEndpoint="/api/captcha/verify"
difficulty="normal"
disturbance="50"
imageStyle="random"
allowedStyles="classic,snow,distortion,wave"
size="normal"
showAudio="true"
showListen="true"
@success='captchaToken = event.detail.responseToken'
@failure='formError = event.detail.extra.message'
/>
```
The component uses Tailwind utilities and `--wire-*` theme variables. It has no companion component CSS file.
### Main props
`provider`, `siteKey`, `type`, `action`, `presentation`, `difficulty`, `disturbance`, `imageStyle`, `allowedStyles`, `excludedStyles`, `randomizeStyle`, `locale`, `size`, `color`, `class`, `name`, `endpoint`, `verifyEndpoint`, `responseField`, labels/messages, `autoLoad`, `autoVerify`, `showVerify`, `showRefresh`, `showAudio`, `showListen`, `showStatus`, `disabled`, `required`, and the backward-compatible `compact` alias.
### Component sizes
Use one of the three supported display modes:
```wrn
<Captcha size="compact" action="small-form" />
<Captcha size="normal" action="standard-form" />
<Captcha size="big" action="security-page" />
```
`small`/`sm` are accepted as aliases for `compact`, while `large`/`lg` are accepted as aliases for `big`. The old `compact="true"` prop still forces compact mode.
### Listen button visibility
Audio remains available by default. Hide the Listen and Use audio controls with either of these props:
```wrn
<Captcha showListen="false" action="without-listen-button" />
<Captcha showAudio="false" action="without-audio-alternative" />
```
`showListen` is the direct UI switch. `showAudio` remains the broader backward-compatible audio switch.
### Im not a robot checkbox
```wrn
<Captcha
type="not-robot"
action="contact-submit"
size="compact"
showListen="false"
/>
```
The checkbox is not a client-only boolean. Clicking it completes a self-hosted invisible challenge that is time-limited, attempt-limited, one-time-use, action-bound, optionally session/hostname/IP-bound, and verified on the server. It is a low-friction anti-automation layer; use adaptive escalation to a visual or external provider for high-risk traffic.
### Visual disturbance
Use `disturbance` for visual and image-selection challenges. It accepts an integer from `25` through `75`:
- `25`: light disturbance and easiest readability
- `50`: balanced default
- `75`: maximum supported dots, line crossings, glyph movement, and image-tile noise
The browser sends this value to the challenge API, and the server validates the range before generating the challenge. It is also returned in challenge metadata.
### Generated image renderer styles
Text, number, alphanumeric, and calculation CAPTCHA images support 18 concrete renderers plus a random mode:
`classic`, `collision`, `snow`, `corrosion`, `spiderweb`, `cross-shadow`, `split`, `split2`, `cut`, `darts`, `distortion`, `stitch`, `striped`, `wave`, `grid-noise`, `scribble`, `pixel`, and `broken-lines`.
Use a fixed style:
```wrn
<Captcha
type="alphanumeric"
action="signup"
imageStyle="spiderweb"
disturbance="55"
/>
```
Use a new random style whenever the challenge is refreshed:
```wrn
<Captcha
type="number"
action="login"
imageStyle="random"
difficulty="normal"
/>
```
Control the random pool with comma-separated component props or arrays in the TypeScript API:
```wrn
<Captcha
type="alphanumeric"
action="checkout"
imageStyle="random"
allowedStyles="classic,snow,distortion,wave"
excludedStyles="collision"
/>
```
```ts
const challenge = await engine.create({
action: "checkout",
type: "alphanumeric",
imageStyle: "random",
allowedStyles: ["classic", "snow", "distortion", "wave"],
excludedStyles: ["collision"],
});
```
Set `randomizeStyle: true` to force random selection even when `imageStyle` names a concrete renderer. The resolved style, requested style, and active pool are returned in challenge metadata. The answer is never embedded in metadata or browser JavaScript.
### Events
`@ready`, `@challenge`, `@input`, `@verify`, `@success`, `@failure`, `@expired`, `@refresh`, `@audioStart`, `@audioEnd`, and `@error`.
## Protect a validated form API
Validate a cloned request first, then consume the CAPTCHA response token. This prevents a valid token from being consumed when ordinary field validation fails.
```ts
import { captchaGuard } from "@wrnexus/captcha/server";
import { parseBody } from "@wrnexus/validation";
import contactSchema from "../schemas/contact.ts";
import { engine } from "../lib/captcha.ts";
const guard = captchaGuard({
action: "contact-submit",
engine,
bindHostname: true,
bindSession: true,
});
export async function POST(ctx) {
const validation = await parseBody(contactSchema, ctx.req.clone());
if (!validation.ok) return validation.response;
return guard(ctx, async () =>
Response.json({ ok: true, submission: validation.value }),
);
}
```
The CAPTCHA runtime binds its required-form check in the capture phase, so a `data-schema` validator cannot submit the form before CAPTCHA verification. After a successful form request, the component automatically creates a fresh challenge.
## Validate a schema and CAPTCHA together
```ts
const result = await parseWithCaptcha(signupSchema, body, ctx, {
action: "signup",
engine,
});
if (!result.ok) return Response.json({ ok: false, errors: result.errors }, { status: 400 });
```
## Page gate
```ts
export default captchaPageGate({
action: "reports-access",
engine,
challengePath: "/captcha",
policy: {
mode: "session",
verifiedForMs: 15 * 60_000,
routeGroups: ["/reports"],
},
});
```
Use `mode: "always"` for every visit, `mode: "session"` for a temporary grant, or `mode: "adaptive"` with `signals(ctx)`.
The challenge page should post the return path as a normal hidden field instead of constructing JavaScript inside the HTML `action` attribute:
```wrn
<form method="post" action="/api/page-grant">
<input type="hidden" name="returnTo" value='{returnTo}' />
<Captcha action="reports-access" />
<button type="submit">Continue</button>
</form>
```
The `/api/page-grant` route reads `returnTo`, restricts it to the current origin, and redirects only after `captchaPageGate()` has verified and stored the temporary session grant.
## External providers
```ts
const turnstile = turnstileProvider({
secretKey: process.env.TURNSTILE_SECRET!,
siteKey: process.env.PUBLIC_TURNSTILE_SITE_KEY!,
expectedHostnames: ["example.com"],
expectedAction: "signup",
});
```
```wrn
<Captcha
provider="turnstile"
siteKey="PUBLIC_SITE_KEY"
action="signup"
/>
```
Use the matching provider in `captchaGuard({ provider: turnstile })`. reCAPTCHA and hCaptcha adapters follow the same pattern.
## Managed provider
```ts
const managed = managedCaptchaProvider({
baseUrl: "https://captcha.example.com",
siteKey: process.env.PUBLIC_CAPTCHA_SITE_KEY!,
secretKey: process.env.CAPTCHA_SECRET_KEY!,
});
```
For direct browser challenge creation, configure the components `endpoint` as the managed `/v1/challenges` URL and its `verifyEndpoint` as `/v1/solve`. Keep the secret key only in the server provider.
## Stores
- `MemoryCaptchaStore`: development and one-process applications
- `SqliteCaptchaStore`: adapter for SQLite-like `prepare().run/get/all()` clients
- `RedisCaptchaStore`: shared TTL storage with Lua-backed atomic consumption when `eval` is available
- `CaptchaStore`: implement this interface for PostgreSQL, MySQL, MongoDB, or another backend
## Audio
`AssetAudioRenderer` concatenates bundled English PCM WAV clips without calling an external service. Supply a custom `CaptchaAudioRenderer` for recorded voices, Hindi or other languages, or managed text-to-speech.
## DevToolbar
Register `captchaPlugin()` in the WRNexusJS config. The audit panel checks for likely client-side secrets, missing action bindings, missing provider site keys, optional CAPTCHA fields, accessible alternatives, and server-verification reminders.
## Testing
Use deterministic custom generators in unit tests. Never require users or CI to solve random CAPTCHA images. The package includes engine, provider, policy, HTTP, storage, replay, expiry, binding, and audio authorization tests.
## Custom challenge generator
```ts
import { defineCaptchaGenerator, createCaptchaEngine } from "@wrnexus/captcha";
const wordChallenge = defineCaptchaGenerator({
type: "word" as const,
generate(context) {
const answer = "NEXUS";
return {
type: "word",
presentation: "visual",
prompt: "Enter the displayed word",
answer,
answerKind: "text",
image: renderYourImage(answer),
inputMode: "text",
};
},
});
const engine = createCaptchaEngine({ secret, generators: [wordChallenge] });
```
Applications may also implement `CaptchaStore`, `CaptchaAudioRenderer`, or use `defineCaptchaProvider()` for a completely custom service.
+33
View File
@@ -0,0 +1,33 @@
# Security model
CAPTCHA is an anti-automation signal, not authentication or authorization. Continue using CSRF protection, rate limiting, validation, secure sessions, and access control.
## Enforced by the self-hosted engine
- Answers are stored as HMAC-SHA-256 digests with a per-challenge salt.
- Challenge IDs and response tokens use cryptographically secure random bytes.
- Challenges expire, have attempt limits, and are consumed after a correct answer.
- Response tokens are opaque, hashed at rest, action-bound, and single-use by default.
- Optional hostname, session, and IP bindings are validated with constant-time comparisons.
- Audio URLs contain a random access key and are private/no-store.
- Public challenge payloads never include the answer. Renderer names and disturbance metadata are safe to expose because they do not reveal the answer.
- Same-origin HTTP handlers and independent create/verify rate limits are enabled by default.
- The `not-robot` checkbox uses timing, honeypot, expiry, attempt limits, bindings, and one-use verification; it is intentionally low-friction and should escalate to a stronger challenge when risk is high.
## Required deployment controls
1. Set `CAPTCHA_SECRET` to at least 32 unpredictable characters and rotate it through your secret manager.
2. Use Redis, SQL, or another shared atomic store in multi-instance production deployments.
3. Always verify the response token on the server. Never trust only a client event or hidden input.
4. Bind every CAPTCHA to a stable action such as `signup`, `login`, or `contact-submit`.
5. Restrict managed and third-party keys to expected hostnames.
6. Never pass a secret key to `<Captcha />` or serialize it into browser output.
7. Apply route-level rate limits before expensive image/audio generation.
8. Use TLS in production and avoid logging answers, raw tokens, secret keys, or unnecessary raw IP addresses.
9. Offer an accessible alternative to visual challenges. Hard renderers and disturbance near 75 should never be the only available path.
10. Prefer `imageStyle="random"` or a controlled pool to reduce static segmentation patterns, but do not treat renderer randomness as a replacement for server verification, expiry, attempt limits, or rate limiting.
11. Keep provider SDK behavior and verification rules current before release.
## Reporting
Report suspected vulnerabilities privately to the WorkRoot/WRNexusJS maintainers. Do not open a public issue containing active keys, tokens, or exploit details.
+5
View File
@@ -0,0 +1,5 @@
# Bundled English audio clips
These PCM WAV clips were generated locally with eSpeak for the initial English CAPTCHA vocabulary. They cover digits, letters, and calculation words. Applications may replace the renderer or asset directory to use recorded voices, additional languages, or an external text-to-speech service.
The package does not transmit text to an external speech provider. Keep an accessible non-audio alternative available and rate-limit audio playback endpoints.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+878
View File
@@ -0,0 +1,878 @@
(function () {
"use strict";
var RUNTIME_KEY = "__wrnexusCaptchaRuntime";
var existingRuntime = window[RUNTIME_KEY];
if (existingRuntime && typeof existingRuntime.scan === "function") {
existingRuntime.scan(document);
return;
}
var states = new WeakMap();
var scriptPromises = new Map();
var instanceCounter = 0;
function bool(value, fallback) {
if (value === undefined || value === null || value === "") return fallback;
return value === true || value === "true" || value === "1";
}
function numberInRange(value, fallback, minimum, maximum) {
var parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.max(minimum, Math.min(maximum, Math.round(parsed)));
}
function normalizeSize(value, compact) {
if (compact) return "compact";
var normalized = String(value || "normal").trim().toLowerCase();
if (normalized === "compact" || normalized === "small" || normalized === "sm") return "compact";
if (normalized === "big" || normalized === "large" || normalized === "lg") return "big";
return "normal";
}
function commaList(value) {
return String(value || "")
.split(",")
.map(function (item) { return item.trim().toLowerCase(); })
.filter(Boolean);
}
function text(root, selector, value) {
var element = root.querySelector(selector);
if (element) element.textContent = value == null ? "" : String(value);
}
function show(element, visible) {
if (!element) return;
element.hidden = !visible;
}
function setBusy(root, busy) {
root.setAttribute("aria-busy", busy ? "true" : "false");
}
function config(root) {
var data = root.dataset;
var responseField = data.captchaResponseField || data.captchaName || "wrn-captcha-response";
var compact = bool(data.captchaCompact, false);
var size = normalizeSize(data.captchaSize, compact);
return {
provider: data.captchaProvider || "self-hosted",
siteKey: data.captchaSiteKey || "",
type: data.captchaType || "alphanumeric",
action: data.captchaAction || "form-submit",
presentation: data.captchaPresentation || "visual",
difficulty: data.captchaDifficulty || "normal",
disturbance: numberInRange(data.captchaDisturbance, 50, 25, 75),
imageStyle: data.captchaImageStyle || "random",
allowedStyles: commaList(data.captchaAllowedStyles),
excludedStyles: commaList(data.captchaExcludedStyles),
randomizeStyle: bool(data.captchaRandomizeStyle, false),
locale: data.captchaLocale || "en",
size: size,
endpoint: data.captchaEndpoint || "/__wrnexus/captcha/challenge",
verifyEndpoint: data.captchaVerifyEndpoint || "/__wrnexus/captcha/verify",
responseField: responseField,
autoLoad: bool(data.captchaAutoLoad, true),
autoVerify: bool(data.captchaAutoVerify, false),
showVerify: bool(data.captchaShowVerify, true),
showRefresh: bool(data.captchaShowRefresh, true),
showAudio: bool(data.captchaShowAudio, true),
showListen: bool(data.captchaShowListen, true),
showStatus: bool(data.captchaShowStatus, true),
disabled: bool(data.captchaDisabled, false),
required: bool(data.captchaRequired, true),
compact: compact,
requiredMessage: data.captchaRequiredMessage || "Please complete the security check.",
incorrectMessage: data.captchaIncorrectMessage || "That answer was not correct. Try again.",
expiredMessage: data.captchaExpiredMessage || "This challenge expired. Load a new one.",
networkMessage: data.captchaNetworkMessage || "The verification service is unavailable. Try again.",
};
}
function eventDetail(state, extra) {
var challenge = state.challenge;
return {
component: "Captcha",
provider: state.config.provider,
type: challenge && challenge.type ? challenge.type : state.config.type,
action: state.config.action,
disturbance: state.config.disturbance,
imageStyle: challenge && challenge.metadata && challenge.metadata.imageStyle
? challenge.metadata.imageStyle
: state.config.imageStyle,
requestedImageStyle: challenge && challenge.metadata && challenge.metadata.requestedImageStyle
? challenge.metadata.requestedImageStyle
: state.config.imageStyle,
imageStylePool: challenge && challenge.metadata && challenge.metadata.imageStylePool
? challenge.metadata.imageStylePool
: state.config.allowedStyles,
size: state.config.size,
status: state.status,
challengeId: challenge && challenge.id ? challenge.id : "",
responseToken: state.responseToken,
expiresAt: challenge && challenge.expiresAt ? challenge.expiresAt : null,
extra: extra || null,
};
}
function emit(state, name, extra) {
state.root.dispatchEvent(
new CustomEvent(name, {
bubbles: true,
detail: eventDetail(state, extra),
}),
);
}
function setResponseToken(state, token) {
state.responseToken = token || "";
var input = state.root.querySelector("[data-captcha-response]");
if (input) {
input.name = state.config.responseField;
input.value = state.responseToken;
}
}
function isNotRobot(state) {
return Boolean(
(state.challenge && state.challenge.type === "not-robot") ||
(!state.challenge && state.config.type === "not-robot"),
);
}
function updateNotRobotState(state) {
var panel = state.root.querySelector("[data-captcha-not-robot]");
var button = state.root.querySelector("[data-captcha-not-robot-button]");
var empty = state.root.querySelector("[data-captcha-not-robot-empty]");
var spinner = state.root.querySelector("[data-captcha-not-robot-spinner]");
var check = state.root.querySelector("[data-captcha-not-robot-check]");
var control = state.root.querySelector("[data-captcha-not-robot-control]");
var label = state.root.querySelector("[data-captcha-not-robot-label]");
var active = isNotRobot(state);
var pending = state.notRobotPending || state.status === "verifying";
var verified = state.status === "verified";
show(panel, active && state.status !== "loading" && state.status !== "idle");
if (!active) return;
if (button) {
button.disabled = state.config.disabled || pending || verified || state.status === "expired";
button.setAttribute("aria-pressed", verified ? "true" : "false");
}
show(empty, !pending && !verified);
show(spinner, pending && !verified);
show(check, verified);
if (control) control.dataset.verified = verified ? "true" : "false";
if (label) {
label.textContent = verified ? "Verified" : pending ? "Checking…" : "I'm not a robot";
}
}
function setStatus(state, status, message) {
state.status = status;
state.root.dataset.captchaStatus = status;
setBusy(state.root, status === "loading" || status === "verifying");
var loading = state.root.querySelector("[data-captcha-loading]");
var challenge = state.root.querySelector("[data-captcha-challenge]");
var success = state.root.querySelector("[data-captcha-success]");
var error = state.root.querySelector("[data-captcha-error]");
var badge = state.root.querySelector("[data-captcha-verified-badge]");
var verifyButton = state.root.querySelector("[data-captcha-verify]");
var verifyIcon = state.root.querySelector("[data-captcha-verify-icon]");
var verifySpinner = state.root.querySelector("[data-captcha-verify-spinner]");
var verifyLabel = state.root.querySelector("[data-captcha-verify-label]");
var answer = state.root.querySelector("[data-captcha-answer]");
show(loading, status === "loading");
show(challenge, status !== "loading" && status !== "idle" && state.config.provider !== "turnstile" && state.config.provider !== "recaptcha" && state.config.provider !== "hcaptcha");
show(badge, status === "verified");
if (state.config.showStatus) {
show(success, status === "verified");
show(error, Boolean(message) && status !== "verified");
} else {
show(success, false);
show(error, false);
}
if (message && status === "verified") text(state.root, "[data-captcha-success-message]", message);
if (message && status !== "verified") text(state.root, "[data-captcha-error-message]", message);
if (answer) {
answer.disabled = state.config.disabled || status === "verified" || status === "expired" || status === "loading" || status === "verifying";
answer.setAttribute("aria-invalid", status === "incorrect" ? "true" : "false");
}
if (verifyButton) {
verifyButton.disabled = state.config.disabled || status === "loading" || status === "verifying" || status === "verified" || status === "expired";
}
show(verifyIcon, status !== "verifying");
show(verifySpinner, status === "verifying");
if (verifyLabel) verifyLabel.textContent = status === "verifying" ? "Verifying…" : "Verify";
updateNotRobotState(state);
updateControls(state);
}
function clearTimer(state) {
if (state.timer) {
window.clearInterval(state.timer);
state.timer = null;
}
}
function updateCountdown(state) {
var countdown = state.root.querySelector("[data-captcha-countdown]");
if (!countdown || !state.challenge || !state.challenge.expiresAt) {
show(countdown, false);
return;
}
var remaining = Math.max(0, Math.ceil((Number(state.challenge.expiresAt) - Date.now()) / 1000));
countdown.textContent = remaining + "s";
show(countdown, remaining > 0 && state.status !== "verified");
if (remaining <= 0 && state.status !== "verified" && state.status !== "expired") {
clearTimer(state);
setResponseToken(state, "");
setStatus(state, "expired", state.config.expiredMessage);
emit(state, "expired");
}
}
function startTimer(state) {
clearTimer(state);
if (!state.challenge || !state.challenge.expiresAt) return;
updateCountdown(state);
state.timer = window.setInterval(function () {
updateCountdown(state);
}, 1000);
}
function resetUi(state) {
if (state.audioPlayer) {
state.audioPlayer.pause();
state.audioPlayer.removeAttribute("src");
state.audioPlayer.load();
state.audioPlayer = null;
}
if (state.notRobotTimer) {
window.clearTimeout(state.notRobotTimer);
state.notRobotTimer = null;
}
state.notRobotPending = false;
state.challengeLoadedAt = 0;
state.answer = "";
state.selections = [];
state.challenge = null;
setResponseToken(state, "");
var answer = state.root.querySelector("[data-captcha-answer]");
var items = state.root.querySelector("[data-captcha-items]");
var imageWrap = state.root.querySelector("[data-captcha-image-wrap]");
var providerMount = state.root.querySelector("[data-captcha-provider-mount]");
var honeypot = state.root.querySelector("[data-captcha-honeypot]");
var notRobot = state.root.querySelector("[data-captcha-not-robot]");
if (answer) answer.value = "";
if (items) items.replaceChildren();
show(items, false);
show(imageWrap, false);
show(notRobot, false);
if (honeypot) {
honeypot.value = "";
honeypot.name = "";
}
if (providerMount && state.config.provider !== "turnstile" && state.config.provider !== "recaptcha" && state.config.provider !== "hcaptcha") {
providerMount.replaceChildren();
show(providerMount, false);
}
}
function updateControls(state) {
var challenge = state.challenge;
var audio = state.root.querySelector("[data-captcha-audio]");
var audioAlternative = state.root.querySelector("[data-captcha-audio-alternative]");
var refresh = state.root.querySelector("[data-captcha-refresh]");
var verify = state.root.querySelector("[data-captcha-verify]");
var footer = state.root.querySelector("[data-captcha-footer]");
var isExternal = state.config.provider === "turnstile" || state.config.provider === "recaptcha" || state.config.provider === "hcaptcha";
var notRobot = isNotRobot(state);
var locked = state.config.disabled || state.status === "loading" || state.status === "verifying" || state.notRobotPending;
show(footer, !notRobot);
show(audio, !notRobot && state.config.showAudio && state.config.showListen && Boolean(challenge && challenge.audioUrl) && state.status !== "verified");
show(audioAlternative, !notRobot && state.config.showAudio && Boolean(challenge && challenge.type === "image") && state.status !== "verified");
show(refresh, !notRobot && state.config.showRefresh && state.status !== "verified");
show(verify, !notRobot && state.config.showVerify && !isExternal);
if (audio) audio.disabled = locked;
if (audioAlternative) audioAlternative.disabled = locked;
if (refresh) refresh.disabled = locked;
if (verify) verify.disabled = locked || state.status === "verified" || state.status === "expired";
}
function renderItems(state, challenge) {
var container = state.root.querySelector("[data-captcha-items]");
var template = state.root.querySelector("[data-captcha-item-template]");
if (!container || !template || !Array.isArray(challenge.items) || challenge.items.length === 0) {
show(container, false);
return;
}
container.replaceChildren();
container.setAttribute("aria-label", challenge.prompt || "Select matching images");
challenge.items.forEach(function (item, index) {
var fragment = template.content.cloneNode(true);
var button = fragment.querySelector("button");
var image = fragment.querySelector("[data-captcha-item-image]");
if (!button || !image) return;
button.dataset.captchaItemId = item.id;
button.setAttribute("aria-label", item.alt || "Challenge tile " + (index + 1));
image.src = item.image;
image.alt = "";
button.addEventListener("click", function () {
toggleItem(state, item.id, button);
});
container.appendChild(fragment);
});
show(container, true);
}
function toggleItem(state, itemId, button) {
if (state.config.disabled || state.status !== "ready") return;
var index = state.selections.indexOf(itemId);
var selected = index >= 0;
if (selected) {
state.selections.splice(index, 1);
} else {
var maximum = Number(state.challenge && state.challenge.maxSelections ? state.challenge.maxSelections : 0);
if (maximum > 0 && state.selections.length >= maximum) {
var removed = state.selections.shift();
var previousButton = state.root.querySelector('[data-captcha-item-id="' + CSS.escape(removed) + '"]');
if (previousButton) {
previousButton.setAttribute("aria-pressed", "false");
show(previousButton.querySelector("[data-captcha-item-check]"), false);
}
}
state.selections.push(itemId);
}
var nowSelected = state.selections.indexOf(itemId) >= 0;
button.setAttribute("aria-pressed", nowSelected ? "true" : "false");
show(button.querySelector("[data-captcha-item-check]"), nowSelected);
emit(state, "input", { selections: state.selections.slice() });
var minimum = Number(state.challenge && state.challenge.minSelections ? state.challenge.minSelections : 0);
if (state.config.autoVerify && minimum > 0 && state.selections.length >= minimum) verify(state);
}
function renderChallenge(state, challenge) {
state.challenge = challenge;
state.challengeLoadedAt = Date.now();
state.notRobotPending = false;
state.answer = "";
state.selections = [];
var notRobot = challenge.type === "not-robot";
state.root.dataset.captchaType = challenge.type || state.config.type;
state.root.dataset.captchaResolvedImageStyle = challenge.metadata && challenge.metadata.imageStyle
? String(challenge.metadata.imageStyle)
: state.config.imageStyle;
text(state.root, "[data-captcha-prompt]", challenge.prompt || "Complete the security challenge.");
var promptRow = state.root.querySelector("[data-captcha-prompt-row]");
var notRobotPanel = state.root.querySelector("[data-captcha-not-robot]");
show(promptRow, !notRobot);
show(notRobotPanel, notRobot);
var imageWrap = state.root.querySelector("[data-captcha-image-wrap]");
var image = state.root.querySelector("[data-captcha-image]");
if (!notRobot && challenge.image && image) {
image.src = challenge.image;
show(imageWrap, true);
} else {
if (image) image.removeAttribute("src");
show(imageWrap, false);
}
if (notRobot) {
var items = state.root.querySelector("[data-captcha-items]");
if (items) items.replaceChildren();
show(items, false);
} else {
renderItems(state, challenge);
}
var answerWrap = state.root.querySelector("[data-captcha-answer-wrap]");
var answer = state.root.querySelector("[data-captcha-answer]");
var needsAnswer = !notRobot && !challenge.items && challenge.inputMode !== "none";
show(answerWrap, needsAnswer);
if (answer) {
answer.value = "";
answer.inputMode = challenge.inputMode === "numeric" ? "numeric" : "text";
answer.setAttribute("aria-invalid", "false");
}
var honeypot = state.root.querySelector("[data-captcha-honeypot]");
if (honeypot) honeypot.name = challenge.honeypotField || "";
setStatus(state, "ready", "");
startTimer(state);
emit(state, "challenge", challenge);
emit(state, "ready");
if (state.config.autoVerify && !notRobot && challenge.inputMode === "none" && !challenge.items) {
window.setTimeout(function () {
verify(state);
}, 0);
}
}
async function createChallenge(state, requestedPresentation) {
if (state.config.disabled) return;
if (state.config.provider === "turnstile" || state.config.provider === "recaptcha" || state.config.provider === "hcaptcha") {
await mountExternal(state);
return;
}
clearTimer(state);
resetUi(state);
setStatus(state, "loading", "");
try {
var response = await fetch(state.config.endpoint, {
method: "POST",
credentials: "same-origin",
headers: {
"content-type": "application/json",
accept: "application/json",
},
body: JSON.stringify({
siteKey: state.config.siteKey,
action: state.config.action,
type: state.config.type,
presentation: requestedPresentation || state.config.presentation,
difficulty: state.config.difficulty,
disturbance: state.config.disturbance,
imageStyle: state.config.imageStyle,
allowedStyles: state.config.allowedStyles,
excludedStyles: state.config.excludedStyles,
randomizeStyle: state.config.randomizeStyle,
locale: state.config.locale,
responseField: state.config.responseField,
}),
});
var result = await response.json().catch(function () {
return null;
});
if (!response.ok || !result || !result.id) {
throw new Error(result && result.message ? result.message : "Challenge request failed");
}
renderChallenge(state, result);
} catch (error) {
setStatus(state, "network-error", state.config.networkMessage);
emit(state, "error", { error: error instanceof Error ? error.message : String(error) });
}
}
async function verify(state) {
state.notRobotPending = false;
if (state.config.disabled || state.status === "loading" || state.status === "verifying" || state.status === "expired" || state.status === "verified") {
updateNotRobotState(state);
updateControls(state);
return;
}
if (!state.challenge) {
await createChallenge(state);
return;
}
var answerInput = state.root.querySelector("[data-captcha-answer]");
var honeypotInput = state.root.querySelector("[data-captcha-honeypot]");
state.answer = answerInput ? answerInput.value : "";
setStatus(state, "verifying", "");
emit(state, "verify");
try {
var response = await fetch(state.challenge.verifyUrl || state.config.verifyEndpoint, {
method: "POST",
credentials: "same-origin",
headers: {
"content-type": "application/json",
accept: "application/json",
},
body: JSON.stringify({
challengeId: state.challenge.id,
action: state.config.action,
answer: state.answer,
selections: state.selections,
honeypot: honeypotInput ? honeypotInput.value : "",
timingToken: state.challenge.timingToken || "",
}),
});
var result = await response.json().catch(function () {
return null;
});
if (!response.ok || !result || !result.success) {
setResponseToken(state, "");
var expired = Boolean(result && result.code === "expired");
setStatus(
state,
expired ? "expired" : "incorrect",
expired
? state.config.expiredMessage
: result && result.message
? result.message
: state.config.incorrectMessage,
);
emit(state, "failure", result || null);
return;
}
clearTimer(state);
setResponseToken(state, result.responseToken || "");
setStatus(state, "verified", "Verification completed.");
emit(state, "success", result);
} catch (error) {
setStatus(state, "network-error", state.config.networkMessage);
emit(state, "error", { error: error instanceof Error ? error.message : String(error) });
}
}
function verifyNotRobot(state) {
if (state.config.disabled || state.notRobotPending || state.status !== "ready") return;
if (!state.challenge || state.challenge.type !== "not-robot") {
createChallenge(state);
return;
}
var configuredMinimum = Number(
state.challenge.metadata && state.challenge.metadata.minCompletionMs !== undefined
? state.challenge.metadata.minCompletionMs
: 800,
);
var minimum = Number.isFinite(configuredMinimum) ? Math.max(0, configuredMinimum) : 800;
var elapsed = Math.max(0, Date.now() - state.challengeLoadedAt);
var remaining = Math.max(0, minimum - elapsed);
state.notRobotPending = true;
updateNotRobotState(state);
updateControls(state);
emit(state, "input", { checked: true });
var complete = function () {
state.notRobotTimer = null;
state.notRobotPending = false;
verify(state);
};
if (remaining > 0) state.notRobotTimer = window.setTimeout(complete, remaining);
else complete();
}
function providerDefinition(provider) {
if (provider === "turnstile") {
return {
url: "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit",
globalName: "turnstile",
};
}
if (provider === "recaptcha") {
return {
url: "https://www.google.com/recaptcha/api.js?render=explicit",
globalName: "grecaptcha",
};
}
if (provider === "hcaptcha") {
return {
url: "https://js.hcaptcha.com/1/api.js?render=explicit",
globalName: "hcaptcha",
};
}
return null;
}
function loadProviderScript(definition) {
if (window[definition.globalName]) return Promise.resolve(window[definition.globalName]);
if (scriptPromises.has(definition.url)) return scriptPromises.get(definition.url);
var promise = new Promise(function (resolve, reject) {
var script = document.querySelector('script[src="' + definition.url + '"]');
if (!script) {
script = document.createElement("script");
script.src = definition.url;
script.async = true;
script.defer = true;
document.head.appendChild(script);
}
script.addEventListener(
"load",
function () {
if (window[definition.globalName]) resolve(window[definition.globalName]);
else reject(new Error("CAPTCHA provider did not initialize"));
},
{ once: true },
);
script.addEventListener("error", function () { reject(new Error("CAPTCHA provider script failed")); }, { once: true });
});
scriptPromises.set(definition.url, promise);
return promise;
}
async function mountExternal(state) {
var definition = providerDefinition(state.config.provider);
if (!definition) return;
if (!state.config.siteKey) {
setStatus(state, "provider-error", "A public site key is required.");
return;
}
var mount = state.root.querySelector("[data-captcha-provider-mount]");
if (!mount) return;
show(mount, true);
setStatus(state, "loading", "");
try {
var api = await loadProviderScript(definition);
if (state.externalWidgetId !== null && typeof api.reset === "function") {
setResponseToken(state, "");
api.reset(state.externalWidgetId);
setStatus(state, "ready", "");
emit(state, "ready");
return;
}
mount.replaceChildren();
state.externalApi = api;
state.externalWidgetId = api.render(mount, {
sitekey: state.config.siteKey,
theme: "auto",
size: state.config.size === "compact" ? "compact" : state.config.provider === "turnstile" ? "flexible" : "normal",
action: state.config.action,
callback: function (token) {
setResponseToken(state, token);
setStatus(state, "verified", "Verification completed.");
emit(state, "success");
},
"expired-callback": function () {
setResponseToken(state, "");
setStatus(state, "expired", state.config.expiredMessage);
emit(state, "expired");
},
"error-callback": function (providerError) {
setResponseToken(state, "");
setStatus(state, "provider-error", state.config.networkMessage);
emit(state, "error", { error: providerError || "provider-error" });
},
});
setStatus(state, "ready", "");
emit(state, "ready");
} catch (error) {
setStatus(state, "provider-error", state.config.networkMessage);
emit(state, "error", { error: error instanceof Error ? error.message : String(error) });
}
}
function playAudio(state) {
if (!state.challenge || !state.challenge.audioUrl || state.config.disabled) return;
if (state.audioPlayer) {
state.audioPlayer.pause();
state.audioPlayer.currentTime = 0;
}
var audio = new Audio();
state.audioPlayer = audio;
audio.preload = "auto";
audio.volume = 1;
audio.src = new URL(state.challenge.audioUrl, window.location.href).href;
audio.addEventListener("ended", function () {
if (state.audioPlayer === audio) state.audioPlayer = null;
emit(state, "audioEnd");
emit(state, "audio-end");
}, { once: true });
audio.addEventListener("error", function () {
if (state.audioPlayer === audio) state.audioPlayer = null;
var mediaError = audio.error;
setStatus(state, "network-error", "The audio challenge could not be played. Load a new challenge and try again.");
emit(state, "error", {
code: "audio-playback-failed",
audioUrl: audio.src,
mediaErrorCode: mediaError ? mediaError.code : null,
});
}, { once: true });
audio.load();
audio.play().then(function () {
emit(state, "audioStart");
emit(state, "audio-start");
}).catch(function (error) {
if (state.audioPlayer === audio) state.audioPlayer = null;
setStatus(state, "network-error", "The audio challenge could not be played. Check the audio endpoint and try again.");
emit(state, "error", {
code: "audio-playback-rejected",
audioUrl: audio.src,
error: error instanceof Error ? error.message : String(error),
});
});
}
function onFormSubmit(state, event) {
if (!state.config.required || state.responseToken) return;
event.preventDefault();
event.stopImmediatePropagation();
setStatus(state, "incorrect", state.config.requiredMessage);
emit(state, "failure", { code: "missing-input" });
var target = state.root.querySelector("[data-captcha-answer]") || state.root.querySelector("[data-captcha-not-robot-button]") || state.root.querySelector("[data-captcha-verify]");
if (target && typeof target.focus === "function") target.focus();
}
function initialize(root) {
if (!(root instanceof HTMLElement) || states.has(root)) return;
if (!root.id) {
instanceCounter += 1;
root.id = "wrn-captcha-" + instanceCounter;
}
var state = {
root: root,
config: config(root),
challenge: null,
answer: "",
selections: [],
responseToken: "",
status: "idle",
timer: null,
notRobotTimer: null,
notRobotPending: false,
challengeLoadedAt: 0,
externalApi: null,
externalWidgetId: null,
audioPlayer: null,
form: root.closest("form"),
};
states.set(root, state);
root.dataset.captchaSize = state.config.size;
var response = root.querySelector("[data-captcha-response]");
if (response) response.name = state.config.responseField;
var answer = root.querySelector("[data-captcha-answer]");
var verifyButton = root.querySelector("[data-captcha-verify]");
var refreshButton = root.querySelector("[data-captcha-refresh]");
var audioButton = root.querySelector("[data-captcha-audio]");
var audioAlternative = root.querySelector("[data-captcha-audio-alternative]");
var notRobotButton = root.querySelector("[data-captcha-not-robot-button]");
if (answer) {
answer.addEventListener("input", function () {
state.answer = answer.value;
emit(state, "input", { answerLength: state.answer.length });
});
answer.addEventListener("keydown", function (event) {
if (event.key === "Enter") {
event.preventDefault();
verify(state);
}
});
}
if (verifyButton) verifyButton.addEventListener("click", function () { verify(state); });
if (notRobotButton) notRobotButton.addEventListener("click", function () { verifyNotRobot(state); });
if (refreshButton) refreshButton.addEventListener("click", function () {
emit(state, "refresh");
createChallenge(state, state.config.presentation);
});
if (audioButton) audioButton.addEventListener("click", function () { playAudio(state); });
if (audioAlternative) audioAlternative.addEventListener("click", function () {
emit(state, "refresh", { presentation: "audio" });
createChallenge(state, "audio");
});
root.addEventListener("captcha-reset", function () {
emit(state, "refresh");
createChallenge(state, state.config.presentation);
});
if (state.form) {
state.form.addEventListener("submit", function (event) {
onFormSubmit(state, event);
}, true);
state.form.addEventListener("wire:success", function () {
createChallenge(state, state.config.presentation);
});
}
updateNotRobotState(state);
updateControls(state);
if (state.config.disabled) setStatus(state, "disabled", "");
else if (state.config.autoLoad) createChallenge(state, state.config.presentation);
}
function scan(scope) {
var host = scope || document;
if (host instanceof Element && host.matches("[data-wrn-captcha]")) initialize(host);
host.querySelectorAll("[data-wrn-captcha]").forEach(initialize);
}
var runtime = {
scan: scan,
reset: function (element) {
var root = typeof element === "string" ? document.querySelector(element) : element;
var state = root ? states.get(root) : null;
if (state) createChallenge(state, state.config.presentation);
},
verify: function (element) {
var root = typeof element === "string" ? document.querySelector(element) : element;
var state = root ? states.get(root) : null;
return state ? verify(state) : Promise.resolve();
},
};
window[RUNTIME_KEY] = runtime;
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () { scan(document); }, { once: true });
} else {
scan(document);
}
new MutationObserver(function (records) {
records.forEach(function (record) {
record.addedNodes.forEach(function (node) {
if (node.nodeType === 1) scan(node);
});
});
}).observe(document.documentElement, { childList: true, subtree: true });
})();
+320
View File
@@ -0,0 +1,320 @@
component Captcha {
props {
provider = "self-hosted"
siteKey = ""
type = "alphanumeric"
action = "form-submit"
presentation = "visual"
difficulty = "normal"
disturbance = 50
imageStyle = "random"
allowedStyles = ""
excludedStyles = ""
randomizeStyle = false
locale = "en"
size = "normal"
color = "primary"
class = ""
id = ""
name = "wrn-captcha-response"
endpoint = "/__wrnexus/captcha/challenge"
verifyEndpoint = "/__wrnexus/captcha/verify"
responseField = "wrn-captcha-response"
label = "Security verification"
description = "Complete the challenge to continue."
helpText = ""
requiredMessage = "Please complete the security check."
incorrectMessage = "That answer was not correct. Try again."
expiredMessage = "This challenge expired. Load a new one."
networkMessage = "The verification service is unavailable. Try again."
autoLoad = true
autoVerify = false
showVerify = true
showRefresh = true
showAudio = true
showListen = true
showStatus = true
disabled = false
required = true
compact = false
@event ready = function
@event challenge = function
@event input = function
@event verify = function
@event success = function
@event failure = function
@event expired = function
@event refresh = function
@event audioStart = function
@event audioEnd = function
@event error = function
}
view {
<section
{...attrs}
id='{id}'
data-wrn-captcha
data-captcha-provider='{provider}'
data-captcha-site-key='{siteKey}'
data-captcha-type='{type}'
data-captcha-action='{action}'
data-captcha-presentation='{presentation}'
data-captcha-difficulty='{difficulty}'
data-captcha-disturbance='{disturbance}'
data-captcha-image-style='{imageStyle}'
data-captcha-allowed-styles='{allowedStyles}'
data-captcha-excluded-styles='{excludedStyles}'
data-captcha-randomize-style='{randomizeStyle}'
data-captcha-locale='{locale}'
data-captcha-size='{size}'
data-captcha-color='{color}'
data-captcha-endpoint='{endpoint}'
data-captcha-verify-endpoint='{verifyEndpoint}'
data-captcha-response-field='{responseField}'
data-captcha-name='{name}'
data-captcha-auto-load='{autoLoad}'
data-captcha-auto-verify='{autoVerify}'
data-captcha-show-verify='{showVerify}'
data-captcha-show-refresh='{showRefresh}'
data-captcha-show-audio='{showAudio}'
data-captcha-show-listen='{showListen}'
data-captcha-show-status='{showStatus}'
data-captcha-disabled='{disabled}'
data-captcha-required='{required}'
data-captcha-compact='{compact}'
data-captcha-required-message='{requiredMessage}'
data-captcha-incorrect-message='{incorrectMessage}'
data-captcha-expired-message='{expiredMessage}'
data-captcha-network-message='{networkMessage}'
data-captcha-status="idle"
data-server-verification-required="true"
aria-busy="false"
class='group/captcha relative flex w-full max-w-lg flex-col gap-3 rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4 text-[var(--wire-color-text)] shadow-[var(--wire-shadow-1)] [--captcha-accent:var(--wire-color-primary)] data-[captcha-color=secondary]:[--captcha-accent:var(--wire-color-secondary)] data-[captcha-color=success]:[--captcha-accent:var(--wire-color-success)] data-[captcha-color=warning]:[--captcha-accent:var(--wire-color-warning)] data-[captcha-color=danger]:[--captcha-accent:var(--wire-color-danger)] data-[captcha-color=info]:[--captcha-accent:var(--wire-color-info)] data-[captcha-size=compact]:max-w-xs data-[captcha-size=compact]:gap-1.5 data-[captcha-size=compact]:p-2 data-[captcha-size=big]:max-w-2xl data-[captcha-size=big]:gap-4 data-[captcha-size=big]:p-5 data-[captcha-compact=true]:max-w-xs data-[captcha-compact=true]:gap-1.5 data-[captcha-compact=true]:p-2 data-[captcha-type=not-robot]:max-w-sm data-[captcha-type=not-robot]:gap-0 data-[captcha-type=not-robot]:p-0 data-[captcha-disabled=true]:pointer-events-none data-[captcha-disabled=true]:opacity-60 data-[captcha-status=verified]:border-[var(--wire-color-success)] data-[captcha-status=incorrect]:border-[var(--wire-color-danger)] data-[captcha-status=expired]:border-[var(--wire-color-danger)] data-[captcha-status=network-error]:border-[var(--wire-color-danger)] data-[captcha-status=provider-error]:border-[var(--wire-color-danger)] {class}'
>
<script
src="/assets/wrnexus/captcha.js"
defer
data-wrn-captcha-runtime
></script>
<header data-captcha-header class='flex items-start justify-between gap-3 group-data-[captcha-size=compact]/captcha:gap-1.5 group-data-[captcha-type=not-robot]/captcha:hidden'>
<div class='min-w-0'>
<div class='flex items-center gap-2 group-data-[captcha-size=compact]/captcha:gap-1.5'>
<span
aria-hidden="true"
class='icon-[lucide--shield-check] size-5 shrink-0 text-[var(--captcha-accent)] group-data-[captcha-size=compact]/captcha:size-4'
></span>
<h3 class='m-0 text-sm font-semibold leading-5 text-[var(--wire-color-text)] group-data-[captcha-size=compact]/captcha:text-[11px] group-data-[captcha-size=compact]/captcha:leading-4 group-data-[captcha-size=big]/captcha:text-base'>
{label}
</h3>
</div>
<p class='m-0 mt-1 text-xs leading-5 text-[var(--wire-color-muted)] group-data-[captcha-size=compact]/captcha:hidden group-data-[captcha-size=big]/captcha:text-sm'>
{description}
</p>
</div>
<span
data-captcha-verified-badge
hidden
class='shrink-0 items-center gap-1 rounded-full bg-[color-mix(in_srgb,var(--wire-color-success)_12%,transparent)] px-2 py-1 text-xs font-semibold text-[var(--wire-color-success)] group-data-[captcha-size=compact]/captcha:gap-0.5 group-data-[captcha-size=compact]/captcha:px-1.5 group-data-[captcha-size=compact]/captcha:py-0.5 group-data-[captcha-size=compact]/captcha:text-[10px]'
>
<span aria-hidden="true" class='icon-[lucide--circle-check] size-3.5 group-data-[captcha-size=compact]/captcha:size-3'></span>
Verified
</span>
</header>
<div
data-captcha-provider-mount
hidden
class='flex min-h-16 w-full items-center justify-center overflow-hidden rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-surface-2)] p-2 group-data-[captcha-size=compact]/captcha:min-h-12 group-data-[captcha-size=compact]/captcha:p-1.5'
></div>
<div
data-captcha-loading
hidden
class='flex min-h-28 items-center justify-center gap-2 rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-surface-2)] text-sm text-[var(--wire-color-muted)] group-data-[captcha-size=compact]/captcha:min-h-14 group-data-[captcha-size=compact]/captcha:gap-1.5 group-data-[captcha-size=compact]/captcha:text-[11px] group-data-[captcha-size=compact]/captcha:leading-4 group-data-[captcha-size=big]/captcha:min-h-36 group-data-[captcha-type=not-robot]/captcha:m-3 group-data-[captcha-type=not-robot]/captcha:min-h-16'
>
<span aria-hidden="true" class='icon-[lucide--loader-circle] size-5 animate-spin group-data-[captcha-size=compact]/captcha:size-4'></span>
Loading challenge…
</div>
<div data-captcha-challenge hidden class='flex flex-col gap-3 group-data-[captcha-size=compact]/captcha:gap-1.5 group-data-[captcha-size=big]/captcha:gap-4 group-data-[captcha-type=not-robot]/captcha:p-3'>
<div data-captcha-prompt-row class='flex items-start justify-between gap-3 group-data-[captcha-size=compact]/captcha:gap-2'>
<p data-captcha-prompt class='m-0 text-sm font-medium text-[var(--wire-color-text)] group-data-[captcha-size=compact]/captcha:text-[11px] group-data-[captcha-size=compact]/captcha:leading-4'></p>
<span data-captcha-countdown hidden class='shrink-0 text-xs tabular-nums text-[var(--wire-color-muted)] group-data-[captcha-size=compact]/captcha:text-[10px]'></span>
</div>
<div data-captcha-not-robot hidden class='w-full'>
<button
data-captcha-not-robot-button
type="button"
aria-pressed="false"
class='flex min-h-20 w-full items-center gap-3 rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-4 py-3 text-left transition-[border-color,box-shadow,background-color] hover:border-[var(--captcha-accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--captcha-accent)] disabled:cursor-wait disabled:opacity-80 group-data-[captcha-size=compact]/captcha:min-h-16 group-data-[captcha-size=compact]/captcha:gap-2.5 group-data-[captcha-size=compact]/captcha:px-3 group-data-[captcha-size=compact]/captcha:py-2.5 group-data-[captcha-size=big]/captcha:min-h-24 group-data-[captcha-size=big]/captcha:px-5 group-data-[captcha-size=big]/captcha:py-4'
>
<span
data-captcha-not-robot-control
aria-hidden="true"
class='inline-flex size-7 shrink-0 items-center justify-center rounded-md border-2 border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] text-white transition-colors data-[verified=true]:border-[var(--wire-color-success)] data-[verified=true]:bg-[var(--wire-color-success)] group-data-[captcha-size=compact]/captcha:size-6 group-data-[captcha-size=big]/captcha:size-8'
>
<span data-captcha-not-robot-empty class='size-full'></span>
<span data-captcha-not-robot-spinner hidden class='icon-[lucide--loader-circle] size-4 animate-spin text-[var(--captcha-accent)]'></span>
<span data-captcha-not-robot-check hidden class='icon-[lucide--check] size-5'></span>
</span>
<span data-captcha-not-robot-label class='min-w-0 flex-1 text-sm font-semibold text-[var(--wire-color-text)] group-data-[captcha-size=compact]/captcha:text-[11px] group-data-[captcha-size=compact]/captcha:leading-4 group-data-[captcha-size=big]/captcha:text-base'>
I'm not a robot
</span>
<span class='flex shrink-0 flex-col items-center gap-0.5 text-[10px] leading-none text-[var(--wire-color-muted)]'>
<span aria-hidden="true" class='icon-[lucide--shield-check] size-6 text-[var(--captcha-accent)] group-data-[captcha-size=compact]/captcha:size-5 group-data-[captcha-size=big]/captcha:size-7'></span>
<span>WRNexus</span>
</span>
</button>
</div>
<div
data-captcha-image-wrap
hidden
class='overflow-hidden rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-white'
>
<img
data-captcha-image
src=""
alt="CAPTCHA challenge"
draggable="false"
class='block h-auto min-h-20 w-full select-none object-contain group-data-[captcha-size=compact]/captcha:max-h-24 group-data-[captcha-size=compact]/captcha:min-h-12 group-data-[captcha-size=big]/captcha:min-h-28'
/>
</div>
<div
data-captcha-items
hidden
role="group"
class='grid grid-cols-2 gap-2 group-data-[captcha-size=compact]/captcha:gap-1 sm:grid-cols-3 group-data-[captcha-size=big]/captcha:gap-3'
></div>
<template data-captcha-item-template>
<button
type="button"
aria-pressed="false"
class='group relative overflow-hidden rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-white p-1 group-data-[captcha-size=compact]/captcha:p-0.5 transition-[border-color,box-shadow,transform] duration-[var(--wire-motion-fast)] hover:border-[var(--captcha-accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--captcha-accent)] aria-[pressed=true]:border-[var(--captcha-accent)] aria-[pressed=true]:ring-2 aria-[pressed=true]:ring-[var(--captcha-accent)]'
>
<img
data-captcha-item-image
src=""
alt=""
draggable="false"
class='aspect-[4/3] w-full select-none object-cover'
/>
<span
data-captcha-item-check
hidden
aria-hidden="true"
class='absolute right-1.5 top-1.5 inline-flex size-5 group-data-[captcha-size=compact]/captcha:right-1 group-data-[captcha-size=compact]/captcha:top-1 group-data-[captcha-size=compact]/captcha:size-4 items-center justify-center rounded-full bg-[var(--captcha-accent)] text-white shadow-sm'
>
<span class='icon-[lucide--check] size-3.5'></span>
</span>
</button>
</template>
<label data-captcha-answer-wrap hidden class='flex flex-col gap-1.5 group-data-[captcha-size=compact]/captcha:gap-1'>
<span class='text-xs font-semibold text-[var(--wire-color-text)] group-data-[captcha-size=compact]/captcha:text-[10px]'>Your answer</span>
<input
data-captcha-answer
type="text"
inputmode="text"
autocomplete="off"
autocapitalize="characters"
spellcheck="false"
class='h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] px-3 text-base font-semibold tracking-[0.18em] text-[var(--wire-color-text)] outline-none transition-[border-color,box-shadow] placeholder:tracking-normal focus:border-[var(--captcha-accent)] focus:ring-2 focus:ring-[var(--captcha-accent)] disabled:cursor-not-allowed disabled:bg-[var(--wire-color-surface-2)] aria-[invalid=true]:border-[var(--wire-color-danger)] aria-[invalid=true]:ring-2 aria-[invalid=true]:ring-[var(--wire-color-danger)] group-data-[captcha-size=compact]/captcha:h-8 group-data-[captcha-size=compact]/captcha:px-2 group-data-[captcha-size=compact]/captcha:text-xs group-data-[captcha-size=compact]/captcha:tracking-[0.12em] group-data-[captcha-size=big]/captcha:h-12 group-data-[captcha-size=big]/captcha:px-4 group-data-[captcha-size=big]/captcha:text-lg'
placeholder="Enter the answer"
/>
</label>
<input
data-captcha-honeypot
type="text"
name=""
value=""
tabindex="-1"
autocomplete="off"
aria-hidden="true"
class='pointer-events-none absolute -left-[10000px] top-auto h-px w-px overflow-hidden opacity-0'
/>
</div>
<div data-captcha-success hidden role="status" aria-live="polite" class='flex items-start gap-1.5 text-xs leading-5 text-[var(--wire-color-success)] group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-size=compact]/captcha:text-[10px] group-data-[captcha-size=compact]/captcha:leading-4 group-data-[captcha-type=not-robot]/captcha:hidden'>
<span aria-hidden="true" class='icon-[lucide--circle-check] mt-0.5 size-3.5 shrink-0'></span>
<span data-captcha-success-message>Verification completed.</span>
</div>
<div data-captcha-error hidden role="alert" aria-live="polite" class='flex items-start gap-1.5 text-xs leading-5 text-[var(--wire-color-danger)] group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-size=compact]/captcha:text-[10px] group-data-[captcha-size=compact]/captcha:leading-4 group-data-[captcha-type=not-robot]/captcha:mx-3 group-data-[captcha-type=not-robot]/captcha:mb-3'>
<span aria-hidden="true" class='icon-[lucide--circle-alert] mt-0.5 size-3.5 shrink-0'></span>
<span data-captcha-error-message></span>
</div>
<p data-captcha-help class='m-0 text-xs leading-5 text-[var(--wire-color-muted)] group-data-[captcha-size=compact]/captcha:text-[10px] group-data-[captcha-size=compact]/captcha:leading-4 group-data-[captcha-type=not-robot]/captcha:hidden'>
{helpText}
</p>
<footer data-captcha-footer class='flex flex-wrap items-center justify-between gap-2 group-data-[captcha-size=compact]/captcha:flex-nowrap group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-type=not-robot]/captcha:hidden'>
<div class='flex flex-wrap items-center gap-1.5 group-data-[captcha-size=compact]/captcha:flex-nowrap group-data-[captcha-size=compact]/captcha:gap-0.5'>
<button
data-captcha-audio
hidden
type="button"
class='inline-flex h-9 items-center justify-center gap-1.5 rounded-[var(--wire-radius-sm)] px-2.5 text-xs group-data-[captcha-size=compact]/captcha:h-8 group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-size=compact]/captcha:px-1.5 group-data-[captcha-size=compact]/captcha:text-[10px] font-semibold text-[var(--wire-color-muted)] transition-colors hover:bg-[var(--wire-color-surface-2)] hover:text-[var(--captcha-accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--captcha-accent)] disabled:cursor-not-allowed disabled:opacity-50'
>
<span aria-hidden="true" class='icon-[lucide--volume-2] size-4 group-data-[captcha-size=compact]/captcha:size-3.5'></span>
Listen
</button>
<button
data-captcha-audio-alternative
hidden
type="button"
class='inline-flex h-9 items-center justify-center gap-1.5 rounded-[var(--wire-radius-sm)] px-2.5 text-xs group-data-[captcha-size=compact]/captcha:h-8 group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-size=compact]/captcha:px-1.5 group-data-[captcha-size=compact]/captcha:text-[10px] font-semibold text-[var(--wire-color-muted)] transition-colors hover:bg-[var(--wire-color-surface-2)] hover:text-[var(--captcha-accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--captcha-accent)] disabled:cursor-not-allowed disabled:opacity-50'
>
<span aria-hidden="true" class='icon-[lucide--ear] size-4 group-data-[captcha-size=compact]/captcha:size-3.5'></span>
Use audio
</button>
<button
data-captcha-refresh
type="button"
class='inline-flex h-9 items-center justify-center gap-1.5 rounded-[var(--wire-radius-sm)] px-2.5 text-xs group-data-[captcha-size=compact]/captcha:h-8 group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-size=compact]/captcha:px-1.5 group-data-[captcha-size=compact]/captcha:text-[10px] font-semibold text-[var(--wire-color-muted)] transition-colors hover:bg-[var(--wire-color-surface-2)] hover:text-[var(--captcha-accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--captcha-accent)] disabled:cursor-not-allowed disabled:opacity-50'
>
<span aria-hidden="true" class='icon-[lucide--refresh-cw] size-4 group-data-[captcha-size=compact]/captcha:size-3.5'></span>
New challenge
</button>
</div>
<button
data-captcha-verify
type="button"
class='inline-flex h-9 items-center justify-center gap-1.5 rounded-[var(--wire-radius-sm)] bg-[var(--captcha-accent)] px-3 text-xs group-data-[captcha-size=compact]/captcha:h-8 group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-size=compact]/captcha:px-2.5 group-data-[captcha-size=compact]/captcha:text-[10px] font-semibold text-white shadow-sm transition-[filter,transform] hover:brightness-95 active:translate-y-px focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--captcha-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--wire-color-surface)] disabled:cursor-not-allowed disabled:opacity-50'
>
<span data-captcha-verify-icon aria-hidden="true" class='icon-[lucide--shield-check] size-4 group-data-[captcha-size=compact]/captcha:size-3.5'></span>
<span data-captcha-verify-spinner hidden aria-hidden="true" class='icon-[lucide--loader-circle] size-4 animate-spin group-data-[captcha-size=compact]/captcha:size-3.5'></span>
<span data-captcha-verify-label>Verify</span>
</button>
</footer>
<input
data-captcha-response
type="hidden"
name='{responseField}'
value=""
/>
<p data-captcha-credit class='m-0 text-[11px] leading-4 text-[var(--wire-color-muted)] group-data-[captcha-size=compact]/captcha:hidden group-data-[captcha-type=not-robot]/captcha:hidden'>
Protected by WRNexus CAPTCHA. Server-side verification is required.
</p>
</section>
}
}
+53
View File
@@ -0,0 +1,53 @@
{
"name": "@wrnexus/captcha",
"version": "0.3.6",
"description": "First-class CAPTCHA challenges, providers, verification guards, page gates, and WRNexusJS UI.",
"type": "module",
"sideEffects": false,
"main": "./src/index.ts",
"types": "./src/index.ts",
"files": [
"src",
"components",
"assets",
"README.md",
"SECURITY.md",
"CHANGELOG.md"
],
"exports": {
".": "./src/index.ts",
"./server": "./src/server/index.ts",
"./client": "./src/client/index.ts",
"./plugin": "./src/plugin.ts",
"./types": "./src/types.ts",
"./stores/memory": "./src/stores/memory.ts",
"./stores/sqlite": "./src/stores/sqlite.ts",
"./stores/redis": "./src/stores/redis.ts",
"./providers/self-hosted": "./src/providers/self-hosted.ts",
"./providers/managed": "./src/providers/managed.ts",
"./providers/turnstile": "./src/providers/turnstile.ts",
"./providers/recaptcha": "./src/providers/recaptcha.ts",
"./providers/hcaptcha": "./src/providers/hcaptcha.ts",
"./components/*": "./components/*",
"./providers/custom": "./src/providers/custom.ts",
"./providers": "./src/providers/index.ts",
"./stores": "./src/stores/memory.ts",
"./challenges": "./src/challenges/index.ts",
"./audio": "./src/audio/index.ts"
},
"scripts": {
"test": "bun test",
"typecheck": "tsc --noEmit",
"check": "bun run typecheck && bun run test"
},
"dependencies": {
"@wrnexus/core": "workspace:*",
"@wrnexus/plugin": "workspace:*",
"@wrnexus/validation": "workspace:*"
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5.9.2",
"@wrnexus/syntax": "workspace:*"
}
}
+1
View File
@@ -0,0 +1 @@
export * from "./renderer.ts";
+239
View File
@@ -0,0 +1,239 @@
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type { CaptchaAudioRenderer } from "../types.ts";
interface ParsedWav {
sampleRate: number;
channels: number;
bitsPerSample: number;
data: Uint8Array;
}
function u16(view: DataView, offset: number): number {
return view.getUint16(offset, true);
}
function u32(view: DataView, offset: number): number {
return view.getUint32(offset, true);
}
function parseWav(bytes: Uint8Array): ParsedWav {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
if (bytes.length < 44 || String.fromCharCode(...bytes.slice(0, 4)) !== "RIFF") {
throw new Error("CAPTCHA audio asset is not a WAV file");
}
let offset = 12;
let sampleRate = 0;
let channels = 0;
let bitsPerSample = 0;
let data: Uint8Array | undefined;
while (offset + 8 <= bytes.length) {
const id = String.fromCharCode(...bytes.slice(offset, offset + 4));
const size = u32(view, offset + 4);
const start = offset + 8;
if (id === "fmt ") {
const format = u16(view, start);
if (format !== 1) throw new Error("CAPTCHA audio assets must use PCM WAV");
channels = u16(view, start + 2);
sampleRate = u32(view, start + 4);
bitsPerSample = u16(view, start + 14);
} else if (id === "data") {
data = bytes.slice(start, start + size);
}
offset = start + size + (size % 2);
}
if (!sampleRate || !channels || !bitsPerSample || !data) {
throw new Error("Invalid CAPTCHA WAV asset");
}
return { sampleRate, channels, bitsPerSample, data };
}
function writeAscii(target: Uint8Array, offset: number, value: string): void {
for (let index = 0; index < value.length; index++) {
target[offset + index] = value.charCodeAt(index);
}
}
function createWav(
parts: Uint8Array[],
sampleRate: number,
channels: number,
bitsPerSample: number,
): Uint8Array {
const dataLength = parts.reduce((sum, part) => sum + part.length, 0);
const out = new Uint8Array(44 + dataLength);
const view = new DataView(out.buffer);
const blockAlign = channels * (bitsPerSample / 8);
const byteRate = sampleRate * blockAlign;
writeAscii(out, 0, "RIFF");
view.setUint32(4, 36 + dataLength, true);
writeAscii(out, 8, "WAVE");
writeAscii(out, 12, "fmt ");
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, channels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, byteRate, true);
view.setUint16(32, blockAlign, true);
view.setUint16(34, bitsPerSample, true);
writeAscii(out, 36, "data");
view.setUint32(40, dataLength, true);
let cursor = 44;
for (const part of parts) {
out.set(part, cursor);
cursor += part.length;
}
return out;
}
function safeToken(value: string): string {
const token = value.toLowerCase().trim();
if (!/^[a-z0-9]+$/.test(token)) {
throw new Error(`Unsupported audio token: ${value}`);
}
return token;
}
function addCandidate(candidates: string[], value: string | undefined): void {
if (!value) return;
const normalized = value.trim();
if (normalized && !candidates.includes(normalized)) candidates.push(normalized);
}
/**
* Resolve the bundled audio directory in source, workspace, installed-package,
* and bundled-server layouts. A production server bundle changes import.meta.url,
* so package resolution and cwd fallbacks are required in addition to the
* source-relative path.
*/
export function resolveCaptchaAudioAssetsDir(explicitDir?: string): string {
const candidates: string[] = [];
addCandidate(candidates, explicitDir);
addCandidate(candidates, process.env.WRNEXUS_CAPTCHA_AUDIO_DIR);
try {
const packageEntry = createRequire(import.meta.url).resolve("@wrnexus/captcha/audio");
addCandidate(
candidates,
join(dirname(dirname(dirname(packageEntry))), "assets", "audio"),
);
} catch {
// The source-relative and cwd fallbacks below still support direct source use.
}
const sourcePackageRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
addCandidate(candidates, join(sourcePackageRoot, "assets", "audio"));
let current = process.cwd();
for (let depth = 0; depth < 8; depth += 1) {
addCandidate(candidates, join(current, "packages", "captcha", "assets", "audio"));
addCandidate(candidates, join(current, "node_modules", "@wrnexus", "captcha", "assets", "audio"));
const parent = dirname(current);
if (parent === current) break;
current = parent;
}
for (const candidate of candidates) {
if (existsSync(join(candidate, "en"))) return candidate;
}
throw new Error(
[
"Unable to locate WRNexusJS CAPTCHA audio assets.",
"Set WRNEXUS_CAPTCHA_AUDIO_DIR or pass assetsDir to AssetAudioRenderer.",
`Checked: ${candidates.join(", ")}`,
].join(" "),
);
}
export interface AssetAudioRendererOptions {
assetsDir?: string;
gapMs?: number;
}
/**
* Concatenates bundled English PCM word clips. Applications can replace this
* renderer with cloud TTS or their own localized renderer without changing the
* challenge engine.
*/
export class AssetAudioRenderer implements CaptchaAudioRenderer {
readonly contentType = "audio/wav";
private readonly assetsDir: string;
private readonly gapMs: number;
private readonly cache = new Map<string, ParsedWav>();
constructor(options: AssetAudioRendererOptions = {}) {
this.assetsDir = resolveCaptchaAudioAssetsDir(options.assetsDir);
this.gapMs = options.gapMs ?? 180;
}
async render(sequence: string[], locale: string): Promise<Uint8Array> {
const language = locale.toLowerCase().split("-")[0] || "en";
if (language !== "en") {
throw new Error(
`No bundled CAPTCHA audio assets for locale '${locale}'. Supply a custom CaptchaAudioRenderer.`,
);
}
if (!sequence.length) throw new Error("Cannot render an empty CAPTCHA audio sequence");
const clips = await Promise.all(
sequence.map((token) => this.load(language, safeToken(token))),
);
const first = clips[0]!;
for (const clip of clips) {
if (
clip.sampleRate !== first.sampleRate ||
clip.channels !== first.channels ||
clip.bitsPerSample !== first.bitsPerSample
) {
throw new Error("CAPTCHA audio assets must share one PCM format");
}
}
const bytesPerSample = first.channels * (first.bitsPerSample / 8);
const silenceBytes = Math.floor((first.sampleRate * this.gapMs) / 1000) * bytesPerSample;
const silence = new Uint8Array(silenceBytes);
const parts: Uint8Array[] = [];
for (let index = 0; index < clips.length; index += 1) {
if (index) parts.push(silence);
parts.push(clips[index]!.data);
}
return createWav(parts, first.sampleRate, first.channels, first.bitsPerSample);
}
private async load(language: string, token: string): Promise<ParsedWav> {
const key = `${language}/${token}`;
const cached = this.cache.get(key);
if (cached) return cached;
const bytes = new Uint8Array(
await readFile(join(this.assetsDir, language, `${token}.wav`)),
);
const parsed = parseWav(bytes);
this.cache.set(key, parsed);
return parsed;
}
}
export function createAssetAudioRenderer(
options?: AssetAudioRendererOptions,
): AssetAudioRenderer {
return new AssetAudioRenderer(options);
}
+45
View File
@@ -0,0 +1,45 @@
export const BITMAP_FONT: Record<string, readonly string[]> = {
"0": ["01110", "10001", "10011", "10101", "11001", "10001", "01110"],
"1": ["00100", "01100", "00100", "00100", "00100", "00100", "01110"],
"2": ["01110", "10001", "00001", "00010", "00100", "01000", "11111"],
"3": ["11110", "00001", "00001", "01110", "00001", "00001", "11110"],
"4": ["00010", "00110", "01010", "10010", "11111", "00010", "00010"],
"5": ["11111", "10000", "10000", "11110", "00001", "00001", "11110"],
"6": ["01110", "10000", "10000", "11110", "10001", "10001", "01110"],
"7": ["11111", "00001", "00010", "00100", "01000", "01000", "01000"],
"8": ["01110", "10001", "10001", "01110", "10001", "10001", "01110"],
"9": ["01110", "10001", "10001", "01111", "00001", "00001", "01110"],
A: ["01110", "10001", "10001", "11111", "10001", "10001", "10001"],
B: ["11110", "10001", "10001", "11110", "10001", "10001", "11110"],
C: ["01111", "10000", "10000", "10000", "10000", "10000", "01111"],
D: ["11110", "10001", "10001", "10001", "10001", "10001", "11110"],
E: ["11111", "10000", "10000", "11110", "10000", "10000", "11111"],
F: ["11111", "10000", "10000", "11110", "10000", "10000", "10000"],
G: ["01111", "10000", "10000", "10111", "10001", "10001", "01110"],
H: ["10001", "10001", "10001", "11111", "10001", "10001", "10001"],
I: ["01110", "00100", "00100", "00100", "00100", "00100", "01110"],
J: ["00111", "00010", "00010", "00010", "10010", "10010", "01100"],
K: ["10001", "10010", "10100", "11000", "10100", "10010", "10001"],
L: ["10000", "10000", "10000", "10000", "10000", "10000", "11111"],
M: ["10001", "11011", "10101", "10101", "10001", "10001", "10001"],
N: ["10001", "11001", "10101", "10011", "10001", "10001", "10001"],
O: ["01110", "10001", "10001", "10001", "10001", "10001", "01110"],
P: ["11110", "10001", "10001", "11110", "10000", "10000", "10000"],
Q: ["01110", "10001", "10001", "10001", "10101", "10010", "01101"],
R: ["11110", "10001", "10001", "11110", "10100", "10010", "10001"],
S: ["01111", "10000", "10000", "01110", "00001", "00001", "11110"],
T: ["11111", "00100", "00100", "00100", "00100", "00100", "00100"],
U: ["10001", "10001", "10001", "10001", "10001", "10001", "01110"],
V: ["10001", "10001", "10001", "10001", "10001", "01010", "00100"],
W: ["10001", "10001", "10001", "10101", "10101", "10101", "01010"],
X: ["10001", "10001", "01010", "00100", "01010", "10001", "10001"],
Y: ["10001", "10001", "01010", "00100", "00100", "00100", "00100"],
Z: ["11111", "00001", "00010", "00100", "01000", "10000", "11111"],
"+": ["00000", "00100", "00100", "11111", "00100", "00100", "00000"],
"-": ["00000", "00000", "00000", "11111", "00000", "00000", "00000"],
"*": ["00000", "10001", "01010", "00100", "01010", "10001", "00000"],
"/": ["00001", "00010", "00010", "00100", "01000", "01000", "10000"],
"=": ["00000", "11111", "00000", "11111", "00000", "00000", "00000"],
"?": ["01110", "10001", "00001", "00010", "00100", "00000", "00100"],
" ": ["00000", "00000", "00000", "00000", "00000", "00000", "00000"],
};

Some files were not shown because too many files have changed in this diff Show More