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
+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>
<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>
<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>
<div class="catalog-grid">
<article class="catalog-card">
@@ -46,7 +46,7 @@ page AdvancedFormsShowcase {
<article class="catalog-card">
<div class="catalog-card-preview">
<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 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>
@@ -10,7 +10,7 @@ page BaseShowcase {
<span class="showcase-eyebrow">Component category</span>
<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>
<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>
<div class="catalog-grid">
<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">
<article class="demo-case">
<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>
</header>
<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-render"><div data-component="Button" label="Save &amp; Submit" as="button" type="submit" class="showcase-instance showcase-instance--1"></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>
<pre><code>&lt;Button
label="Save &amp; Submit"
@@ -90,7 +90,7 @@ page ButtonDetail {
</article>
<article class="demo-case">
<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>
</header>
<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-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>
<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>
<pre><code>&lt;Button
label="Learn more"
@@ -114,7 +114,7 @@ page ButtonDetail {
</article>
<article class="demo-case">
<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>
</header>
<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-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>
<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>
<pre><code>&lt;Button
label="Upgrade plan"
@@ -130,113 +130,6 @@ page ButtonDetail {
variant="secondary"
size="lg"
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>
</section>
</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>
</main>
<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>
</div>
<nav class="detail-pagination">
@@ -1,19 +1,19 @@
// Generated by scripts/generate-showcase.mjs. Do not edit directly.
page InputNumberDetail {
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_label = ctx.url.searchParams.get("pg_label") ?? "Input Number"
state playground_name = ctx.url.searchParams.get("pg_name") ?? ""
state playground_value = ctx.url.searchParams.get("pg_value") ?? "sample-1"
state playground_placeholder = ctx.url.searchParams.get("pg_placeholder") ?? "Enter a sample value"
state playground_name = ctx.url.searchParams.get("pg_name") ?? "default-quantity"
state playground_value = ctx.url.searchParams.get("pg_value") ?? "1"
state playground_placeholder = ctx.url.searchParams.get("pg_placeholder") ?? ""
state playground_type = ctx.url.searchParams.get("pg_type") ?? "number"
state playground_min = ctx.url.searchParams.get("pg_min") ?? ""
state playground_max = ctx.url.searchParams.get("pg_max") ?? ""
state playground_step = ctx.url.searchParams.get("pg_step") ?? ""
state playground_min = ctx.url.searchParams.get("pg_min") ?? "0"
state playground_max = ctx.url.searchParams.get("pg_max") ?? "99"
state playground_step = ctx.url.searchParams.get("pg_step") ?? "1"
state playground_disabled = ctx.url.searchParams.get("pg_disabled") ?? "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 {
title = "Input Number"
description = "Theme-aware, responsive input number component."
@@ -40,15 +40,19 @@ page InputNumberDetail {
<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>
<pre><code data-playground-code>&lt;InputNumber
value="sample-1"
placeholder="Enter a sample value"
size="md"
name="default-quantity"
value="1"
min="0"
max="99"
step="1"
/&gt;</code></pre>
</section>
<div class="playground-status" data-playground-status aria-live="polite"></div>
</div>
<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>
<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>
</div>
</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">
<article class="demo-case">
<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>
</header>
<div class="demo-workbench">
<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-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>
<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>
<pre><code>&lt;InputNumber
value="sample-1"
placeholder="Enter a sample value"
size="md"
name="default-quantity"
value="1"
min="0"
max="99"
step="1"
/&gt;</code></pre>
</section>
</div>
</article>
<article class="demo-case">
<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>
</header>
<div class="demo-workbench">
<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-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>
<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>
<pre><code>&lt;InputNumber
size="sm"
label="Compact example"
value="sample-2"
placeholder="Enter a sample value"
size="md"
label="Select quantity"
name="labeled-quantity"
value="1"
min="1"
max="25"
step="1"
/&gt;</code></pre>
</section>
</div>
</article>
<article class="demo-case">
<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>
</header>
<div class="demo-workbench">
<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-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>
<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>
<pre><code>&lt;InputNumber
size="lg"
label="Advanced example"
value="sample-3"
placeholder="Search by name, email, or identifier"
size="md"
label="Select quantity"
name="vertical-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">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>
</section>
</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>
</main>
<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>
</div>
<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="showcase-stats">
<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>8</strong><span>Focused categories</span></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 · 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>
}
}
@@ -245,6 +245,15 @@ function sampleValue(prop, component, variation) {
if (Object.hasOwn(configuration, prop.name)) return configuration[prop.name];
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 isObject = prop.default === "{}" || prop.type === "object";
if (isArray) return sampleArray(prop, component, variation);
@@ -1096,20 +1105,192 @@ function togglePasswordConfiguration(variation) {
};
}
const demoUses = (component) =>
component.name === "Button"
? buttonUses
: component.name === "AdvancedSelect"
? advancedSelectUses
: component.name === "ComboBox"
? comboboxUses
: component.name === "PinInput"
? pinInputUses
: component.name === "StrongPassword"
? strongPasswordUses
: component.name === "TogglePassword"
? togglePasswordUses
: defaultUses;
const inputNumberUses = [
{
eyebrow: "Basic usage",
title: "Default controls",
description: "Use standard decrement and increment buttons alongside a numeric input field.",
},
{
eyebrow: "Basic usage",
title: "Labeled input style",
description:
"Add supporting label text and rounded controls for a more form-like number input.",
},
{
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) {
if (