release: WRNexusJS 0.4.0

This commit is contained in:
2026-07-27 12:42:18 +05:30
parent 8b728a3e5d
commit 30e5721e84
250 changed files with 10065 additions and 3923 deletions
+1 -12
View File
@@ -1,6 +1,6 @@
# WRNexus UI component reference
This reference is generated from the 86 packaged `.wrn` component sources. Props marked required have no default; all others show their runtime default.
This reference is generated from the 85 packaged `.wrn` component sources. Props marked required have no default; all others show their runtime default.
## Advanced-forms
@@ -330,17 +330,6 @@ Theme-aware, responsive tree view component.
- Slots: `default`
- Events: None
## Core
### Captcha
Reusable captcha component.
- Mount: `data-component="Captcha"`
- Props: `provider: string = "self-hosted"`, `siteKey: string = ""`, `type: string = "alphanumeric"`, `action: string = "form-submit"`, `presentation: string = "visual"`, `difficulty: string = "normal"`, `locale: string = "en"`, `size: string = "default"`, `color: string = "primary"`, `class: string = ""`, `id: string = ""`, `name: string = "wrn-captcha-response"`, `endpoint: string = "/__wrnexus/captcha/challenge"`, `verifyEndpoint: string = "/__wrnexus/captcha/verify"`, `responseField: string = "wrn-captcha-response"`, `label: string = "Security verification"`, `description: string = "Complete the challenge to continue."`, `helpText: string = ""`, `requiredMessage: string = "Please complete the security check."`, `incorrectMessage: string = "That answer was not correct. Try again."`, `expiredMessage: string = "This challenge expired. Load a new one."`, `networkMessage: string = "The verification service is unavailable. Try again."`, `autoLoad: boolean = true`, `autoVerify: boolean = false`, `showVerify: boolean = true`, `showRefresh: boolean = true`, `showAudio: boolean = true`, `showStatus: boolean = true`, `disabled: boolean = false`, `required: boolean = true`, `compact: boolean = false`
- Slots: None
- Events: `ready`, `challenge`, `input`, `verify`, `success`, `failure`, `expired`, `refresh`, `audioStart`, `audioEnd`, `error`
## Forms
### Checkbox
+14 -12
View File
@@ -15,7 +15,7 @@ driven by `var(--wire-*)` theme tokens, so components restyle instantly when the
theme changes. The tiny JS surface (`src/index.ts`) exists only so the toolchain
(CLI build + dev server) can locate the component directory and stylesheet.
The complete PDF-aligned catalog currently contains **891 components**. The
The complete PDF-aligned catalog currently contains **85 components**. The
generated `COMPONENTS.md` and `component-reference.json` files document every
mount name, prop, inferred type, default/required status, slot, event, category,
and source file directly from the packaged `.wrn` source.
@@ -35,10 +35,11 @@ In practice you rarely install this directly: `@wrnexus/cli` and
## Components
Components live as `.wrn` files under `packages/ui/components/`. The mount name
is the **lowercase file basename** (e.g. `button.wrn``data-component="button"`).
Each accepts a `class` prop (appended to its root element) and most render their
body from either a named prop or the default slot.
Components live as `.wrn` files under `packages/ui/components/`. The canonical mount
name comes from the component declaration (for example, `component Button` mounts as
`data-component="Button"`). Component lookup is case-insensitive, so existing lowercase
mounts continue to work. Each component accepts a `class` prop (appended to its root
element), and most render their body from either a named prop or the default slot.
### Layout
@@ -91,16 +92,17 @@ equivalent is exported as `@wrnexus/ui/component-reference.json`.
## API
The JS module (`@wrnexus/ui`) exposes four helpers used by the build tooling to
The JS module (`@wrnexus/ui`) exposes five helpers used by the build tooling to
locate the component assets. There is no component code to import — the components
are `.wrn` files rendered server-side.
| Export | Signature | Returns |
| ------------------ | ---------------- | ------------------------------------------------------------------------------------------ |
| `uiComponentsDir` | `() => string` | Absolute path to the `.wrn` component directory (feed to `buildRouter`'s `componentDirs`). |
| `uiCssPath` | `() => string` | Absolute path to `ui.css`. |
| `uiCss` | `() => string` | The `ui.css` file contents (all `.wire-*` classes, themed via tokens). |
| `uiComponentNames` | `() => string[]` | Sorted list of built-in component names (e.g. for `wrnexus eject` listing). |
| Export | Signature | Returns |
| ------------------ | -------------------------- | ------------------------------------------------------------------------------------------ |
| `uiComponentsDir` | `() => string` | Absolute path to the `.wrn` component directory (feed to `buildRouter`'s `componentDirs`). |
| `uiCssPath` | `() => string` | Absolute path to `ui.css`. |
| `uiCss` | `() => string` | The `ui.css` file contents (all `.wire-*` classes, themed via tokens). |
| `uiComponentNames` | `() => string[]` | Sorted list of declared built-in component names. |
| `uiComponentPath` | `(name: string) => string` | Absolute source path for a declared component name or case-insensitive alias. |
### `./ui.css` asset export
File diff suppressed because it is too large Load Diff
-320
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ui",
"version": "0.3.6",
"version": "0.4.0",
"private": true,
"type": "module",
"main": "src/index.ts",
+52 -8
View File
@@ -21,7 +21,7 @@ import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const packageRoot = join(here, "..");
let cachedCss: string | undefined;
let cachedNames: string[] | undefined;
let cachedComponentFiles: Map<string, string> | undefined;
/** Absolute path to the directory of Wire UI component `.wrn` files. */
export function uiComponentsDir(): string {
@@ -38,11 +38,55 @@ export function uiCss(): string {
return (cachedCss ??= readFileSync(uiCssPath(), "utf8"));
}
/** Names of the built-in components (e.g. for `wrnexus eject` listing). */
export function uiComponentNames(): string[] {
cachedNames ??= readdirSync(uiComponentsDir())
.filter((f) => f.endsWith(".wrn"))
.map((f) => f.replace(/\.wrn$/, ""))
.sort();
return [...cachedNames];
function uiComponentFiles(): Map<string, string> {
if (cachedComponentFiles) return cachedComponentFiles;
const files = new Map<string, string>();
const componentFiles = readdirSync(uiComponentsDir()).filter((entry) => entry.endsWith(".wrn"));
for (const file of componentFiles) {
const path = join(uiComponentsDir(), file);
const source = readFileSync(path, "utf8");
const declaration = /^\s*component\s+([A-Za-z][A-Za-z0-9_]*)\b/m.exec(source);
if (!declaration) {
throw new Error(`UI component source does not declare a component: ${path}`);
}
const name = declaration[1]!;
const previous = files.get(name);
if (previous) {
throw new Error(`Duplicate UI component declaration '${name}' in ${previous} and ${path}`);
}
files.set(name, path);
}
cachedComponentFiles = files;
return files;
}
/** Names declared by the bundled components, independent of filename casing. */
export function uiComponentNames(): string[] {
return [...uiComponentFiles().keys()].sort((left, right) => left.localeCompare(right));
}
/** Absolute path to a bundled component by its declared component name. */
export function uiComponentPath(name: string): string {
const files = uiComponentFiles();
const exact = files.get(name);
if (exact) return exact;
const normalized = name.toLowerCase();
const matches = [...files.entries()].filter(
([componentName]) => componentName.toLowerCase() === normalized,
);
if (matches.length === 1) return matches[0]![1];
throw new Error(`Unknown WRNexus UI component '${name}'.`);
}
export { uiComponentReference, findUiComponent, auditUiComponents } from "./metadata.ts";
export type { UiComponentMetadata, UiComponentReference } from "./metadata.ts";
+40
View File
@@ -0,0 +1,40 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { uiComponentsDir } from "./index.ts";
export interface UiComponentMetadata {
name: string;
mount: string;
category?: string;
purpose?: string;
props?: Array<{ name: string; default?: unknown }>;
events?: string[];
}
export interface UiComponentReference {
count: number;
components: UiComponentMetadata[];
}
let referenceCache: UiComponentReference | undefined;
export function uiComponentReference(): UiComponentReference {
if (!referenceCache)
referenceCache = JSON.parse(
readFileSync(join(uiComponentsDir(), "..", "component-reference.json"), "utf8"),
) as UiComponentReference;
return structuredClone(referenceCache);
}
export function findUiComponent(name: string): UiComponentMetadata | undefined {
return uiComponentReference().components.find(
(component) => component.name === name || component.mount === name,
);
}
export function auditUiComponents(): Array<{ component: string; issue: string }> {
const issues: Array<{ component: string; issue: string }> = [];
for (const component of uiComponentReference().components) {
const props = new Set((component.props ?? []).map((prop) => prop.name));
if (!props.has("class"))
issues.push({ component: component.name, issue: "missing class prop" });
if (!props.has("size")) issues.push({ component: component.name, issue: "missing size prop" });
if (!props.has("color"))
issues.push({ component: component.name, issue: "missing color prop" });
}
return issues;
}
+26 -27
View File
@@ -3,14 +3,14 @@ import { readFileSync } from "node:fs";
import { join } from "node:path";
import { compileWireFile } from "../../compiler/src/index.ts";
import { mountHtml, renderComponent } from "../../test/src/index.ts";
import { uiComponentNames, uiComponentsDir, uiCss } from "../src/index.ts";
import { uiComponentNames, uiComponentsDir, uiComponentPath, uiCss } from "../src/index.ts";
test("bundled UI assets are discoverable and readable", () => {
expect(uiComponentNames()).toContain("Button");
expect(uiComponentNames()).toContain("Accordion");
expect(uiComponentNames()).toContain("DataTable");
expect(uiComponentNames()).toContain("WysiwygEditor");
expect(readFileSync(join(uiComponentsDir(), "Button.wrn"), "utf8")).toContain("component Button");
expect(readFileSync(uiComponentPath("Button"), "utf8")).toContain("component Button");
expect(uiCss()).toContain("--wire-");
});
@@ -46,7 +46,7 @@ test("bundled components do not duplicate another component implementation", ()
const implementations = new Map<string, string>();
for (const name of uiComponentNames()) {
const source = readFileSync(join(uiComponentsDir(), `${name}.wrn`), "utf8")
const source = readFileSync(uiComponentPath(name), "utf8")
.replace(/^component\s+[A-Za-z0-9_]+/, "component __NAME__")
.replace(/\r\n/g, "\n")
.trim();
@@ -142,7 +142,7 @@ test("component-system CSS includes responsive, theme-token, focus, and reduced-
test("component boundaries have no default margins and expose the canonical class prop", () => {
for (const name of uiComponentNames()) {
const source = readFileSync(join(uiComponentsDir(), `${name}.wrn`), "utf8");
const source = readFileSync(uiComponentPath(name), "utf8");
const root = source.match(/view\s*\{\s*<[A-Za-z][^>]*>/)?.[0] ?? "";
const classes = root.match(/\bclass="([^"]*)"/)?.[1]?.split(/\s+/) ?? [];
expect(classes.filter((token) => /^-?m[trblxy]?-/.test(token))).toEqual([]);
@@ -154,7 +154,7 @@ test("component boundaries have no default margins and expose the canonical clas
test("every component exposes universal color and size configuration", () => {
for (const name of uiComponentNames()) {
const source = readFileSync(join(uiComponentsDir(), `${name}.wrn`), "utf8");
const source = readFileSync(uiComponentPath(name), "utf8");
expect(source).toMatch(/^\s+color\s*=/m);
expect(source).toMatch(/^\s+size\s*=/m);
}
@@ -162,7 +162,7 @@ test("every component exposes universal color and size configuration", () => {
// test("component markup keeps user-facing content and data behind props", () => {
// for (const name of uiComponentNames()) {
// const source = readFileSync(join(uiComponentsDir(), `${name}.wrn`), "utf8");
// const source = readFileSync(uiComponentPath(name), "utf8");
// const view = source.slice(source.indexOf("view {"));
// const literalText = [...view.matchAll(/>([^<>{}]*[A-Za-z][^<>{}]*)</g)]
// .map((match) => match[1].trim())
@@ -182,7 +182,7 @@ test("every component exposes universal color and size configuration", () => {
test("layout boundaries do not add default padding", () => {
for (const name of ["Container", "Columns", "Grid", "LayoutSplitter", "Typography"]) {
const source = readFileSync(join(uiComponentsDir(), `${name}.wrn`), "utf8");
const source = readFileSync(uiComponentPath(name), "utf8");
const root = source.match(/view\s*\{\s*<[A-Za-z][^>]*>/)?.[0] ?? "";
const classes = root.match(/\bclass="([^"]*)"/)?.[1]?.split(/\s+/) ?? [];
expect(classes.filter((token) => /^p[trblxy]?-/.test(token))).toEqual([]);
@@ -191,13 +191,13 @@ test("layout boundaries do not add default padding", () => {
test("every bundled UI component compiles", () => {
for (const name of uiComponentNames()) {
const path = join(uiComponentsDir(), `${name}.wrn`);
const path = uiComponentPath(name);
expect(() => compileWireFile(readFileSync(path, "utf8"), path)).not.toThrow();
}
});
test("icon buttons expose their accessible label as a hover and focus tooltip", () => {
const button = readFileSync(join(uiComponentsDir(), "Button.wrn"), "utf8");
const button = readFileSync(uiComponentPath("Button"), "utf8");
expect(button).toContain('class="wire-btn__tooltip"');
expect(button).toContain("{ariaLabel || label}");
expect(uiCss()).toContain(".wire-btn:hover > .wire-btn__tooltip");
@@ -205,7 +205,7 @@ test("icon buttons expose their accessible label as a hover and focus tooltip",
});
test("button links navigate by default and only download when the native attribute is passed", () => {
const button = readFileSync(join(uiComponentsDir(), "Button.wrn"), "utf8");
const button = readFileSync(uiComponentPath("Button"), "utf8");
expect(button).not.toMatch(/^\s+download\s*=/m);
expect(button).not.toContain('download="{download}"');
expect(button).toContain("{...attrs}");
@@ -213,14 +213,14 @@ test("button links navigate by default and only download when the native attribu
test("every bundled UI component forwards undeclared native attributes", () => {
for (const name of uiComponentNames()) {
const path = join(uiComponentsDir(), `${name}.wrn`);
const path = uiComponentPath(name);
const output = compileWireFile(readFileSync(path, "utf8"), path);
expect(output).toContain("${__wireSpreadAttrs(__attrs)}");
}
});
test("advanced select opens, filters, and selects without reactive handler errors", async () => {
const source = readFileSync(join(uiComponentsDir(), "AdvancedSelect.wrn"), "utf8");
const source = readFileSync(uiComponentPath("AdvancedSelect"), "utf8");
const html = await renderComponent(source, {
name: "team",
options: [
@@ -256,7 +256,7 @@ test("advanced select opens, filters, and selects without reactive handler error
});
test("advanced select clears, restores its placeholder, and keeps multiple counters accurate", async () => {
const source = readFileSync(join(uiComponentsDir(), "AdvancedSelect.wrn"), "utf8");
const source = readFileSync(uiComponentPath("AdvancedSelect"), "utf8");
const html = await renderComponent(source, {
name: "team",
multiple: true,
@@ -300,7 +300,7 @@ test("advanced select clears, restores its placeholder, and keeps multiple count
});
test("advanced select closes when a pointer event occurs outside it", async () => {
const source = readFileSync(join(uiComponentsDir(), "AdvancedSelect.wrn"), "utf8");
const source = readFileSync(uiComponentPath("AdvancedSelect"), "utf8");
const html = await renderComponent(source, {
name: "team",
options: [{ value: "design", label: "Design" }],
@@ -315,7 +315,7 @@ test("advanced select closes when a pointer event occurs outside it", async () =
});
test("advanced select fetches remote options and appends infinite pages", async () => {
const source = readFileSync(join(uiComponentsDir(), "AdvancedSelect.wrn"), "utf8");
const source = readFileSync(uiComponentPath("AdvancedSelect"), "utf8");
const originalFetch = globalThis.fetch;
const requests: string[] = [];
globalThis.fetch = (async (input: string | URL | Request) => {
@@ -394,7 +394,7 @@ test("advanced select fetches remote options and appends infinite pages", async
});
test("combobox filters editable input, selects suggestions, exposes methods, and clears", async () => {
const source = readFileSync(join(uiComponentsDir(), "ComboBox.wrn"), "utf8");
const source = readFileSync(uiComponentPath("ComboBox"), "utf8");
const html = await renderComponent(source, {
name: "team",
placeholder: "Search teams",
@@ -460,7 +460,7 @@ test("combobox filters editable input, selects suggestions, exposes methods, and
});
test("combobox auto-loads remote suggestions and retains its selected value while searching", async () => {
const source = readFileSync(join(uiComponentsDir(), "ComboBox.wrn"), "utf8");
const source = readFileSync(uiComponentPath("ComboBox"), "utf8");
const originalFetch = globalThis.fetch;
const requests: string[] = [];
globalThis.fetch = (async (input: string | URL | Request) => {
@@ -514,7 +514,7 @@ test("combobox auto-loads remote suggestions and retains its selected value whil
});
test("combobox emits search, select, change, clear, open, and close events", async () => {
const source = readFileSync(join(uiComponentsDir(), "ComboBox.wrn"), "utf8");
const source = readFileSync(uiComponentPath("ComboBox"), "utf8");
const html = await renderComponent(source, {
name: "events-team",
options: [
@@ -560,7 +560,7 @@ test("combobox emits search, select, change, clear, open, and close events", asy
});
test("advanced select emits selection events and remote load and error events", async () => {
const source = readFileSync(join(uiComponentsDir(), "AdvancedSelect.wrn"), "utf8");
const source = readFileSync(uiComponentPath("AdvancedSelect"), "utf8");
const originalFetch = globalThis.fetch;
const originalConsoleError = console.error;
let rejectRequest = false;
@@ -629,7 +629,7 @@ test("advanced select emits selection events and remote load and error events",
});
test("pin input accepts matching characters, advances focus, navigates backward, and completes", async () => {
const source = readFileSync(join(uiComponentsDir(), "PinInput.wrn"), "utf8");
const source = readFileSync(uiComponentPath("PinInput"), "utf8");
const html = await renderComponent(source, {
name: "verificationCode",
length: 4,
@@ -693,7 +693,7 @@ test("pin input accepts matching characters, advances focus, navigates backward,
});
test("pin input renders four separate cells when length is omitted", async () => {
const source = readFileSync(join(uiComponentsDir(), "PinInput.wrn"), "utf8");
const source = readFileSync(uiComponentPath("PinInput"), "utf8");
const html = await renderComponent(source, {
name: "defaultPin",
});
@@ -703,7 +703,7 @@ test("pin input renders four separate cells when length is omitted", async () =>
});
test("pin input distributes filtered clipboard content and emits paste details", async () => {
const source = readFileSync(join(uiComponentsDir(), "PinInput.wrn"), "utf8");
const source = readFileSync(uiComponentPath("PinInput"), "utf8");
const html = await renderComponent(source, {
name: "emailCode",
length: 6,
@@ -737,7 +737,7 @@ test("pin input distributes filtered clipboard content and emits paste details",
});
test("pin input accepts lowercase alphanumeric entry and normalizes it to the configured pattern", async () => {
const source = readFileSync(join(uiComponentsDir(), "PinInput.wrn"), "utf8");
const source = readFileSync(uiComponentPath("PinInput"), "utf8");
const html = await renderComponent(source, {
name: "securityCode",
length: 4,
@@ -757,7 +757,7 @@ test("pin input accepts lowercase alphanumeric entry and normalizes it to the co
});
test("strong password scores input, supports custom special characters, and opens its popover", async () => {
const source = readFileSync(join(uiComponentsDir(), "StrongPassword.wrn"), "utf8");
const source = readFileSync(uiComponentPath("StrongPassword"), "utf8");
const html = await renderComponent(source, {
name: "newPassword",
presentation: "popover",
@@ -766,7 +766,6 @@ test("strong password scores input, supports custom special characters, and open
const dom = mountHtml(html);
const root = dom.querySelector("[data-wrn-strong-password]") as HTMLElement;
const input = dom.querySelector('input[name="newPassword"]') as HTMLInputElement;
const popover = dom.querySelector(".wire-next__strong-password-popover") as HTMLElement;
const strengthEvents: Array<{ level: string; percent: number }> = [];
root.addEventListener("strength", (event) => {
strengthEvents.push(
@@ -807,7 +806,7 @@ test("strong password scores input, supports custom special characters, and open
});
test("toggle password reveals and conceals its value with accessible declared events", async () => {
const source = readFileSync(join(uiComponentsDir(), "TogglePassword.wrn"), "utf8");
const source = readFileSync(uiComponentPath("TogglePassword"), "utf8");
const html = await renderComponent(source, {
name: "accountPassword",
value: "correct-horse",
@@ -837,7 +836,7 @@ test("toggle password reveals and conceals its value with accessible declared ev
});
test("toggle password supports checkbox control and synchronized password fields", async () => {
const source = readFileSync(join(uiComponentsDir(), "TogglePassword.wrn"), "utf8");
const source = readFileSync(uiComponentPath("TogglePassword"), "utf8");
const synchronizedHtml = await renderComponent(source, {
name: "credentials",
fields: [