release: WRNexusJS 0.4.0
This commit is contained in:
@@ -3,7 +3,7 @@ import type { Db, ExecResult } from "@wrnexus/db";
|
||||
import { users } from "./schema.ts";
|
||||
|
||||
export async function GetUserByEmail(db: Db, args: { email: string }): Promise<{ id: number; email: string; name: string; active: boolean; passwordHash: string; createdAt: Date } | null> {
|
||||
return (await db.one("SELECT * FROM users WHERE email = ?", [args.email], users)) as { id: number; email: string; name: string; active: boolean; passwordHash: string; createdAt: Date } | null;
|
||||
return (await db.one("SELECT * FROM users WHERE email = $1", [args.email], users)) as { id: number; email: string; name: string; active: boolean; passwordHash: string; createdAt: Date } | null;
|
||||
}
|
||||
|
||||
export async function ListUsers(db: Db): Promise<{ id: number; name: string; active: boolean }[]> {
|
||||
@@ -11,13 +11,13 @@ export async function ListUsers(db: Db): Promise<{ id: number; name: string; act
|
||||
}
|
||||
|
||||
export async function CountActive(db: Db, args: { active: boolean }): Promise<{ n: number } | null> {
|
||||
return (await db.one("SELECT COUNT(*) AS n FROM users WHERE active = ?", [args.active])) as { n: number } | null;
|
||||
return (await db.one("SELECT COUNT(*) AS n FROM users WHERE active = $1", [args.active])) as { n: number } | null;
|
||||
}
|
||||
|
||||
export async function CreateUser(db: Db, args: { email: string; name: string; active: boolean }): Promise<ExecResult> {
|
||||
return db.exec("INSERT INTO users (email, name, active) VALUES (?, ?, ?)", [args.email, args.name, args.active]);
|
||||
return db.exec("INSERT INTO users (email, name, active) VALUES ($1, $2, $3)", [args.email, args.name, args.active]);
|
||||
}
|
||||
|
||||
export async function DeactivateUser(db: Db, args: { id: number }): Promise<ExecResult> {
|
||||
return db.exec("UPDATE users SET active = 0 WHERE id = ?", [args.id]);
|
||||
return db.exec("UPDATE users SET active = 0 WHERE id = $1", [args.id]);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ function safeReturnPath(value: string | null, baseUrl: URL): string {
|
||||
|
||||
export async function POST(ctx: Context): Promise<Response> {
|
||||
const contentType = ctx.req.headers.get("content-type") ?? "";
|
||||
let submittedReturnTo: string | null = null;
|
||||
let submittedReturnTo: string | null;
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
const body = (await ctx.req.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
|
||||
@@ -1,320 +0,0 @@
|
||||
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>
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import { createCaptchaEngine, createCaptchaHttpHandlers, MemoryCaptchaStore } from "@wrnexus/captcha/server";
|
||||
import {
|
||||
createCaptchaEngine,
|
||||
createCaptchaHttpHandlers,
|
||||
MemoryCaptchaStore,
|
||||
} from "@wrnexus/captcha/server";
|
||||
|
||||
export const captchaEngine = createCaptchaEngine({
|
||||
secret: process.env.CAPTCHA_SECRET ?? "development-only-change-this-captcha-secret-123456",
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
@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; }
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,878 +0,0 @@
|
||||
(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 });
|
||||
})();
|
||||
@@ -3,6 +3,14 @@ import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const showcaseRoot = join(import.meta.dir, "..");
|
||||
const repositoryRoot = join(showcaseRoot, "..", "..");
|
||||
const packageComponentPath = join(
|
||||
repositoryRoot,
|
||||
"packages",
|
||||
"captcha",
|
||||
"components",
|
||||
"Captcha.wrn",
|
||||
);
|
||||
|
||||
function path(relativePath: string): string {
|
||||
return join(showcaseRoot, relativePath);
|
||||
@@ -19,7 +27,6 @@ describe("CAPTCHA showcase structure", () => {
|
||||
"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",
|
||||
@@ -41,7 +48,8 @@ describe("CAPTCHA showcase structure", () => {
|
||||
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";');
|
||||
expect(styles).not.toContain("packages/captcha/components");
|
||||
expect(read("wrnexus.config.ts")).not.toContain("includePackageSources: false");
|
||||
});
|
||||
|
||||
test("documents every bundled challenge mode", () => {
|
||||
@@ -72,7 +80,6 @@ describe("CAPTCHA showcase structure", () => {
|
||||
expect(source).toContain('showListen="false"');
|
||||
});
|
||||
|
||||
|
||||
test("documents every generated image renderer and random pool controls", () => {
|
||||
const source = read("app/pages/styles.wrn");
|
||||
const styles = [
|
||||
@@ -170,15 +177,23 @@ describe("CAPTCHA showcase integration", () => {
|
||||
expect(grantRoute).toContain("Response.redirect");
|
||||
});
|
||||
|
||||
test("registers the CAPTCHA plugin and DevToolbar audit", () => {
|
||||
const source = read("wrnexus.config.ts");
|
||||
test("auto-discovers the CAPTCHA package without manual plugin or script wiring", () => {
|
||||
const config = read("wrnexus.config.ts");
|
||||
const packageJson = read("package.json");
|
||||
const component = readFileSync(packageComponentPath, "utf8");
|
||||
|
||||
expect(source).toContain("captchaPlugin");
|
||||
expect(source).toContain("enableDevToolbar: true");
|
||||
expect(packageJson).toContain('"@wrnexus/captcha": "workspace:*"');
|
||||
expect(existsSync(path("app/components/Captcha.wrn"))).toBe(false);
|
||||
expect(existsSync(packageComponentPath)).toBe(true);
|
||||
expect(config).not.toContain("captchaPlugin");
|
||||
expect(config).not.toContain("plugins:");
|
||||
expect(component).toContain('data-wrnexus-runtime="captcha"');
|
||||
expect(component).not.toContain("<script");
|
||||
expect(component).not.toContain("captcha.js");
|
||||
});
|
||||
|
||||
test("ships the expected component events and response field", () => {
|
||||
const source = read("app/components/Captcha.wrn");
|
||||
const source = readFileSync(packageComponentPath, "utf8");
|
||||
const expectedEvents = [
|
||||
"ready",
|
||||
"challenge",
|
||||
@@ -195,14 +210,14 @@ describe("CAPTCHA showcase integration", () => {
|
||||
|
||||
expect(source).toContain('name = "wrn-captcha-response"');
|
||||
expect(source).toContain('responseField = "wrn-captcha-response"');
|
||||
expect(source).toContain('disturbance = 50');
|
||||
expect(source).toContain("disturbance = 50");
|
||||
expect(source).toContain('size = "normal"');
|
||||
expect(source).toContain('showListen = true');
|
||||
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');
|
||||
expect(source).toContain("randomizeStyle = false");
|
||||
expect(source).toContain("data-captcha-not-robot-button");
|
||||
|
||||
for (const eventName of expectedEvents) {
|
||||
expect(source).toContain(`@event ${eventName} = function`);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { AppConfig } from "@wrnexus/styles";
|
||||
import { captchaPlugin } from "@wrnexus/captcha/plugin";
|
||||
|
||||
const config: AppConfig & { plugins?: unknown[] } = {
|
||||
const config: AppConfig = {
|
||||
seo: {
|
||||
title: "WRNexus CAPTCHA Showcase",
|
||||
description: "Number, text, calculation, image, audio, invisible, managed, and third-party CAPTCHA examples.",
|
||||
description:
|
||||
"Number, text, calculation, image, audio, invisible, managed, and third-party CAPTCHA examples.",
|
||||
canonicalBase: "http://localhost:3000",
|
||||
robots: "noindex,nofollow",
|
||||
},
|
||||
@@ -12,12 +12,21 @@ const config: AppConfig & { plugins?: unknown[] } = {
|
||||
styles: {
|
||||
entry: "app/styles/global.css",
|
||||
process: async ({ entryPath, appRoot, mode }) => {
|
||||
const args = ["@tailwindcss/cli", "-i", entryPath!];
|
||||
if (!entryPath) throw new Error("CAPTCHA showcase stylesheet entry was not resolved.");
|
||||
const args = ["@tailwindcss/cli", "-i", entryPath];
|
||||
if (mode === "production") args.push("--minify");
|
||||
return await Bun.$.cwd(appRoot)`bunx ${args}`.text();
|
||||
const result = await Bun.$.cwd(appRoot).nothrow()`bunx ${args}`;
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(
|
||||
result.stderr.toString().trim() ||
|
||||
result.stdout.toString().trim() ||
|
||||
"Tailwind CSS processing failed.",
|
||||
);
|
||||
}
|
||||
return result.stdout.toString();
|
||||
},
|
||||
failureMode: "throw",
|
||||
},
|
||||
plugins: [captchaPlugin({ enableDevToolbar: true })],
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -46,11 +46,11 @@ 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="md" color="primary" name="default-quantity" value="1" min="0" max="99" step="1"></div></div>
|
||||
<div class="catalog-card-stage"><div data-component="InputNumber" size="md" color="primary" variant="default" 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>
|
||||
<div class="catalog-card-footer"><span>13 props · 0 slots</span><a href="/components/input-number">Explore component <span aria-hidden="true">→</span></a></div>
|
||||
<div class="catalog-card-footer"><span>40 props · 0 slots</span><a href="/components/input-number">Explore component <span aria-hidden="true">→</span></a></div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="catalog-card">
|
||||
|
||||
@@ -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>81 live configurations</span></div>
|
||||
<div class="category-meta"><span>27 components</span><span>Multiple use cases</span><span>86 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">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">Default</span><h2>Primary action</h2><p>The standard high-emphasis action for forms and product workflows.</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 & Submit" as="button" type="submit" class="showcase-instance showcase-instance--1"></div></div>
|
||||
</div>
|
||||
<section class="demo-code" aria-label="Production default usage">
|
||||
<section class="demo-code" aria-label="Primary 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><Button
|
||||
label="Save & Submit"
|
||||
@@ -90,7 +90,7 @@ page ButtonDetail {
|
||||
</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">Outline · Small</span><h2>Secondary navigation</h2><p>A compact link button with an end icon and restrained emphasis.</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="Compact application usage">
|
||||
<section class="demo-code" aria-label="Secondary navigation usage">
|
||||
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
|
||||
<pre><code><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">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">Secondary · Large · Pill</span><h2>Prominent rounded action</h2><p>A large secondary action using the optional pill shape.</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="Rich configuration usage">
|
||||
<section class="demo-code" aria-label="Prominent rounded 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><Button
|
||||
label="Upgrade plan"
|
||||
@@ -130,6 +130,113 @@ page ButtonDetail {
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
pill="true"
|
||||
/></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><Button
|
||||
label="Open settings"
|
||||
as="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
icon="icon-[lucide--settings]"
|
||||
ariaLabel="Open settings"
|
||||
/></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><Button
|
||||
label="Delete project"
|
||||
as="button"
|
||||
variant="destructive"
|
||||
icon="icon-[lucide--trash-2]"
|
||||
/></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><Button
|
||||
label="Read documentation"
|
||||
as="a"
|
||||
href="#button-demo"
|
||||
variant="link"
|
||||
/></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><Button
|
||||
label="Publish"
|
||||
loadingLabel="Publishing…"
|
||||
as="button"
|
||||
loading="true"
|
||||
/></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><Button
|
||||
label="Unavailable action"
|
||||
as="button"
|
||||
variant="outline"
|
||||
disabled="true"
|
||||
/></code></pre>
|
||||
</section>
|
||||
</div>
|
||||
@@ -138,7 +245,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">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>
|
||||
<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>
|
||||
</aside>
|
||||
</div>
|
||||
<nav class="detail-pagination">
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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>343</strong><span>Live configurations</span></div>
|
||||
<div class="showcase-stat"><strong>348</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 · 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>
|
||||
<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 · 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>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1269,6 +1269,9 @@ function inputNumberConfiguration(variation) {
|
||||
|
||||
function demoUses(component) {
|
||||
switch (component.name) {
|
||||
case "Button":
|
||||
return buttonUses;
|
||||
|
||||
case "AdvancedSelect":
|
||||
return advancedSelectUses;
|
||||
|
||||
|
||||
@@ -85,9 +85,9 @@ test("every component has a detail page with its expected live use cases", () =>
|
||||
return [...source.matchAll(/<div data-component="([^"]+)"/g)].map((match) => match[1]);
|
||||
});
|
||||
|
||||
// Every detail page has one playground plus three standard demos. Button,
|
||||
// AdvancedSelect, ComboBox, PinInput, and TogglePassword add specialized capability demos.
|
||||
expect(detailMounts).toHaveLength(reference.count * 4 + 83);
|
||||
// Every detail page has one playground plus three standard demos. Interactive
|
||||
// components add specialized capability demos beyond that shared baseline.
|
||||
expect(detailMounts).toHaveLength(reference.count * 4 + 95);
|
||||
for (const component of reference.components) {
|
||||
const expectedMounts =
|
||||
component.mount === "Button"
|
||||
@@ -96,11 +96,15 @@ test("every component has a detail page with its expected live use cases", () =>
|
||||
? 42
|
||||
: component.mount === "ComboBox"
|
||||
? 22
|
||||
: component.mount === "PinInput"
|
||||
? 19
|
||||
: component.mount === "TogglePassword"
|
||||
? 11
|
||||
: 4;
|
||||
: component.mount === "InputNumber"
|
||||
? 13
|
||||
: component.mount === "PinInput"
|
||||
? 19
|
||||
: component.mount === "StrongPassword"
|
||||
? 7
|
||||
: component.mount === "TogglePassword"
|
||||
? 11
|
||||
: 4;
|
||||
expect(detailMounts.filter((mount) => mount === component.mount)).toHaveLength(expectedMounts);
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
Reference in New Issue
Block a user