diff --git a/.publish/ai/package.json b/.publish/ai/package.json index d30609eb..e59c74ed 100644 --- a/.publish/ai/package.json +++ b/.publish/ai/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ai", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.", "license": "MIT", diff --git a/.publish/authz/package.json b/.publish/authz/package.json index fcd87efd..3c861948 100644 --- a/.publish/authz/package.json +++ b/.publish/authz/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/authz", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "description": "@wrnexus/authz — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/cli/package.json b/.publish/cli/package.json index 21cd4405..e490338e 100644 --- a/.publish/cli/package.json +++ b/.publish/cli/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/cli", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "description": "@wrnexus/cli — part of the WrNexus framework.", "license": "MIT", @@ -28,18 +28,18 @@ "wrnexus": "./dist/index.js" }, "dependencies": { - "@wrnexus/core": "^0.5.1", - "@wrnexus/router": "^0.5.1", - "@wrnexus/csr": "^0.5.1", - "@wrnexus/compiler": "^0.5.1", - "@wrnexus/styles": "^0.5.1", - "@wrnexus/dev-server": "^0.5.1", - "@wrnexus/ui": "^0.5.1", - "@wrnexus/validation": "^0.5.1", - "@wrnexus/i18n": "^0.5.1", - "@wrnexus/db": "^0.5.1", - "@wrnexus/plugin": "^0.5.1", - "@wrnexus/syntax": "^0.5.1" + "@wrnexus/core": "^0.5.10", + "@wrnexus/router": "^0.5.10", + "@wrnexus/csr": "^0.5.10", + "@wrnexus/compiler": "^0.5.10", + "@wrnexus/styles": "^0.5.10", + "@wrnexus/dev-server": "^0.5.10", + "@wrnexus/ui": "^0.5.10", + "@wrnexus/validation": "^0.5.10", + "@wrnexus/i18n": "^0.5.10", + "@wrnexus/db": "^0.5.10", + "@wrnexus/plugin": "^0.5.10", + "@wrnexus/syntax": "^0.5.10" }, "files": [ "dist" diff --git a/.publish/compiler/package.json b/.publish/compiler/package.json index c9d89e6a..cc0fbefd 100644 --- a/.publish/compiler/package.json +++ b/.publish/compiler/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/compiler", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "description": "@wrnexus/compiler — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/syntax": "^0.5.1" + "@wrnexus/syntax": "^0.5.10" }, "files": [ "dist" diff --git a/.publish/core/package.json b/.publish/core/package.json index 52020a47..59f9294e 100644 --- a/.publish/core/package.json +++ b/.publish/core/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/core", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "description": "@wrnexus/core — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/csr/package.json b/.publish/csr/package.json index 71123d31..6b17c97c 100644 --- a/.publish/csr/package.json +++ b/.publish/csr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/csr", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "description": "@wrnexus/csr — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.5.1" + "@wrnexus/core": "^0.5.10" }, "files": [ "dist" diff --git a/.publish/db/package.json b/.publish/db/package.json index dea37566..f906adbc 100644 --- a/.publish/db/package.json +++ b/.publish/db/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/db", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "description": "@wrnexus/db — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/dev-server/package.json b/.publish/dev-server/package.json index bcd62ac6..2d68550a 100644 --- a/.publish/dev-server/package.json +++ b/.publish/dev-server/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dev-server", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "description": "@wrnexus/dev-server — part of the WrNexus framework.", "license": "MIT", @@ -25,20 +25,20 @@ } }, "dependencies": { - "@wrnexus/core": "^0.5.1", - "@wrnexus/dev-toolbar": "^0.5.1", - "@wrnexus/router": "^0.5.1", - "@wrnexus/ssr": "^0.5.1", - "@wrnexus/csr": "^0.5.1", - "@wrnexus/compiler": "^0.5.1", - "@wrnexus/styles": "^0.5.1", - "@wrnexus/ui": "^0.5.1", - "@wrnexus/validation": "^0.5.1", - "@wrnexus/i18n": "^0.5.1", - "@wrnexus/db": "^0.5.1", - "@wrnexus/pubsub": "^0.5.1", - "@wrnexus/uploader": "^0.5.1", - "@wrnexus/plugin": "^0.5.1" + "@wrnexus/core": "^0.5.10", + "@wrnexus/dev-toolbar": "^0.5.10", + "@wrnexus/router": "^0.5.10", + "@wrnexus/ssr": "^0.5.10", + "@wrnexus/csr": "^0.5.10", + "@wrnexus/compiler": "^0.5.10", + "@wrnexus/styles": "^0.5.10", + "@wrnexus/ui": "^0.5.10", + "@wrnexus/validation": "^0.5.10", + "@wrnexus/i18n": "^0.5.10", + "@wrnexus/db": "^0.5.10", + "@wrnexus/pubsub": "^0.5.10", + "@wrnexus/uploader": "^0.5.10", + "@wrnexus/plugin": "^0.5.10" }, "files": [ "dist" diff --git a/.publish/dev-toolbar/package.json b/.publish/dev-toolbar/package.json index e17f604c..ae4760e7 100644 --- a/.publish/dev-toolbar/package.json +++ b/.publish/dev-toolbar/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dev-toolbar", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "description": "@wrnexus/dev-toolbar — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/encryption/package.json b/.publish/encryption/package.json index 1e2bd293..d2971bcb 100644 --- a/.publish/encryption/package.json +++ b/.publish/encryption/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/encryption", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "description": "@wrnexus/encryption — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/helpers/package.json b/.publish/helpers/package.json index 5139cd55..876010e3 100644 --- a/.publish/helpers/package.json +++ b/.publish/helpers/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/helpers", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "description": "Safe convenience helpers for WrNexus request contexts and common application flows.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.5.1" + "@wrnexus/core": "^0.5.10" }, "files": [ "dist" diff --git a/.publish/i18n/package.json b/.publish/i18n/package.json index 16e5bc43..37c8ed19 100644 --- a/.publish/i18n/package.json +++ b/.publish/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/i18n", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "description": "@wrnexus/i18n — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.5.1" + "@wrnexus/core": "^0.5.10" }, "files": [ "dist" diff --git a/.publish/jwt/package.json b/.publish/jwt/package.json index 610f5b82..92647973 100644 --- a/.publish/jwt/package.json +++ b/.publish/jwt/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/jwt", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "description": "@wrnexus/jwt — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/mobile/package.json b/.publish/mobile/package.json index 24e704ea..5326aca8 100644 --- a/.publish/mobile/package.json +++ b/.publish/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/mobile", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "description": "@wrnexus/mobile — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/native": "^0.5.1" + "@wrnexus/native": "^0.5.10" }, "files": [ "dist" diff --git a/.publish/native/package.json b/.publish/native/package.json index 9a1f9d34..02f2cc12 100644 --- a/.publish/native/package.json +++ b/.publish/native/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/native", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "description": "@wrnexus/native — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/oauth/package.json b/.publish/oauth/package.json index 034cedbb..a332ef95 100644 --- a/.publish/oauth/package.json +++ b/.publish/oauth/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/oauth", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "description": "@wrnexus/oauth — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/plugin/package.json b/.publish/plugin/package.json index 12411b4b..bd494417 100644 --- a/.publish/plugin/package.json +++ b/.publish/plugin/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/plugin", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "description": "@wrnexus/plugin — part of the WrNexus framework.", "license": "MIT", @@ -33,7 +33,7 @@ } }, "dependencies": { - "@wrnexus/syntax": "^0.5.1" + "@wrnexus/syntax": "^0.5.10" }, "files": [ "dist" diff --git a/.publish/pubsub/package.json b/.publish/pubsub/package.json index 176c29dc..ba69a496 100644 --- a/.publish/pubsub/package.json +++ b/.publish/pubsub/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/pubsub", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "description": "@wrnexus/pubsub — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/queue/package.json b/.publish/queue/package.json index 19c2db64..1848a1b1 100644 --- a/.publish/queue/package.json +++ b/.publish/queue/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/queue", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "description": "@wrnexus/queue — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/reactive/package.json b/.publish/reactive/package.json index 35d3e818..85938559 100644 --- a/.publish/reactive/package.json +++ b/.publish/reactive/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/reactive", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "description": "@wrnexus/reactive — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/router/package.json b/.publish/router/package.json index 074466d0..4fc9b4b3 100644 --- a/.publish/router/package.json +++ b/.publish/router/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/router", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "description": "@wrnexus/router — part of the WrNexus framework.", "license": "MIT", @@ -21,8 +21,8 @@ } }, "dependencies": { - "@wrnexus/compiler": "^0.5.1", - "@wrnexus/core": "^0.5.1" + "@wrnexus/compiler": "^0.5.10", + "@wrnexus/core": "^0.5.10" }, "files": [ "dist" diff --git a/.publish/ssr/package.json b/.publish/ssr/package.json index 28afcd68..a55019b1 100644 --- a/.publish/ssr/package.json +++ b/.publish/ssr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ssr", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "description": "@wrnexus/ssr — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.5.1" + "@wrnexus/core": "^0.5.10" }, "files": [ "dist" diff --git a/.publish/styles/package.json b/.publish/styles/package.json index fd69119d..90013b69 100644 --- a/.publish/styles/package.json +++ b/.publish/styles/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/styles", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "description": "@wrnexus/styles — part of the WrNexus framework.", "license": "MIT", @@ -21,9 +21,9 @@ } }, "dependencies": { - "@wrnexus/uploader": "^0.5.1", - "@wrnexus/core": "^0.5.1", - "@wrnexus/plugin": "^0.5.1" + "@wrnexus/uploader": "^0.5.10", + "@wrnexus/core": "^0.5.10", + "@wrnexus/plugin": "^0.5.10" }, "files": [ "dist" diff --git a/.publish/syntax/package.json b/.publish/syntax/package.json index eb8ff6ae..6ede2db6 100644 --- a/.publish/syntax/package.json +++ b/.publish/syntax/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/syntax", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "description": "@wrnexus/syntax — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/test/package.json b/.publish/test/package.json index ffd45fd8..53bd036b 100644 --- a/.publish/test/package.json +++ b/.publish/test/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/test", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "description": "@wrnexus/test — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/tracking/package.json b/.publish/tracking/package.json index a677da9c..e4a9acef 100644 --- a/.publish/tracking/package.json +++ b/.publish/tracking/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/tracking", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "description": "@wrnexus/tracking — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/ui/COMPONENTS.md b/.publish/ui/COMPONENTS.md index 5cfe2a91..c845e5c9 100644 --- a/.publish/ui/COMPONENTS.md +++ b/.publish/ui/COMPONENTS.md @@ -1,6 +1,6 @@ # WRNexus UI component reference -This reference is generated from the 85 packaged `.wrn` component sources. Props marked required have no default; all others show their runtime default. +This reference is generated from the 90 packaged `.wrn` component sources. Props marked required have no default; all others show their runtime default. ## Advanced-forms @@ -330,15 +330,35 @@ Theme-aware, responsive tree view component. - Slots: `default` - Events: `select`, `toggle`, `expand`, `collapse` +## Blocks + +### PortalDashboard + +Responsive portal dashboard with metrics, actions, tasks, and updates. + +- Mount: `data-component="PortalDashboard"` +- Props: `size: string = "default"`, `color: string = "primary"`, `eyebrow: string = "Overview"`, `eyebrowKey: string = ""`, `title: string = "Dashboard"`, `titleKey: string = ""`, `description: string = ""`, `descriptionKey: string = ""`, `userName: string = ""`, `metrics: string = []`, `actions: string = []`, `updates: string = []`, `tasks: string = []`, `class: string = ""` +- Slots: `hero-action` +- Events: `action`, `navigate` + ## Forms +### AuthForm + +Reusable authentication form for sign-in, registration, recovery, reset, and MFA. + +- Mount: `data-component="AuthForm"` +- Props: `size: string = "default"`, `color: string = "primary"`, `mode: string = "sign-in"`, `action: string = "/api/auth/login"`, `method: string = "post"`, `title: string = "Sign in"`, `description: string = ""`, `returnTo: string = ""`, `schema: string = ""`, `showRemember: boolean = true`, `showName: boolean = true`, `submitLabel: string = "Continue"`, `class: string = ""` +- Slots: `default` +- Events: `submit`, `change`, `input`, `focus`, `blur` + ### Checkbox Theme-aware, responsive checkbox component. - Mount: `data-component="Checkbox"` - Props: `size: string = "default"`, `color: string = "primary"`, `id: string = ""`, `name: string = ""`, `label: string = "Checkbox"`, `hiddenLabel: boolean = false`, `placeholder: string = ""`, `variant: string = "normal"`, `icon: string = ""`, `iconPosition: string = "start"`, `value: string = "on"`, `values: string = []`, `options: string = []`, `checked: boolean = false`, `indeterminate: boolean = false`, `orientation: string = "vertical"`, `card: boolean = false`, `rightAligned: boolean = false`, `list: boolean = false`, `helperText: string = ""`, `cornerHint: string = ""`, `error: string = ""`, `inline: boolean = false`, `readonly: boolean = false`, `disabled: boolean = false`, `required: boolean = false`, `class: string = ""` -- Slots: None +- Slots: `default` - Events: `input`, `change`, `focus`, `blur`, `invalid` ### ColorPicker @@ -543,6 +563,15 @@ Theme-aware, responsive wysiwyg editor component. ## Layout +### AuthSplitLayout + +Responsive 50/50 authentication layout with content and form regions. + +- Mount: `data-component="AuthSplitLayout"` +- Props: `size: string = "default"`, `color: string = "primary"`, `eyebrow: string = "Secure identity"`, `title: string = "Welcome back"`, `description: string = ""`, `brand: string = "Police Management System"`, `features: string = []`, `class: string = ""` +- Slots: `aside-extra`, `form` +- Events: None + ### Columns Theme-aware, responsive columns component. @@ -644,6 +673,15 @@ Theme-aware, responsive breadcrumb component. - Slots: `default` - Events: `navigate`, `click` +### Footer + +Responsive application footer with structured links and pre/post content slots. + +- Mount: `data-component="Footer"` +- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Footer navigation"`, `items: string = []`, `columns: number = 3`, `maxWidth: string = "compact"`, `copyright: string = ""`, `class: string = ""` +- Slots: `pre-footer`, `post-footer`, `copyright-left`, `copyright-right` +- Events: `select`, `action` + ### MegaMenu Theme-aware, responsive mega menu component. @@ -667,9 +705,9 @@ Theme-aware, responsive nav component. Theme-aware, responsive navbar component. - Mount: `data-component="Navbar"` -- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Navbar"`, `items: string = []`, `active: string = ""`, `orientation: string = "horizontal"`, `class: string = ""` -- Slots: `default` -- Events: `toggle`, `open`, `close` +- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Primary navigation"`, `topbarLabel: string = "Utility navigation"`, `brand: string = {}`, `items: string = []`, `actions: string = []`, `active: string = ""`, `sticky: boolean = false`, `openOnHover: boolean = false`, `maxWidth: string = "full"`, `mobileLabel: string = "Toggle navigation"`, `class: string = ""` +- Slots: `topbar`, `actions` +- Events: `toggle`, `open`, `close`, `select`, `action` ### Pagination @@ -680,6 +718,15 @@ Theme-aware, responsive pagination component. - Slots: `default` - Events: `change`, `previous`, `next` +### PreferenceSwitcher + +Accessible theme, accent color, and language preference controls. + +- Mount: `data-component="PreferenceSwitcher"` +- Props: `size: string = "default"`, `color: string = "primary"`, `themeLabel: string = "Theme"`, `colorLabel: string = "Accent color"`, `languageLabel: string = "Language"`, `languages: string = [`, `colors: string = [`, `compact: boolean = true`, `class: string = ""` +- Slots: None +- Events: `theme`, `color`, `language` + ### Scrollspy Theme-aware, responsive scrollspy component. @@ -694,9 +741,9 @@ Theme-aware, responsive scrollspy component. Theme-aware, responsive sidebar component. - Mount: `data-component="Sidebar"` -- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Sidebar"`, `items: string = []`, `active: string = ""`, `orientation: string = "horizontal"`, `class: string = ""` +- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Sidebar"`, `items: string = []`, `active: string = ""`, `orientation: string = "horizontal"`, `mobileLabel: string = "Open navigation"`, `class: string = ""` - Slots: `default` -- Events: `toggle`, `open`, `close` +- Events: `toggle`, `open`, `close`, `select` ### Stepper @@ -741,9 +788,9 @@ Theme-aware, responsive drawer component. Theme-aware, responsive dropdown component. - Mount: `data-component="Dropdown"` -- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Dropdown"`, `description: string = ""`, `open: boolean = false`, `placement: string = "bottom"`, `closeLabel: string = "Close"`, `class: string = ""` -- Slots: `default` -- Events: `open`, `close`, `select`, `change` +- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Open menu"`, `items: string = []`, `placement: string = "end"`, `class: string = ""` +- Slots: `trigger`, `header`, `footer` +- Events: `toggle`, `open`, `close`, `select` ### Modal diff --git a/.publish/ui/component-catalog.json b/.publish/ui/component-catalog.json index a0744d62..1b3bfeb3 100644 --- a/.publish/ui/component-catalog.json +++ b/.publish/ui/component-catalog.json @@ -191,6 +191,11 @@ "category": "navigation", "purpose": "Theme-aware, responsive navbar component." }, + { + "name": "Footer", + "category": "navigation", + "purpose": "Responsive application footer with structured links and pre/post content slots." + }, { "name": "MegaMenu", "category": "navigation", @@ -429,6 +434,26 @@ "name": "WysiwygEditor", "category": "integrations", "purpose": "Theme-aware, responsive wysiwyg editor component." + }, + { + "name": "PreferenceSwitcher", + "category": "navigation", + "purpose": "Accessible theme, accent color, and language preference controls." + }, + { + "name": "PortalDashboard", + "category": "blocks", + "purpose": "Responsive portal dashboard with metrics, actions, tasks, and updates." + }, + { + "name": "AuthSplitLayout", + "category": "layout", + "purpose": "Responsive 50/50 authentication layout with content and form regions." + }, + { + "name": "AuthForm", + "category": "forms", + "purpose": "Reusable authentication form for sign-in, registration, recovery, reset, and MFA." } ] } diff --git a/.publish/ui/component-reference.json b/.publish/ui/component-reference.json index 32d524f1..5d977840 100644 --- a/.publish/ui/component-reference.json +++ b/.publish/ui/component-reference.json @@ -1,6 +1,6 @@ { "generatedFrom": "packages/ui/components/*.wrn", - "count": 85, + "count": 90, "components": [ { "name": "Accordion", @@ -675,6 +675,154 @@ "events": ["dismiss", "action"], "source": "components/alert.wrn" }, + { + "name": "AuthForm", + "mount": "AuthForm", + "category": "forms", + "purpose": "Reusable authentication form for sign-in, registration, recovery, reset, and MFA.", + "props": [ + { + "name": "size", + "type": "string", + "required": false, + "default": "\"default\"" + }, + { + "name": "color", + "type": "string", + "required": false, + "default": "\"primary\"" + }, + { + "name": "mode", + "type": "string", + "required": false, + "default": "\"sign-in\"" + }, + { + "name": "action", + "type": "string", + "required": false, + "default": "\"/api/auth/login\"" + }, + { + "name": "method", + "type": "string", + "required": false, + "default": "\"post\"" + }, + { + "name": "title", + "type": "string", + "required": false, + "default": "\"Sign in\"" + }, + { + "name": "description", + "type": "string", + "required": false, + "default": "\"\"" + }, + { + "name": "returnTo", + "type": "string", + "required": false, + "default": "\"\"" + }, + { + "name": "schema", + "type": "string", + "required": false, + "default": "\"\"" + }, + { + "name": "showRemember", + "type": "boolean", + "required": false, + "default": "true" + }, + { + "name": "showName", + "type": "boolean", + "required": false, + "default": "true" + }, + { + "name": "submitLabel", + "type": "string", + "required": false, + "default": "\"Continue\"" + }, + { + "name": "class", + "type": "string", + "required": false, + "default": "\"\"" + } + ], + "slots": ["default"], + "events": ["submit", "change", "input", "focus", "blur"], + "source": "components/AuthForm.wrn" + }, + { + "name": "AuthSplitLayout", + "mount": "AuthSplitLayout", + "category": "layout", + "purpose": "Responsive 50/50 authentication layout with content and form regions.", + "props": [ + { + "name": "size", + "type": "string", + "required": false, + "default": "\"default\"" + }, + { + "name": "color", + "type": "string", + "required": false, + "default": "\"primary\"" + }, + { + "name": "eyebrow", + "type": "string", + "required": false, + "default": "\"Secure identity\"" + }, + { + "name": "title", + "type": "string", + "required": false, + "default": "\"Welcome back\"" + }, + { + "name": "description", + "type": "string", + "required": false, + "default": "\"\"" + }, + { + "name": "brand", + "type": "string", + "required": false, + "default": "\"Police Management System\"" + }, + { + "name": "features", + "type": "string", + "required": false, + "default": "[]" + }, + { + "name": "class", + "type": "string", + "required": false, + "default": "\"\"" + } + ], + "slots": ["aside-extra", "form"], + "events": [], + "source": "components/AuthSplitLayout.wrn" + }, { "name": "Avatar", "mount": "Avatar", @@ -2062,7 +2210,7 @@ "default": "\"\"" } ], - "slots": [], + "slots": ["default"], "events": ["input", "change", "focus", "blur", "invalid"], "source": "components/Checkbox.wrn" }, @@ -3436,34 +3584,22 @@ "default": "\"primary\"" }, { - "name": "title", + "name": "label", "type": "string", "required": false, - "default": "\"Dropdown\"" + "default": "\"Open menu\"" }, { - "name": "description", + "name": "items", "type": "string", "required": false, - "default": "\"\"" - }, - { - "name": "open", - "type": "boolean", - "required": false, - "default": "false" + "default": "[]" }, { "name": "placement", "type": "string", "required": false, - "default": "\"bottom\"" - }, - { - "name": "closeLabel", - "type": "string", - "required": false, - "default": "\"Close\"" + "default": "\"end\"" }, { "name": "class", @@ -3472,8 +3608,8 @@ "default": "\"\"" } ], - "slots": ["default"], - "events": ["open", "close", "select", "change"], + "slots": ["trigger", "header", "footer"], + "events": ["toggle", "open", "close", "select"], "source": "components/Dropdown.wrn" }, { @@ -3755,6 +3891,65 @@ "events": ["cancel", "retry", "complete"], "source": "components/FileUploadProgress.wrn" }, + { + "name": "Footer", + "mount": "Footer", + "category": "navigation", + "purpose": "Responsive application footer with structured links and pre/post content slots.", + "props": [ + { + "name": "size", + "type": "string", + "required": false, + "default": "\"default\"" + }, + { + "name": "color", + "type": "string", + "required": false, + "default": "\"primary\"" + }, + { + "name": "label", + "type": "string", + "required": false, + "default": "\"Footer navigation\"" + }, + { + "name": "items", + "type": "string", + "required": false, + "default": "[]" + }, + { + "name": "columns", + "type": "number", + "required": false, + "default": "3" + }, + { + "name": "maxWidth", + "type": "string", + "required": false, + "default": "\"compact\"" + }, + { + "name": "copyright", + "type": "string", + "required": false, + "default": "\"\"" + }, + { + "name": "class", + "type": "string", + "required": false, + "default": "\"\"" + } + ], + "slots": ["pre-footer", "post-footer", "copyright-left", "copyright-right"], + "events": ["select", "action"], + "source": "components/Footer.wrn" + }, { "name": "Grid", "mount": "Grid", @@ -5039,7 +5234,19 @@ "name": "label", "type": "string", "required": false, - "default": "\"Navbar\"" + "default": "\"Primary navigation\"" + }, + { + "name": "topbarLabel", + "type": "string", + "required": false, + "default": "\"Utility navigation\"" + }, + { + "name": "brand", + "type": "string", + "required": false, + "default": "{}" }, { "name": "items", @@ -5047,6 +5254,12 @@ "required": false, "default": "[]" }, + { + "name": "actions", + "type": "string", + "required": false, + "default": "[]" + }, { "name": "active", "type": "string", @@ -5054,10 +5267,28 @@ "default": "\"\"" }, { - "name": "orientation", + "name": "sticky", + "type": "boolean", + "required": false, + "default": "false" + }, + { + "name": "openOnHover", + "type": "boolean", + "required": false, + "default": "false" + }, + { + "name": "maxWidth", "type": "string", "required": false, - "default": "\"horizontal\"" + "default": "\"full\"" + }, + { + "name": "mobileLabel", + "type": "string", + "required": false, + "default": "\"Toggle navigation\"" }, { "name": "class", @@ -5066,8 +5297,8 @@ "default": "\"\"" } ], - "slots": ["default"], - "events": ["toggle", "open", "close"], + "slots": ["topbar", "actions"], + "events": ["toggle", "open", "close", "select", "action"], "source": "components/Navbar.wrn" }, { @@ -5349,6 +5580,166 @@ "events": ["open", "close"], "source": "components/Popover.wrn" }, + { + "name": "PortalDashboard", + "mount": "PortalDashboard", + "category": "blocks", + "purpose": "Responsive portal dashboard with metrics, actions, tasks, and updates.", + "props": [ + { + "name": "size", + "type": "string", + "required": false, + "default": "\"default\"" + }, + { + "name": "color", + "type": "string", + "required": false, + "default": "\"primary\"" + }, + { + "name": "eyebrow", + "type": "string", + "required": false, + "default": "\"Overview\"" + }, + { + "name": "eyebrowKey", + "type": "string", + "required": false, + "default": "\"\"" + }, + { + "name": "title", + "type": "string", + "required": false, + "default": "\"Dashboard\"" + }, + { + "name": "titleKey", + "type": "string", + "required": false, + "default": "\"\"" + }, + { + "name": "description", + "type": "string", + "required": false, + "default": "\"\"" + }, + { + "name": "descriptionKey", + "type": "string", + "required": false, + "default": "\"\"" + }, + { + "name": "userName", + "type": "string", + "required": false, + "default": "\"\"" + }, + { + "name": "metrics", + "type": "string", + "required": false, + "default": "[]" + }, + { + "name": "actions", + "type": "string", + "required": false, + "default": "[]" + }, + { + "name": "updates", + "type": "string", + "required": false, + "default": "[]" + }, + { + "name": "tasks", + "type": "string", + "required": false, + "default": "[]" + }, + { + "name": "class", + "type": "string", + "required": false, + "default": "\"\"" + } + ], + "slots": ["hero-action"], + "events": ["action", "navigate"], + "source": "components/PortalDashboard.wrn" + }, + { + "name": "PreferenceSwitcher", + "mount": "PreferenceSwitcher", + "category": "navigation", + "purpose": "Accessible theme, accent color, and language preference controls.", + "props": [ + { + "name": "size", + "type": "string", + "required": false, + "default": "\"default\"" + }, + { + "name": "color", + "type": "string", + "required": false, + "default": "\"primary\"" + }, + { + "name": "themeLabel", + "type": "string", + "required": false, + "default": "\"Theme\"" + }, + { + "name": "colorLabel", + "type": "string", + "required": false, + "default": "\"Accent color\"" + }, + { + "name": "languageLabel", + "type": "string", + "required": false, + "default": "\"Language\"" + }, + { + "name": "languages", + "type": "string", + "required": false, + "default": "[" + }, + { + "name": "colors", + "type": "string", + "required": false, + "default": "[" + }, + { + "name": "compact", + "type": "boolean", + "required": false, + "default": "true" + }, + { + "name": "class", + "type": "string", + "required": false, + "default": "\"\"" + } + ], + "slots": [], + "events": ["theme", "color", "language"], + "source": "components/PreferenceSwitcher.wrn" + }, { "name": "Progress", "mount": "Progress", @@ -6110,6 +6501,12 @@ "required": false, "default": "\"horizontal\"" }, + { + "name": "mobileLabel", + "type": "string", + "required": false, + "default": "\"Open navigation\"" + }, { "name": "class", "type": "string", @@ -6118,7 +6515,7 @@ } ], "slots": ["default"], - "events": ["toggle", "open", "close"], + "events": ["toggle", "open", "close", "select"], "source": "components/Sidebar.wrn" }, { diff --git a/.publish/ui/components/AvatarGroup.wrn b/.publish/ui/components/AvatarGroup.wrn index 1bb009c6..45623306 100644 --- a/.publish/ui/components/AvatarGroup.wrn +++ b/.publish/ui/components/AvatarGroup.wrn @@ -53,7 +53,9 @@ component AvatarGroup { role="group" aria-label="Avatar group" > -
+
{#each visibleMembers() as item} - + {#if item.src} - {item.alt || item.name || ''} + {item.alt || item.name || ''} {:else if item.initials} - + {:else} - + {/if} {#if showTooltips && (item.tooltip || item.name)} - + {item.tooltip || item.name} {/if} @@ -83,7 +102,9 @@ component AvatarGroup { {/each} {#if hiddenMembers().length > 0} - + - {#if description}

{description}

{/if} - -
+
+ + + + + +
} } diff --git a/.publish/ui/components/Navbar.wrn b/.publish/ui/components/Navbar.wrn index 69f23205..b6f85083 100644 --- a/.publish/ui/components/Navbar.wrn +++ b/.publish/ui/components/Navbar.wrn @@ -3,18 +3,138 @@ component Navbar { @event toggle = function @event open = function @event close = function + @event select = function + @event action = function size = "default" color = "primary" - label = "Navbar" + label = "Primary navigation" + topbarLabel = "Utility navigation" + brand = {} items = [] + actions = [] active = "" - orientation = "horizontal" + sticky = false + openOnHover = false + maxWidth = "full" + mobileLabel = "Toggle navigation" class = "" } + + state mobileOpen = false + + functions { + function toggleNavigation() { + mobileOpen = !mobileOpen + $emit("toggle", { open: mobileOpen }) + $emit(mobileOpen ? "open" : "close", { source: "mobile" }) + } + + function selectItem(item, level) { + mobileOpen = false + $emit("select", { item: item, value: item.value || "", level: level }) + } + + function selectAction(item) { + mobileOpen = false + $emit("action", { item: item, value: item.value || "" }) + } + + function isItemActive(item) { + if (!item) return false + if (item.value && item.value === active) return true + if (!item.children || !item.children.length) return false + return item.children.some(function (child) { + return isItemActive(child) + }) + } + + function toggleDropdown(event, item) { + $emit(event.currentTarget.open ? "open" : "close", { + source: "menu", + item: item + }) + } + } + view { - +
+
+ +
+ +
+ + {#if brand.logo} + + {/if} + {#if brand.icon} + + {/if} + {#if brand.label || brand.description} + + {#if brand.label}{brand.label}{/if} + {#if brand.description}{brand.description}{/if} + + {/if} + + + + +
+ + +
+ {#each actions as item} + + {#if item.icon}{/if} + {item.label} + + {/each} + +
+
+
+
} } diff --git a/.publish/ui/components/Sidebar.wrn b/.publish/ui/components/Sidebar.wrn index 32bcbc99..7e64904a 100644 --- a/.publish/ui/components/Sidebar.wrn +++ b/.publish/ui/components/Sidebar.wrn @@ -3,18 +3,77 @@ component Sidebar { @event toggle = function @event open = function @event close = function + @event select = function size = "default" color = "primary" label = "Sidebar" items = [] active = "" orientation = "horizontal" + mobileLabel = "Open navigation" class = "" } + + state mobileOpen = false + + functions { + function toggleSidebar() { + mobileOpen = !mobileOpen + $emit("toggle", { open: mobileOpen }) + $emit(mobileOpen ? "open" : "close", { source: "mobile" }) + } + + function closeSidebar() { + mobileOpen = false + $emit("close", { source: "mobile" }) + } + + function selectItem(item, level) { + mobileOpen = false + $emit("select", { item: item, value: item.value || "", level: level }) + } + } + view { - +
+ + + +
} } diff --git a/.publish/ui/components/checkbox.wrn b/.publish/ui/components/checkbox.wrn index 63461a67..b945892e 100644 --- a/.publish/ui/components/checkbox.wrn +++ b/.publish/ui/components/checkbox.wrn @@ -38,25 +38,136 @@ component Checkbox { function preventReadonly(sourceEvent) { if (readonly) sourceEvent.preventDefault() } } view { -
-
{#if options.length || cornerHint}{label}{#if cornerHint}{cornerHint}{/if}{/if}
+
+
+ {#if options.length || cornerHint} + + {label} + + {#if cornerHint} + + {cornerHint} + + {/if} + {/if} +
{#if options.length} -
+
{#each options as option} -
{:else} - +
+ +
{/if} - {#if helperText}{helperText}{/if}{error} + {#if helperText} + + {helperText} + + {/if} + + {error} +
} } diff --git a/.publish/ui/package.json b/.publish/ui/package.json index 391a5d98..ee15435b 100644 --- a/.publish/ui/package.json +++ b/.publish/ui/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ui", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "description": "@wrnexus/ui — part of the WrNexus framework.", "license": "MIT", @@ -26,7 +26,7 @@ "./ui.css": "./ui.css" }, "dependencies": { - "@wrnexus/core": "^0.5.1" + "@wrnexus/core": "^0.5.10" }, "files": [ "dist", diff --git a/.publish/ui/ui.css b/.publish/ui/ui.css index f02cd380..cf3c7ae8 100644 --- a/.publish/ui/ui.css +++ b/.publish/ui/ui.css @@ -221,18 +221,26 @@ --wire-btn-contrast: var(--wire-color-primary-contrast); } .wire-btn--variant-default, +.wire-btn--variant-solid, .wire-btn--default, +.wire-btn--solid, .wire-btn--primary { color: var(--wire-btn-contrast); background: var(--wire-btn-color); - box-shadow: 0 0.75rem 1.75rem color-mix(in srgb, var(--wire-btn-color) 20%, transparent); + box-shadow: + 0 1px 2px color-mix(in srgb, black 20%, transparent), + 0 0 0 1px color-mix(in srgb, var(--wire-btn-color) 72%, transparent); } .wire-btn--variant-default:hover, +.wire-btn--variant-solid:hover, .wire-btn--default:hover, +.wire-btn--solid:hover, .wire-btn--primary:hover { background: color-mix(in srgb, var(--wire-btn-color) 84%, black); - transform: translateY(-2px); - box-shadow: 0 1rem 2rem color-mix(in srgb, var(--wire-btn-color) 28%, transparent); + transform: translateY(-1px); + box-shadow: + 0 0.35rem 0.9rem color-mix(in srgb, var(--wire-btn-color) 20%, transparent), + 0 0 0 1px color-mix(in srgb, var(--wire-btn-color) 82%, transparent); } .wire-btn--variant-outline, .wire-btn--outline { @@ -315,20 +323,20 @@ } .wire-btn--size-sm, .wire-btn--sm { - min-height: 1.75rem; + min-height: 2.125rem; padding-inline: 0.625rem; gap: 0.25rem; font-size: 0.8rem; } .wire-btn--size-default, .wire-btn--md { - min-height: 2rem; + min-height: 2.5rem; padding-inline: 0.75rem; font-size: 0.875rem; } .wire-btn--size-lg, .wire-btn--lg { - min-height: 2.25rem; + min-height: 2.75rem; padding-inline: 0.875rem; font-size: 0.925rem; } @@ -1314,8 +1322,8 @@ transition-duration: 0.01ms !important; } } -:where(button, a, input, select, textarea):focus-visible { - outline: 2px solid rgb(124 58 237); +:where(button, a):focus-visible { + outline: 2px solid var(--wire-color-primary); outline-offset: 2px; } @@ -4920,6 +4928,1164 @@ background: var(--wire-color-surface-2); } +/* Full application navigation */ +.wire-navbar { + position: relative; + z-index: 40; + width: 100%; + color: var(--wire-color-text); + background: color-mix(in srgb, var(--wire-color-surface) 96%, transparent); + border-bottom: 1px solid var(--wire-color-border); +} +.wire-navbar--sticky { + position: sticky; + top: 0; + backdrop-filter: blur(16px); +} +.wire-navbar__topbar:empty, +.wire-navbar__topbar:not(:has(*)) { + display: none; +} +.wire-navbar__topbar:not(:empty) { + display: flex; + min-height: 2.25rem; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.35rem clamp(1rem, 4vw, 3rem); + color: var(--wire-color-muted); + background: var(--wire-color-surface-2); + border-bottom: 1px solid var(--wire-color-border); + font-size: 0.75rem; + font-weight: 400; +} +.wire-navbar__main { + display: flex; + min-height: 4.25rem; + width: min(100%, 90rem); + margin-inline: auto; + padding: 0.75rem clamp(1rem, 4vw, 3rem); + align-items: center; + gap: 1.5rem; +} +.wire-navbar--width-full .wire-navbar__main { + width: 100%; + max-width: none; +} +.wire-navbar--width-compact .wire-navbar__main { + width: min(86%, 80rem); +} +.wire-navbar__brand, +.wire-navbar__menu-link, +.wire-navbar__dropdown summary, +.wire-navbar__action, +.wire-navbar__panel a { + color: inherit; + text-decoration: none; +} +.wire-navbar__brand { + display: inline-flex; + min-width: 0; + align-items: center; + gap: 0.75rem; + flex: 0 0 auto; +} +.wire-navbar__brand-logo { + display: block; + width: auto; + max-width: 11rem; + height: 2.5rem; + object-fit: contain; +} +.wire-navbar__brand-icon { + width: 2.5rem; + height: 2.5rem; + color: var(--wire-component-color, var(--wire-color-primary)); +} +.wire-navbar__brand-copy { + display: grid; + min-width: 0; + line-height: 1.2; +} +.wire-navbar__brand-copy strong { + font-size: 0.9rem; + font-weight: 600; +} +.wire-navbar__brand-copy small { + margin-top: 0.2rem; + color: var(--wire-color-muted); + font-size: 0.6875rem; + font-weight: 400; +} +.wire-navbar__collapse, +.wire-navbar__menus, +.wire-navbar__actions { + display: flex; + align-items: center; +} +.wire-navbar__collapse { + min-width: 0; + flex: 1; + gap: 1rem; +} +.wire-navbar__menus { + justify-content: center; + flex: 1; + gap: 0.25rem; +} +.wire-navbar__actions { + justify-content: flex-end; + gap: 0.5rem; +} +.wire-navbar__menu-link, +.wire-navbar__dropdown > summary, +.wire-navbar__action { + display: inline-flex; + min-height: 2.75rem; + padding: 0.65rem 0.8rem; + align-items: center; + gap: 0.45rem; + border-radius: 0.65rem; + cursor: pointer; + font-size: 0.8125rem; + font-weight: 500; + white-space: nowrap; +} +.wire-navbar__menu-link:hover, +.wire-navbar__dropdown > summary:hover { + color: var(--wire-color-text); + background: var(--wire-color-surface-2); +} +.wire-navbar__menu-link[aria-current="page"], +.wire-navbar__dropdown > summary[aria-current="page"] { + color: var(--wire-component-color, var(--wire-color-primary)); + font-weight: 600; + background: color-mix( + in srgb, + var(--wire-component-color, var(--wire-color-primary)) 12%, + transparent + ); + box-shadow: inset 0 -2px 0 var(--wire-component-color, var(--wire-color-primary)); +} +.wire-navbar__menu-link[aria-current="page"]:hover, +.wire-navbar__dropdown > summary[aria-current="page"]:hover { + color: var(--wire-component-color, var(--wire-color-primary)); + background: color-mix( + in srgb, + var(--wire-component-color, var(--wire-color-primary)) 16%, + transparent + ); +} +.wire-navbar__dropdown { + position: relative; +} +.wire-navbar__dropdown > summary { + list-style: none; +} +.wire-navbar__dropdown > summary::-webkit-details-marker { + display: none; +} +.wire-navbar__chevron { + width: 0.45rem; + height: 0.45rem; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + transform: rotate(45deg) translateY(-0.15rem); + transition: transform var(--wire-motion-fast, 150ms) ease; +} +.wire-navbar__dropdown[open] .wire-navbar__chevron { + transform: rotate(225deg) translate(-0.1rem, -0.1rem); +} +.wire-navbar__panel { + position: absolute; + top: calc(100% + 0.55rem); + left: 50%; + display: grid; + width: max-content; + min-width: 15rem; + max-width: min(90vw, 64rem); + padding: 0.65rem; + gap: 0.45rem; + border: 1px solid var(--wire-color-border); + border-radius: 0.9rem; + color: var(--wire-color-text); + background: var(--wire-color-surface); + box-shadow: 0 18px 48px rgb(15 23 42 / 0.16); + opacity: 1; + transform: translateX(-50%) translateY(0) scale(1); + transform-origin: top center; + transition: + opacity 180ms ease, + transform 180ms cubic-bezier(0.22, 1, 0.36, 1), + display 180ms allow-discrete; +} +.wire-navbar__panel::before { + position: absolute; + right: 0; + bottom: 100%; + left: 0; + height: 0.65rem; + content: ""; +} +.wire-navbar__dropdown:not([open]) > .wire-navbar__panel { + display: none; + opacity: 0; + transform: translateX(-50%) translateY(-0.5rem) scale(0.98); +} +@starting-style { + .wire-navbar__dropdown[open] > .wire-navbar__panel { + opacity: 0; + transform: translateX(-50%) translateY(-0.5rem) scale(0.98); + } +} +.wire-navbar__dropdown--mega { + position: static; +} +.wire-navbar__dropdown--mega .wire-navbar__panel { + right: auto; + left: 50%; + width: min(calc(100vw - 2rem), 64rem); +} +.wire-navbar__panel--columns-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} +.wire-navbar__panel--columns-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} +.wire-navbar__panel--columns-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} +.wire-navbar__panel-intro { + grid-column: 1 / -1; + margin: 0; + padding: 0.5rem 0.65rem; + color: var(--wire-color-muted); + font-size: 0.8125rem; + font-weight: 400; +} +.wire-navbar__group { + display: grid; + align-content: start; + gap: 0.25rem; +} +.wire-navbar__group-title { + padding: 0.55rem 0.65rem 0.25rem; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} +.wire-navbar__group > small { + padding: 0 0.65rem 0.4rem; + color: var(--wire-color-muted); + font-size: 0.7rem; + font-weight: 400; +} +.wire-navbar__panel a { + display: flex; + min-height: 2.75rem; + padding: 0.65rem; + align-items: flex-start; + gap: 0.65rem; + border-radius: 0.6rem; + font-size: 0.8rem; + font-weight: 450; +} +.wire-navbar__panel a:hover { + background: var(--wire-color-surface-2); +} +.wire-navbar__panel a span:last-child { + display: grid; + gap: 0.15rem; +} +.wire-navbar__panel a strong { + font-weight: 500; +} +.wire-navbar__panel a small { + color: var(--wire-color-muted); + font-size: 0.7rem; + font-weight: 400; +} +.wire-navbar__action { + border: 1px solid transparent; +} +.wire-navbar__action--primary { + color: var(--wire-color-on-primary, #fff); + background: var(--wire-component-color, var(--wire-color-primary)); + font-weight: 600; +} +.wire-navbar__action--outline { + border-color: var(--wire-color-border); +} +.wire-navbar__toggle { + display: none; + width: 2.75rem; + height: 2.75rem; + margin-left: auto; + padding: 0.65rem; + border: 1px solid var(--wire-color-border); + border-radius: 0.65rem; + color: inherit; + background: var(--wire-color-surface); +} +.wire-navbar__toggle span { + display: block; + height: 2px; + margin: 0.25rem 0; + background: currentColor; +} + +/* Structured responsive footer */ +.wire-footer { + width: 100%; + color: var(--wire-color-text); + background: var(--wire-color-surface); + border-top: 1px solid var(--wire-color-border); + font-size: 0.875rem; + font-weight: 400; +} +.wire-footer__grid { + display: grid; + grid-template-columns: minmax(13rem, 1.2fr) minmax(0, 3fr) minmax(13rem, 1fr); + width: min(100%, 90rem); + margin-inline: auto; + padding: clamp(2rem, 5vw, 4rem) clamp(1rem, 4vw, 3rem); + gap: clamp(1.5rem, 4vw, 3.5rem); +} +.wire-footer--width-full .wire-footer__grid { + width: 100%; + max-width: none; +} +.wire-footer__pre:empty, +.wire-footer__post:empty, +.wire-footer__pre:not(:has(*)), +.wire-footer__post:not(:has(*)) { + display: none; +} +.wire-footer__pre, +.wire-footer__post { + min-width: 0; +} +.wire-footer__pre strong, +.wire-footer__post strong { + font-size: 0.875rem; + font-weight: 600; +} +.wire-footer__pre p, +.wire-footer__post p { + font-size: 0.8125rem; + font-weight: 400; + line-height: 1.6; +} +.wire-footer__links { + display: grid; + grid-template-columns: repeat(var(--wire-footer-columns, 3), minmax(0, 1fr)); + align-content: start; + gap: 1.5rem; +} +.wire-footer__column { + display: grid; + min-width: 0; + align-content: start; + gap: 0.8rem; +} +.wire-footer__column-links { + display: grid; + align-content: start; + gap: 0.55rem; +} +.wire-footer--columns-1 { + --wire-footer-columns: 1; +} +.wire-footer--columns-2 { + --wire-footer-columns: 2; +} +.wire-footer--columns-3 { + --wire-footer-columns: 3; +} +.wire-footer--columns-4 { + --wire-footer-columns: 4; +} +.wire-footer--columns-5 { + --wire-footer-columns: 5; +} +.wire-footer--columns-6 { + --wire-footer-columns: 6; +} +.wire-footer__heading { + display: block; + color: var(--wire-color-text); + font-size: 0.8125rem; + font-weight: 600; +} +.wire-footer__link { + display: flex; + width: fit-content; + min-height: 2rem; + align-items: center; + gap: 0.4rem; + color: var(--wire-color-muted); + font-size: 0.8125rem; + font-weight: 400; + text-decoration: none; +} +.wire-footer__link:hover { + color: var(--wire-component-color, var(--wire-color-primary)); + text-decoration: underline; + text-underline-offset: 0.2em; +} +.wire-footer__bottom { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + align-items: center; + gap: 1rem; + padding: 1rem clamp(1rem, 4vw, 3rem); + color: var(--wire-color-muted); + border-top: 1px solid var(--wire-color-border); + font-size: 0.75rem; + font-weight: 400; +} +.wire-footer__bottom--slots { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); +} +.wire-footer__bottom--slots:not( + :has(.wire-footer__copyright-left > *, .wire-footer__copyright-right > *) +) { + display: none; +} +.wire-footer__copyright { + text-align: center; +} +.wire-footer__copyright-left, +.wire-footer__copyright-right { + display: flex; + min-width: 0; + align-items: center; + gap: 0.75rem; +} +.wire-footer__copyright-right { + justify-content: flex-end; + text-align: right; +} +.wire-footer__copyright-left:empty, +.wire-footer__copyright-right:empty, +.wire-footer__copyright-left:not(:has(*)), +.wire-footer__copyright-right:not(:has(*)) { + min-height: 0; +} + +@media (max-width: 900px) { + .wire-navbar--width-compact .wire-navbar__main { + width: 100%; + } + .wire-navbar__toggle { + display: block; + } + .wire-navbar__collapse { + position: absolute; + top: 100%; + right: 0; + left: 0; + display: none; + padding: 0.75rem 1rem 1rem; + align-items: stretch; + flex-direction: column; + background: var(--wire-color-surface); + border-bottom: 1px solid var(--wire-color-border); + box-shadow: 0 16px 32px rgb(15 23 42 / 0.12); + max-height: calc(100dvh - 4rem); + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + } + .wire-navbar__collapse.is-open { + display: flex; + } + .wire-navbar__menus, + .wire-navbar__actions { + width: 100%; + align-items: stretch; + flex-direction: column; + } + .wire-navbar__menu-link, + .wire-navbar__dropdown > summary, + .wire-navbar__action { + width: 100%; + } + .wire-navbar__panel, + .wire-navbar__dropdown--mega .wire-navbar__panel { + position: static; + width: 100%; + max-width: none; + margin-top: 0.25rem; + box-shadow: none; + transform: none; + overflow: hidden; + animation: wire-navbar-mobile-panel 180ms ease both; + } + .wire-navbar__dropdown:not([open]) > .wire-navbar__panel, + .wire-navbar__dropdown[open] > .wire-navbar__panel { + transform: none; + } + .wire-navbar__panel[class*="wire-navbar__panel--columns-"] { + grid-template-columns: 1fr; + } + .wire-navbar__panel::before { + display: none; + } + .wire-footer__grid { + grid-template-columns: 1fr; + } + .wire-footer__links { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .wire-footer--columns-1 .wire-footer__links { + grid-template-columns: 1fr; + } + .wire-footer--columns-2 .wire-footer__links { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +@media (max-width: 600px) { + .wire-navbar__brand-copy small { + display: none; + } + .wire-footer__links { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .wire-footer--columns-1 .wire-footer__links { + grid-template-columns: 1fr; + } + .wire-footer__bottom, + .wire-footer__bottom--slots { + grid-template-columns: 1fr; + justify-items: center; + text-align: center; + } + .wire-footer__copyright-left, + .wire-footer__copyright-right { + justify-content: center; + text-align: center; + } +} +@media (max-width: 420px) { + .wire-footer__links { + grid-template-columns: 1fr; + } +} + +/* Portal display, accent, and language preferences */ +.wire-preferences { + display: inline-flex; + align-items: center; + gap: 0.25rem; +} +.wire-preferences__menu { + position: relative; +} +.wire-preferences__menu > summary { + display: inline-flex; + min-width: 2.25rem; + min-height: 2.25rem; + padding: 0.45rem 0.6rem; + align-items: center; + justify-content: center; + gap: 0.4rem; + border: 1px solid transparent; + border-radius: 0.6rem; + color: var(--wire-color-muted); + cursor: pointer; + list-style: none; +} +.wire-preferences__menu > summary::-webkit-details-marker { + display: none; +} +.wire-preferences__menu > summary:hover, +.wire-preferences__menu[open] > summary { + color: var(--wire-color-text); + border-color: var(--wire-color-border); + background: var(--wire-color-surface); +} +.wire-preferences__menu > summary > span:first-child { + width: 1rem; + height: 1rem; +} +.wire-preferences__current-language { + min-width: 1.25rem; + color: var(--wire-color-text); + font-size: 0.625rem; + font-weight: 650; + letter-spacing: 0.02em; +} +.wire-preferences__panel { + position: absolute; + z-index: 80; + top: calc(100% + 0.5rem); + right: 0; + display: grid; + width: max-content; + min-width: 11rem; + max-width: min(22rem, calc(100vw - 2rem)); + max-height: min(28rem, calc(100dvh - 2rem)); + overflow-y: auto; + padding: 0.75rem; + gap: 0.6rem; + border: 1px solid var(--wire-color-border); + border-radius: 0.8rem; + color: var(--wire-color-text); + background: var(--wire-color-surface); + box-shadow: var(--wire-shadow-lg, 0 18px 48px rgb(0 0 0 / 0.18)); + animation: wire-component-enter 160ms ease both; +} + +@keyframes wire-navbar-mobile-panel { + from { + opacity: 0; + transform: translateY(-0.35rem); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Responsive grouped application sidebar */ +.wire-sidebar-shell { + width: 15rem; + flex: none; + color: var(--wire-color-text); + background: var(--wire-color-surface); + border-right: 1px solid var(--wire-color-border); + align-self: stretch; + height: 100%; + min-height: 0; +} +.wire-sidebar-toggle, +.wire-sidebar-backdrop, +.wire-sidebar-panel__head { + display: none; +} +.wire-sidebar-panel { + position: sticky; + top: 0; + display: block; + width: 100%; + max-height: 100%; + padding: 0.75rem; + overflow-y: auto; + overscroll-behavior: contain; +} +.wire-sidebar-items { + display: grid; + gap: 0.15rem; +} +.wire-sidebar-items > a, +.wire-sidebar-group > summary, +.wire-sidebar-group__children a { + display: flex; + width: 100%; + min-height: 2.25rem; + padding: 0.5rem 0.65rem; + align-items: center; + gap: 0.55rem; + border-radius: 0.5rem; + color: var(--wire-color-muted); + font-size: 0.75rem; + font-weight: 450; + line-height: 1.35; + text-decoration: none; +} +.wire-sidebar-items > a:hover, +.wire-sidebar-group > summary:hover, +.wire-sidebar-group__children a:hover { + color: var(--wire-color-text); + background: var(--wire-color-surface-2); +} +.wire-sidebar-items a[aria-current="page"] { + color: var(--wire-color-primary); + background: color-mix(in srgb, var(--wire-color-primary) 12%, transparent); + box-shadow: inset 3px 0 0 var(--wire-color-primary); + font-weight: 600; +} +.wire-sidebar-group.has-active > summary { + position: relative; + color: var(--wire-color-text); + background: var(--wire-color-surface-2); +} +.wire-sidebar-group.has-active > summary::before { + position: absolute; + left: 0; + width: 3px; + height: 1.25rem; + border-radius: 999px; + content: ""; + background: var(--wire-color-primary); +} +.wire-sidebar-items :where(a, summary) > span:first-child:not(:last-child) { + width: 0.95rem; + height: 0.95rem; + flex: none; +} +.wire-sidebar-group > summary { + cursor: pointer; + list-style: none; +} +.wire-sidebar-group > summary::-webkit-details-marker { + display: none; +} +.wire-sidebar-group > summary > span:nth-last-child(2) { + min-width: 0; + flex: 1; +} +.wire-sidebar-group > summary > span:last-child { + width: 0.8rem; + height: 0.8rem; + transition: transform 180ms ease; +} +.wire-sidebar-group[open] > summary > span:last-child { + transform: rotate(180deg); +} +.wire-sidebar-group__children { + display: grid; + margin: 0.15rem 0 0.35rem 1rem; + padding-left: 0.5rem; + gap: 0.1rem; + border-left: 1px solid var(--wire-color-border); + animation: wire-sidebar-children 180ms ease both; +} +.wire-sidebar-group__children a { + min-height: 2rem; + padding-block: 0.4rem; + font-size: 0.7rem; +} +@keyframes wire-sidebar-children { + from { + opacity: 0; + transform: translateY(-0.25rem); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 1023px) { + .wire-sidebar-shell { + width: 100%; + min-height: 0; + border-right: 0; + border-bottom: 1px solid var(--wire-color-border); + } + .wire-sidebar-toggle { + display: inline-flex; + min-height: 2.5rem; + margin: 0.65rem; + padding: 0.55rem 0.75rem; + align-items: center; + gap: 0.55rem; + border: 1px solid var(--wire-color-border); + border-radius: 0.6rem; + color: var(--wire-color-text); + background: var(--wire-color-surface); + font-size: 0.75rem; + font-weight: 550; + } + .wire-sidebar-toggle > span:first-child { + width: 1rem; + height: 1rem; + } + .wire-sidebar-backdrop { + position: fixed; + z-index: 69; + inset: 0; + width: 100%; + height: 100%; + border: 0; + background: rgb(0 0 0 / 0.58); + opacity: 0; + pointer-events: none; + transition: opacity 220ms ease; + } + .wire-sidebar-panel { + position: fixed; + z-index: 70; + top: 0; + bottom: 0; + left: 0; + width: min(86vw, 19rem); + max-height: none; + padding: 0.75rem; + background: var(--wire-color-surface); + box-shadow: 20px 0 50px rgb(0 0 0 / 0.28); + transform: translateX(-105%); + visibility: hidden; + transition: + transform 240ms cubic-bezier(0.22, 1, 0.36, 1), + visibility 240ms; + } + .wire-sidebar-shell.is-open .wire-sidebar-backdrop { + display: block; + opacity: 1; + pointer-events: auto; + } + .wire-sidebar-shell.is-open .wire-sidebar-panel { + transform: translateX(0); + visibility: visible; + } + .wire-sidebar-panel__head { + display: flex; + min-height: 3rem; + margin-bottom: 0.5rem; + padding: 0.35rem 0.45rem 0.7rem; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--wire-color-border); + } + .wire-sidebar-panel__head strong { + font-size: 0.8rem; + font-weight: 600; + } + .wire-sidebar-panel__head button { + display: grid; + width: 2.25rem; + height: 2.25rem; + place-items: center; + border-radius: 0.5rem; + color: var(--wire-color-muted); + } + .wire-sidebar-panel__head button span { + width: 1rem; + height: 1rem; + } +} +.wire-preferences__panel > strong { + padding-inline: 0.25rem; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; +} +.wire-preferences__options { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.4rem; +} +.wire-preferences__panel button { + display: inline-flex; + min-height: 2.25rem; + padding: 0.5rem 0.65rem; + align-items: center; + gap: 0.5rem; + border-radius: 0.55rem; + color: var(--wire-color-text); + background: var(--wire-color-surface-2); + font-size: 0.75rem; + font-weight: 500; +} +.wire-preferences__panel button:hover { + outline: 2px solid color-mix(in srgb, var(--wire-color-primary) 40%, transparent); +} +.wire-preferences__panel button[aria-pressed="true"] { + color: var(--wire-color-primary); + background: color-mix(in srgb, var(--wire-color-primary) 12%, var(--wire-color-surface)); + outline: 1px solid color-mix(in srgb, var(--wire-color-primary) 55%, transparent); +} +.wire-preferences__swatches { + display: grid; + grid-template-columns: repeat(3, 2rem); + gap: 0.5rem; +} +.wire-preferences__swatches button { + width: 2rem; + min-height: 2rem; + padding: 0; + border: 3px solid var(--wire-color-surface); + border-radius: 999px; + background: var(--wire-preference-swatch); + box-shadow: 0 0 0 1px var(--wire-color-border); +} +.wire-preferences__swatches button[aria-pressed="true"] { + box-shadow: + 0 0 0 2px var(--wire-color-surface), + 0 0 0 4px var(--wire-color-primary); +} +.wire-preferences__panel--language button { + width: 100%; + background: transparent; +} +.wire-preferences__panel--language button span { + display: grid; + width: 1.65rem; + height: 1.65rem; + place-items: center; + border-radius: 0.45rem; + background: var(--wire-color-surface-2); + font-size: 0.65rem; +} +.wire-preferences__panel--language button[aria-pressed="true"]::after { + width: 0.9rem; + height: 0.9rem; + margin-left: auto; + content: ""; + background: currentColor; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m5 12 4 4L19 6'/%3E%3C/svg%3E") + center / contain no-repeat; +} + +/* Reusable responsive portal dashboard */ +.wire-portal-dashboard { + width: min(100%, 96rem); + margin-inline: auto; + color: var(--wire-color-text); +} +.wire-portal-dashboard__hero { + display: flex; + padding: clamp(1.25rem, 3vw, 2rem); + align-items: flex-end; + justify-content: space-between; + gap: 1rem; + border: 1px solid var(--wire-color-border); + border-radius: 1rem; + background: + radial-gradient( + circle at 85% 0%, + color-mix(in srgb, var(--wire-color-primary) 20%, transparent), + transparent 34% + ), + var(--wire-color-surface); + box-shadow: var(--wire-shadow-sm); +} +.wire-portal-dashboard__eyebrow, +.wire-portal-dashboard__panel-heading small { + color: var(--wire-color-primary); + font-size: 0.7rem; + font-weight: 650; + letter-spacing: 0.09em; + text-transform: uppercase; +} +.wire-portal-dashboard__hero h1 { + margin: 0.35rem 0 0; + font-size: clamp(1.4rem, 3vw, 2.25rem); + font-weight: 650; + letter-spacing: -0.035em; +} +.wire-portal-dashboard__hero p { + max-width: 48rem; + margin: 0.5rem 0 0; + color: var(--wire-color-muted); + font-size: 0.875rem; + line-height: 1.6; +} +.wire-portal-dashboard__metrics { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin-top: 1rem; + gap: 1rem; +} +.wire-portal-dashboard__metric { + display: flex; + min-width: 0; + padding: 1rem; + align-items: center; + gap: 0.85rem; + border: 1px solid var(--wire-color-border); + border-radius: 0.85rem; + background: var(--wire-color-surface); + box-shadow: var(--wire-shadow-sm); +} +.wire-portal-dashboard__metric-icon { + width: 2.25rem; + height: 2.25rem; + flex: none; + padding: 0.55rem; + border-radius: 0.7rem; + color: var(--wire-color-primary); + background: color-mix(in srgb, var(--wire-color-primary) 12%, transparent); +} +.wire-portal-dashboard__metric div { + display: grid; + min-width: 0; +} +.wire-portal-dashboard__metric small, +.wire-portal-dashboard__metric span { + overflow: hidden; + color: var(--wire-color-muted); + font-size: 0.7rem; + text-overflow: ellipsis; + white-space: nowrap; +} +.wire-portal-dashboard__metric strong { + margin-block: 0.1rem; + font-size: 1.3rem; + font-weight: 650; +} +.wire-portal-dashboard__grid { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(18rem, 0.8fr); + margin-top: 1rem; + gap: 1rem; +} +.wire-portal-dashboard__panel { + min-width: 0; + padding: 1.1rem; + border: 1px solid var(--wire-color-border); + border-radius: 0.9rem; + background: var(--wire-color-surface); + box-shadow: var(--wire-shadow-sm); +} +.wire-portal-dashboard__panel--actions { + grid-row: span 2; +} +.wire-portal-dashboard__panel-heading { + display: flex; + margin-bottom: 0.9rem; + align-items: center; + justify-content: space-between; +} +.wire-portal-dashboard__panel-heading h2 { + margin: 0.2rem 0 0; + font-size: 1rem; + font-weight: 600; +} +.wire-portal-dashboard__actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.65rem; +} +.wire-portal-dashboard__actions a, +.wire-portal-dashboard__list a { + display: flex; + min-width: 0; + padding: 0.8rem; + align-items: center; + gap: 0.7rem; + border: 1px solid var(--wire-color-border); + border-radius: 0.7rem; + color: inherit; + text-decoration: none; + transition: + border-color 150ms ease, + background 150ms ease, + transform 150ms ease; +} +.wire-portal-dashboard__actions a:hover, +.wire-portal-dashboard__list a:hover { + border-color: color-mix(in srgb, var(--wire-color-primary) 45%, var(--wire-color-border)); + background: var(--wire-color-surface-2); + transform: translateY(-1px); +} +.wire-portal-dashboard__actions a > span:first-child { + width: 1.15rem; + height: 1.15rem; + flex: none; + color: var(--wire-color-primary); +} +.wire-portal-dashboard__actions a > span:nth-child(2), +.wire-portal-dashboard__list a > span:nth-child(2) { + display: grid; + min-width: 0; + flex: 1; +} +.wire-portal-dashboard__actions a > span:last-child { + width: 0.9rem; + height: 0.9rem; + color: var(--wire-color-muted); +} +.wire-portal-dashboard__actions strong, +.wire-portal-dashboard__list strong, +.wire-portal-dashboard__timeline strong { + font-size: 0.8rem; + font-weight: 550; +} +.wire-portal-dashboard__actions small, +.wire-portal-dashboard__list small, +.wire-portal-dashboard__timeline p, +.wire-portal-dashboard__timeline small { + margin: 0.15rem 0 0; + color: var(--wire-color-muted); + font-size: 0.7rem; + font-style: normal; + line-height: 1.45; +} +.wire-portal-dashboard__list { + display: grid; + gap: 0.5rem; +} +.wire-portal-dashboard__list a { + border: 0; + border-bottom: 1px solid var(--wire-color-border); + border-radius: 0; +} +.wire-portal-dashboard__status { + width: 0.55rem; + height: 0.55rem; + flex: none; + border-radius: 999px; + background: var(--wire-color-muted); +} +.wire-portal-dashboard__status--urgent { + background: var(--wire-color-danger); +} +.wire-portal-dashboard__status--warning { + background: var(--wire-color-warning); +} +.wire-portal-dashboard__status--success { + background: var(--wire-color-success); +} +.wire-portal-dashboard__list em { + color: var(--wire-color-muted); + font-size: 0.7rem; + font-style: normal; +} +.wire-portal-dashboard__timeline { + display: grid; + gap: 0.8rem; +} +.wire-portal-dashboard__timeline article { + display: flex; + gap: 0.7rem; +} +.wire-portal-dashboard__timeline article > span { + width: 1rem; + height: 1rem; + margin-top: 0.15rem; + flex: none; + color: var(--wire-color-primary); +} +.wire-portal-dashboard__timeline p { + margin-block: 0.15rem; +} + +@media (max-width: 1100px) { + .wire-portal-dashboard__metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .wire-portal-dashboard__grid { + grid-template-columns: 1fr; + } + .wire-portal-dashboard__panel--actions { + grid-row: auto; + } +} +@media (max-width: 640px) { + .wire-preferences__panel { + position: fixed; + top: auto; + right: 1rem; + bottom: 1rem; + left: 1rem; + width: auto; + } + .wire-portal-dashboard__hero { + align-items: flex-start; + flex-direction: column; + } + .wire-portal-dashboard__metrics, + .wire-portal-dashboard__actions { + grid-template-columns: 1fr; + } +} + .wire-next--field { display: grid; width: min(100%, 28rem); @@ -4939,6 +6105,338 @@ font: inherit; font-weight: 500; } + +/* Reusable dropdown and account menu */ +.wire-dropdown { + position: relative; + flex: none; +} +.wire-dropdown > summary { + display: inline-flex; + min-height: 2.5rem; + padding: 0.3rem 0.45rem; + align-items: center; + gap: 0.5rem; + border: 1px solid transparent; + border-radius: 0.7rem; + color: var(--wire-color-text); + cursor: pointer; + list-style: none; +} +.wire-dropdown > summary::-webkit-details-marker { + display: none; +} +.wire-dropdown > summary:hover, +.wire-dropdown[open] > summary { + border-color: var(--wire-color-border); + background: var(--wire-color-surface-2); +} +.wire-dropdown__chevron { + width: 0.75rem; + height: 0.75rem; + color: var(--wire-color-muted); + transition: transform 180ms ease; +} +.wire-dropdown[open] .wire-dropdown__chevron { + transform: rotate(180deg); +} +.wire-dropdown__panel { + position: absolute; + z-index: 85; + top: calc(100% + 0.5rem); + right: 0; + display: grid; + width: max-content; + min-width: 14rem; + max-width: min(22rem, calc(100vw - 2rem)); + padding: 0.5rem; + gap: 0.15rem; + border: 1px solid var(--wire-color-border); + border-radius: 0.8rem; + color: var(--wire-color-text); + background: var(--wire-color-surface); + box-shadow: var(--wire-shadow-lg); + animation: wire-component-enter 170ms ease both; +} +.wire-dropdown--start .wire-dropdown__panel { + right: auto; + left: 0; +} +.wire-dropdown__panel hr { + width: 100%; + margin: 0.35rem 0; + border: 0; + border-top: 1px solid var(--wire-color-border); +} +.wire-dropdown__heading { + padding: 0.5rem 0.6rem 0.25rem; + color: var(--wire-color-muted); + font-size: 0.65rem; + font-weight: 650; + letter-spacing: 0.07em; + text-transform: uppercase; +} +.wire-dropdown__item { + display: flex; + min-height: 2.5rem; + padding: 0.55rem 0.6rem; + align-items: center; + gap: 0.6rem; + border-radius: 0.55rem; + color: inherit; + font-size: 0.75rem; + text-decoration: none; +} +.wire-dropdown__item:hover { + background: var(--wire-color-surface-2); +} +.wire-dropdown__item > span:first-child:not(:last-child) { + width: 1rem; + height: 1rem; + flex: none; + color: var(--wire-color-muted); +} +.wire-dropdown__item > span:nth-child(2) { + display: grid; + min-width: 0; + flex: 1; +} +.wire-dropdown__item strong { + font-weight: 550; +} +.wire-dropdown__item small { + margin-top: 0.1rem; + color: var(--wire-color-muted); + font-size: 0.65rem; +} +.wire-dropdown__item--danger { + color: var(--wire-color-danger); +} +@media (max-width: 640px) { + .wire-dropdown__panel { + position: fixed; + top: auto; + right: 1rem; + bottom: 1rem; + left: 1rem; + width: auto; + max-height: calc(100dvh - 2rem); + overflow-y: auto; + } +} + +/* Shared authentication shell and forms */ +.wire-auth-split { + display: grid; + width: 100%; + height: 100dvh; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + color: var(--wire-color-text); + background: var(--wire-color-bg); +} +.wire-auth-split__content, +.wire-auth-split__form { + min-width: 0; + padding: clamp(1.5rem, 5vw, 5rem); +} +.wire-auth-split__content { + display: flex; + height: 100dvh; + min-height: 0; + overflow: hidden; + justify-content: space-between; + flex-direction: column; + background: + radial-gradient( + circle at 15% 15%, + color-mix(in srgb, var(--wire-color-primary) 22%, transparent), + transparent 34% + ), + linear-gradient(145deg, var(--wire-color-surface-2), var(--wire-color-bg)); + border-right: 1px solid var(--wire-color-border); +} +.wire-auth-split__brand { + display: flex; + align-items: center; + gap: 0.75rem; +} +.wire-auth-split__brand > span:first-child { + width: 2.5rem; + height: 2.5rem; + color: var(--wire-color-primary); +} +.wire-auth-split__brand > span:last-child { + display: grid; +} +.wire-auth-split__brand strong { + font-size: 0.875rem; + font-weight: 650; +} +.wire-auth-split__brand small, +.wire-auth-split__legal { + color: var(--wire-color-muted); + font-size: 0.65rem; +} +.wire-auth-split__message { + width: min(100%, 36rem); + margin-block: 3rem; +} +.wire-auth-split__eyebrow { + color: var(--wire-color-primary); + font-size: 0.7rem; + font-weight: 650; + letter-spacing: 0.09em; + text-transform: uppercase; +} +.wire-auth-split__message h1 { + max-width: 14ch; + margin: 0.65rem 0 0; + font-size: clamp(2rem, 4vw, 3.75rem); + font-weight: 650; + letter-spacing: -0.045em; + line-height: 1.08; +} +.wire-auth-split__message > p { + max-width: 34rem; + margin: 1rem 0 0; + color: var(--wire-color-muted); + font-size: 0.875rem; + line-height: 1.7; +} +.wire-auth-split__features { + display: grid; + margin-top: 2rem; + gap: 1rem; +} +.wire-auth-split__features article { + display: flex; + align-items: flex-start; + gap: 0.75rem; +} +.wire-auth-split__features article > span:first-child { + width: 1.1rem; + height: 1.1rem; + margin-top: 0.1rem; + flex: none; + color: var(--wire-color-primary); +} +.wire-auth-split__features article > span:last-child { + display: grid; +} +.wire-auth-split__features strong { + font-size: 0.75rem; + font-weight: 600; +} +.wire-auth-split__features small { + margin-top: 0.15rem; + color: var(--wire-color-muted); + font-size: 0.68rem; +} +.wire-auth-split__form { + display: grid; + height: 100dvh; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + place-items: center; + background: var(--wire-color-surface); +} +.wire-auth-split__form-inner { + width: min(100%, 30rem); +} +.wire-auth-form { + width: 100%; +} +.wire-auth-form > header { + display: flex; + margin-bottom: 1.5rem; + align-items: flex-start; + gap: 0.85rem; +} +.wire-auth-form__icon { + width: 2rem; + height: 2rem; + flex: none; + color: var(--wire-color-primary); +} +.wire-auth-form h2 { + margin: 0; + font-size: 1.5rem; + font-weight: 650; + letter-spacing: -0.025em; +} +.wire-auth-form header p { + margin: 0.35rem 0 0; + color: var(--wire-color-muted); + font-size: 0.75rem; + line-height: 1.5; +} +.wire-auth-form form { + display: grid; + gap: 1rem; +} +.wire-auth-form form > .wire-next--field, +.wire-auth-form form > .wire-field { + width: 100%; + max-width: none; +} +.wire-auth-form__options { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + font-size: 0.7rem; +} +.wire-auth-form__options a { + color: var(--wire-color-primary); + text-decoration: none; +} +.wire-auth-form__submit { + width: 100%; + justify-content: center; +} +.wire-auth-form__submit > .wire-btn { + width: 100%; + min-height: 3rem; + border-radius: 0.75rem; + box-shadow: 0 0.8rem 1.8rem color-mix(in srgb, var(--wire-btn-color) 20%, transparent); +} +@media (max-width: 800px) { + .wire-auth-split { + height: auto; + min-height: 100dvh; + overflow: visible; + grid-template-columns: 1fr; + } + .wire-auth-split__content { + height: auto; + min-height: auto; + overflow: visible; + padding-bottom: 2rem; + border-right: 0; + border-bottom: 1px solid var(--wire-color-border); + } + .wire-auth-split__message { + margin-block: 2rem 0; + } + .wire-auth-split__message h1 { + font-size: clamp(1.75rem, 8vw, 2.5rem); + } + .wire-auth-split__features, + .wire-auth-split__legal { + display: none; + } + .wire-auth-split__form { + height: auto; + min-height: auto; + overflow: visible; + padding-block: 2.5rem; + place-items: start center; + } +} .wire-next--field :where(input, textarea, select):focus-visible { border-color: var(--wire-color-primary); box-shadow: 0 0 0 3px color-mix(in srgb, var(--wire-color-primary) 16%, transparent); @@ -4953,7 +6451,7 @@ width: min(100%, 34rem); min-width: 0; color: var(--wire-color-text); - font-size: 0.875rem; + font-size: 0.8125rem; font-weight: 500; } @@ -4977,7 +6475,7 @@ .wire-next__field-heading label { color: var(--wire-color-text); - font-weight: 700; + font-weight: 600; } .wire-next__field-hint, @@ -4997,6 +6495,7 @@ border-color: var(--wire-color-border); border-radius: var(--wire-radius-sm); background: var(--wire-color-surface); + font-size: 0.8125rem; } .wire-next__field-control[data-icon-position="start"] > :is(input, textarea, select) { @@ -5172,6 +6671,8 @@ .wire-next__choice-field { display: grid; + width: 100%; + max-width: none; gap: 0.4rem; } @@ -5181,7 +6682,40 @@ align-items: center; gap: 0.65rem; color: var(--wire-color-text); - font-weight: 650; + width: 100%; + font-size: 0.75rem; + font-weight: 500; +} + +.wire-next__checkbox-content { + display: grid; + width: 100%; + min-width: 0; + align-items: start; + grid-template-columns: minmax(0, 1fr); +} + +.wire-next__checkbox-content > .wire-next__choice { + width: 100%; + min-width: 0; + align-items: flex-start; +} + +.wire-next__checkbox-slot { + display: block; + min-width: 0; + flex: 1 1 auto; + color: var(--wire-color-muted); + font-size: 0.75rem; + line-height: 1.45; +} + +.wire-next__checkbox-slot:empty { + display: none; +} + +.wire-next__checkbox-slot :is(a, button) { + color: var(--wire-field-color); } .wire-next__choice input { @@ -5773,8 +7307,10 @@ .wire-next--toggle-password { display: grid; - width: min(100%, 28rem); + width: 100%; min-width: 0; + max-width: none; + box-sizing: border-box; gap: 0.45rem; padding: 0; border: 0; @@ -5782,8 +7318,8 @@ .wire-next--toggle-password > label { color: var(--wire-color-text); - font-size: 0.82em; - font-weight: 750; + font-size: 0.75rem; + font-weight: 600; } .wire-next__password-fields { @@ -5807,6 +7343,7 @@ display: flex; min-width: 0; align-items: center; + width: 100%; } .wire-next__password-control > input { @@ -5820,6 +7357,7 @@ color: var(--wire-color-text); background: var(--wire-color-bg); font: inherit; + font-size: 0.8125rem; transition: border-color var(--wire-motion-base) var(--wire-ease-standard), box-shadow var(--wire-motion-base) var(--wire-ease-standard); diff --git a/.publish/uploader/package.json b/.publish/uploader/package.json index c36983b9..add50066 100644 --- a/.publish/uploader/package.json +++ b/.publish/uploader/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/uploader", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "description": "@wrnexus/uploader — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.5.1" + "@wrnexus/core": "^0.5.10" }, "files": [ "dist" diff --git a/.publish/validation/package.json b/.publish/validation/package.json index 4082e872..1ee8c4fb 100644 --- a/.publish/validation/package.json +++ b/.publish/validation/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/validation", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "description": "@wrnexus/validation — part of the WrNexus framework.", "license": "MIT", diff --git a/bun.lock b/bun.lock index eb1631b6..1f667062 100644 --- a/bun.lock +++ b/bun.lock @@ -83,11 +83,11 @@ }, "packages/ai": { "name": "@wrnexus/ai", - "version": "0.4.0", + "version": "0.5.10", }, "packages/auth": { "name": "@wrnexus/auth", - "version": "0.5.0-alpha.1", + "version": "0.5.10", "dependencies": { "@wrnexus/authz": "workspace:*", "@wrnexus/captcha": "workspace:*", @@ -98,6 +98,7 @@ "@wrnexus/oauth": "workspace:*", "@wrnexus/plugin": "workspace:*", "@wrnexus/queue": "workspace:*", + "@wrnexus/ui": "workspace:*", "@wrnexus/validation": "workspace:*", }, "devDependencies": { @@ -108,11 +109,11 @@ }, "packages/authz": { "name": "@wrnexus/authz", - "version": "0.4.0", + "version": "0.5.10", }, "packages/captcha": { "name": "@wrnexus/captcha", - "version": "0.4.0", + "version": "0.5.10", "dependencies": { "@wrnexus/core": "workspace:*", "@wrnexus/plugin": "workspace:*", @@ -126,7 +127,7 @@ }, "packages/cli": { "name": "@wrnexus/cli", - "version": "0.4.0", + "version": "0.5.10", "bin": { "wrnexus": "src/index.ts", }, @@ -147,29 +148,29 @@ }, "packages/compiler": { "name": "@wrnexus/compiler", - "version": "0.4.0", + "version": "0.5.10", "dependencies": { "@wrnexus/syntax": "workspace:*", }, }, "packages/core": { "name": "@wrnexus/core", - "version": "0.4.0", + "version": "0.5.10", }, "packages/csr": { "name": "@wrnexus/csr", - "version": "0.4.0", + "version": "0.5.10", "dependencies": { "@wrnexus/core": "workspace:*", }, }, "packages/db": { "name": "@wrnexus/db", - "version": "0.4.0", + "version": "0.5.10", }, "packages/dev-server": { "name": "@wrnexus/dev-server", - "version": "0.4.0", + "version": "0.5.10", "dependencies": { "@wrnexus/compiler": "workspace:*", "@wrnexus/core": "workspace:*", @@ -189,7 +190,7 @@ }, "packages/dev-toolbar": { "name": "@wrnexus/dev-toolbar", - "version": "0.4.0", + "version": "0.5.10", "devDependencies": { "@types/bun": "latest", "typescript": "^5.9.2", @@ -197,63 +198,63 @@ }, "packages/encryption": { "name": "@wrnexus/encryption", - "version": "0.4.0", + "version": "0.5.10", }, "packages/helpers": { "name": "@wrnexus/helpers", - "version": "0.4.0", + "version": "0.5.10", "dependencies": { "@wrnexus/core": "workspace:*", }, }, "packages/i18n": { "name": "@wrnexus/i18n", - "version": "0.4.0", + "version": "0.5.10", "dependencies": { "@wrnexus/core": "workspace:*", }, }, "packages/jwt": { "name": "@wrnexus/jwt", - "version": "0.4.0", + "version": "0.5.10", }, "packages/mobile": { "name": "@wrnexus/mobile", - "version": "0.4.0", + "version": "0.5.10", "dependencies": { "@wrnexus/native": "workspace:*", }, }, "packages/native": { "name": "@wrnexus/native", - "version": "0.4.0", + "version": "0.5.10", }, "packages/oauth": { "name": "@wrnexus/oauth", - "version": "0.4.0", + "version": "0.5.10", }, "packages/plugin": { "name": "@wrnexus/plugin", - "version": "0.4.0", + "version": "0.5.10", "dependencies": { "@wrnexus/syntax": "workspace:*", }, }, "packages/pubsub": { "name": "@wrnexus/pubsub", - "version": "0.4.0", + "version": "0.5.10", }, "packages/queue": { "name": "@wrnexus/queue", - "version": "0.4.0", + "version": "0.5.10", }, "packages/reactive": { "name": "@wrnexus/reactive", - "version": "0.4.0", + "version": "0.5.10", }, "packages/router": { "name": "@wrnexus/router", - "version": "0.4.0", + "version": "0.5.10", "dependencies": { "@wrnexus/compiler": "workspace:*", "@wrnexus/core": "workspace:*", @@ -261,14 +262,14 @@ }, "packages/ssr": { "name": "@wrnexus/ssr", - "version": "0.4.0", + "version": "0.5.10", "dependencies": { "@wrnexus/core": "workspace:*", }, }, "packages/styles": { "name": "@wrnexus/styles", - "version": "0.4.0", + "version": "0.5.10", "dependencies": { "@wrnexus/core": "workspace:*", "@wrnexus/plugin": "workspace:*", @@ -277,37 +278,37 @@ }, "packages/syntax": { "name": "@wrnexus/syntax", - "version": "0.4.0", + "version": "0.5.10", }, "packages/test": { "name": "@wrnexus/test", - "version": "0.4.0", + "version": "0.5.10", }, "packages/tracking": { "name": "@wrnexus/tracking", - "version": "0.4.0", + "version": "0.5.10", }, "packages/ui": { "name": "@wrnexus/ui", - "version": "0.4.0", + "version": "0.5.10", "dependencies": { "@wrnexus/core": "workspace:*", }, }, "packages/uploader": { "name": "@wrnexus/uploader", - "version": "0.4.0", + "version": "0.5.10", "dependencies": { "@wrnexus/core": "workspace:*", }, }, "packages/validation": { "name": "@wrnexus/validation", - "version": "0.4.0", + "version": "0.5.10", }, "services/managed-captcha": { "name": "@wrnexus/managed-captcha-service", - "version": "0.4.0", + "version": "0.5.0", "dependencies": { "@wrnexus/captcha": "workspace:*", }, diff --git a/editors/vscode/CHANGELOG.md b/editors/vscode/CHANGELOG.md index 12e48944..bcf77a29 100644 --- a/editors/vscode/CHANGELOG.md +++ b/editors/vscode/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.5.1 + +- Updated compiler-backed diagnostics for native array and object state values. +- Added support for unquoted component prop expressions such as `items={items}`, + inline arrays, and inline objects. +- Added readable, idempotent formatting for native JSON state values and direct + structured component props. +- Added extension release validation to prevent the bundled compiler from + drifting behind the framework parser. + ## 0.3.0 - Added language support for `runtime`, `hydrate`, `computed`, `effect`, `security`, diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 386c9e7e..c0d10eed 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -2,7 +2,7 @@ "name": "wrnexus", "displayName": "WRNexus Language Support", "description": "Complete language support for WRNexus .wrn files, including highlighting, formatting, diagnostics, snippets, lifecycle hooks, state watchers, component functions, completions, and definition navigation.", - "version": "0.5.0", + "version": "0.5.1", "publisher": "wrnexus", "private": true, "license": "SEE LICENSE IN LICENSE", diff --git a/editors/vscode/src/compiler.cjs b/editors/vscode/src/compiler.cjs index c0e0fda3..c187ed8a 100644 --- a/editors/vscode/src/compiler.cjs +++ b/editors/vscode/src/compiler.cjs @@ -3,6 +3,142 @@ const __nodeRequire = require; const __path = __nodeRequire("node:path"); const __modules = { +"packages/compiler/src/cache.ts": function (module, exports, require, __filename, __dirname) { +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.DependencyGraph = void 0; +exports.compilationKey = compilationKey; +exports.createCompilationCache = createCompilationCache; +const node_crypto_1 = require("node:crypto"); +const syntax_1 = require("@wrnexus/syntax"); +const codegen_ts_1 = require("./codegen.js"); +function compileSource(source, filePath) { + const richDiagnostics = (0, syntax_1.diagnose)(source, { file: filePath, accessibility: true }); + const errors = richDiagnostics.filter((diagnostic) => diagnostic.severity === "error"); + if (errors.length > 0) { + throw new syntax_1.ParseError(errors.map((diagnostic) => diagnostic.message).join("\n"), errors[0].code); + } + const ast = (0, syntax_1.parse)(source); + return { + code: `// compiled from .wrn\n${(0, codegen_ts_1.generate)(ast)}`, + ast, + diagnostics: richDiagnostics.map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`), + richDiagnostics, + }; +} +function compilationKey(source, file = "", salt = "") { + return (0, node_crypto_1.createHash)("sha256") + .update(file) + .update("\0") + .update(salt) + .update("\0") + .update(source) + .digest("hex"); +} +function createCompilationCache(options = {}) { + const maxEntries = options.maxEntries ?? 500; + if (!Number.isInteger(maxEntries) || maxEntries < 1) + throw new RangeError("maxEntries must be positive"); + const now = options.now ?? Date.now; + const entries = new Map(); + let hits = 0; + let misses = 0; + function touch(key, value) { + entries.delete(key); + entries.set(key, value); + while (entries.size > maxEntries) + entries.delete(entries.keys().next().value); + } + return { + compile(source, file = "", salt = "") { + const key = compilationKey(source, file, salt); + const existing = entries.get(key); + if (existing) { + hits++; + touch(key, existing); + return existing; + } + misses++; + const result = compileSource(source, file); + const entry = { + ...result, + key, + file, + sourceHash: (0, node_crypto_1.createHash)("sha256").update(source).digest("hex"), + createdAt: now(), + }; + touch(key, entry); + return entry; + }, + get(key) { + const entry = entries.get(key); + if (entry) + touch(key, entry); + return entry; + }, + invalidate(file) { + let removed = 0; + for (const [key, entry] of entries) { + if (!file || entry.file === file) { + entries.delete(key); + removed++; + } + } + return removed; + }, + clear() { + entries.clear(); + }, + size: () => entries.size, + stats: () => ({ hits, misses, entries: entries.size }), + }; +} +class DependencyGraph { + #dependencies = new Map(); + #dependents = new Map(); + set(file, dependencies) { + this.remove(file); + const values = new Set(dependencies); + this.#dependencies.set(file, values); + for (const dependency of values) { + const set = this.#dependents.get(dependency) ?? new Set(); + set.add(file); + this.#dependents.set(dependency, set); + } + } + remove(file) { + for (const dependency of this.#dependencies.get(file) ?? []) { + const set = this.#dependents.get(dependency); + set?.delete(file); + if (set?.size === 0) + this.#dependents.delete(dependency); + } + this.#dependencies.delete(file); + } + dependencies(file) { + return [...(this.#dependencies.get(file) ?? [])].sort(); + } + dependents(file) { + return [...(this.#dependents.get(file) ?? [])].sort(); + } + affected(file) { + const found = new Set(); + const queue = [file]; + while (queue.length) { + const current = queue.shift(); + for (const dependent of this.#dependents.get(current) ?? []) { + if (found.has(dependent)) + continue; + found.add(dependent); + queue.push(dependent); + } + } + return [...found].sort(); + } +} +exports.DependencyGraph = DependencyGraph; + +}, "packages/compiler/src/codegen.ts": function (module, exports, require, __filename, __dirname) { "use strict"; /** @@ -937,6 +1073,9 @@ function exprRefsState(expr, stateNames) { } return false; } +function exprRefsComponentReactiveValue(expr, ctx) { + return exprRefsState(expr, ctx.stateNames) || exprRefsState(expr, ctx.functionNames); +} function viewHasEvents(nodes) { return nodes.some((node) => { if (node.type === "text") @@ -999,7 +1138,7 @@ function compileText(raw, ctx) { // list renderer fills it per item; it has no server-side value. out += escLit(`{${expr}}`); } - else if (exprRefsState(expr, ctx.stateNames)) { + else if (exprRefsComponentReactiveValue(expr, ctx)) { // State interpolation: bake the initial value AND keep it reactive via a // data-text span, so no-JS clients see the real value and hydration // updates it in place. `count` → `0`. @@ -1156,6 +1295,7 @@ function renderComponentNode(node, ctx) { }); } } + const isExplicitComponentMount = node.attrs.some((attribute) => attribute.name === "data-component"); const attrs = node.attrs .filter((a) => a.name !== "class" && !a.name.startsWith("class:")) .map((a) => { @@ -1172,7 +1312,7 @@ function renderComponentNode(node, ctx) { if (isHtmlBooleanAttribute(a.name)) { const expression = wholeAttributeExpression(a.value); if (expression) { - const referencesState = exprRefsState(a.value, ctx.stateNames); + const referencesState = exprRefsComponentReactiveValue(a.value, ctx); const referencesLoopVariable = elementContext.loopVars ? exprRefsState(a.value, elementContext.loopVars) : false; @@ -1189,8 +1329,12 @@ function renderComponentNode(node, ctx) { if (a.value === "true" || a.value === "") return ` ${a.name}`; } - const rendered = ` ${a.name}="${compileAttrValue(a.value, elementContext)}"`; - const referencesState = exprRefsState(a.value, ctx.stateNames); + const wholeExpression = wholeAttributeExpression(a.value); + const compiledValue = isExplicitComponentMount && wholeExpression + ? `\${__wireProp(${elementContext.resolveExpr(wholeExpression)})}` + : compileAttrValue(a.value, elementContext); + const rendered = ` ${a.name}="${compiledValue}"`; + const referencesState = exprRefsComponentReactiveValue(a.value, ctx); const referencesLoopVariable = elementContext.loopVars ? exprRefsState(a.value, elementContext.loopVars) : false; @@ -1219,7 +1363,7 @@ function renderComponentNode(node, ctx) { }) .join(""); const staticClassValue = staticClasses.join(" "); - const classReferencesState = exprRefsState(staticClassValue, ctx.stateNames); + const classReferencesState = exprRefsComponentReactiveValue(staticClassValue, ctx); const classReferencesLoopVariable = elementContext.loopVars ? exprRefsState(staticClassValue, elementContext.loopVars) : false; @@ -1243,9 +1387,7 @@ function renderComponentNode(node, ctx) { const loopLocalsAttribute = serverLoopLocalsAttribute(ctx); const allAttrs = `${loopLocalsAttribute}` + `${ctx.forwardRestAttrs ? "${__wireSpreadAttrs(__attrs)}" : ""}` + - `${ctx.forwardRestAttrs && ctx.eventNames?.length - ? ` data-wrn-events="${attrEscape(ctx.eventNames.join(","))}"` - : ""}` + + `${ctx.eventNames?.length ? ` data-wrn-events="${attrEscape(ctx.eventNames.join(","))}"` : ""}` + `${classAttribute}` + `${classReactiveBinding}` + `${classBindings}` + @@ -1261,9 +1403,6 @@ function generateComponent(ast) { if (ast.imports.length > 0) out.push(ast.imports.join("\n")); const hasServerEach = viewHasServerEach(ast.view); - if (hasServerEach) { - out.push(`import { Buffer } from "node:buffer";`); - } const effectiveProps = ast.kind === "layout" && !ast.props.some((prop) => prop.name === "content") ? [ { @@ -1300,6 +1439,9 @@ function generateComponent(ast) { }; const ctx = { stateNames, + functionNames: new Set(ast.functions.flatMap((body) => { + return Array.from(body.matchAll(/(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/g), (match) => match[1]); + })), resolveExpr, eventNames: ast.events.map((event) => event.name), }; @@ -1330,6 +1472,9 @@ function generateComponent(ast) { ast.computed.length > 0 || viewHasEvents(ast.view) || behavior !== null); + if (hasServerEach || needsScope) { + out.push(`import { Buffer as __WrnexusBuffer } from "node:buffer";`); + } const scopeKeys = [ ...effectiveProps.map((prop) => prop.name), ...ast.states.map((state) => state.name), @@ -1354,16 +1499,16 @@ function generateComponent(ast) { const returnExpr = needsScope ? "`" + styleTag + - `
` + + `
` + viewCode + "
`" : "`" + styleTag + viewCode + "`"; const scopeLine = needsScope && scopeKeys.length > 0 - ? ` const __scope = __wrnexusScopeDecl({ ${scopeKeys + ? ` const __scopeState = { ${scopeKeys .map((key) => `${JSON.stringify(key)}: ${nameRefs.get(key)}`) - .join(", ")} });\n` + .join(", ")} };\n const __scope = __wrnexusScopeDecl(__scopeState);\n const __scopePayload = __WrnexusBuffer.from(JSON.stringify(__scopeState), "utf8").toString("base64");\n` : needsScope - ? ` const __scope = "";\n` + ? ` const __scopeState = {};\n const __scope = "";\n const __scopePayload = __WrnexusBuffer.from("{}", "utf8").toString("base64");\n` : ""; if (ast.kind === "layout") { out.push(`export const __wrnexusLayout = ${JSON.stringify(ast.name)};`); @@ -1553,7 +1698,7 @@ function __wireRaw(v: any): string { }`); if (hasServerEach) { out.push(`function __wrnexusEncodeLoopLocals(value: Record): string { - return Buffer.from(JSON.stringify(value), "utf8").toString("base64"); + return __WrnexusBuffer.from(JSON.stringify(value), "utf8").toString("base64"); }`); } if (needsScope) { @@ -1661,7 +1806,7 @@ function renderPageComponentAttr(attr, dynamicExpressions) { * `@wrnexus/syntax` package. This package owns platform-specific codegen. */ Object.defineProperty(exports, "__esModule", { value: true }); -exports.runtimeTypeOf = exports.inferredRuntimeType = exports.eraseFunctionTypes = exports.LexError = exports.Lexer = exports.NativeCompileError = exports.generateNative = exports.generate = exports.ParseError = exports.parse = exports.formatDiagnostic = exports.diagnosticFromError = exports.diagnose = exports.assertValidAst = void 0; +exports.DependencyGraph = exports.createCompilationCache = exports.compilationKey = exports.runtimeTypeOf = exports.inferredRuntimeType = exports.eraseFunctionTypes = exports.LexError = exports.Lexer = exports.NativeCompileError = exports.generateNative = exports.generate = exports.ParseError = exports.parse = exports.formatDiagnostic = exports.diagnosticFromError = exports.diagnose = exports.assertValidAst = void 0; exports.compileNativeWireFile = compileNativeWireFile; exports.compileWireFile = compileWireFile; exports.compile = compile; @@ -1725,6 +1870,10 @@ function compile(source, filePath = "") { richDiagnostics, }; } +var cache_ts_1 = require("./cache.js"); +Object.defineProperty(exports, "compilationKey", { enumerable: true, get: function () { return cache_ts_1.compilationKey; } }); +Object.defineProperty(exports, "createCompilationCache", { enumerable: true, get: function () { return cache_ts_1.createCompilationCache; } }); +Object.defineProperty(exports, "DependencyGraph", { enumerable: true, get: function () { return cache_ts_1.DependencyGraph; } }); }, "packages/compiler/src/native-codegen.ts": function (module, exports, require, __filename, __dirname) { @@ -2211,7 +2360,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) { for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.positionAt = exports.isRuntimeTarget = exports.isHydrationStrategy = exports.formatDiagnostic = exports.diagnosticFromError = exports.diagnose = exports.classifyParseError = exports.assertValidAst = exports.validateTypedInitializer = exports.runtimeTypeOf = exports.inferredRuntimeType = exports.eraseFunctionTypes = exports.VOID_ELEMENTS = exports.ParseError = exports.parseHtmlView = exports.parse = exports.LexError = exports.Lexer = void 0; +exports.supportsSyntaxFeature = exports.diagnosticSummary = exports.sliceSource = exports.createSourceRange = exports.WRN_SYNTAX_FEATURES = exports.WRN_SYNTAX_VERSION = exports.positionAt = exports.isRuntimeTarget = exports.isHydrationStrategy = exports.formatDiagnostic = exports.diagnosticFromError = exports.diagnose = exports.classifyParseError = exports.assertValidAst = exports.validateTypedInitializer = exports.runtimeTypeOf = exports.inferredRuntimeType = exports.eraseFunctionTypes = exports.VOID_ELEMENTS = exports.ParseError = exports.parseHtmlView = exports.parse = exports.LexError = exports.Lexer = void 0; var tokenizer_ts_1 = require("./tokenizer.js"); Object.defineProperty(exports, "Lexer", { enumerable: true, get: function () { return tokenizer_ts_1.Lexer; } }); Object.defineProperty(exports, "LexError", { enumerable: true, get: function () { return tokenizer_ts_1.LexError; } }); @@ -2235,6 +2384,13 @@ Object.defineProperty(exports, "isHydrationStrategy", { enumerable: true, get: f Object.defineProperty(exports, "isRuntimeTarget", { enumerable: true, get: function () { return diagnostics_ts_1.isRuntimeTarget; } }); Object.defineProperty(exports, "positionAt", { enumerable: true, get: function () { return diagnostics_ts_1.positionAt; } }); __exportStar(require("./spec.js"), exports); +var versioning_ts_1 = require("./versioning.js"); +Object.defineProperty(exports, "WRN_SYNTAX_VERSION", { enumerable: true, get: function () { return versioning_ts_1.WRN_SYNTAX_VERSION; } }); +Object.defineProperty(exports, "WRN_SYNTAX_FEATURES", { enumerable: true, get: function () { return versioning_ts_1.WRN_SYNTAX_FEATURES; } }); +Object.defineProperty(exports, "createSourceRange", { enumerable: true, get: function () { return versioning_ts_1.createSourceRange; } }); +Object.defineProperty(exports, "sliceSource", { enumerable: true, get: function () { return versioning_ts_1.sliceSource; } }); +Object.defineProperty(exports, "diagnosticSummary", { enumerable: true, get: function () { return versioning_ts_1.diagnosticSummary; } }); +Object.defineProperty(exports, "supportsSyntaxFeature", { enumerable: true, get: function () { return versioning_ts_1.supportsSyntaxFeature; } }); }, "packages/syntax/src/parser.ts": function (module, exports, require, __filename, __dirname) { @@ -2475,7 +2631,11 @@ function parse(source) { else { expect("eq"); } - states.push({ name: sName, valueType, expr: lx.readToLineEnd() }); + // State values may be multiline structured expressions. Use the same + // balanced initializer reader as props so formatted arrays/objects + // remain one declaration instead of exposing their inner braces as + // component members on the next line. + states.push({ name: sName, valueType, expr: lx.readPropInitializer() }); break; } case "computed": { @@ -2755,14 +2915,29 @@ function parseHtmlView(src, pos) { while (i < src.length && isWs(src[i])) i++; }; - /** Read a `{...}` interpolation (brace-balanced), braces included. */ + /** Read a `{...}` interpolation (brace-balanced and quote-aware), braces included. */ const readInterpolation = () => { const start = i; let depth = 0; + let quote = null; for (; i < src.length; i++) { - if (src[i] === "{") + const char = src[i]; + if (quote) { + if (char === "\\" && i + 1 < src.length) { + i++; + continue; + } + if (char === quote) + quote = null; + continue; + } + if (char === '"' || char === "'" || char === "`") { + quote = char; + continue; + } + if (char === "{") depth++; - else if (src[i] === "}" && --depth === 0) { + else if (char === "}" && --depth === 0) { i++; return src.slice(start, i); } @@ -2852,7 +3027,12 @@ function parseHtmlView(src, pos) { if (src[i] === "=") { i++; skipWs(); - attrs.push({ name, value: readQuoted(), event: false }); + const value = src[i] === '"' || src[i] === "'" + ? readQuoted() + : src[i] === "{" + ? readInterpolation() + : fail(`Expected a quoted value or {...} expression after '${name}='`); + attrs.push({ name, value, event: false }); } else { attrs.push({ name, value: "", event: false, boolean: true }); @@ -3447,6 +3627,54 @@ function eraseFunctionTypes(source) { }); } +}, +"packages/syntax/src/versioning.ts": function (module, exports, require, __filename, __dirname) { +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.WRN_SYNTAX_FEATURES = exports.WRN_SYNTAX_VERSION = void 0; +exports.createSourceRange = createSourceRange; +exports.sliceSource = sliceSource; +exports.diagnosticSummary = diagnosticSummary; +exports.supportsSyntaxFeature = supportsSyntaxFeature; +/** Current stable syntax contract. Bump only when parsers/codegen need migration. */ +exports.WRN_SYNTAX_VERSION = "0.4"; +exports.WRN_SYNTAX_FEATURES = Object.freeze({ + "typed-declarations": true, + layouts: true, + "server-client-blocks": true, + effects: true, + watch: true, + lifecycle: true, + "embedded-api": true, + realtime: true, + "runtime-markers": true, +}); +function createSourceRange(start, end) { + if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start) { + throw new RangeError(`Invalid source range ${start}..${end}`); + } + return { start, end }; +} +function sliceSource(source, range) { + return source.slice(range.start, range.end); +} +function diagnosticSummary(diagnostics) { + const summary = { errors: 0, warnings: 0, info: 0, codes: {} }; + for (const diagnostic of diagnostics) { + if (diagnostic.severity === "error") + summary.errors++; + else if (diagnostic.severity === "warning") + summary.warnings++; + else + summary.info++; + summary.codes[diagnostic.code] = (summary.codes[diagnostic.code] ?? 0) + 1; + } + return summary; +} +function supportsSyntaxFeature(feature) { + return Object.prototype.hasOwnProperty.call(exports.WRN_SYNTAX_FEATURES, feature); +} + } }; const __aliases = { diff --git a/editors/vscode/src/diagnostics.js b/editors/vscode/src/diagnostics.js index f3cebf43..2e53389a 100644 --- a/editors/vscode/src/diagnostics.js +++ b/editors/vscode/src/diagnostics.js @@ -1155,18 +1155,21 @@ function validateRequiredView(document, source, rootKind, rootMatch) { ]; } -function validateLayoutUsage(document, source, rootKind) { +function validateLayoutUsage(document, source, rootKind, rootMatch) { const diagnostics = []; - if (rootKind !== "page" && /^\s*layout\s*=/m.test(source)) { - const match = /^\s*layout\s*=/m.exec(source); + if (rootKind !== "page") { + const bodyRange = getRootBodyRange(source, rootMatch); + const layoutMember = findRootMembers(source, bodyRange.start, bodyRange.end).find( + (member) => member.name === "layout", + ); - if (match) { + if (layoutMember) { diagnostics.push( createDiagnostic( document, - match.index, - match.index + match[0].length, + layoutMember.start, + layoutMember.end, '`layout = "..."` is only valid inside a page.', vscode.DiagnosticSeverity.Error, "wrn-invalid-layout-member", @@ -1229,7 +1232,7 @@ function validateDocument(document) { diagnostics.push(...validateRequiredView(document, source, declaration.kind, declaration.match)); - diagnostics.push(...validateLayoutUsage(document, source, declaration.kind)); + diagnostics.push(...validateLayoutUsage(document, source, declaration.kind, declaration.match)); return diagnostics; } @@ -1325,6 +1328,7 @@ module.exports = { validateDocument, validateHtmlTags, validateLifecycleBlocks, + validateLayoutUsage, validateRootMembers, validateWatchBlocks, }; diff --git a/editors/vscode/src/formatter.js b/editors/vscode/src/formatter.js index 60acf5a3..9904b6e2 100644 --- a/editors/vscode/src/formatter.js +++ b/editors/vscode/src/formatter.js @@ -153,18 +153,95 @@ function findOpeningTagEnd(value) { function parseAttributes(value) { const attributes = []; + let index = 0; - const pattern = /[^\s"'=<>`]+(?:\s*=\s*(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s"'=<>`]+))?/g; + while (index < value.length) { + while (index < value.length && /\s/.test(value[index])) index += 1; + if (index >= value.length) break; - let match; + const start = index; + while (index < value.length && !/[\s=]/.test(value[index])) index += 1; + while (index < value.length && /\s/.test(value[index])) index += 1; - while ((match = pattern.exec(value)) !== null) { - attributes.push(match[0].trim()); + if (value[index] === "=") { + index += 1; + while (index < value.length && /\s/.test(value[index])) index += 1; + + const quote = value[index]; + if (quote === '"' || quote === "'") { + index += 1; + let escaped = false; + while (index < value.length) { + const character = value[index++]; + if (escaped) escaped = false; + else if (character === "\\") escaped = true; + else if (character === quote) break; + } + } else if (value[index] === "{") { + let depth = 0; + let expressionQuote = null; + let escaped = false; + while (index < value.length) { + const character = value[index++]; + if (expressionQuote !== null) { + if (escaped) escaped = false; + else if (character === "\\") escaped = true; + else if (character === expressionQuote) expressionQuote = null; + continue; + } + if (character === '"' || character === "'" || character === "`") { + expressionQuote = character; + } else if (character === "{") { + depth += 1; + } else if (character === "}" && --depth === 0) { + break; + } + } + } else { + while (index < value.length && !/\s/.test(value[index])) index += 1; + } + } + + const attribute = value.slice(start, index).trim(); + if (attribute) attributes.push(attribute); } return attributes; } +function parseStructuredAttribute(attribute) { + const match = /^([^\s=]+)\s*=\s*\{([\s\S]*)\}$/.exec(attribute); + if (!match) return null; + + const expression = match[2].trim(); + if (!expression.startsWith("[") && !expression.startsWith("{")) return null; + + try { + return { + name: match[1], + value: JSON.parse(expression), + }; + } catch { + return null; + } +} + +function formatAttribute(attribute, indentation, unit) { + const structured = parseStructuredAttribute(attribute); + if (!structured) return [`${indentation}${attribute}`]; + + const jsonLines = JSON.stringify(structured.value, null, unit).split("\n"); + if (jsonLines.length === 1) { + return [`${indentation}${structured.name}={${jsonLines[0]}}`]; + } + + return [ + `${indentation}${structured.name}={${jsonLines[0]}`, + ...jsonLines.slice(1, -1).map((line) => `${indentation}${line}`), + `${indentation}${jsonLines.at(-1)}}`, + ]; +} + function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } @@ -257,7 +334,7 @@ function formatOpeningTag(value, unit, depth, printWidth = 100, multilineAttribu const lines = [ `${baseIndent}<${parsed.tagName}`, - ...parsed.attributes.map((attribute) => `${childIndent}${attribute}`), + ...parsed.attributes.flatMap((attribute) => formatAttribute(attribute, childIndent, unit)), ]; if (parsed.selfClosing) { @@ -328,7 +405,7 @@ function countLeadingClosingBraces(value) { index += 1; } - if (value[index] !== "}") { + if (value[index] !== "}" && value[index] !== "]") { break; } @@ -399,6 +476,10 @@ function countStructuralBraces(value) { openings += 1; } else if (character === "}") { closings += 1; + } else if (character === "[") { + openings += 1; + } else if (character === "]") { + closings += 1; } } @@ -457,6 +538,27 @@ function expandInlineControlBlocks(lines) { }); } +function expandStructuredStateDeclarations(lines, unit) { + return lines.flatMap((line) => { + const match = /^(\s*state\s+[A-Za-z_$][\w$]*\s*=\s*)([\\[{][\s\S]*)$/.exec(line); + if (!match) return [line]; + + try { + const parsed = JSON.parse(match[2].trim()); + const jsonLines = JSON.stringify(parsed, null, unit).split("\n"); + if (jsonLines.length === 1) return [`${match[1]}${jsonLines[0]}`]; + + const leading = match[1].match(/^\s*/)?.[0] ?? ""; + return [ + `${match[1]}${jsonLines[0]}`, + ...jsonLines.slice(1).map((jsonLine) => `${leading}${jsonLine}`), + ]; + } catch { + return [line]; + } + }); +} + function formatWrn(source, options = {}) { const unit = options.insertSpaces === false ? "\t" : " ".repeat(options.tabSize ?? 4); @@ -469,7 +571,10 @@ function formatWrn(source, options = {}) { let controlDepth = 0; let index = 0; - const inputLines = expandInlineControlBlocks(source.replace(/\r\n/g, "\n").split("\n")); + const sourceLines = source.replace(/\r\n/g, "\n").split("\n"); + const inputLines = expandInlineControlBlocks( + expandStructuredStateDeclarations(sourceLines, unit), + ); const output = []; @@ -587,6 +692,7 @@ function formatWrn(source, options = {}) { module.exports = { countStructuralBraces, formatOpeningTag, + formatAttribute, formatWrn, parseAttributes, parseOpeningTag, diff --git a/editors/vscode/test/diagnostics.test.js b/editors/vscode/test/diagnostics.test.js index 641d0fc3..103b1953 100644 --- a/editors/vscode/test/diagnostics.test.js +++ b/editors/vscode/test/diagnostics.test.js @@ -31,6 +31,7 @@ const { maskLeadingTrivia, validateBalancedCharacters, validateHtmlTags, + validateLayoutUsage, validateRootMembers, } = require("../src/diagnostics"); Module._load = originalLoad; @@ -136,6 +137,41 @@ test("ignores member-like words inside component line and block comments", () => ); }); +test("allows a component prop named layout but rejects a root layout member", () => { + const component = `component Card { + props { + layout = "vertical" + } + view {
{layout}
} + }`; + const componentDeclaration = findTopLevelDeclaration({}, component); + + assert.deepEqual( + validateLayoutUsage( + mockDocument(component), + component, + componentDeclaration.kind, + componentDeclaration.match, + ), + [], + ); + + const invalid = `component Card { + layout = "dashboard" + view {
} + }`; + const invalidDeclaration = findTopLevelDeclaration({}, invalid); + const diagnostics = validateLayoutUsage( + mockDocument(invalid), + invalid, + invalidDeclaration.kind, + invalidDeclaration.match, + ); + + assert.equal(diagnostics.length, 1); + assert.equal(diagnostics[0].code, "wrn-invalid-layout-member"); +}); + test("ignores HTML-like tags inside WRN comments", () => { const source = `// The
below listens for child events. // is only documentation in this comment. diff --git a/editors/vscode/test/formatter.test.js b/editors/vscode/test/formatter.test.js index 4bb47c30..ac7e5651 100644 --- a/editors/vscode/test/formatter.test.js +++ b/editors/vscode/test/formatter.test.js @@ -20,6 +20,69 @@ test("preserves nested prop defaults while formatting", () => { assert.equal(formatWrn(formatted, { insertSpaces: true, tabSize: 2 }), formatted); }); +test("formats native JSON state values with readable indentation", () => { + const source = `component Footer { +state footerItems = [{"label":"Accessibility","href":"/accessibility","value":"accessibility"},{"label":"Privacy","href":"/privacy","value":"privacy"}] +view {
} +} +`; + const expected = `component Footer { + state footerItems = [ + { + "label": "Accessibility", + "href": "/accessibility", + "value": "accessibility" + }, + { + "label": "Privacy", + "href": "/privacy", + "value": "privacy" + } + ] + view {
} +} +`; + const options = { insertSpaces: true, tabSize: 2 }; + + assert.equal(formatWrn(source, options), expected); + assert.equal(formatWrn(expected, options), expected); +}); + +test("formats direct structured component props and preserves expression props", () => { + const source = `component Demo { + view { +
+ } +} +`; + const expected = `component Demo { + view { +
+ } +} +`; + const options = { insertSpaces: true, tabSize: 2, multilineAttributes: true }; + + assert.equal(formatWrn(source, options), expected); + assert.equal(formatWrn(expected, options), expected); +}); + test("formats public event declarations as individual props members", () => { const source = `component Search {\nprops { value = "" @event search = function @event clear = function }\nview { }\n}\n`; const formatted = formatWrn(source, { insertSpaces: true, tabSize: 2 }); diff --git a/editors/vscode/test/validate.mjs b/editors/vscode/test/validate.mjs index 7fc0f3d7..31bd855d 100644 --- a/editors/vscode/test/validate.mjs +++ b/editors/vscode/test/validate.mjs @@ -102,6 +102,25 @@ try { compiler.compileWireFile(good); ok("compiler accepts valid .wrn"); + const structuredProps = `page FooterExample { + state footerItems = [ + { + "label": "Accessibility", + "href": "/accessibility", + "value": "accessibility" + } + ] + view { +
+ } +}`; + compiler.compileWireFile(structuredProps); + ok("compiler accepts native structured state and unquoted prop expressions"); + const badSrc = `page Home {\n view {

Hi

}\n}`; let threw = false; try { diff --git a/package.json b/package.json index 6854251d..87e4ea03 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wrnexus", - "version": "0.5.1", + "version": "0.5.4", "private": true, "type": "module", "description": "An SSR-first full-stack web framework with server-rendered reactive components. Bun-first, Node-friendly.", @@ -25,7 +25,7 @@ "lint:fix": "eslint . --fix", "format": "prettier . --write", "format:check": "prettier . --check", - "check": "bun run typecheck \u0026\u0026 bun run lint \u0026\u0026 bun run test \u0026\u0026 bun run format:check", + "check": "bun run typecheck && bun run lint && bun run test && bun run format:check", "verify:0.3": "node scripts/verify-0.3.mjs", "verify:0.4": "node scripts/verify-0.4.mjs", "validate:0.4": "node scripts/validate-0.4.mjs", @@ -44,7 +44,7 @@ "typescript-eslint": "latest" }, "engines": { - "bun": "\u003e=1.3.0" + "bun": ">=1.3.0" }, "overrides": { "esbuild": "0.28.1" diff --git a/packages/ai/package.json b/packages/ai/package.json index 7b419b15..7bbfd91c 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ai", - "version": "0.5.1", + "version": "0.5.10", "private": true, "type": "module", "description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.", diff --git a/packages/auth/components/AuthenticatorSetup.wrn b/packages/auth/components/AuthenticatorSetup.wrn index 49088090..80d29cec 100644 --- a/packages/auth/components/AuthenticatorSetup.wrn +++ b/packages/auth/components/AuthenticatorSetup.wrn @@ -36,13 +36,9 @@ component AuthenticatorSetup { novalidate class='mt-5 space-y-4 {credentialId == "" ? "" : "hidden"}' > -
- - -

-
+ - + +
diff --git a/packages/auth/components/ForgotPassword.wrn b/packages/auth/components/ForgotPassword.wrn index ee91da6a..bc36e7a9 100644 --- a/packages/auth/components/ForgotPassword.wrn +++ b/packages/auth/components/ForgotPassword.wrn @@ -5,6 +5,7 @@ component ForgotPassword { title = "Recover your account" description = "Enter your email, phone, or username." identifierLabel = "Account identifier" + identifierPlaceholder = "Enter your registered email, mobile number, or username" submitLabel = "Send recovery link" successMessage = "If an account matches, a recovery link has been sent." signInHref = "/sign-in" @@ -17,14 +18,11 @@ component ForgotPassword {

{title}

{description}

-
- - - -
+ + - +
+ + + - + + + +
+ - + + + + - -
+
+ + } } diff --git a/packages/captcha/package.json b/packages/captcha/package.json index c832861e..d24d45b2 100644 --- a/packages/captcha/package.json +++ b/packages/captcha/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/captcha", - "version": "0.5.1", + "version": "0.5.10", "description": "First-class CAPTCHA challenges, providers, verification guards, page gates, and WRNexusJS UI.", "type": "module", "sideEffects": false, @@ -38,7 +38,7 @@ "scripts": { "test": "bun test", "typecheck": "tsc --noEmit", - "check": "bun run typecheck \u0026\u0026 bun run test" + "check": "bun run typecheck && bun run test" }, "dependencies": { "@wrnexus/core": "workspace:*", diff --git a/packages/captcha/src/engine.ts b/packages/captcha/src/engine.ts index ae295168..f196bb71 100644 --- a/packages/captcha/src/engine.ts +++ b/packages/captcha/src/engine.ts @@ -38,6 +38,13 @@ const DEFAULT_TOKEN_TTL_MS = 300_000; const DEFAULT_MAX_ATTEMPTS = 3; const DEFAULT_MIN_COMPLETION_MS = 800; const DEFAULT_RESPONSE_FIELD = "wrn-captcha-response"; +const RANDOM_CHALLENGE_TYPES: CaptchaChallengeType[] = [ + "number", + "alpha", + "alphanumeric", + "calculation", + "image", +]; function failure( action: string, @@ -150,13 +157,22 @@ export class DefaultCaptchaEngine implements CaptchaEngine { this.randomBytes = options.randomBytes ?? defaultRandomBytes; for (const generator of options.generators ?? defaultCaptchaGenerators()) this.generators.set(generator.type, generator); - if (!this.generators.has(this.defaultType)) + if (this.defaultType !== "random" && !this.generators.has(this.defaultType)) throw new Error(`No CAPTCHA generator registered for ${this.defaultType}`); } async create(options: CreateCaptchaOptions): Promise { const action = assertAction(options.action); - const requestedType = options.type ?? this.defaultType; + const configuredType = options.type ?? this.defaultType; + const randomInt = (min: number, max: number): number => + randomInteger(this.randomBytes, min, max); + const randomTypes = RANDOM_CHALLENGE_TYPES.filter((type) => this.generators.has(type)); + if (configuredType === "random" && randomTypes.length === 0) + throw new Error("No interactive CAPTCHA generators are registered"); + const requestedType = + configuredType === "random" + ? randomTypes[randomInt(0, randomTypes.length - 1)] + : configuredType; const requestedPresentation = options.presentation ?? (requestedType === "honeypot" || requestedType === "timing" || requestedType === "not-robot" @@ -169,8 +185,6 @@ export class DefaultCaptchaEngine implements CaptchaEngine { const now = this.now(); const difficulty = options.difficulty ?? this.defaultDifficulty; const disturbance = normalizeDisturbance(options.disturbance, difficulty); - const randomInt = (min: number, max: number): number => - randomInteger(this.randomBytes, min, max); const imageStyle = resolveCaptchaImageStyle({ imageStyle: options.imageStyle, allowedStyles: options.allowedStyles, diff --git a/packages/captcha/src/types.ts b/packages/captcha/src/types.ts index 65f24a54..a6f876e9 100644 --- a/packages/captcha/src/types.ts +++ b/packages/captcha/src/types.ts @@ -4,6 +4,7 @@ export type CaptchaProviderName = "self-hosted" | "wrnexus-managed" | "turnstile" | "recaptcha" | "hcaptcha" | (string & {}); export type CaptchaChallengeType = + | "random" | "number" | "alpha" | "alphanumeric" diff --git a/packages/captcha/test/client-runtime.test.ts b/packages/captcha/test/client-runtime.test.ts index 6dcb991b..68db3bcd 100644 --- a/packages/captcha/test/client-runtime.test.ts +++ b/packages/captcha/test/client-runtime.test.ts @@ -23,9 +23,14 @@ test("Captcha browser runtime is valid JavaScript and exposes the expected lifec expect(source).toContain("normalizeSize"); expect(source).toContain("verifyNotRobot"); expect(source).toContain("stopImmediatePropagation"); + expect(source).toContain("__wireSubmitGuards"); + expect(source).toContain("__wireValidateBound"); + expect(source).toContain("passesSubmitGuard"); expect(source).toContain('addEventListener("submit"'); expect(source).toContain('addEventListener("wire:error"'); expect(source).toContain("state.config.resetOnError || captchaFailed"); + expect(source).toContain('state.status !== "ready" && state.status !== "incorrect"'); + expect(source).toContain('state.status === "incorrect") setStatus(state, "ready", "")'); expect(source).toContain("Verified response tokens are single-use"); expect(source).toContain("MutationObserver"); expect(source).toContain("new CustomEvent(name"); diff --git a/packages/captcha/test/engine.test.ts b/packages/captcha/test/engine.test.ts index e84b23d2..9bf8ad7a 100644 --- a/packages/captcha/test/engine.test.ts +++ b/packages/captcha/test/engine.test.ts @@ -49,6 +49,22 @@ function fixture() { } describe("self-hosted CAPTCHA engine", () => { + test("random challenges resolve to an interactive challenge type", async () => { + const engine = createCaptchaEngine({ + secret: "captcha-test-secret-with-at-least-thirty-two-characters", + store: new MemoryCaptchaStore(), + defaultType: "random", + minCompletionMs: 0, + }); + const supported = new Set(["number", "alpha", "alphanumeric", "calculation", "image"]); + + for (let index = 0; index < 12; index += 1) { + const challenge = await engine.create({ action: "random-check" }); + expect(supported.has(challenge.type)).toBe(true); + expect(challenge.type).not.toBe("random"); + } + }); + test("creates, solves, and consumes a challenge and response token", async () => { const { engine } = fixture(); const challenge = await engine.create({ diff --git a/packages/cli/package.json b/packages/cli/package.json index 67c3ad0b..f1773608 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/cli", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/cli/src/build.ts b/packages/cli/src/build.ts index cc1c88d2..65e8c609 100644 --- a/packages/cli/src/build.ts +++ b/packages/cli/src/build.ts @@ -419,6 +419,7 @@ await createProductionServer( security: ${JSON.stringify(config.security ?? {})}, observability: ${JSON.stringify(config.observability ?? {})}, tenancy: ${JSON.stringify(config.tenancy ?? {})}, + navigation: ${JSON.stringify(config.navigation ?? {})}, }, ); `; diff --git a/packages/cli/src/create.ts b/packages/cli/src/create.ts index 3f49a12f..43b8bb13 100644 --- a/packages/cli/src/create.ts +++ b/packages/cli/src/create.ts @@ -263,16 +263,14 @@ const config: AppConfig = { }, }, - // Fonts — the framework emits optimized markup (preconnect, subsetted - // Google Fonts with font-display, self-hosted @font-face with preload) and - // auto-extends the CSP for Google Fonts. Uncomment to use a custom font: + // Fonts — optimized preconnect, subsetted weights, font-display, and CSP. + fonts: { + sans: '"Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif', + google: [{ family: "Plus Jakarta Sans", weights: [400, 500, 600, 700] }], + }, // - // fonts: { - // sans: '"Inter", ui-sans-serif, system-ui, sans-serif', - // google: [{ family: "Inter", weights: [400, 500, 600, 700] }], // // Or self-host (fastest, no third party) — drop files in public/fonts/: // // local: [{ family: "Inter", src: "/fonts/inter.woff2", weight: "100 900", preload: true }], - // }, // security: { // cors: { @@ -304,13 +302,7 @@ Allow: / @custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *)); body { - font-family: - ui-sans-serif, - system-ui, - -apple-system, - "Segoe UI", - Roboto, - sans-serif; + font-family: var(--wire-font-sans, "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif); } `, "app/layouts/document.wrn": `// Global document layout. The framework renders this once around the selected diff --git a/packages/cli/src/update.ts b/packages/cli/src/update.ts index 8f44dbb5..c8028c69 100644 --- a/packages/cli/src/update.ts +++ b/packages/cli/src/update.ts @@ -1103,6 +1103,32 @@ const MIGRATIONS: Migration[] = [ // Syntax and compiler support are package-managed; existing applications remain compatible. }, }, + { + version: "0.5.2", + id: "multiline-wrn-json-and-stable-gateway-runtime", + description: + "Supports formatted multiline array and object state initializers and keeps gateway children on the matching framework runtime.", + apply() { + // Syntax, compiler, and gateway fixes are package-managed; no application rewrite is required. + }, + }, + { + version: "0.5.3", + id: "public-workspace-origins-and-schema-backed-auth-forms", + description: + "Keeps cross-app redirects on configured public origins and enables schema-backed auth forms with active navigation states.", + apply() { + // Gateway, helper, validation, and UI improvements are package-managed. + }, + }, + { + version: "0.5.10", + id: "latest-release-after-upgrade", + description: "Latest New Packages and release ready packages", + apply() { + // Gateway, helper, validation, and UI improvements are package-managed. + }, + }, ]; /** Release tooling uses this to require an explicit migration entry per version. */ diff --git a/packages/cli/test/create.test.ts b/packages/cli/test/create.test.ts index 3d5c50ac..0b6f1889 100644 --- a/packages/cli/test/create.test.ts +++ b/packages/cli/test/create.test.ts @@ -69,9 +69,13 @@ test("scaffoldApp pins the current framework and exposes llms.txt publicly", () expect(pkg.devDependencies["@wrnexus/cli"]).toBe(version); expect(pkg.devDependencies["@iconify/tailwind4"]).toBe("^1.2.3"); expect(pkg.devDependencies["@iconify-json/lucide"]).toBe("^1.2.118"); - expect(readFileSync(join(root, "app", "styles", "global.css"), "utf8")).toContain( - '@plugin "@iconify/tailwind4";', - ); + const globalCss = readFileSync(join(root, "app", "styles", "global.css"), "utf8"); + expect(globalCss).toContain('@plugin "@iconify/tailwind4";'); + expect(globalCss).toContain('"Plus Jakarta Sans"'); + expect(globalCss).toContain("--wire-font-sans"); + const appConfig = readFileSync(join(root, "wrnexus.config.ts"), "utf8"); + expect(appConfig).toContain('family: "Plus Jakarta Sans"'); + expect(appConfig).toContain("weights: [400, 500, 600, 700]"); expect(existsSync(join(root, "public", "llms.txt"))).toBe(true); expect(existsSync(join(root, "llms.txt"))).toBe(false); expect(readFileSync(join(root, ".prettierignore"), "utf8")).toContain("CLAUDE.md"); diff --git a/packages/compiler/package.json b/packages/compiler/package.json index 06175d14..17a1624a 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/compiler", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/compiler/src/codegen.ts b/packages/compiler/src/codegen.ts index c3e61b53..466ce8a7 100644 --- a/packages/compiler/src/codegen.ts +++ b/packages/compiler/src/codegen.ts @@ -1437,6 +1437,10 @@ function renderComponentNode(node: ViewNode, ctx: CompCtx): string { } } + const isExplicitComponentMount = node.attrs.some( + (attribute) => attribute.name === "data-component", + ); + const attrs = node.attrs .filter((a) => a.name !== "class" && !a.name.startsWith("class:")) .map((a) => { @@ -1476,7 +1480,12 @@ function renderComponentNode(node: ViewNode, ctx: CompCtx): string { if (a.value === "true" || a.value === "") return ` ${a.name}`; } - const rendered = ` ${a.name}="${compileAttrValue(a.value, elementContext)}"`; + const wholeExpression = wholeAttributeExpression(a.value); + const compiledValue = + isExplicitComponentMount && wholeExpression + ? `\${__wireProp(${elementContext.resolveExpr(wholeExpression)})}` + : compileAttrValue(a.value, elementContext); + const rendered = ` ${a.name}="${compiledValue}"`; const referencesState = exprRefsComponentReactiveValue(a.value, ctx); diff --git a/packages/compiler/test/compiler.test.ts b/packages/compiler/test/compiler.test.ts index b37e307f..af06437c 100644 --- a/packages/compiler/test/compiler.test.ts +++ b/packages/compiler/test/compiler.test.ts @@ -98,6 +98,20 @@ test("component event declarations compile to public root metadata", async () => expect(render({})).toContain('data-wrn-events="complete"'); }); +test("explicit nested component mounts serialize structured expression props", () => { + const output = compileWireFile(`component Shell { + state brand = { "label": "WRNexus" } + state items = [{ "label": "Home", "href": "/" }] + view { +
+ } + }`); + + expect(output).toContain('data-component="Navbar"'); + expect(output).toContain('brand="${__wireProp(brand)}"'); + expect(output).toContain('items="${__wireProp(items)}"'); +}); + test("typed props, required props, state, custom types, and function parameters compile", () => { const source = `component TypedPicker { types { diff --git a/packages/core/package.json b/packages/core/package.json index cc3b7396..31b18de8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/core", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/csr/package.json b/packages/csr/package.json index ce3a6e54..97854dcf 100644 --- a/packages/csr/package.json +++ b/packages/csr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/csr", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/csr/src/reactive-runtime.ts b/packages/csr/src/reactive-runtime.ts index dabd54e3..3a98eb9a 100644 --- a/packages/csr/src/reactive-runtime.ts +++ b/packages/csr/src/reactive-runtime.ts @@ -2011,10 +2011,234 @@ export const REACTIVE_RUNTIME = String.raw` } ensureBehaviorObserver(); + hydrateNavbarControllers(host); + hydratePreferenceControllers(host); + hydrateSidebarControllers(host); + hydrateDropdownControllers(host); hydrateSelectControllers(host); hydratePinInputControllers(host); } + var navbarOutsideClickBound = false; + var preferenceOutsideClickBound = false; + var dropdownOutsideClickBound = false; + + function hydrateSidebarControllers(root) { + var host = root || document; + var sidebars = []; + if (host.nodeType === 1 && host.matches && host.matches(".wire-sidebar-shell")) { + sidebars.push(host); + } + if (host.querySelectorAll) { + Array.prototype.push.apply(sidebars, host.querySelectorAll(".wire-sidebar-shell")); + } + sidebars.forEach(function (sidebar) { + var current = window.location.pathname.replace(/\/+$/, "") || "/"; + var best = null; + var bestLength = -1; + sidebar.querySelectorAll(".wire-sidebar-items a[href]").forEach(function (link) { + var url; + try { url = new URL(link.getAttribute("href"), window.location.origin); } catch (_) { return; } + var path = url.pathname.replace(/\/+$/, "") || "/"; + var matches = path === current || (path !== "/" && current.indexOf(path + "/") === 0); + link.classList.remove("is-active"); + if (!matches || path.length <= bestLength) return; + best = link; + bestLength = path.length; + }); + sidebar.querySelectorAll(".wire-sidebar-items a[aria-current='page']").forEach(function (link) { + if (link !== best) link.removeAttribute("aria-current"); + }); + sidebar.querySelectorAll(".wire-sidebar-group.has-active").forEach(function (group) { + group.classList.remove("has-active"); + }); + if (best) { + best.classList.add("is-active"); + best.setAttribute("aria-current", "page"); + var group = best.closest(".wire-sidebar-group"); + if (group) { + group.setAttribute("open", ""); + group.classList.add("has-active"); + } + } + }); + } + + function hydrateDropdownControllers(root) { + var host = root || document; + var dropdowns = []; + if (host.nodeType === 1 && host.matches && host.matches("[data-wrn-dropdown]")) { + dropdowns.push(host); + } + if (host.querySelectorAll) { + Array.prototype.push.apply(dropdowns, host.querySelectorAll("[data-wrn-dropdown]")); + } + dropdowns.forEach(function (dropdown) { + if (dropdown.dataset.wrnDropdownBound === "true") return; + dropdown.dataset.wrnDropdownBound = "true"; + dropdown.addEventListener("toggle", function () { + if (!dropdown.open) return; + document.querySelectorAll("[data-wrn-dropdown][open]").forEach(function (candidate) { + if (candidate !== dropdown) candidate.removeAttribute("open"); + }); + }); + }); + if (!dropdownOutsideClickBound) { + dropdownOutsideClickBound = true; + document.addEventListener("pointerdown", function (event) { + document.querySelectorAll("[data-wrn-dropdown][open]").forEach(function (dropdown) { + if (!dropdown.contains(event.target)) dropdown.removeAttribute("open"); + }); + }); + document.addEventListener("keydown", function (event) { + if (event.key !== "Escape") return; + document.querySelectorAll("[data-wrn-dropdown][open]").forEach(function (dropdown) { + dropdown.removeAttribute("open"); + var summary = dropdown.querySelector(":scope > summary"); + if (summary) summary.focus(); + }); + }); + } + } + + function hydratePreferenceControllers(root) { + var host = root || document; + var switchers = []; + if (host.nodeType === 1 && host.matches && host.matches("[data-wrn-preferences]")) { + switchers.push(host); + } + if (host.querySelectorAll) { + Array.prototype.push.apply( + switchers, + host.querySelectorAll("[data-wrn-preferences]"), + ); + } + switchers.forEach(function (switcher) { + if (switcher.dataset.wrnPreferencesBound === "true") return; + switcher.dataset.wrnPreferencesBound = "true"; + switcher.addEventListener("toggle", function (event) { + var opened = event.target; + if (!opened || opened.tagName !== "DETAILS" || !opened.open) return; + switcher.querySelectorAll(".wire-preferences__menu[open]").forEach(function (menu) { + if (menu !== opened) menu.removeAttribute("open"); + }); + }, true); + }); + if (!preferenceOutsideClickBound) { + preferenceOutsideClickBound = true; + document.addEventListener("pointerdown", function (event) { + document.querySelectorAll("[data-wrn-preferences]").forEach(function (switcher) { + if (switcher.contains(event.target)) return; + switcher.querySelectorAll(".wire-preferences__menu[open]").forEach(function (menu) { + menu.removeAttribute("open"); + }); + }); + }); + document.addEventListener("keydown", function (event) { + if (event.key !== "Escape") return; + document.querySelectorAll("[data-wrn-preferences] .wire-preferences__menu[open]").forEach(function (menu) { + menu.removeAttribute("open"); + var summary = menu.querySelector(":scope > summary"); + if (summary) summary.focus(); + }); + }); + } + } + + function hydrateNavbarControllers(root) { + var host = root || document; + var navbars = []; + if (host.nodeType === 1 && host.matches && host.matches("[data-wrn-navbar]")) { + navbars.push(host); + } + if (host.querySelectorAll) { + Array.prototype.push.apply( + navbars, + host.querySelectorAll("[data-wrn-navbar]"), + ); + } + + navbars.forEach(function (navbar) { + var current = window.location.pathname.replace(/\/+$/, "") || "/"; + var best = null; + var bestLength = -1; + navbar.querySelectorAll(".wire-navbar__menus a[href]").forEach(function (link) { + var url; + try { url = new URL(link.getAttribute("href"), window.location.origin); } catch (_) { return; } + if (url.origin !== window.location.origin) return; + var path = url.pathname.replace(/\/+$/, "") || "/"; + var matches = path === current || (path !== "/" && current.indexOf(path + "/") === 0); + if (!matches || path.length <= bestLength) return; + best = link; + bestLength = path.length; + }); + navbar.querySelectorAll(".wire-navbar__menus [aria-current='page']").forEach(function (item) { + item.removeAttribute("aria-current"); + }); + if (best) { + best.setAttribute("aria-current", "page"); + var parentMenu = best.closest(".wire-navbar__dropdown"); + var parentSummary = parentMenu && parentMenu.querySelector(":scope > summary"); + if (parentSummary) parentSummary.setAttribute("aria-current", "page"); + } + + if (navbar.dataset.wrnNavbarBound === "true") return; + navbar.dataset.wrnNavbarBound = "true"; + var hoverEnabled = navbar.getAttribute("data-open-on-hover") === "true"; + var hoverCapable = + typeof window.matchMedia !== "function" || + window.matchMedia("(hover: hover) and (pointer: fine)").matches; + if (hoverEnabled && hoverCapable) { + navbar.querySelectorAll(".wire-navbar__dropdown").forEach(function (menu) { + menu.addEventListener("pointerenter", function () { + if (menu.__wrnNavbarCloseTimer) { + clearTimeout(menu.__wrnNavbarCloseTimer); + menu.__wrnNavbarCloseTimer = null; + } + navbar.querySelectorAll(".wire-navbar__dropdown[open]").forEach(function (candidate) { + if (candidate !== menu) candidate.removeAttribute("open"); + }); + menu.setAttribute("open", ""); + }); + menu.addEventListener("pointerleave", function () { + if (menu.__wrnNavbarCloseTimer) clearTimeout(menu.__wrnNavbarCloseTimer); + menu.__wrnNavbarCloseTimer = setTimeout(function () { + menu.removeAttribute("open"); + menu.__wrnNavbarCloseTimer = null; + }, 240); + }); + }); + } + navbar.addEventListener("toggle", function (event) { + var opened = event.target; + if (!opened || opened.tagName !== "DETAILS" || !opened.open) return; + navbar.querySelectorAll(".wire-navbar__dropdown[open]").forEach(function (menu) { + if (menu !== opened) menu.removeAttribute("open"); + }); + }, true); + }); + + if (!navbarOutsideClickBound) { + navbarOutsideClickBound = true; + document.addEventListener("pointerdown", function (event) { + document.querySelectorAll("[data-wrn-navbar]").forEach(function (navbar) { + if (navbar.contains(event.target)) return; + navbar.querySelectorAll(".wire-navbar__dropdown[open]").forEach(function (menu) { + menu.removeAttribute("open"); + }); + }); + }); + document.addEventListener("keydown", function (event) { + if (event.key !== "Escape") return; + document.querySelectorAll("[data-wrn-navbar] .wire-navbar__dropdown[open]").forEach(function (menu) { + menu.removeAttribute("open"); + var summary = menu.querySelector(":scope > summary"); + if (summary) summary.focus(); + }); + }); + } + } + var selectOutsideClickBound = false; function selectScopeApi(root) { diff --git a/packages/db/package.json b/packages/db/package.json index 9e45ed3f..11346ad4 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/db", - "version": "0.5.1", + "version": "0.5.10", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/db/src/adapters/bunsql.ts b/packages/db/src/adapters/bunsql.ts index 5687694e..f450aeec 100644 --- a/packages/db/src/adapters/bunsql.ts +++ b/packages/db/src/adapters/bunsql.ts @@ -23,14 +23,21 @@ interface ExecMeta { insertId?: number | bigint; } -function runnerFor(client: BunSqlClient): TxHandle { +/** Convert the framework's portable positional placeholders for PostgreSQL. */ +export function sqlForDialect(sql: string, dialect: Dialect): string { + if (dialect !== "postgres" || !sql.includes("?")) return sql; + let index = 0; + return sql.replace(/\?/g, () => `$${++index}`); +} + +function runnerFor(client: BunSqlClient, dialect: Dialect): TxHandle { return { async query(sql, params = []): Promise { - const rows = (await client.unsafe(sql, params)) as Iterable; + const rows = (await client.unsafe(sqlForDialect(sql, dialect), params)) as Iterable; return Array.from(rows); }, async exec(sql, params = []) { - const meta = (await client.unsafe(sql, params)) as ExecMeta; + const meta = (await client.unsafe(sqlForDialect(sql, dialect), params)) as ExecMeta; const id = meta.lastInsertRowid ?? meta.insertId; return { changes: Number(meta.affectedRows ?? meta.count ?? 0), @@ -44,13 +51,13 @@ function runnerFor(client: BunSqlClient): TxHandle { export function bunSql(url: string, dialect: Dialect): Driver { const Ctor = (Bun as unknown as { SQL: new (u: string) => BunSqlClient }).SQL; const client = new Ctor(url); - const runner = runnerFor(client); + const runner = runnerFor(client, dialect); return { dialect, query: runner.query, exec: runner.exec, transaction(fn) { - return client.begin((tx) => fn(runnerFor(tx))); + return client.begin((tx) => fn(runnerFor(tx, dialect))); }, close() { return client.close(); diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 8f502dd7..70daae19 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -12,7 +12,9 @@ import type { Db } from "./driver.ts"; const DEFAULT = "default"; -const registry = new Map(); +type DbFactory = () => Db; +type RegistryEntry = { db?: Db; factory?: DbFactory }; +const registry = new Map(); /** Set the default database (called by the runtime at startup). */ export function setDb(db: Db): Db; @@ -21,7 +23,7 @@ export function setDb(name: string, db: Db): Db; export function setDb(a: string | Db, b?: Db): Db { const name = typeof a === "string" ? a : DEFAULT; const db = typeof a === "string" ? b! : a; - registry.set(name, db); + registry.set(name, { db }); return db; } @@ -30,10 +32,18 @@ export function registerDb(name: string, db: Db): Db { return setDb(name, db); } +/** + * Register a named database without opening its connection pool. The first + * `getDb(name)` call creates and caches the connection. + */ +export function registerLazyDb(name: string, factory: DbFactory): void { + registry.set(name, { factory }); +} + /** The default database, or a named one. Throws if it isn't configured. */ export function getDb(name = DEFAULT): Db { - const db = registry.get(name); - if (!db) { + const entry = registry.get(name); + if (!entry) { throw new Error( name === DEFAULT ? "No database configured. Add `db: { driver, url }` to wrnexus.config.ts." @@ -41,7 +51,11 @@ export function getDb(name = DEFAULT): Db { `(e.g. databases: { ${name}: { driver, url } }).`, ); } - return db; + if (!entry.db) { + if (!entry.factory) throw new Error(`Database '${name}' has no connection factory.`); + entry.db = entry.factory(); + } + return entry.db; } /** Whether the default (or a named) database has been configured. */ @@ -56,6 +70,8 @@ export function databaseNames(): string[] { /** Close every configured database and clear the registry. */ export async function closeDatabases(): Promise { - for (const db of registry.values()) await db.close(); + for (const entry of registry.values()) { + if (entry.db) await entry.db.close(); + } registry.clear(); } diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index ce4a40bd..ab0ea444 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -10,7 +10,15 @@ export { v, table, Column } from "./schema.ts"; export type { Model, Columns, ColumnDef, BaseType } from "./schema.ts"; export { createDb } from "./driver.ts"; export type { Db, Driver, Row, ExecResult, TxHandle } from "./driver.ts"; -export { setDb, getDb, hasDb, registerDb, databaseNames, closeDatabases } from "./client.ts"; +export { + setDb, + getDb, + hasDb, + registerDb, + registerLazyDb, + databaseNames, + closeDatabases, +} from "./client.ts"; export { createTableSql } from "./sql.ts"; export type { Dialect } from "./sql.ts"; export { diff --git a/packages/db/src/migrate.ts b/packages/db/src/migrate.ts index 1dfa130c..5d6b40c2 100644 --- a/packages/db/src/migrate.ts +++ b/packages/db/src/migrate.ts @@ -65,6 +65,7 @@ export async function appliedMigrations(db: Db): Promise { /** Apply an ordered migration list (each in a transaction). Returns applied names. */ export async function applyMigrations(db: Db, migrations: readonly Migration[]): Promise { + if (migrations.length === 0) return []; const applied = new Set(await appliedMigrations(db)); const pending = migrations.filter((migration) => !applied.has(migration.name)); const done: string[] = []; diff --git a/packages/db/test/bunsql-placeholders.test.ts b/packages/db/test/bunsql-placeholders.test.ts new file mode 100644 index 00000000..c15df331 --- /dev/null +++ b/packages/db/test/bunsql-placeholders.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from "bun:test"; +import { sqlForDialect } from "../src/adapters/bunsql.ts"; + +test("PostgreSQL converts portable placeholders to numbered parameters", () => { + expect(sqlForDialect("SELECT * FROM users WHERE id = ? AND status = ?", "postgres")).toBe( + "SELECT * FROM users WHERE id = $1 AND status = $2", + ); +}); + +test("MySQL and parameter-free PostgreSQL statements remain unchanged", () => { + expect(sqlForDialect("SELECT * FROM users WHERE id = ?", "mysql")).toBe( + "SELECT * FROM users WHERE id = ?", + ); + expect(sqlForDialect("CREATE TABLE users (id TEXT)", "postgres")).toBe( + "CREATE TABLE users (id TEXT)", + ); +}); diff --git a/packages/db/test/db.test.ts b/packages/db/test/db.test.ts index ab640401..155b06a1 100644 --- a/packages/db/test/db.test.ts +++ b/packages/db/test/db.test.ts @@ -8,12 +8,14 @@ import { createDb, createTableSql, parseMigration, + applyMigrations, migrate, status, rollback, parseQueries, generateQueriesFile, } from "../src/index.ts"; +import type { Db } from "../src/index.ts"; import { sqlite } from "../src/adapters/sqlite.ts"; import { bunSql } from "../src/adapters/bunsql.ts"; @@ -95,6 +97,20 @@ test("migration runner: parse, migrate, status, rollback", async () => { db.close(); }); +test("an empty migration set does not touch the database", async () => { + const unreachable = async () => { + throw new Error("database should remain lazy"); + }; + const db = { + all: unreachable, + one: unreachable, + exec: unreachable, + tx: unreachable, + } as unknown as Db; + + expect(await applyMigrations(db, [])).toEqual([]); +}); + test("query generator infers params and result types", () => { const q = parseQueries( "-- name: GetByEmail :one\nSELECT * FROM users WHERE email = :email;\n" + diff --git a/packages/db/test/registry.test.ts b/packages/db/test/registry.test.ts index 6deafd8e..192d1223 100644 --- a/packages/db/test/registry.test.ts +++ b/packages/db/test/registry.test.ts @@ -5,6 +5,7 @@ import { getDb, hasDb, registerDb, + registerLazyDb, databaseNames, closeDatabases, } from "../src/index.ts"; @@ -45,3 +46,35 @@ test("getDb throws a helpful error for an unknown named database", async () => { expect(() => getDb("nope")).toThrow(/No database named 'nope'/); await closeDatabases(); }); + +test("named databases can be registered without opening a connection", async () => { + await closeDatabases(); + let calls = 0; + const analytics = createDb(sqlite(":memory:")); + + registerLazyDb("analytics", () => { + calls++; + return analytics; + }); + + expect(hasDb("analytics")).toBe(true); + expect(databaseNames()).toEqual(["analytics"]); + expect(calls).toBe(0); + expect(getDb("analytics")).toBe(analytics); + expect(getDb("analytics")).toBe(analytics); + expect(calls).toBe(1); + + await closeDatabases(); +}); + +test("closing the registry does not instantiate unused lazy databases", async () => { + await closeDatabases(); + let calls = 0; + registerLazyDb("unused", () => { + calls++; + return createDb(sqlite(":memory:")); + }); + + await closeDatabases(); + expect(calls).toBe(0); +}); diff --git a/packages/dev-server/package.json b/packages/dev-server/package.json index 0f18607d..2f673298 100644 --- a/packages/dev-server/package.json +++ b/packages/dev-server/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dev-server", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/dev-server/src/gateway.ts b/packages/dev-server/src/gateway.ts index 0d26864a..41c5e8a8 100644 --- a/packages/dev-server/src/gateway.ts +++ b/packages/dev-server/src/gateway.ts @@ -10,7 +10,8 @@ */ import { spawn, type ChildProcess } from "node:child_process"; -import { join, resolve } from "node:path"; +import { existsSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { RESTART_EXIT_CODE } from "./restart.ts"; @@ -150,11 +151,15 @@ export function stripInternalError(res: Response): Response { } /** Preserve an intentional verifier redirect while keeping other failures opaque. */ -export function forwardAuthFailure(res: Response, verifierUrl: string): Response { +export function forwardAuthFailure( + res: Response, + verifierUrl: string, + verifierPublicOrigin = verifierUrl, +): Response { const location = res.headers.get("location"); if (res.status >= 300 && res.status < 400 && location) { try { - const redirect = new URL(location, verifierUrl); + const redirect = new URL(location, verifierPublicOrigin); if (redirect.protocol === "http:" || redirect.protocol === "https:") { return new Response(null, { status: res.status, headers: { location: redirect.href } }); } @@ -197,6 +202,7 @@ async function checkAuth( req: Request, ip: string, internalOrigins: Readonly>, + publicOrigins: Readonly>, publicOrigin?: string, ): Promise { if (!auth) return null; @@ -226,6 +232,10 @@ async function checkAuth( if (auth.forward) { const verifyUrl = resolveForwardAuthUrl(auth.forward, internalOrigins); + const verifierPublicOrigin = + typeof auth.forward.app === "string" + ? publicOrigins[auth.forward.app] + : new URL(auth.forward.url).origin; try { const res = await fetch(verifyUrl, { @@ -240,7 +250,7 @@ async function checkAuth( `[wrnexus] forward-auth verifier error: ${req.method} ${requestUrl.pathname} via ${verifyUrl} returned ${res.status}${diagnostic ? ` — ${diagnostic}` : ""}`, ); } - return forwardAuthFailure(res, verifyUrl); + return forwardAuthFailure(res, verifyUrl, verifierPublicOrigin); } } catch (error) { console.error( @@ -351,11 +361,21 @@ export async function startGateway(opts: GatewayOptions): Promise [app.name, app.publicOrigin ?? `http://${app.domains[0]}:${port}`]), + const workspaceOrigins: Readonly> = Object.freeze( + Object.fromEntries( + opts.apps.map((app) => [app.name, app.publicOrigin ?? `http://${app.domains[0]}:${port}`]), + ), ); const displayHost = hostname === "0.0.0.0" || hostname === "::" ? "localhost" : hostname; - const serveEntry = fileURLToPath(import.meta.resolve("@wrnexus/dev-server/serve-entry")); + // When the CLI is executed directly from a framework checkout, keep child + // apps on that same source tree. Resolving the package name from an external + // workspace can otherwise select its older installed release on restart. + const sourceServeEntry = fileURLToPath(new URL("./serve-entry.ts", import.meta.url)); + const usesSourceServeEntry = existsSync(sourceServeEntry); + const serveEntry = usesSourceServeEntry + ? sourceServeEntry + : fileURLToPath(import.meta.resolve("@wrnexus/dev-server/serve-entry")); + const frameworkRoot = resolve(dirname(sourceServeEntry), "../../.."); let stopping = false; const targets: Target[] = opts.apps.map((app, i) => { @@ -397,6 +417,11 @@ export async function startGateway(opts: GatewayOptions): Promise { plugins: configuredPlugins, observability: opts.observability, tenancy: opts.tenancy, + navigation: opts.navigation, }; await pluginRunner.configure(pluginConfig); @@ -304,8 +314,14 @@ export async function startServer(opts: ServeOptions): Promise { } }; if (opts.db) await connectAndMigrate(null, opts.db); - for (const [name, cfg] of Object.entries(opts.databases ?? {})) - await connectAndMigrate(name, cfg); + for (const [name, cfg] of Object.entries(opts.databases ?? {})) { + const migrationDir = join(appDir, "db", name, "migrations"); + const hasMigrations = + loadMigrations(migrationDir).length > 0 || + resolvePackageMigrations(pluginContributions.migrations, name).length > 0; + if (hasMigrations) await connectAndMigrate(name, cfg); + else registerLazyDb(name, () => connectFromConfig(cfg, appRoot)); + } // File-upload storage: build a driver per configured store (local dir / S3). // Relative local dirs resolve against the app root; served/served-back below. @@ -358,6 +374,7 @@ export async function startServer(opts: ServeOptions): Promise { security: opts.security, observability: opts.observability, tenancy: opts.tenancy, + navigation: opts.navigation, clientRuntimes: pluginContributions.clientRuntimes, hub, realtimeBus: realtimeBusFromConfig(opts.realtime), diff --git a/packages/dev-server/src/prod.ts b/packages/dev-server/src/prod.ts index 46d07b31..f125f2ed 100644 --- a/packages/dev-server/src/prod.ts +++ b/packages/dev-server/src/prod.ts @@ -25,10 +25,11 @@ import { type PwaConfig, type ObservabilityConfig, type TenancyConfig, + type NavigationConfig, } from "@wrnexus/styles"; import { VALIDATE_RUNTIME } from "@wrnexus/validation"; import { I18N_RUNTIME, type ResolvedI18n } from "@wrnexus/i18n"; -import { setDb, registerDb, getDb, hasDb, migrate } from "@wrnexus/db"; +import { setDb, registerLazyDb, getDb, hasDb, migrate } from "@wrnexus/db"; import { connectFromConfig } from "@wrnexus/db/connect"; import { configureStorage, @@ -133,6 +134,8 @@ export interface ProdOptions { observability?: ObservabilityConfig; /** Built-in tenant identity resolution. */ tenancy?: TenancyConfig; + /** Page navigation strategy. */ + navigation?: NavigationConfig; port?: number; hostname?: string; maxBodyBytes?: number; @@ -287,14 +290,7 @@ export function createProductionHandlers( } } for (const [name, cfg] of Object.entries(opts.databases ?? {})) { - try { - registerDb(name, connectFromConfig(cfg)); - } catch (err) { - console.warn( - `[wrnexus] database '${name}' setup failed:`, - err instanceof Error ? err.message : err, - ); - } + registerLazyDb(name, () => connectFromConfig(cfg)); } // File-upload storage. Relative local dirs resolve against the deployment cwd @@ -334,6 +330,7 @@ export function createProductionHandlers( security: opts.security, observability: opts.observability, tenancy: opts.tenancy, + navigation: opts.navigation, maxBodyBytes: opts.maxBodyBytes, realtimeBus: realtimeBusFromConfig(opts.realtime), }); diff --git a/packages/dev-server/src/runtime.ts b/packages/dev-server/src/runtime.ts index 2ca8e943..c0bfdd2f 100644 --- a/packages/dev-server/src/runtime.ts +++ b/packages/dev-server/src/runtime.ts @@ -133,6 +133,8 @@ export interface RuntimeDeps { assetVersion?: string; /** Package browser runtimes resolved by the plugin system. */ clientRuntimes?: ClientRuntimeDefinition[]; + /** Page navigation strategy. `document` disables same-origin link interception. */ + navigation?: { mode?: "client" | "document" }; /** Raw HTML appended to every page head (e.g. CDN framework links). */ head?: string; /** Global SEO defaults. */ @@ -1140,9 +1142,9 @@ export function createHandlers(deps: RuntimeDeps): Handlers { selfClose === "/" ? { inner: "", end: openEnd } : readElementBody(body, tag!, openEnd); i = end; - const normalizedName = name.toLowerCase(); + const normalizedName = normalizeComponentName(name); const component = router.components.find( - (candidate) => candidate.name.toLowerCase() === normalizedName, + (candidate) => normalizeComponentName(candidate.name) === normalizedName, ); if (!component) { console.warn(`[wrnexus] no component registered for '${name}'`); @@ -1312,7 +1314,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers { if (deps.i18n) body = translateHtml(body, ctx.t); // Point 3: only ship the JS this page actually uses. - const scripts = collectScripts(body, deps.clientRuntimes).map((script) => + const scripts = collectScripts(body, deps.clientRuntimes, deps.navigation).map((script) => versionRenderScript(script, deps.assetVersion), ); if (pwaServiceWorkerEnabled) @@ -1669,6 +1671,17 @@ export function resolveTProps( return props; } +/** + * Normalize component identifiers for registry lookup. + * + * WRN declarations use PascalCase (`PublicHeader`) while explicit + * `data-component` mounts commonly use kebab-case (`public-header`) or + * snake_case (`public_header`). All three forms identify the same component. + */ +export function normalizeComponentName(name: string): string { + return name.toLowerCase().replace(/[-_]/g, ""); +} + /** * Decide which framework scripts a rendered page needs. Components are already * server-rendered into the HTML; the only script is the reactive runtime, and @@ -1677,10 +1690,11 @@ export function resolveTProps( export function collectScripts( body: string, clientRuntimes: readonly ClientRuntimeDefinition[] = [], + navigation: { mode?: "client" | "document" } = {}, ): RenderScript[] { - // Client-side navigation is an app-wide progressive enhancement: it must load - // on every page (you navigate *from* any page), and degrades to full loads. - const scripts: RenderScript[] = ["/__wrnexus/nav.js"]; + // Client navigation is optional. In document mode, links retain native browser + // behavior and each route receives a fresh server-rendered HTML document. + const scripts: RenderScript[] = navigation.mode === "document" ? [] : ["/__wrnexus/nav.js"]; if (/\bdata-scope=/.test(body) || /\bdata-wrnexus-csr=/.test(body)) { scripts.push("/__wrnexus/reactive.js"); } diff --git a/packages/dev-server/src/serve-entry.ts b/packages/dev-server/src/serve-entry.ts index d84a863f..61756ece 100644 --- a/packages/dev-server/src/serve-entry.ts +++ b/packages/dev-server/src/serve-entry.ts @@ -60,6 +60,7 @@ const server = await startServer({ plugins: config.plugins, observability: config.observability, tenancy: config.tenancy, + navigation: config.navigation, }); const router = server.router; diff --git a/packages/dev-server/test/component-name.test.ts b/packages/dev-server/test/component-name.test.ts new file mode 100644 index 00000000..ced6bf0f --- /dev/null +++ b/packages/dev-server/test/component-name.test.ts @@ -0,0 +1,13 @@ +import { expect, test } from "bun:test"; +import { normalizeComponentName } from "../src/runtime.ts"; + +test("component lookup treats PascalCase, kebab-case, and snake_case as equivalent", () => { + expect(normalizeComponentName("PublicHeader")).toBe("publicheader"); + expect(normalizeComponentName("public-header")).toBe("publicheader"); + expect(normalizeComponentName("public_header")).toBe("publicheader"); +}); + +test("component lookup preserves meaningful alphanumeric differences", () => { + expect(normalizeComponentName("Card2")).not.toBe(normalizeComponentName("Card")); + expect(normalizeComponentName("PublicFooter")).not.toBe(normalizeComponentName("PublicHeader")); +}); diff --git a/packages/dev-server/test/gateway.test.ts b/packages/dev-server/test/gateway.test.ts index 9a54ccd2..e553ef39 100644 --- a/packages/dev-server/test/gateway.test.ts +++ b/packages/dev-server/test/gateway.test.ts @@ -49,6 +49,19 @@ test("forward auth preserves intentional verifier redirects", () => { expect(denied.headers.has("location")).toBe(false); }); +test("forward auth exposes the verifier public origin instead of its internal app port", () => { + const redirected = forwardAuthFailure( + new Response(null, { + status: 302, + headers: { location: "/sign-in?returnTo=%2Fadmin" }, + }), + "http://127.0.0.1:85/api/verify", + "http://sso.localhost", + ); + + expect(redirected.headers.get("location")).toBe("http://sso.localhost/sign-in?returnTo=%2Fadmin"); +}); + test("gateway reads and strips internal app diagnostics", async () => { const response = new Response("safe public error", { status: 500, diff --git a/packages/dev-server/test/plugin-assets.test.ts b/packages/dev-server/test/plugin-assets.test.ts index 28bc23bb..e1af6969 100644 --- a/packages/dev-server/test/plugin-assets.test.ts +++ b/packages/dev-server/test/plugin-assets.test.ts @@ -65,3 +65,11 @@ test("collectScripts automatically adds a referenced package runtime once", () = ), ).toHaveLength(1); }); + +test("collectScripts omits client navigation in document mode", () => { + const scripts = collectScripts("
Server rendered
", [], { + mode: "document", + }); + + expect(scripts).not.toContain("/__wrnexus/nav.js"); +}); diff --git a/packages/dev-toolbar/package.json b/packages/dev-toolbar/package.json index 27bda4aa..fa3f610e 100644 --- a/packages/dev-toolbar/package.json +++ b/packages/dev-toolbar/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dev-toolbar", - "version": "0.5.1", + "version": "0.5.10", "private": true, "type": "module", "sideEffects": false, @@ -16,7 +16,7 @@ "scripts": { "test": "bun test", "typecheck": "tsc --noEmit", - "check": "bun run typecheck \u0026\u0026 bun run test" + "check": "bun run typecheck && bun run test" }, "devDependencies": { "@types/bun": "latest", diff --git a/packages/encryption/package.json b/packages/encryption/package.json index 8ca046f2..a9959297 100644 --- a/packages/encryption/package.json +++ b/packages/encryption/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/encryption", - "version": "0.5.1", + "version": "0.5.10", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/helpers/package.json b/packages/helpers/package.json index d8ffd825..e588a7bd 100644 --- a/packages/helpers/package.json +++ b/packages/helpers/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/helpers", - "version": "0.5.1", + "version": "0.5.10", "private": true, "type": "module", "description": "Safe convenience helpers for WrNexus request contexts and common application flows.", diff --git a/packages/helpers/src/index.ts b/packages/helpers/src/index.ts index e68c5403..0035e501 100644 --- a/packages/helpers/src/index.ts +++ b/packages/helpers/src/index.ts @@ -144,6 +144,7 @@ export { currentAppName, currentAppOrigin, workspaceAppOrigins, + workspaceRootDomain, } from "./workspace.ts"; export { backoffDelay, diff --git a/packages/helpers/src/workspace.ts b/packages/helpers/src/workspace.ts index ca55bb49..7ee6a7be 100644 --- a/packages/helpers/src/workspace.ts +++ b/packages/helpers/src/workspace.ts @@ -56,3 +56,26 @@ export function currentAppOrigin(): string | undefined { export function workspaceAppOrigins(): Readonly> { return Object.freeze({ ...workspaceOrigins() }); } + +/** Shared DNS suffix for configured workspace apps (for example `staging.example.com`). */ +export function workspaceRootDomain(): string { + const hosts = Object.values(workspaceOrigins()).map((origin) => + new URL(origin).hostname.toLowerCase(), + ); + if (!hosts.length) { + throw new Error("Workspace origins are unavailable."); + } + + const labels = hosts.map((host) => host.split(".").reverse()); + const common: string[] = []; + for (let index = 0; index < labels[0]!.length; index++) { + const label = labels[0]![index]; + if (!labels.every((parts) => parts[index] === label)) break; + common.push(label!); + } + + if (!common.length) { + throw new Error(`Workspace apps do not share a root domain: ${hosts.join(", ")}.`); + } + return common.reverse().join("."); +} diff --git a/packages/helpers/test/helpers.test.ts b/packages/helpers/test/helpers.test.ts index 559ebb27..01092e68 100644 --- a/packages/helpers/test/helpers.test.ts +++ b/packages/helpers/test/helpers.test.ts @@ -9,6 +9,22 @@ import { stableStringify, } from "../src/index.ts"; +test("derives the shared workspace root domain from configured public app origins", async () => { + const previous = process.env.WRNEXUS_WORKSPACE_ORIGINS; + process.env.WRNEXUS_WORKSPACE_ORIGINS = JSON.stringify({ + admin: "https://admin.staging.example.com", + citizen: "https://citizen.staging.example.com", + sso: "https://sso.staging.example.com", + }); + try { + const { workspaceRootDomain } = await import("../src/workspace.ts"); + expect(workspaceRootDomain()).toBe("staging.example.com"); + } finally { + if (previous === undefined) delete process.env.WRNEXUS_WORKSPACE_ORIGINS; + else process.env.WRNEXUS_WORKSPACE_ORIGINS = previous; + } +}); + function context(url: string, headers: HeadersInit = {}) { const parsed = new URL(url); return createContext(new Request(parsed, { headers }), parsed); diff --git a/packages/i18n/package.json b/packages/i18n/package.json index 5bda8d7e..ce80bd24 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/i18n", - "version": "0.5.1", + "version": "0.5.10", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts index b9697323..13c8cf6f 100644 --- a/packages/i18n/src/index.ts +++ b/packages/i18n/src/index.ts @@ -156,7 +156,14 @@ export const I18N_RUNTIME = String.raw` location.reload(); } function bind(root) { + var currentLang = (window.__wireI18n && window.__wireI18n.lang) || document.documentElement.lang; + (root || document).querySelectorAll("[data-wrn-preferences]").forEach(function (switcher) { + switcher.setAttribute("data-current-language", currentLang); + var currentLabel = switcher.querySelector(".wire-preferences__current-language"); + if (currentLabel) currentLabel.textContent = String(currentLang || "en").toUpperCase(); + }); (root || document).querySelectorAll("[data-wire-lang-set]").forEach(function (n) { + n.setAttribute("aria-pressed", String(n.getAttribute("data-wire-lang-set") === currentLang)); if (n.__wireLangBound) return; n.__wireLangBound = 1; n.addEventListener("click", function () { set(n.getAttribute("data-wire-lang-set")); }); }); diff --git a/packages/jwt/package.json b/packages/jwt/package.json index 3728953f..ce46633a 100644 --- a/packages/jwt/package.json +++ b/packages/jwt/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/jwt", - "version": "0.5.1", + "version": "0.5.10", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/mobile/package.json b/packages/mobile/package.json index dd371439..96fd6e0f 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/mobile", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/native/package.json b/packages/native/package.json index 82b90491..b2125ea6 100644 --- a/packages/native/package.json +++ b/packages/native/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/native", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/oauth/package.json b/packages/oauth/package.json index 2bb2ad58..5c743748 100644 --- a/packages/oauth/package.json +++ b/packages/oauth/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/oauth", - "version": "0.5.1", + "version": "0.5.10", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 3dc7a9ef..8590c483 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/plugin", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/pubsub/package.json b/packages/pubsub/package.json index b94e1a7c..1e5b3ca1 100644 --- a/packages/pubsub/package.json +++ b/packages/pubsub/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/pubsub", - "version": "0.5.1", + "version": "0.5.10", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/queue/package.json b/packages/queue/package.json index 01065038..4b4b0ae0 100644 --- a/packages/queue/package.json +++ b/packages/queue/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/queue", - "version": "0.5.1", + "version": "0.5.10", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/reactive/package.json b/packages/reactive/package.json index e2d72af3..1d7f3152 100644 --- a/packages/reactive/package.json +++ b/packages/reactive/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/reactive", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/router/package.json b/packages/router/package.json index 312ef4f5..ad35bab8 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/router", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/ssr/package.json b/packages/ssr/package.json index a02e73f5..3dc66eed 100644 --- a/packages/ssr/package.json +++ b/packages/ssr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ssr", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/styles/package.json b/packages/styles/package.json index 85588954..44898cfb 100644 --- a/packages/styles/package.json +++ b/packages/styles/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/styles", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/styles/src/config.ts b/packages/styles/src/config.ts index ab1caf17..052cfb90 100644 --- a/packages/styles/src/config.ts +++ b/packages/styles/src/config.ts @@ -182,6 +182,15 @@ export interface BuildConfig { adapter?: "bun" | "node" | "static" | "serverless" | "edge" | string; } +export interface NavigationConfig { + /** + * `client` progressively enhances same-origin links with in-place page swaps. + * `document` keeps normal browser navigation so every route performs a fresh + * server-rendered document request. + */ + mode?: "client" | "document"; +} + export interface AppConfig { /** Compiler/dev/build plugins, resolved in deterministic pre/normal/post order. */ plugins?: PluginInput; @@ -195,6 +204,8 @@ export interface AppConfig { tenancy?: TenancyConfig; /** Build cache, source map, report, and deployment adapter settings. */ build?: BuildConfig; + /** Page navigation strategy. Defaults to progressive client navigation. */ + navigation?: NavigationConfig; /** Development-only page diagnostics toolbar. Enabled by default in development. */ devToolbar?: boolean | DevToolbarConfig; /** Raw HTML appended to every page's `` (e.g. CDN stylesheet links). */ @@ -294,8 +305,12 @@ export async function loadRawConfig(appRoot: string): Promise { /** Load `wrnexus.config.*`, applying the active profile's overrides. */ export async function loadAppConfig(appRoot: string, profile?: string): Promise { - const base = await loadRawConfig(appRoot); const active = profile ?? resolveProfile(); + // Configuration modules commonly read DATABASE_URL and other settings during + // module evaluation. Load the profile cascade before importing the module so + // development and production behave consistently. + loadEnv(appRoot, active); + const base = await loadRawConfig(appRoot); const override = base.profiles?.[active]; const merged: AppConfig = override ? deepMerge(base, override) : { ...base }; delete merged.profiles; diff --git a/packages/styles/src/index.ts b/packages/styles/src/index.ts index 14a345eb..33e99dd5 100644 --- a/packages/styles/src/index.ts +++ b/packages/styles/src/index.ts @@ -15,6 +15,7 @@ export type { ExplainedConfig, ExperimentalConfig, MobileConfig, + NavigationConfig, ObservabilityConfig, PerformanceConfig, TenancyConfig, diff --git a/packages/styles/src/theme.ts b/packages/styles/src/theme.ts index a395e318..e688d0dd 100644 --- a/packages/styles/src/theme.ts +++ b/packages/styles/src/theme.ts @@ -243,18 +243,18 @@ export const DEFAULT_THEMES: Record = { "color-error": "#dc2626", radius: "8px", "radius-sm": "5px", - "font-sans": "system-ui, -apple-system, Segoe UI, Roboto, sans-serif", + "font-sans": '"Plus Jakarta Sans",ui-sans-serif,system-ui,sans-serif', "shadow-1": "0 1px 2px rgba(16,24,40,0.06), 0 1px 3px rgba(16,24,40,0.1)", }, dark: { "color-scheme": "dark", - "color-bg": "#0b1020", - "color-background": "#0b1020", - "color-foreground": "#e7ecff", - "color-surface-raised": "#1a223b", - "color-surface-muted": "#1c243f", - "color-text-muted": "#aab4d8", - "color-text-subtle": "#8995bd", + "color-bg": "#000000", + "color-background": "#000000", + "color-foreground": "#f5f5f5", + "color-surface-raised": "#111111", + "color-surface-muted": "#181818", + "color-text-muted": "#b3b3b3", + "color-text-subtle": "#8a8a8a", "color-border-strong": "#ffffff38", "color-primary-soft": "#6c8cff24", "color-secondary-soft": "#a78bfa24", @@ -262,10 +262,10 @@ export const DEFAULT_THEMES: Record = { "color-success-soft": "#4ade8024", "color-warning-soft": "#fbbf2424", "color-danger-soft": "#f8717124", - "color-code-background": "#060a15", - "color-code-surface": "#10172a", - "color-code-text": "#e7ecff", - "color-code-muted": "#9aa6d0", + "color-code-background": "#050505", + "color-code-surface": "#101010", + "color-code-text": "#f5f5f5", + "color-code-muted": "#a3a3a3", "color-code-border": "#ffffff24", "shadow-sm": "0 1px 2px rgba(0,0,0,0.28)", "shadow-md": "0 10px 30px rgba(0,0,0,0.35)", @@ -273,17 +273,17 @@ export const DEFAULT_THEMES: Record = { "space-section": "5rem", "space-section-sm": "3.5rem", "container-max": "80rem", - "color-surface": "#141a30", - "color-surface-2": "#1c243f", - "color-text": "#e7ecff", - "color-muted": "#9aa6d0", + "color-surface": "#0a0a0a", + "color-surface-2": "#171717", + "color-text": "#f5f5f5", + "color-muted": "#a3a3a3", "color-border": "#ffffff1f", "color-primary": "#6c8cff", "color-primary-hover": "#8aa2ff", - "color-primary-contrast": "#0b1020", + "color-primary-contrast": "#000000", "color-secondary": "#a78bfa", "color-secondary-hover": "#c4b5fd", - "color-secondary-contrast": "#0b1020", + "color-secondary-contrast": "#000000", "color-info": "#38bdf8", "color-danger": "#f87171", "color-success": "#4ade80", @@ -291,7 +291,7 @@ export const DEFAULT_THEMES: Record = { "color-error": "#f87171", radius: "10px", "radius-sm": "6px", - "font-sans": "system-ui, -apple-system, Segoe UI, Roboto, sans-serif", + "font-sans": '"Plus Jakarta Sans",ui-sans-serif,system-ui,sans-serif', "shadow-1": "0 1px 2px rgba(0,0,0,0.3), 0 4px 16px rgba(0,0,0,0.35)", }, }; @@ -338,7 +338,10 @@ export function renderThemeCss(theme: ResolvedTheme): string { for (const name of theme.names) { blocks.push(`[data-theme="${name}"]{${tokensToDeclarations(theme.themes[name]!)}}`); } - return blocks.join("\n") + "\n"; + return ( + blocks.join("\n") + + '\nhtml,body{font-family:var(--wire-font-sans,"Plus Jakarta Sans",ui-sans-serif,system-ui,sans-serif);}\n' + ); } /** @@ -351,14 +354,59 @@ export function renderThemeRuntime(theme: ResolvedTheme): string { return `(function(){ var COOKIE=${JSON.stringify(THEME_COOKIE)}; var THEMES=${names}; + var ACCENT_COOKIE="wire-accent"; + var COOKIE_MAX_AGE=31536000; + var ACCENTS={ + blue:["#2563eb","#1d4ed8","#ffffff"], + cyan:["#0891b2","#0e7490","#ffffff"], + emerald:["#059669","#047857","#ffffff"], + violet:["#7c3aed","#6d28d9","#ffffff"], + rose:["#e11d48","#be123c","#ffffff"], + amber:["#d97706","#b45309","#ffffff"] + }; var el=document.documentElement; + function readCookie(name){ + var prefix=encodeURIComponent(name)+"="; + var parts=document.cookie?document.cookie.split(";"):[]; + for(var i=0;i { } }); +test("loadAppConfig loads env before evaluating the config module", async () => { + const dir = mkdtempSync(join(tmpdir(), "wire-config-env-")); + writeFileSync(join(dir, ".env"), "DATABASE_URL=postgres://pms-from-env\n"); + writeFileSync( + join(dir, "wrnexus.config.mjs"), + `export default { + db: { driver: "postgres", url: process.env.DATABASE_URL }, + };`, + ); + + try { + delete process.env.DATABASE_URL; + const config = await loadAppConfig(dir, "development"); + expect(config.db?.url).toBe("postgres://pms-from-env"); + } finally { + delete process.env.DATABASE_URL; + rmSync(dir, { recursive: true, force: true }); + } +}); + test("package-aware styles add relative imports and Tailwind scan sources", async () => { const root = mkdtempSync(join(tmpdir(), "wrnexus-styles-")); try { diff --git a/packages/styles/test/theme.test.ts b/packages/styles/test/theme.test.ts index 48bca863..ed0b9fb3 100644 --- a/packages/styles/test/theme.test.ts +++ b/packages/styles/test/theme.test.ts @@ -54,6 +54,17 @@ test("resolveThemeConfig merges user tokens over built-in light/dark", () => { expect(t.themes.dark["color-bg"]).toBeDefined(); // built-in kept }); +test("built-in themes use Plus Jakarta Sans and neutral black dark surfaces", () => { + const t = resolveThemeConfig(); + expect(t.themes.light["font-sans"]).toBe( + '"Plus Jakarta Sans",ui-sans-serif,system-ui,sans-serif', + ); + expect(t.themes.dark["font-sans"]).toBe('"Plus Jakarta Sans",ui-sans-serif,system-ui,sans-serif'); + expect(t.themes.dark["color-bg"]).toBe("#000000"); + expect(t.themes.dark["color-surface"]).toBe("#0a0a0a"); + expect(t.themes.dark["color-surface-2"]).toBe("#171717"); +}); + test("resolveThemeName validates against configured names", () => { const t = resolveThemeConfig(); expect(resolveThemeName("light", t)).toBe("light"); @@ -71,6 +82,7 @@ test("renderThemeCss emits :root + per-theme blocks and --wire-* vars", () => { expect(css).toContain('[data-theme="dark"]{'); expect(css).toContain("--wire-color-primary:#6c8cff"); expect(css).toContain("color-scheme:dark"); // reserved token → native property + expect(css).toContain("font-family:var(--wire-font-sans"); }); test("renderThemeRuntime bakes the theme names for cycling", () => { diff --git a/packages/syntax/package.json b/packages/syntax/package.json index 2fd0b785..bc67aae1 100644 --- a/packages/syntax/package.json +++ b/packages/syntax/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/syntax", - "version": "0.5.1", + "version": "0.5.10", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/syntax/src/parser.ts b/packages/syntax/src/parser.ts index 4cdeb369..3ce71897 100644 --- a/packages/syntax/src/parser.ts +++ b/packages/syntax/src/parser.ts @@ -409,7 +409,11 @@ export function parse(source: string): PageAst { } else { expect("eq"); } - states.push({ name: sName, valueType, expr: lx.readToLineEnd() }); + // State values may be multiline structured expressions. Use the same + // balanced initializer reader as props so formatted arrays/objects + // remain one declaration instead of exposing their inner braces as + // component members on the next line. + states.push({ name: sName, valueType, expr: lx.readPropInitializer() }); break; } case "computed": { diff --git a/packages/syntax/test/syntax.test.ts b/packages/syntax/test/syntax.test.ts index 4f25bfaa..f798e6e8 100644 --- a/packages/syntax/test/syntax.test.ts +++ b/packages/syntax/test/syntax.test.ts @@ -30,6 +30,32 @@ component Navigation { expect(sidebar.attrs.find((attr) => attr.name === "options")?.value).toBe('{{"dense":true}}'); }); +test("parses formatted multiline arrays and objects in state", () => { + const ast = parse(`component Navigation { + state items = [ + { + "label": "Home", + "href": "/" + }, + { + "label": "Services", + "children": [ + { "label": "Reports", "href": "/reports" } + ] + } + ] + state options = { + "dense": true, + "columns": 4 + } + view { } + }`); + + expect(ast.states[0]?.expr).toContain('"Services"'); + expect(ast.states[0]?.expr).toContain('"Reports"'); + expect(ast.states[1]?.expr).toContain('"columns": 4'); +}); + test("parses WRN 0.3 execution, data, security, and reactivity blocks", () => { const ast = parse(`page Dashboard { runtime = "universal" diff --git a/packages/test/package.json b/packages/test/package.json index 3ba47518..87f9a816 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/test", - "version": "0.5.1", + "version": "0.5.10", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/tracking/package.json b/packages/tracking/package.json index 3dc056ef..6e629d60 100644 --- a/packages/tracking/package.json +++ b/packages/tracking/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/tracking", - "version": "0.5.1", + "version": "0.5.10", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/ui/COMPONENTS.md b/packages/ui/COMPONENTS.md index 5cfe2a91..c845e5c9 100644 --- a/packages/ui/COMPONENTS.md +++ b/packages/ui/COMPONENTS.md @@ -1,6 +1,6 @@ # WRNexus UI component reference -This reference is generated from the 85 packaged `.wrn` component sources. Props marked required have no default; all others show their runtime default. +This reference is generated from the 90 packaged `.wrn` component sources. Props marked required have no default; all others show their runtime default. ## Advanced-forms @@ -330,15 +330,35 @@ Theme-aware, responsive tree view component. - Slots: `default` - Events: `select`, `toggle`, `expand`, `collapse` +## Blocks + +### PortalDashboard + +Responsive portal dashboard with metrics, actions, tasks, and updates. + +- Mount: `data-component="PortalDashboard"` +- Props: `size: string = "default"`, `color: string = "primary"`, `eyebrow: string = "Overview"`, `eyebrowKey: string = ""`, `title: string = "Dashboard"`, `titleKey: string = ""`, `description: string = ""`, `descriptionKey: string = ""`, `userName: string = ""`, `metrics: string = []`, `actions: string = []`, `updates: string = []`, `tasks: string = []`, `class: string = ""` +- Slots: `hero-action` +- Events: `action`, `navigate` + ## Forms +### AuthForm + +Reusable authentication form for sign-in, registration, recovery, reset, and MFA. + +- Mount: `data-component="AuthForm"` +- Props: `size: string = "default"`, `color: string = "primary"`, `mode: string = "sign-in"`, `action: string = "/api/auth/login"`, `method: string = "post"`, `title: string = "Sign in"`, `description: string = ""`, `returnTo: string = ""`, `schema: string = ""`, `showRemember: boolean = true`, `showName: boolean = true`, `submitLabel: string = "Continue"`, `class: string = ""` +- Slots: `default` +- Events: `submit`, `change`, `input`, `focus`, `blur` + ### Checkbox Theme-aware, responsive checkbox component. - Mount: `data-component="Checkbox"` - Props: `size: string = "default"`, `color: string = "primary"`, `id: string = ""`, `name: string = ""`, `label: string = "Checkbox"`, `hiddenLabel: boolean = false`, `placeholder: string = ""`, `variant: string = "normal"`, `icon: string = ""`, `iconPosition: string = "start"`, `value: string = "on"`, `values: string = []`, `options: string = []`, `checked: boolean = false`, `indeterminate: boolean = false`, `orientation: string = "vertical"`, `card: boolean = false`, `rightAligned: boolean = false`, `list: boolean = false`, `helperText: string = ""`, `cornerHint: string = ""`, `error: string = ""`, `inline: boolean = false`, `readonly: boolean = false`, `disabled: boolean = false`, `required: boolean = false`, `class: string = ""` -- Slots: None +- Slots: `default` - Events: `input`, `change`, `focus`, `blur`, `invalid` ### ColorPicker @@ -543,6 +563,15 @@ Theme-aware, responsive wysiwyg editor component. ## Layout +### AuthSplitLayout + +Responsive 50/50 authentication layout with content and form regions. + +- Mount: `data-component="AuthSplitLayout"` +- Props: `size: string = "default"`, `color: string = "primary"`, `eyebrow: string = "Secure identity"`, `title: string = "Welcome back"`, `description: string = ""`, `brand: string = "Police Management System"`, `features: string = []`, `class: string = ""` +- Slots: `aside-extra`, `form` +- Events: None + ### Columns Theme-aware, responsive columns component. @@ -644,6 +673,15 @@ Theme-aware, responsive breadcrumb component. - Slots: `default` - Events: `navigate`, `click` +### Footer + +Responsive application footer with structured links and pre/post content slots. + +- Mount: `data-component="Footer"` +- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Footer navigation"`, `items: string = []`, `columns: number = 3`, `maxWidth: string = "compact"`, `copyright: string = ""`, `class: string = ""` +- Slots: `pre-footer`, `post-footer`, `copyright-left`, `copyright-right` +- Events: `select`, `action` + ### MegaMenu Theme-aware, responsive mega menu component. @@ -667,9 +705,9 @@ Theme-aware, responsive nav component. Theme-aware, responsive navbar component. - Mount: `data-component="Navbar"` -- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Navbar"`, `items: string = []`, `active: string = ""`, `orientation: string = "horizontal"`, `class: string = ""` -- Slots: `default` -- Events: `toggle`, `open`, `close` +- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Primary navigation"`, `topbarLabel: string = "Utility navigation"`, `brand: string = {}`, `items: string = []`, `actions: string = []`, `active: string = ""`, `sticky: boolean = false`, `openOnHover: boolean = false`, `maxWidth: string = "full"`, `mobileLabel: string = "Toggle navigation"`, `class: string = ""` +- Slots: `topbar`, `actions` +- Events: `toggle`, `open`, `close`, `select`, `action` ### Pagination @@ -680,6 +718,15 @@ Theme-aware, responsive pagination component. - Slots: `default` - Events: `change`, `previous`, `next` +### PreferenceSwitcher + +Accessible theme, accent color, and language preference controls. + +- Mount: `data-component="PreferenceSwitcher"` +- Props: `size: string = "default"`, `color: string = "primary"`, `themeLabel: string = "Theme"`, `colorLabel: string = "Accent color"`, `languageLabel: string = "Language"`, `languages: string = [`, `colors: string = [`, `compact: boolean = true`, `class: string = ""` +- Slots: None +- Events: `theme`, `color`, `language` + ### Scrollspy Theme-aware, responsive scrollspy component. @@ -694,9 +741,9 @@ Theme-aware, responsive scrollspy component. Theme-aware, responsive sidebar component. - Mount: `data-component="Sidebar"` -- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Sidebar"`, `items: string = []`, `active: string = ""`, `orientation: string = "horizontal"`, `class: string = ""` +- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Sidebar"`, `items: string = []`, `active: string = ""`, `orientation: string = "horizontal"`, `mobileLabel: string = "Open navigation"`, `class: string = ""` - Slots: `default` -- Events: `toggle`, `open`, `close` +- Events: `toggle`, `open`, `close`, `select` ### Stepper @@ -741,9 +788,9 @@ Theme-aware, responsive drawer component. Theme-aware, responsive dropdown component. - Mount: `data-component="Dropdown"` -- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Dropdown"`, `description: string = ""`, `open: boolean = false`, `placement: string = "bottom"`, `closeLabel: string = "Close"`, `class: string = ""` -- Slots: `default` -- Events: `open`, `close`, `select`, `change` +- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Open menu"`, `items: string = []`, `placement: string = "end"`, `class: string = ""` +- Slots: `trigger`, `header`, `footer` +- Events: `toggle`, `open`, `close`, `select` ### Modal diff --git a/packages/ui/component-catalog.json b/packages/ui/component-catalog.json index a0744d62..1b3bfeb3 100644 --- a/packages/ui/component-catalog.json +++ b/packages/ui/component-catalog.json @@ -191,6 +191,11 @@ "category": "navigation", "purpose": "Theme-aware, responsive navbar component." }, + { + "name": "Footer", + "category": "navigation", + "purpose": "Responsive application footer with structured links and pre/post content slots." + }, { "name": "MegaMenu", "category": "navigation", @@ -429,6 +434,26 @@ "name": "WysiwygEditor", "category": "integrations", "purpose": "Theme-aware, responsive wysiwyg editor component." + }, + { + "name": "PreferenceSwitcher", + "category": "navigation", + "purpose": "Accessible theme, accent color, and language preference controls." + }, + { + "name": "PortalDashboard", + "category": "blocks", + "purpose": "Responsive portal dashboard with metrics, actions, tasks, and updates." + }, + { + "name": "AuthSplitLayout", + "category": "layout", + "purpose": "Responsive 50/50 authentication layout with content and form regions." + }, + { + "name": "AuthForm", + "category": "forms", + "purpose": "Reusable authentication form for sign-in, registration, recovery, reset, and MFA." } ] } diff --git a/packages/ui/component-reference.json b/packages/ui/component-reference.json index 32d524f1..5d977840 100644 --- a/packages/ui/component-reference.json +++ b/packages/ui/component-reference.json @@ -1,6 +1,6 @@ { "generatedFrom": "packages/ui/components/*.wrn", - "count": 85, + "count": 90, "components": [ { "name": "Accordion", @@ -675,6 +675,154 @@ "events": ["dismiss", "action"], "source": "components/alert.wrn" }, + { + "name": "AuthForm", + "mount": "AuthForm", + "category": "forms", + "purpose": "Reusable authentication form for sign-in, registration, recovery, reset, and MFA.", + "props": [ + { + "name": "size", + "type": "string", + "required": false, + "default": "\"default\"" + }, + { + "name": "color", + "type": "string", + "required": false, + "default": "\"primary\"" + }, + { + "name": "mode", + "type": "string", + "required": false, + "default": "\"sign-in\"" + }, + { + "name": "action", + "type": "string", + "required": false, + "default": "\"/api/auth/login\"" + }, + { + "name": "method", + "type": "string", + "required": false, + "default": "\"post\"" + }, + { + "name": "title", + "type": "string", + "required": false, + "default": "\"Sign in\"" + }, + { + "name": "description", + "type": "string", + "required": false, + "default": "\"\"" + }, + { + "name": "returnTo", + "type": "string", + "required": false, + "default": "\"\"" + }, + { + "name": "schema", + "type": "string", + "required": false, + "default": "\"\"" + }, + { + "name": "showRemember", + "type": "boolean", + "required": false, + "default": "true" + }, + { + "name": "showName", + "type": "boolean", + "required": false, + "default": "true" + }, + { + "name": "submitLabel", + "type": "string", + "required": false, + "default": "\"Continue\"" + }, + { + "name": "class", + "type": "string", + "required": false, + "default": "\"\"" + } + ], + "slots": ["default"], + "events": ["submit", "change", "input", "focus", "blur"], + "source": "components/AuthForm.wrn" + }, + { + "name": "AuthSplitLayout", + "mount": "AuthSplitLayout", + "category": "layout", + "purpose": "Responsive 50/50 authentication layout with content and form regions.", + "props": [ + { + "name": "size", + "type": "string", + "required": false, + "default": "\"default\"" + }, + { + "name": "color", + "type": "string", + "required": false, + "default": "\"primary\"" + }, + { + "name": "eyebrow", + "type": "string", + "required": false, + "default": "\"Secure identity\"" + }, + { + "name": "title", + "type": "string", + "required": false, + "default": "\"Welcome back\"" + }, + { + "name": "description", + "type": "string", + "required": false, + "default": "\"\"" + }, + { + "name": "brand", + "type": "string", + "required": false, + "default": "\"Police Management System\"" + }, + { + "name": "features", + "type": "string", + "required": false, + "default": "[]" + }, + { + "name": "class", + "type": "string", + "required": false, + "default": "\"\"" + } + ], + "slots": ["aside-extra", "form"], + "events": [], + "source": "components/AuthSplitLayout.wrn" + }, { "name": "Avatar", "mount": "Avatar", @@ -2062,7 +2210,7 @@ "default": "\"\"" } ], - "slots": [], + "slots": ["default"], "events": ["input", "change", "focus", "blur", "invalid"], "source": "components/Checkbox.wrn" }, @@ -3436,34 +3584,22 @@ "default": "\"primary\"" }, { - "name": "title", + "name": "label", "type": "string", "required": false, - "default": "\"Dropdown\"" + "default": "\"Open menu\"" }, { - "name": "description", + "name": "items", "type": "string", "required": false, - "default": "\"\"" - }, - { - "name": "open", - "type": "boolean", - "required": false, - "default": "false" + "default": "[]" }, { "name": "placement", "type": "string", "required": false, - "default": "\"bottom\"" - }, - { - "name": "closeLabel", - "type": "string", - "required": false, - "default": "\"Close\"" + "default": "\"end\"" }, { "name": "class", @@ -3472,8 +3608,8 @@ "default": "\"\"" } ], - "slots": ["default"], - "events": ["open", "close", "select", "change"], + "slots": ["trigger", "header", "footer"], + "events": ["toggle", "open", "close", "select"], "source": "components/Dropdown.wrn" }, { @@ -3755,6 +3891,65 @@ "events": ["cancel", "retry", "complete"], "source": "components/FileUploadProgress.wrn" }, + { + "name": "Footer", + "mount": "Footer", + "category": "navigation", + "purpose": "Responsive application footer with structured links and pre/post content slots.", + "props": [ + { + "name": "size", + "type": "string", + "required": false, + "default": "\"default\"" + }, + { + "name": "color", + "type": "string", + "required": false, + "default": "\"primary\"" + }, + { + "name": "label", + "type": "string", + "required": false, + "default": "\"Footer navigation\"" + }, + { + "name": "items", + "type": "string", + "required": false, + "default": "[]" + }, + { + "name": "columns", + "type": "number", + "required": false, + "default": "3" + }, + { + "name": "maxWidth", + "type": "string", + "required": false, + "default": "\"compact\"" + }, + { + "name": "copyright", + "type": "string", + "required": false, + "default": "\"\"" + }, + { + "name": "class", + "type": "string", + "required": false, + "default": "\"\"" + } + ], + "slots": ["pre-footer", "post-footer", "copyright-left", "copyright-right"], + "events": ["select", "action"], + "source": "components/Footer.wrn" + }, { "name": "Grid", "mount": "Grid", @@ -5039,7 +5234,19 @@ "name": "label", "type": "string", "required": false, - "default": "\"Navbar\"" + "default": "\"Primary navigation\"" + }, + { + "name": "topbarLabel", + "type": "string", + "required": false, + "default": "\"Utility navigation\"" + }, + { + "name": "brand", + "type": "string", + "required": false, + "default": "{}" }, { "name": "items", @@ -5047,6 +5254,12 @@ "required": false, "default": "[]" }, + { + "name": "actions", + "type": "string", + "required": false, + "default": "[]" + }, { "name": "active", "type": "string", @@ -5054,10 +5267,28 @@ "default": "\"\"" }, { - "name": "orientation", + "name": "sticky", + "type": "boolean", + "required": false, + "default": "false" + }, + { + "name": "openOnHover", + "type": "boolean", + "required": false, + "default": "false" + }, + { + "name": "maxWidth", "type": "string", "required": false, - "default": "\"horizontal\"" + "default": "\"full\"" + }, + { + "name": "mobileLabel", + "type": "string", + "required": false, + "default": "\"Toggle navigation\"" }, { "name": "class", @@ -5066,8 +5297,8 @@ "default": "\"\"" } ], - "slots": ["default"], - "events": ["toggle", "open", "close"], + "slots": ["topbar", "actions"], + "events": ["toggle", "open", "close", "select", "action"], "source": "components/Navbar.wrn" }, { @@ -5349,6 +5580,166 @@ "events": ["open", "close"], "source": "components/Popover.wrn" }, + { + "name": "PortalDashboard", + "mount": "PortalDashboard", + "category": "blocks", + "purpose": "Responsive portal dashboard with metrics, actions, tasks, and updates.", + "props": [ + { + "name": "size", + "type": "string", + "required": false, + "default": "\"default\"" + }, + { + "name": "color", + "type": "string", + "required": false, + "default": "\"primary\"" + }, + { + "name": "eyebrow", + "type": "string", + "required": false, + "default": "\"Overview\"" + }, + { + "name": "eyebrowKey", + "type": "string", + "required": false, + "default": "\"\"" + }, + { + "name": "title", + "type": "string", + "required": false, + "default": "\"Dashboard\"" + }, + { + "name": "titleKey", + "type": "string", + "required": false, + "default": "\"\"" + }, + { + "name": "description", + "type": "string", + "required": false, + "default": "\"\"" + }, + { + "name": "descriptionKey", + "type": "string", + "required": false, + "default": "\"\"" + }, + { + "name": "userName", + "type": "string", + "required": false, + "default": "\"\"" + }, + { + "name": "metrics", + "type": "string", + "required": false, + "default": "[]" + }, + { + "name": "actions", + "type": "string", + "required": false, + "default": "[]" + }, + { + "name": "updates", + "type": "string", + "required": false, + "default": "[]" + }, + { + "name": "tasks", + "type": "string", + "required": false, + "default": "[]" + }, + { + "name": "class", + "type": "string", + "required": false, + "default": "\"\"" + } + ], + "slots": ["hero-action"], + "events": ["action", "navigate"], + "source": "components/PortalDashboard.wrn" + }, + { + "name": "PreferenceSwitcher", + "mount": "PreferenceSwitcher", + "category": "navigation", + "purpose": "Accessible theme, accent color, and language preference controls.", + "props": [ + { + "name": "size", + "type": "string", + "required": false, + "default": "\"default\"" + }, + { + "name": "color", + "type": "string", + "required": false, + "default": "\"primary\"" + }, + { + "name": "themeLabel", + "type": "string", + "required": false, + "default": "\"Theme\"" + }, + { + "name": "colorLabel", + "type": "string", + "required": false, + "default": "\"Accent color\"" + }, + { + "name": "languageLabel", + "type": "string", + "required": false, + "default": "\"Language\"" + }, + { + "name": "languages", + "type": "string", + "required": false, + "default": "[" + }, + { + "name": "colors", + "type": "string", + "required": false, + "default": "[" + }, + { + "name": "compact", + "type": "boolean", + "required": false, + "default": "true" + }, + { + "name": "class", + "type": "string", + "required": false, + "default": "\"\"" + } + ], + "slots": [], + "events": ["theme", "color", "language"], + "source": "components/PreferenceSwitcher.wrn" + }, { "name": "Progress", "mount": "Progress", @@ -6110,6 +6501,12 @@ "required": false, "default": "\"horizontal\"" }, + { + "name": "mobileLabel", + "type": "string", + "required": false, + "default": "\"Open navigation\"" + }, { "name": "class", "type": "string", @@ -6118,7 +6515,7 @@ } ], "slots": ["default"], - "events": ["toggle", "open", "close"], + "events": ["toggle", "open", "close", "select"], "source": "components/Sidebar.wrn" }, { diff --git a/packages/ui/components/AuthForm.wrn b/packages/ui/components/AuthForm.wrn new file mode 100644 index 00000000..489e0fb4 --- /dev/null +++ b/packages/ui/components/AuthForm.wrn @@ -0,0 +1,84 @@ +component AuthForm { + props { + @event submit = function + @event change = function + @event input = function + @event focus = function + @event blur = function + size = "default" + color = "primary" + mode = "sign-in" + action = "/api/auth/login" + method = "post" + title = "Sign in" + description = "" + returnTo = "" + schema = "" + showRemember = true + showName = true + submitLabel = "Continue" + class = "" + } + + functions { + function schemaName() { + if (schema) return schema + if (mode === "sign-in") return "auth-login" + if (mode === "register") return "auth-register" + if (mode === "recover") return "auth-password-request" + if (mode === "reset") return "auth-password-reset" + if (mode === "mfa") return "auth-mfa" + return "auth-empty" + } + + function submitForm(event) { + $emit("submit", { event: event, mode: mode, action: action }) + } + function fieldEvent(type, event) { + const detail = event.detail || {} + $emit(type, { event: event, name: detail.name || "", value: detail.value || "" }) + } + } + + view { +
+
+ +

{title}

{#if description}

{description}

{/if}
+
+
+ {#if returnTo}{/if} + {#if mode === "register" && showName} + + {/if} + {#if mode === "sign-in" || mode === "register" || mode === "recover"} + + {/if} + {#if mode === "sign-in" || mode === "register" || mode === "reset"} + + {/if} + {#if mode === "register" || mode === "reset"} + + {/if} + {#if mode === "mfa"} + + {/if} + {#if mode === "sign-in" && showRemember} + + {/if} +
+ } +} diff --git a/packages/ui/components/AuthSplitLayout.wrn b/packages/ui/components/AuthSplitLayout.wrn new file mode 100644 index 00000000..771c2902 --- /dev/null +++ b/packages/ui/components/AuthSplitLayout.wrn @@ -0,0 +1,83 @@ +component AuthSplitLayout { + props { + size = "default" + color = "primary" + eyebrow = "Secure identity" + title = "Welcome back" + description = "" + brand = "Police Management System" + features = [] + class = "" + } + + view { +
+
+
+ + {brand}Unified identity and access +
+
+ + {eyebrow} + +

{title}

+ {#if description} +

{description}

+ {/if} +
+ {#each features as item} +
+ + {item.label} + {#if item.description} + {item.description} + {/if} + +
+ {/each} +
+ +
+ + Authorised access only · Activity may be monitored and audited. + +
+
+
+ +
+
+
+ } +} diff --git a/packages/ui/components/AvatarGroup.wrn b/packages/ui/components/AvatarGroup.wrn index 1bb009c6..45623306 100644 --- a/packages/ui/components/AvatarGroup.wrn +++ b/packages/ui/components/AvatarGroup.wrn @@ -53,7 +53,9 @@ component AvatarGroup { role="group" aria-label="Avatar group" > -
+
{#each visibleMembers() as item} - + {#if item.src} - {item.alt || item.name || ''} + {item.alt || item.name || ''} {:else if item.initials} - + {:else} - + {/if} {#if showTooltips && (item.tooltip || item.name)} - + {item.tooltip || item.name} {/if} @@ -83,7 +102,9 @@ component AvatarGroup { {/each} {#if hiddenMembers().length > 0} - + - {#if description}

{description}

{/if} - -
+
+ + + + + +
} } diff --git a/packages/ui/components/Footer.wrn b/packages/ui/components/Footer.wrn new file mode 100644 index 00000000..610b4343 --- /dev/null +++ b/packages/ui/components/Footer.wrn @@ -0,0 +1,101 @@ +component Footer { + props { + @event select = function + @event action = function + size = "default" + color = "primary" + label = "Footer navigation" + items = [] + columns = 3 + maxWidth = "compact" + copyright = "" + class = "" + } + + functions { + function groupedItems() { + const groups = [] + let group = null + items.forEach((item) => { + if (item.type === "header") { + group = { + heading: item, + links: [] + } + groups.push(group) + return + } + if (!group) { + group = { + heading: null, + links: [] + } + groups.push(group) + } + group.links.push(item) + }) + return groups + } + + function selectItem(item) { + $emit(item.type === "action" ? "action" : "select", { + item: item, + value: item.value || "" + }) + } + } + + view { + + } +} diff --git a/packages/ui/components/Navbar.wrn b/packages/ui/components/Navbar.wrn index 69f23205..b6f85083 100644 --- a/packages/ui/components/Navbar.wrn +++ b/packages/ui/components/Navbar.wrn @@ -3,18 +3,138 @@ component Navbar { @event toggle = function @event open = function @event close = function + @event select = function + @event action = function size = "default" color = "primary" - label = "Navbar" + label = "Primary navigation" + topbarLabel = "Utility navigation" + brand = {} items = [] + actions = [] active = "" - orientation = "horizontal" + sticky = false + openOnHover = false + maxWidth = "full" + mobileLabel = "Toggle navigation" class = "" } + + state mobileOpen = false + + functions { + function toggleNavigation() { + mobileOpen = !mobileOpen + $emit("toggle", { open: mobileOpen }) + $emit(mobileOpen ? "open" : "close", { source: "mobile" }) + } + + function selectItem(item, level) { + mobileOpen = false + $emit("select", { item: item, value: item.value || "", level: level }) + } + + function selectAction(item) { + mobileOpen = false + $emit("action", { item: item, value: item.value || "" }) + } + + function isItemActive(item) { + if (!item) return false + if (item.value && item.value === active) return true + if (!item.children || !item.children.length) return false + return item.children.some(function (child) { + return isItemActive(child) + }) + } + + function toggleDropdown(event, item) { + $emit(event.currentTarget.open ? "open" : "close", { + source: "menu", + item: item + }) + } + } + view { - +
+
+ +
+ +
+ + {#if brand.logo} + + {/if} + {#if brand.icon} + + {/if} + {#if brand.label || brand.description} + + {#if brand.label}{brand.label}{/if} + {#if brand.description}{brand.description}{/if} + + {/if} + + + + +
+ + +
+ {#each actions as item} + + {#if item.icon}{/if} + {item.label} + + {/each} + +
+
+
+
} } diff --git a/packages/ui/components/PortalDashboard.wrn b/packages/ui/components/PortalDashboard.wrn new file mode 100644 index 00000000..e8001518 --- /dev/null +++ b/packages/ui/components/PortalDashboard.wrn @@ -0,0 +1,98 @@ +component PortalDashboard { + props { + @event action = function + @event navigate = function + size = "default" + color = "primary" + eyebrow = "Overview" + eyebrowKey = "" + title = "Dashboard" + titleKey = "" + description = "" + descriptionKey = "" + userName = "" + metrics = [] + actions = [] + updates = [] + tasks = [] + class = "" + } + + functions { + function chooseAction(item, index) { + $emit("action", { item: item, value: item.value || "", index: index }) + } + + function chooseNavigation(item, index) { + $emit("navigate", { item: item, value: item.value || "", index: index }) + } + } + + view { +
+
+
+ {#if eyebrowKey}{eyebrow}{/if} + {#if !eyebrowKey}{eyebrow}{/if} +

+ {#if titleKey}{title}{/if} + {#if !titleKey}{title}{/if} + {#if userName}, {userName}{/if} +

+ {#if descriptionKey}

{description}

{/if} + {#if !descriptionKey && description}

{description}

{/if} +
+
+
+ +
+ {#each metrics as item} +
+ +
{item.label}{item.value}{#if item.detail}{item.detail}{/if}
+
+ {/each} +
+ +
+
+
Shortcuts

Quick actions

+
+ {#each actions as item, index} + + + {item.label}{#if item.description}{item.description}{/if} + + + {/each} +
+
+ +
+
Priority

Tasks requiring attention

+ +
+ +
+
Latest

Recent updates

+
+ {#each updates as item} +
+ +
{item.label}{#if item.detail}

{item.detail}

{/if}{item.time || ''}
+
+ {/each} +
+
+
+
+ } +} diff --git a/packages/ui/components/PreferenceSwitcher.wrn b/packages/ui/components/PreferenceSwitcher.wrn new file mode 100644 index 00000000..29962022 --- /dev/null +++ b/packages/ui/components/PreferenceSwitcher.wrn @@ -0,0 +1,82 @@ +component PreferenceSwitcher { + props { + @event theme = function + @event color = function + @event language = function + size = "default" + color = "primary" + themeLabel = "Theme" + colorLabel = "Accent color" + languageLabel = "Language" + languages = [ + {"label": "English", "shortLabel": "EN", "value": "en"}, + {"label": "हिन्दी", "shortLabel": "हि", "value": "hi"}, + {"label": "मराठी", "shortLabel": "म", "value": "mr"} + ] + colors = [ + {"label": "Blue", "value": "blue", "hex": "#2563eb"}, + {"label": "Cyan", "value": "cyan", "hex": "#0891b2"}, + {"label": "Emerald", "value": "emerald", "hex": "#059669"}, + {"label": "Violet", "value": "violet", "hex": "#7c3aed"}, + {"label": "Rose", "value": "rose", "hex": "#e11d48"}, + {"label": "Amber", "value": "amber", "hex": "#d97706"} + ] + compact = true + class = "" + } + + functions { + function choose(type, value) { + $emit(type, { value: value }) + } + } + + view { +
+
+ + + {#if !compact}{themeLabel}{/if} + +
+ {themeLabel} +
+ + +
+
+
+ +
+ + + {#if !compact}{colorLabel}{/if} + +
+ {colorLabel} +
+ {#each colors as item} + + {/each} +
+
+
+ +
+ + + + {#if !compact}{languageLabel}{/if} + +
+ {languageLabel} + {#each languages as item} + + {/each} +
+
+
+ } +} diff --git a/packages/ui/components/Sidebar.wrn b/packages/ui/components/Sidebar.wrn index 32bcbc99..7e64904a 100644 --- a/packages/ui/components/Sidebar.wrn +++ b/packages/ui/components/Sidebar.wrn @@ -3,18 +3,77 @@ component Sidebar { @event toggle = function @event open = function @event close = function + @event select = function size = "default" color = "primary" label = "Sidebar" items = [] active = "" orientation = "horizontal" + mobileLabel = "Open navigation" class = "" } + + state mobileOpen = false + + functions { + function toggleSidebar() { + mobileOpen = !mobileOpen + $emit("toggle", { open: mobileOpen }) + $emit(mobileOpen ? "open" : "close", { source: "mobile" }) + } + + function closeSidebar() { + mobileOpen = false + $emit("close", { source: "mobile" }) + } + + function selectItem(item, level) { + mobileOpen = false + $emit("select", { item: item, value: item.value || "", level: level }) + } + } + view { - +
+ + + +
} } diff --git a/packages/ui/components/checkbox.wrn b/packages/ui/components/checkbox.wrn index 63461a67..b945892e 100644 --- a/packages/ui/components/checkbox.wrn +++ b/packages/ui/components/checkbox.wrn @@ -38,25 +38,136 @@ component Checkbox { function preventReadonly(sourceEvent) { if (readonly) sourceEvent.preventDefault() } } view { -
-
{#if options.length || cornerHint}{label}{#if cornerHint}{cornerHint}{/if}{/if}
+
+
+ {#if options.length || cornerHint} + + {label} + + {#if cornerHint} + + {cornerHint} + + {/if} + {/if} +
{#if options.length} -
+
{#each options as option} -
{:else} - +
+ +
{/if} - {#if helperText}{helperText}{/if}{error} + {#if helperText} + + {helperText} + + {/if} + + {error} +
} } diff --git a/packages/ui/package.json b/packages/ui/package.json index 54bd1423..fc7a9ef4 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ui", - "version": "0.5.1", + "version": "0.5.10", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/ui/test/ui.test.ts b/packages/ui/test/ui.test.ts index 0e0091ee..a2e58345 100644 --- a/packages/ui/test/ui.test.ts +++ b/packages/ui/test/ui.test.ts @@ -22,7 +22,7 @@ test("generated component reference documents every bundled component and its pr readFileSync(join(uiComponentsDir(), "..", "component-catalog.json"), "utf8"), ) as { components: Array<{ name: string }> }; expect(reference.count).toBe(uiComponentNames().length); - expect(reference.count).toBe(85); + expect(reference.count).toBe(90); expect(reference.components.map((component) => component.name)).toEqual( expect.arrayContaining(uiComponentNames()), ); @@ -160,6 +160,234 @@ test("screenshot-directed canonical components are bundled", () => { expect(uiComponentNames()).toEqual(expect.arrayContaining(required)); }); +test("navbar supports brands, nested menus, actions, utility slots, and public events", () => { + const source = readFileSync(uiComponentPath("Navbar"), "utf8"); + expect(source).toContain('slot name="topbar"'); + expect(source).toContain('slot name="actions"'); + expect(source).toContain("brand = {}"); + expect(source).toContain("actions = []"); + expect(source).toContain("openOnHover = false"); + expect(source).toContain('maxWidth = "full"'); + expect(source).toContain("item.children"); + expect(source).toContain("child.children"); + expect(source).toContain("wire-navbar__dropdown--{item.type || 'dropdown'}"); + for (const event of ["toggle", "open", "close", "select", "action"]) { + expect(source).toContain(`@event ${event} = function`); + } +}); + +test("auth form uses package schemas, disables native validation, and renders a full-width submit control", async () => { + const source = readFileSync(uiComponentPath("AuthForm"), "utf8"); + expect(source).toContain('return "auth-login"'); + expect(source).toContain('data-schema="{schemaName()}"'); + expect(source).toContain('data-wrnexus-runtime="auth"'); + expect(source).toContain('novalidate="true"'); + expect(source).toContain('fullWidth="true"'); + + const html = await renderComponent(source, { mode: "sign-in" }); + expect(html).toContain('data-schema="auth-login"'); + expect(html).toContain('data-wrnexus-runtime="auth"'); + expect(html).toContain("novalidate"); + expect(html).toContain("wire-auth-form__submit"); + expect(html).toContain('fullWidth="true"'); +}); + +test("footer supports typed entries, responsive columns, pre/post slots, and events", () => { + const source = readFileSync(uiComponentPath("Footer"), "utf8"); + expect(source).toContain('slot name="pre-footer"'); + expect(source).toContain('slot name="post-footer"'); + expect(source).toContain('slot name="copyright-left"'); + expect(source).toContain('slot name="copyright-right"'); + expect(source).toContain('item.type === "header"'); + expect(source).toContain("wire-footer--columns-{columns}"); + expect(source).toContain("@event select = function"); + expect(source).toContain("@event action = function"); +}); + +test("navbar renders a brand, nested dropdowns, mega groups, and actions", async () => { + const html = await renderComponent(readFileSync(uiComponentPath("Navbar"), "utf8"), { + brand: { + label: "Police Management System", + description: "Public portal", + href: "/", + }, + items: [ + { + label: "Services", + type: "dropdown", + children: [{ label: "Reports", href: "/reports" }], + }, + { + label: "Safety", + type: "mega", + columns: 2, + children: [ + { + label: "Resources", + children: [{ label: "Guidance", href: "/guidance" }], + }, + ], + }, + ], + actions: [{ label: "Sign in", href: "/login", variant: "primary" }], + }); + + expect(html).toContain("Police Management System"); + expect(html).toContain("Public portal"); + expect(html).toContain("Reports"); + expect(html).toContain("Guidance"); + expect(html).toContain("wire-navbar__dropdown--mega"); + expect(html).toContain("data-wrn-navbar"); + expect(html).toContain('name="wire-navbar-menu"'); + expect(html).toContain("Sign in"); +}); + +test("navbar closes sibling menus and menus close after an outside pointer press", async () => { + const html = await renderComponent(readFileSync(uiComponentPath("Navbar"), "utf8"), { + items: [ + { label: "Services", children: [{ label: "Reports", href: "/reports" }] }, + { label: "Safety", children: [{ label: "Guidance", href: "/guidance" }] }, + ], + }); + const dom = mountHtml(html); + const menus = Array.from(dom.querySelectorAll(".wire-navbar__dropdown")) as HTMLDetailsElement[]; + + menus[0].open = true; + menus[0].dispatchEvent( + new (dom.window as { Event: typeof Event }).Event("toggle", { bubbles: false }), + ); + menus[1].open = true; + menus[1].dispatchEvent( + new (dom.window as { Event: typeof Event }).Event("toggle", { bubbles: false }), + ); + expect(menus[0].open).toBe(false); + expect(menus[1].open).toBe(true); + + dom.document.body.dispatchEvent( + new (dom.window as { Event: typeof Event }).Event("pointerdown", { bubbles: true }), + ); + expect(menus[1].open).toBe(false); +}); + +test("navbar optionally opens menus on hover and renders full-width mode", async () => { + const html = await renderComponent(readFileSync(uiComponentPath("Navbar"), "utf8"), { + openOnHover: true, + maxWidth: "full", + items: [{ label: "Services", children: [{ label: "Reports", href: "/reports" }] }], + }); + const dom = mountHtml(html); + const navbar = dom.querySelector("[data-wrn-navbar]"); + const menu = dom.querySelector(".wire-navbar__dropdown") as HTMLDetailsElement; + + expect(navbar?.classList.contains("wire-navbar--width-full")).toBe(true); + expect(navbar?.getAttribute("data-open-on-hover")).toBe("true"); + menu.dispatchEvent(new (dom.window as { Event: typeof Event }).Event("pointerenter")); + expect(menu.open).toBe(true); + menu.dispatchEvent(new (dom.window as { Event: typeof Event }).Event("pointerleave")); + expect(menu.open).toBe(true); + await new Promise((resolve) => setTimeout(resolve, 280)); + expect(menu.open).toBe(false); +}); + +test("navbar hover menu stays open while the pointer crosses into its panel", async () => { + const html = await renderComponent(readFileSync(uiComponentPath("Navbar"), "utf8"), { + openOnHover: true, + items: [{ label: "Services", children: [{ label: "Reports", href: "/reports" }] }], + }); + const dom = mountHtml(html); + const menu = dom.querySelector(".wire-navbar__dropdown") as HTMLDetailsElement; + const panel = dom.querySelector(".wire-navbar__panel") as HTMLElement; + const DomEvent = (dom.window as { Event: typeof Event }).Event; + + menu.dispatchEvent(new DomEvent("pointerenter")); + menu.dispatchEvent(new DomEvent("pointerleave")); + panel.dispatchEvent(new DomEvent("pointerenter", { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 280)); + + expect(menu.open).toBe(true); +}); + +test("preference switcher keeps only one popover open and closes outside", async () => { + const html = await renderComponent( + readFileSync(uiComponentPath("PreferenceSwitcher"), "utf8"), + {}, + ); + const dom = mountHtml(html); + const menus = Array.from(dom.querySelectorAll(".wire-preferences__menu")) as HTMLDetailsElement[]; + const DomEvent = (dom.window as { Event: typeof Event }).Event; + + menus[0].open = true; + menus[0].dispatchEvent(new DomEvent("toggle")); + menus[1].open = true; + menus[1].dispatchEvent(new DomEvent("toggle")); + expect(menus[0].open).toBe(false); + expect(menus[1].open).toBe(true); + + dom.document.body.dispatchEvent(new DomEvent("pointerdown", { bubbles: true })); + expect(menus[1].open).toBe(false); +}); + +test("sidebar renders compact grouped submenu navigation and a mobile drawer trigger", async () => { + const html = await renderComponent(readFileSync(uiComponentPath("Sidebar"), "utf8"), { + label: "Operations", + orientation: "vertical", + items: [ + { label: "Dashboard", href: "/dashboard", value: "dashboard" }, + { + label: "Case management", + children: [ + { label: "Assigned cases", href: "/cases", value: "cases" }, + { label: "Evidence", href: "/evidence", value: "evidence" }, + ], + }, + ], + }); + + expect(html).toContain("wire-sidebar-toggle"); + expect(html).toContain("wire-sidebar-backdrop"); + expect(html).toContain("wire-sidebar-group"); + expect(html).toContain("Assigned cases"); + expect(html).toContain("Evidence"); +}); + +test("dropdown renders slotted account triggers, menu structure, and public events", async () => { + const source = readFileSync(uiComponentPath("Dropdown"), "utf8"); + const html = await renderComponent(source, { + label: "Account menu", + items: [ + { type: "header", label: "Account" }, + { label: "Profile", href: "/profile", icon: "icon-[lucide--user]" }, + { type: "divider" }, + { label: "Sign out", href: "/logout", danger: true }, + ], + }); + + expect(source).toContain('slot name="trigger"'); + expect(source).toContain("@event select = function"); + expect(html).toContain("wire-dropdown__panel"); + expect(html).toContain("Profile"); + expect(html).toContain("wire-dropdown__item--danger"); +}); + +test("footer renders header and link entries with the requested column count", async () => { + const html = await renderComponent(readFileSync(uiComponentPath("Footer"), "utf8"), { + columns: 4, + items: [ + { type: "header", label: "Support" }, + { type: "link", label: "Contact", href: "/contact" }, + ], + copyright: "Public service platform", + }); + + expect(html).toContain("wire-footer--columns-4"); + expect(html).toContain("wire-footer__column"); + expect(html).toContain("wire-footer__column-links"); + expect(html).toContain("wire-footer__heading"); + expect(html).toContain("Support"); + expect(html).toContain('href="/contact"'); + expect(html).toContain("Public service platform"); +}); + test("component-system CSS includes responsive, theme-token, focus, and reduced-motion rules", () => { const css = uiCss(); expect(css).toContain("@media (max-width: 768px)"); @@ -184,6 +412,16 @@ test("component-system CSS includes responsive, theme-token, focus, and reduced- expect(css).toContain(".wire-next--table"); expect(css).toContain('[data-show="false"]'); expect(css).toContain("display: none !important"); + expect(css).toMatch( + /\.wire-navbar__brand-copy strong\s*\{[^}]*font-size: 0\.9rem;[^}]*font-weight: 600;/s, + ); + expect(css).toMatch( + /\.wire-navbar__menu-link,[\s\S]*?font-size: 0\.8125rem;[\s\S]*?font-weight: 500;/, + ); + expect(css).toMatch( + /\.wire-footer__heading\s*\{[^}]*font-size: 0\.8125rem;[^}]*font-weight: 600;/s, + ); + expect(css).toMatch(/\.wire-footer__link\s*\{[^}]*font-size: 0\.8125rem;[^}]*font-weight: 400;/s); }); test("component boundaries have no default margins and expose the canonical class prop", () => { diff --git a/packages/ui/ui.css b/packages/ui/ui.css index f02cd380..cf3c7ae8 100644 --- a/packages/ui/ui.css +++ b/packages/ui/ui.css @@ -221,18 +221,26 @@ --wire-btn-contrast: var(--wire-color-primary-contrast); } .wire-btn--variant-default, +.wire-btn--variant-solid, .wire-btn--default, +.wire-btn--solid, .wire-btn--primary { color: var(--wire-btn-contrast); background: var(--wire-btn-color); - box-shadow: 0 0.75rem 1.75rem color-mix(in srgb, var(--wire-btn-color) 20%, transparent); + box-shadow: + 0 1px 2px color-mix(in srgb, black 20%, transparent), + 0 0 0 1px color-mix(in srgb, var(--wire-btn-color) 72%, transparent); } .wire-btn--variant-default:hover, +.wire-btn--variant-solid:hover, .wire-btn--default:hover, +.wire-btn--solid:hover, .wire-btn--primary:hover { background: color-mix(in srgb, var(--wire-btn-color) 84%, black); - transform: translateY(-2px); - box-shadow: 0 1rem 2rem color-mix(in srgb, var(--wire-btn-color) 28%, transparent); + transform: translateY(-1px); + box-shadow: + 0 0.35rem 0.9rem color-mix(in srgb, var(--wire-btn-color) 20%, transparent), + 0 0 0 1px color-mix(in srgb, var(--wire-btn-color) 82%, transparent); } .wire-btn--variant-outline, .wire-btn--outline { @@ -315,20 +323,20 @@ } .wire-btn--size-sm, .wire-btn--sm { - min-height: 1.75rem; + min-height: 2.125rem; padding-inline: 0.625rem; gap: 0.25rem; font-size: 0.8rem; } .wire-btn--size-default, .wire-btn--md { - min-height: 2rem; + min-height: 2.5rem; padding-inline: 0.75rem; font-size: 0.875rem; } .wire-btn--size-lg, .wire-btn--lg { - min-height: 2.25rem; + min-height: 2.75rem; padding-inline: 0.875rem; font-size: 0.925rem; } @@ -1314,8 +1322,8 @@ transition-duration: 0.01ms !important; } } -:where(button, a, input, select, textarea):focus-visible { - outline: 2px solid rgb(124 58 237); +:where(button, a):focus-visible { + outline: 2px solid var(--wire-color-primary); outline-offset: 2px; } @@ -4920,6 +4928,1164 @@ background: var(--wire-color-surface-2); } +/* Full application navigation */ +.wire-navbar { + position: relative; + z-index: 40; + width: 100%; + color: var(--wire-color-text); + background: color-mix(in srgb, var(--wire-color-surface) 96%, transparent); + border-bottom: 1px solid var(--wire-color-border); +} +.wire-navbar--sticky { + position: sticky; + top: 0; + backdrop-filter: blur(16px); +} +.wire-navbar__topbar:empty, +.wire-navbar__topbar:not(:has(*)) { + display: none; +} +.wire-navbar__topbar:not(:empty) { + display: flex; + min-height: 2.25rem; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.35rem clamp(1rem, 4vw, 3rem); + color: var(--wire-color-muted); + background: var(--wire-color-surface-2); + border-bottom: 1px solid var(--wire-color-border); + font-size: 0.75rem; + font-weight: 400; +} +.wire-navbar__main { + display: flex; + min-height: 4.25rem; + width: min(100%, 90rem); + margin-inline: auto; + padding: 0.75rem clamp(1rem, 4vw, 3rem); + align-items: center; + gap: 1.5rem; +} +.wire-navbar--width-full .wire-navbar__main { + width: 100%; + max-width: none; +} +.wire-navbar--width-compact .wire-navbar__main { + width: min(86%, 80rem); +} +.wire-navbar__brand, +.wire-navbar__menu-link, +.wire-navbar__dropdown summary, +.wire-navbar__action, +.wire-navbar__panel a { + color: inherit; + text-decoration: none; +} +.wire-navbar__brand { + display: inline-flex; + min-width: 0; + align-items: center; + gap: 0.75rem; + flex: 0 0 auto; +} +.wire-navbar__brand-logo { + display: block; + width: auto; + max-width: 11rem; + height: 2.5rem; + object-fit: contain; +} +.wire-navbar__brand-icon { + width: 2.5rem; + height: 2.5rem; + color: var(--wire-component-color, var(--wire-color-primary)); +} +.wire-navbar__brand-copy { + display: grid; + min-width: 0; + line-height: 1.2; +} +.wire-navbar__brand-copy strong { + font-size: 0.9rem; + font-weight: 600; +} +.wire-navbar__brand-copy small { + margin-top: 0.2rem; + color: var(--wire-color-muted); + font-size: 0.6875rem; + font-weight: 400; +} +.wire-navbar__collapse, +.wire-navbar__menus, +.wire-navbar__actions { + display: flex; + align-items: center; +} +.wire-navbar__collapse { + min-width: 0; + flex: 1; + gap: 1rem; +} +.wire-navbar__menus { + justify-content: center; + flex: 1; + gap: 0.25rem; +} +.wire-navbar__actions { + justify-content: flex-end; + gap: 0.5rem; +} +.wire-navbar__menu-link, +.wire-navbar__dropdown > summary, +.wire-navbar__action { + display: inline-flex; + min-height: 2.75rem; + padding: 0.65rem 0.8rem; + align-items: center; + gap: 0.45rem; + border-radius: 0.65rem; + cursor: pointer; + font-size: 0.8125rem; + font-weight: 500; + white-space: nowrap; +} +.wire-navbar__menu-link:hover, +.wire-navbar__dropdown > summary:hover { + color: var(--wire-color-text); + background: var(--wire-color-surface-2); +} +.wire-navbar__menu-link[aria-current="page"], +.wire-navbar__dropdown > summary[aria-current="page"] { + color: var(--wire-component-color, var(--wire-color-primary)); + font-weight: 600; + background: color-mix( + in srgb, + var(--wire-component-color, var(--wire-color-primary)) 12%, + transparent + ); + box-shadow: inset 0 -2px 0 var(--wire-component-color, var(--wire-color-primary)); +} +.wire-navbar__menu-link[aria-current="page"]:hover, +.wire-navbar__dropdown > summary[aria-current="page"]:hover { + color: var(--wire-component-color, var(--wire-color-primary)); + background: color-mix( + in srgb, + var(--wire-component-color, var(--wire-color-primary)) 16%, + transparent + ); +} +.wire-navbar__dropdown { + position: relative; +} +.wire-navbar__dropdown > summary { + list-style: none; +} +.wire-navbar__dropdown > summary::-webkit-details-marker { + display: none; +} +.wire-navbar__chevron { + width: 0.45rem; + height: 0.45rem; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + transform: rotate(45deg) translateY(-0.15rem); + transition: transform var(--wire-motion-fast, 150ms) ease; +} +.wire-navbar__dropdown[open] .wire-navbar__chevron { + transform: rotate(225deg) translate(-0.1rem, -0.1rem); +} +.wire-navbar__panel { + position: absolute; + top: calc(100% + 0.55rem); + left: 50%; + display: grid; + width: max-content; + min-width: 15rem; + max-width: min(90vw, 64rem); + padding: 0.65rem; + gap: 0.45rem; + border: 1px solid var(--wire-color-border); + border-radius: 0.9rem; + color: var(--wire-color-text); + background: var(--wire-color-surface); + box-shadow: 0 18px 48px rgb(15 23 42 / 0.16); + opacity: 1; + transform: translateX(-50%) translateY(0) scale(1); + transform-origin: top center; + transition: + opacity 180ms ease, + transform 180ms cubic-bezier(0.22, 1, 0.36, 1), + display 180ms allow-discrete; +} +.wire-navbar__panel::before { + position: absolute; + right: 0; + bottom: 100%; + left: 0; + height: 0.65rem; + content: ""; +} +.wire-navbar__dropdown:not([open]) > .wire-navbar__panel { + display: none; + opacity: 0; + transform: translateX(-50%) translateY(-0.5rem) scale(0.98); +} +@starting-style { + .wire-navbar__dropdown[open] > .wire-navbar__panel { + opacity: 0; + transform: translateX(-50%) translateY(-0.5rem) scale(0.98); + } +} +.wire-navbar__dropdown--mega { + position: static; +} +.wire-navbar__dropdown--mega .wire-navbar__panel { + right: auto; + left: 50%; + width: min(calc(100vw - 2rem), 64rem); +} +.wire-navbar__panel--columns-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} +.wire-navbar__panel--columns-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} +.wire-navbar__panel--columns-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} +.wire-navbar__panel-intro { + grid-column: 1 / -1; + margin: 0; + padding: 0.5rem 0.65rem; + color: var(--wire-color-muted); + font-size: 0.8125rem; + font-weight: 400; +} +.wire-navbar__group { + display: grid; + align-content: start; + gap: 0.25rem; +} +.wire-navbar__group-title { + padding: 0.55rem 0.65rem 0.25rem; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} +.wire-navbar__group > small { + padding: 0 0.65rem 0.4rem; + color: var(--wire-color-muted); + font-size: 0.7rem; + font-weight: 400; +} +.wire-navbar__panel a { + display: flex; + min-height: 2.75rem; + padding: 0.65rem; + align-items: flex-start; + gap: 0.65rem; + border-radius: 0.6rem; + font-size: 0.8rem; + font-weight: 450; +} +.wire-navbar__panel a:hover { + background: var(--wire-color-surface-2); +} +.wire-navbar__panel a span:last-child { + display: grid; + gap: 0.15rem; +} +.wire-navbar__panel a strong { + font-weight: 500; +} +.wire-navbar__panel a small { + color: var(--wire-color-muted); + font-size: 0.7rem; + font-weight: 400; +} +.wire-navbar__action { + border: 1px solid transparent; +} +.wire-navbar__action--primary { + color: var(--wire-color-on-primary, #fff); + background: var(--wire-component-color, var(--wire-color-primary)); + font-weight: 600; +} +.wire-navbar__action--outline { + border-color: var(--wire-color-border); +} +.wire-navbar__toggle { + display: none; + width: 2.75rem; + height: 2.75rem; + margin-left: auto; + padding: 0.65rem; + border: 1px solid var(--wire-color-border); + border-radius: 0.65rem; + color: inherit; + background: var(--wire-color-surface); +} +.wire-navbar__toggle span { + display: block; + height: 2px; + margin: 0.25rem 0; + background: currentColor; +} + +/* Structured responsive footer */ +.wire-footer { + width: 100%; + color: var(--wire-color-text); + background: var(--wire-color-surface); + border-top: 1px solid var(--wire-color-border); + font-size: 0.875rem; + font-weight: 400; +} +.wire-footer__grid { + display: grid; + grid-template-columns: minmax(13rem, 1.2fr) minmax(0, 3fr) minmax(13rem, 1fr); + width: min(100%, 90rem); + margin-inline: auto; + padding: clamp(2rem, 5vw, 4rem) clamp(1rem, 4vw, 3rem); + gap: clamp(1.5rem, 4vw, 3.5rem); +} +.wire-footer--width-full .wire-footer__grid { + width: 100%; + max-width: none; +} +.wire-footer__pre:empty, +.wire-footer__post:empty, +.wire-footer__pre:not(:has(*)), +.wire-footer__post:not(:has(*)) { + display: none; +} +.wire-footer__pre, +.wire-footer__post { + min-width: 0; +} +.wire-footer__pre strong, +.wire-footer__post strong { + font-size: 0.875rem; + font-weight: 600; +} +.wire-footer__pre p, +.wire-footer__post p { + font-size: 0.8125rem; + font-weight: 400; + line-height: 1.6; +} +.wire-footer__links { + display: grid; + grid-template-columns: repeat(var(--wire-footer-columns, 3), minmax(0, 1fr)); + align-content: start; + gap: 1.5rem; +} +.wire-footer__column { + display: grid; + min-width: 0; + align-content: start; + gap: 0.8rem; +} +.wire-footer__column-links { + display: grid; + align-content: start; + gap: 0.55rem; +} +.wire-footer--columns-1 { + --wire-footer-columns: 1; +} +.wire-footer--columns-2 { + --wire-footer-columns: 2; +} +.wire-footer--columns-3 { + --wire-footer-columns: 3; +} +.wire-footer--columns-4 { + --wire-footer-columns: 4; +} +.wire-footer--columns-5 { + --wire-footer-columns: 5; +} +.wire-footer--columns-6 { + --wire-footer-columns: 6; +} +.wire-footer__heading { + display: block; + color: var(--wire-color-text); + font-size: 0.8125rem; + font-weight: 600; +} +.wire-footer__link { + display: flex; + width: fit-content; + min-height: 2rem; + align-items: center; + gap: 0.4rem; + color: var(--wire-color-muted); + font-size: 0.8125rem; + font-weight: 400; + text-decoration: none; +} +.wire-footer__link:hover { + color: var(--wire-component-color, var(--wire-color-primary)); + text-decoration: underline; + text-underline-offset: 0.2em; +} +.wire-footer__bottom { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + align-items: center; + gap: 1rem; + padding: 1rem clamp(1rem, 4vw, 3rem); + color: var(--wire-color-muted); + border-top: 1px solid var(--wire-color-border); + font-size: 0.75rem; + font-weight: 400; +} +.wire-footer__bottom--slots { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); +} +.wire-footer__bottom--slots:not( + :has(.wire-footer__copyright-left > *, .wire-footer__copyright-right > *) +) { + display: none; +} +.wire-footer__copyright { + text-align: center; +} +.wire-footer__copyright-left, +.wire-footer__copyright-right { + display: flex; + min-width: 0; + align-items: center; + gap: 0.75rem; +} +.wire-footer__copyright-right { + justify-content: flex-end; + text-align: right; +} +.wire-footer__copyright-left:empty, +.wire-footer__copyright-right:empty, +.wire-footer__copyright-left:not(:has(*)), +.wire-footer__copyright-right:not(:has(*)) { + min-height: 0; +} + +@media (max-width: 900px) { + .wire-navbar--width-compact .wire-navbar__main { + width: 100%; + } + .wire-navbar__toggle { + display: block; + } + .wire-navbar__collapse { + position: absolute; + top: 100%; + right: 0; + left: 0; + display: none; + padding: 0.75rem 1rem 1rem; + align-items: stretch; + flex-direction: column; + background: var(--wire-color-surface); + border-bottom: 1px solid var(--wire-color-border); + box-shadow: 0 16px 32px rgb(15 23 42 / 0.12); + max-height: calc(100dvh - 4rem); + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + } + .wire-navbar__collapse.is-open { + display: flex; + } + .wire-navbar__menus, + .wire-navbar__actions { + width: 100%; + align-items: stretch; + flex-direction: column; + } + .wire-navbar__menu-link, + .wire-navbar__dropdown > summary, + .wire-navbar__action { + width: 100%; + } + .wire-navbar__panel, + .wire-navbar__dropdown--mega .wire-navbar__panel { + position: static; + width: 100%; + max-width: none; + margin-top: 0.25rem; + box-shadow: none; + transform: none; + overflow: hidden; + animation: wire-navbar-mobile-panel 180ms ease both; + } + .wire-navbar__dropdown:not([open]) > .wire-navbar__panel, + .wire-navbar__dropdown[open] > .wire-navbar__panel { + transform: none; + } + .wire-navbar__panel[class*="wire-navbar__panel--columns-"] { + grid-template-columns: 1fr; + } + .wire-navbar__panel::before { + display: none; + } + .wire-footer__grid { + grid-template-columns: 1fr; + } + .wire-footer__links { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .wire-footer--columns-1 .wire-footer__links { + grid-template-columns: 1fr; + } + .wire-footer--columns-2 .wire-footer__links { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +@media (max-width: 600px) { + .wire-navbar__brand-copy small { + display: none; + } + .wire-footer__links { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .wire-footer--columns-1 .wire-footer__links { + grid-template-columns: 1fr; + } + .wire-footer__bottom, + .wire-footer__bottom--slots { + grid-template-columns: 1fr; + justify-items: center; + text-align: center; + } + .wire-footer__copyright-left, + .wire-footer__copyright-right { + justify-content: center; + text-align: center; + } +} +@media (max-width: 420px) { + .wire-footer__links { + grid-template-columns: 1fr; + } +} + +/* Portal display, accent, and language preferences */ +.wire-preferences { + display: inline-flex; + align-items: center; + gap: 0.25rem; +} +.wire-preferences__menu { + position: relative; +} +.wire-preferences__menu > summary { + display: inline-flex; + min-width: 2.25rem; + min-height: 2.25rem; + padding: 0.45rem 0.6rem; + align-items: center; + justify-content: center; + gap: 0.4rem; + border: 1px solid transparent; + border-radius: 0.6rem; + color: var(--wire-color-muted); + cursor: pointer; + list-style: none; +} +.wire-preferences__menu > summary::-webkit-details-marker { + display: none; +} +.wire-preferences__menu > summary:hover, +.wire-preferences__menu[open] > summary { + color: var(--wire-color-text); + border-color: var(--wire-color-border); + background: var(--wire-color-surface); +} +.wire-preferences__menu > summary > span:first-child { + width: 1rem; + height: 1rem; +} +.wire-preferences__current-language { + min-width: 1.25rem; + color: var(--wire-color-text); + font-size: 0.625rem; + font-weight: 650; + letter-spacing: 0.02em; +} +.wire-preferences__panel { + position: absolute; + z-index: 80; + top: calc(100% + 0.5rem); + right: 0; + display: grid; + width: max-content; + min-width: 11rem; + max-width: min(22rem, calc(100vw - 2rem)); + max-height: min(28rem, calc(100dvh - 2rem)); + overflow-y: auto; + padding: 0.75rem; + gap: 0.6rem; + border: 1px solid var(--wire-color-border); + border-radius: 0.8rem; + color: var(--wire-color-text); + background: var(--wire-color-surface); + box-shadow: var(--wire-shadow-lg, 0 18px 48px rgb(0 0 0 / 0.18)); + animation: wire-component-enter 160ms ease both; +} + +@keyframes wire-navbar-mobile-panel { + from { + opacity: 0; + transform: translateY(-0.35rem); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Responsive grouped application sidebar */ +.wire-sidebar-shell { + width: 15rem; + flex: none; + color: var(--wire-color-text); + background: var(--wire-color-surface); + border-right: 1px solid var(--wire-color-border); + align-self: stretch; + height: 100%; + min-height: 0; +} +.wire-sidebar-toggle, +.wire-sidebar-backdrop, +.wire-sidebar-panel__head { + display: none; +} +.wire-sidebar-panel { + position: sticky; + top: 0; + display: block; + width: 100%; + max-height: 100%; + padding: 0.75rem; + overflow-y: auto; + overscroll-behavior: contain; +} +.wire-sidebar-items { + display: grid; + gap: 0.15rem; +} +.wire-sidebar-items > a, +.wire-sidebar-group > summary, +.wire-sidebar-group__children a { + display: flex; + width: 100%; + min-height: 2.25rem; + padding: 0.5rem 0.65rem; + align-items: center; + gap: 0.55rem; + border-radius: 0.5rem; + color: var(--wire-color-muted); + font-size: 0.75rem; + font-weight: 450; + line-height: 1.35; + text-decoration: none; +} +.wire-sidebar-items > a:hover, +.wire-sidebar-group > summary:hover, +.wire-sidebar-group__children a:hover { + color: var(--wire-color-text); + background: var(--wire-color-surface-2); +} +.wire-sidebar-items a[aria-current="page"] { + color: var(--wire-color-primary); + background: color-mix(in srgb, var(--wire-color-primary) 12%, transparent); + box-shadow: inset 3px 0 0 var(--wire-color-primary); + font-weight: 600; +} +.wire-sidebar-group.has-active > summary { + position: relative; + color: var(--wire-color-text); + background: var(--wire-color-surface-2); +} +.wire-sidebar-group.has-active > summary::before { + position: absolute; + left: 0; + width: 3px; + height: 1.25rem; + border-radius: 999px; + content: ""; + background: var(--wire-color-primary); +} +.wire-sidebar-items :where(a, summary) > span:first-child:not(:last-child) { + width: 0.95rem; + height: 0.95rem; + flex: none; +} +.wire-sidebar-group > summary { + cursor: pointer; + list-style: none; +} +.wire-sidebar-group > summary::-webkit-details-marker { + display: none; +} +.wire-sidebar-group > summary > span:nth-last-child(2) { + min-width: 0; + flex: 1; +} +.wire-sidebar-group > summary > span:last-child { + width: 0.8rem; + height: 0.8rem; + transition: transform 180ms ease; +} +.wire-sidebar-group[open] > summary > span:last-child { + transform: rotate(180deg); +} +.wire-sidebar-group__children { + display: grid; + margin: 0.15rem 0 0.35rem 1rem; + padding-left: 0.5rem; + gap: 0.1rem; + border-left: 1px solid var(--wire-color-border); + animation: wire-sidebar-children 180ms ease both; +} +.wire-sidebar-group__children a { + min-height: 2rem; + padding-block: 0.4rem; + font-size: 0.7rem; +} +@keyframes wire-sidebar-children { + from { + opacity: 0; + transform: translateY(-0.25rem); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 1023px) { + .wire-sidebar-shell { + width: 100%; + min-height: 0; + border-right: 0; + border-bottom: 1px solid var(--wire-color-border); + } + .wire-sidebar-toggle { + display: inline-flex; + min-height: 2.5rem; + margin: 0.65rem; + padding: 0.55rem 0.75rem; + align-items: center; + gap: 0.55rem; + border: 1px solid var(--wire-color-border); + border-radius: 0.6rem; + color: var(--wire-color-text); + background: var(--wire-color-surface); + font-size: 0.75rem; + font-weight: 550; + } + .wire-sidebar-toggle > span:first-child { + width: 1rem; + height: 1rem; + } + .wire-sidebar-backdrop { + position: fixed; + z-index: 69; + inset: 0; + width: 100%; + height: 100%; + border: 0; + background: rgb(0 0 0 / 0.58); + opacity: 0; + pointer-events: none; + transition: opacity 220ms ease; + } + .wire-sidebar-panel { + position: fixed; + z-index: 70; + top: 0; + bottom: 0; + left: 0; + width: min(86vw, 19rem); + max-height: none; + padding: 0.75rem; + background: var(--wire-color-surface); + box-shadow: 20px 0 50px rgb(0 0 0 / 0.28); + transform: translateX(-105%); + visibility: hidden; + transition: + transform 240ms cubic-bezier(0.22, 1, 0.36, 1), + visibility 240ms; + } + .wire-sidebar-shell.is-open .wire-sidebar-backdrop { + display: block; + opacity: 1; + pointer-events: auto; + } + .wire-sidebar-shell.is-open .wire-sidebar-panel { + transform: translateX(0); + visibility: visible; + } + .wire-sidebar-panel__head { + display: flex; + min-height: 3rem; + margin-bottom: 0.5rem; + padding: 0.35rem 0.45rem 0.7rem; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--wire-color-border); + } + .wire-sidebar-panel__head strong { + font-size: 0.8rem; + font-weight: 600; + } + .wire-sidebar-panel__head button { + display: grid; + width: 2.25rem; + height: 2.25rem; + place-items: center; + border-radius: 0.5rem; + color: var(--wire-color-muted); + } + .wire-sidebar-panel__head button span { + width: 1rem; + height: 1rem; + } +} +.wire-preferences__panel > strong { + padding-inline: 0.25rem; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; +} +.wire-preferences__options { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.4rem; +} +.wire-preferences__panel button { + display: inline-flex; + min-height: 2.25rem; + padding: 0.5rem 0.65rem; + align-items: center; + gap: 0.5rem; + border-radius: 0.55rem; + color: var(--wire-color-text); + background: var(--wire-color-surface-2); + font-size: 0.75rem; + font-weight: 500; +} +.wire-preferences__panel button:hover { + outline: 2px solid color-mix(in srgb, var(--wire-color-primary) 40%, transparent); +} +.wire-preferences__panel button[aria-pressed="true"] { + color: var(--wire-color-primary); + background: color-mix(in srgb, var(--wire-color-primary) 12%, var(--wire-color-surface)); + outline: 1px solid color-mix(in srgb, var(--wire-color-primary) 55%, transparent); +} +.wire-preferences__swatches { + display: grid; + grid-template-columns: repeat(3, 2rem); + gap: 0.5rem; +} +.wire-preferences__swatches button { + width: 2rem; + min-height: 2rem; + padding: 0; + border: 3px solid var(--wire-color-surface); + border-radius: 999px; + background: var(--wire-preference-swatch); + box-shadow: 0 0 0 1px var(--wire-color-border); +} +.wire-preferences__swatches button[aria-pressed="true"] { + box-shadow: + 0 0 0 2px var(--wire-color-surface), + 0 0 0 4px var(--wire-color-primary); +} +.wire-preferences__panel--language button { + width: 100%; + background: transparent; +} +.wire-preferences__panel--language button span { + display: grid; + width: 1.65rem; + height: 1.65rem; + place-items: center; + border-radius: 0.45rem; + background: var(--wire-color-surface-2); + font-size: 0.65rem; +} +.wire-preferences__panel--language button[aria-pressed="true"]::after { + width: 0.9rem; + height: 0.9rem; + margin-left: auto; + content: ""; + background: currentColor; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m5 12 4 4L19 6'/%3E%3C/svg%3E") + center / contain no-repeat; +} + +/* Reusable responsive portal dashboard */ +.wire-portal-dashboard { + width: min(100%, 96rem); + margin-inline: auto; + color: var(--wire-color-text); +} +.wire-portal-dashboard__hero { + display: flex; + padding: clamp(1.25rem, 3vw, 2rem); + align-items: flex-end; + justify-content: space-between; + gap: 1rem; + border: 1px solid var(--wire-color-border); + border-radius: 1rem; + background: + radial-gradient( + circle at 85% 0%, + color-mix(in srgb, var(--wire-color-primary) 20%, transparent), + transparent 34% + ), + var(--wire-color-surface); + box-shadow: var(--wire-shadow-sm); +} +.wire-portal-dashboard__eyebrow, +.wire-portal-dashboard__panel-heading small { + color: var(--wire-color-primary); + font-size: 0.7rem; + font-weight: 650; + letter-spacing: 0.09em; + text-transform: uppercase; +} +.wire-portal-dashboard__hero h1 { + margin: 0.35rem 0 0; + font-size: clamp(1.4rem, 3vw, 2.25rem); + font-weight: 650; + letter-spacing: -0.035em; +} +.wire-portal-dashboard__hero p { + max-width: 48rem; + margin: 0.5rem 0 0; + color: var(--wire-color-muted); + font-size: 0.875rem; + line-height: 1.6; +} +.wire-portal-dashboard__metrics { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin-top: 1rem; + gap: 1rem; +} +.wire-portal-dashboard__metric { + display: flex; + min-width: 0; + padding: 1rem; + align-items: center; + gap: 0.85rem; + border: 1px solid var(--wire-color-border); + border-radius: 0.85rem; + background: var(--wire-color-surface); + box-shadow: var(--wire-shadow-sm); +} +.wire-portal-dashboard__metric-icon { + width: 2.25rem; + height: 2.25rem; + flex: none; + padding: 0.55rem; + border-radius: 0.7rem; + color: var(--wire-color-primary); + background: color-mix(in srgb, var(--wire-color-primary) 12%, transparent); +} +.wire-portal-dashboard__metric div { + display: grid; + min-width: 0; +} +.wire-portal-dashboard__metric small, +.wire-portal-dashboard__metric span { + overflow: hidden; + color: var(--wire-color-muted); + font-size: 0.7rem; + text-overflow: ellipsis; + white-space: nowrap; +} +.wire-portal-dashboard__metric strong { + margin-block: 0.1rem; + font-size: 1.3rem; + font-weight: 650; +} +.wire-portal-dashboard__grid { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(18rem, 0.8fr); + margin-top: 1rem; + gap: 1rem; +} +.wire-portal-dashboard__panel { + min-width: 0; + padding: 1.1rem; + border: 1px solid var(--wire-color-border); + border-radius: 0.9rem; + background: var(--wire-color-surface); + box-shadow: var(--wire-shadow-sm); +} +.wire-portal-dashboard__panel--actions { + grid-row: span 2; +} +.wire-portal-dashboard__panel-heading { + display: flex; + margin-bottom: 0.9rem; + align-items: center; + justify-content: space-between; +} +.wire-portal-dashboard__panel-heading h2 { + margin: 0.2rem 0 0; + font-size: 1rem; + font-weight: 600; +} +.wire-portal-dashboard__actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.65rem; +} +.wire-portal-dashboard__actions a, +.wire-portal-dashboard__list a { + display: flex; + min-width: 0; + padding: 0.8rem; + align-items: center; + gap: 0.7rem; + border: 1px solid var(--wire-color-border); + border-radius: 0.7rem; + color: inherit; + text-decoration: none; + transition: + border-color 150ms ease, + background 150ms ease, + transform 150ms ease; +} +.wire-portal-dashboard__actions a:hover, +.wire-portal-dashboard__list a:hover { + border-color: color-mix(in srgb, var(--wire-color-primary) 45%, var(--wire-color-border)); + background: var(--wire-color-surface-2); + transform: translateY(-1px); +} +.wire-portal-dashboard__actions a > span:first-child { + width: 1.15rem; + height: 1.15rem; + flex: none; + color: var(--wire-color-primary); +} +.wire-portal-dashboard__actions a > span:nth-child(2), +.wire-portal-dashboard__list a > span:nth-child(2) { + display: grid; + min-width: 0; + flex: 1; +} +.wire-portal-dashboard__actions a > span:last-child { + width: 0.9rem; + height: 0.9rem; + color: var(--wire-color-muted); +} +.wire-portal-dashboard__actions strong, +.wire-portal-dashboard__list strong, +.wire-portal-dashboard__timeline strong { + font-size: 0.8rem; + font-weight: 550; +} +.wire-portal-dashboard__actions small, +.wire-portal-dashboard__list small, +.wire-portal-dashboard__timeline p, +.wire-portal-dashboard__timeline small { + margin: 0.15rem 0 0; + color: var(--wire-color-muted); + font-size: 0.7rem; + font-style: normal; + line-height: 1.45; +} +.wire-portal-dashboard__list { + display: grid; + gap: 0.5rem; +} +.wire-portal-dashboard__list a { + border: 0; + border-bottom: 1px solid var(--wire-color-border); + border-radius: 0; +} +.wire-portal-dashboard__status { + width: 0.55rem; + height: 0.55rem; + flex: none; + border-radius: 999px; + background: var(--wire-color-muted); +} +.wire-portal-dashboard__status--urgent { + background: var(--wire-color-danger); +} +.wire-portal-dashboard__status--warning { + background: var(--wire-color-warning); +} +.wire-portal-dashboard__status--success { + background: var(--wire-color-success); +} +.wire-portal-dashboard__list em { + color: var(--wire-color-muted); + font-size: 0.7rem; + font-style: normal; +} +.wire-portal-dashboard__timeline { + display: grid; + gap: 0.8rem; +} +.wire-portal-dashboard__timeline article { + display: flex; + gap: 0.7rem; +} +.wire-portal-dashboard__timeline article > span { + width: 1rem; + height: 1rem; + margin-top: 0.15rem; + flex: none; + color: var(--wire-color-primary); +} +.wire-portal-dashboard__timeline p { + margin-block: 0.15rem; +} + +@media (max-width: 1100px) { + .wire-portal-dashboard__metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .wire-portal-dashboard__grid { + grid-template-columns: 1fr; + } + .wire-portal-dashboard__panel--actions { + grid-row: auto; + } +} +@media (max-width: 640px) { + .wire-preferences__panel { + position: fixed; + top: auto; + right: 1rem; + bottom: 1rem; + left: 1rem; + width: auto; + } + .wire-portal-dashboard__hero { + align-items: flex-start; + flex-direction: column; + } + .wire-portal-dashboard__metrics, + .wire-portal-dashboard__actions { + grid-template-columns: 1fr; + } +} + .wire-next--field { display: grid; width: min(100%, 28rem); @@ -4939,6 +6105,338 @@ font: inherit; font-weight: 500; } + +/* Reusable dropdown and account menu */ +.wire-dropdown { + position: relative; + flex: none; +} +.wire-dropdown > summary { + display: inline-flex; + min-height: 2.5rem; + padding: 0.3rem 0.45rem; + align-items: center; + gap: 0.5rem; + border: 1px solid transparent; + border-radius: 0.7rem; + color: var(--wire-color-text); + cursor: pointer; + list-style: none; +} +.wire-dropdown > summary::-webkit-details-marker { + display: none; +} +.wire-dropdown > summary:hover, +.wire-dropdown[open] > summary { + border-color: var(--wire-color-border); + background: var(--wire-color-surface-2); +} +.wire-dropdown__chevron { + width: 0.75rem; + height: 0.75rem; + color: var(--wire-color-muted); + transition: transform 180ms ease; +} +.wire-dropdown[open] .wire-dropdown__chevron { + transform: rotate(180deg); +} +.wire-dropdown__panel { + position: absolute; + z-index: 85; + top: calc(100% + 0.5rem); + right: 0; + display: grid; + width: max-content; + min-width: 14rem; + max-width: min(22rem, calc(100vw - 2rem)); + padding: 0.5rem; + gap: 0.15rem; + border: 1px solid var(--wire-color-border); + border-radius: 0.8rem; + color: var(--wire-color-text); + background: var(--wire-color-surface); + box-shadow: var(--wire-shadow-lg); + animation: wire-component-enter 170ms ease both; +} +.wire-dropdown--start .wire-dropdown__panel { + right: auto; + left: 0; +} +.wire-dropdown__panel hr { + width: 100%; + margin: 0.35rem 0; + border: 0; + border-top: 1px solid var(--wire-color-border); +} +.wire-dropdown__heading { + padding: 0.5rem 0.6rem 0.25rem; + color: var(--wire-color-muted); + font-size: 0.65rem; + font-weight: 650; + letter-spacing: 0.07em; + text-transform: uppercase; +} +.wire-dropdown__item { + display: flex; + min-height: 2.5rem; + padding: 0.55rem 0.6rem; + align-items: center; + gap: 0.6rem; + border-radius: 0.55rem; + color: inherit; + font-size: 0.75rem; + text-decoration: none; +} +.wire-dropdown__item:hover { + background: var(--wire-color-surface-2); +} +.wire-dropdown__item > span:first-child:not(:last-child) { + width: 1rem; + height: 1rem; + flex: none; + color: var(--wire-color-muted); +} +.wire-dropdown__item > span:nth-child(2) { + display: grid; + min-width: 0; + flex: 1; +} +.wire-dropdown__item strong { + font-weight: 550; +} +.wire-dropdown__item small { + margin-top: 0.1rem; + color: var(--wire-color-muted); + font-size: 0.65rem; +} +.wire-dropdown__item--danger { + color: var(--wire-color-danger); +} +@media (max-width: 640px) { + .wire-dropdown__panel { + position: fixed; + top: auto; + right: 1rem; + bottom: 1rem; + left: 1rem; + width: auto; + max-height: calc(100dvh - 2rem); + overflow-y: auto; + } +} + +/* Shared authentication shell and forms */ +.wire-auth-split { + display: grid; + width: 100%; + height: 100dvh; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + color: var(--wire-color-text); + background: var(--wire-color-bg); +} +.wire-auth-split__content, +.wire-auth-split__form { + min-width: 0; + padding: clamp(1.5rem, 5vw, 5rem); +} +.wire-auth-split__content { + display: flex; + height: 100dvh; + min-height: 0; + overflow: hidden; + justify-content: space-between; + flex-direction: column; + background: + radial-gradient( + circle at 15% 15%, + color-mix(in srgb, var(--wire-color-primary) 22%, transparent), + transparent 34% + ), + linear-gradient(145deg, var(--wire-color-surface-2), var(--wire-color-bg)); + border-right: 1px solid var(--wire-color-border); +} +.wire-auth-split__brand { + display: flex; + align-items: center; + gap: 0.75rem; +} +.wire-auth-split__brand > span:first-child { + width: 2.5rem; + height: 2.5rem; + color: var(--wire-color-primary); +} +.wire-auth-split__brand > span:last-child { + display: grid; +} +.wire-auth-split__brand strong { + font-size: 0.875rem; + font-weight: 650; +} +.wire-auth-split__brand small, +.wire-auth-split__legal { + color: var(--wire-color-muted); + font-size: 0.65rem; +} +.wire-auth-split__message { + width: min(100%, 36rem); + margin-block: 3rem; +} +.wire-auth-split__eyebrow { + color: var(--wire-color-primary); + font-size: 0.7rem; + font-weight: 650; + letter-spacing: 0.09em; + text-transform: uppercase; +} +.wire-auth-split__message h1 { + max-width: 14ch; + margin: 0.65rem 0 0; + font-size: clamp(2rem, 4vw, 3.75rem); + font-weight: 650; + letter-spacing: -0.045em; + line-height: 1.08; +} +.wire-auth-split__message > p { + max-width: 34rem; + margin: 1rem 0 0; + color: var(--wire-color-muted); + font-size: 0.875rem; + line-height: 1.7; +} +.wire-auth-split__features { + display: grid; + margin-top: 2rem; + gap: 1rem; +} +.wire-auth-split__features article { + display: flex; + align-items: flex-start; + gap: 0.75rem; +} +.wire-auth-split__features article > span:first-child { + width: 1.1rem; + height: 1.1rem; + margin-top: 0.1rem; + flex: none; + color: var(--wire-color-primary); +} +.wire-auth-split__features article > span:last-child { + display: grid; +} +.wire-auth-split__features strong { + font-size: 0.75rem; + font-weight: 600; +} +.wire-auth-split__features small { + margin-top: 0.15rem; + color: var(--wire-color-muted); + font-size: 0.68rem; +} +.wire-auth-split__form { + display: grid; + height: 100dvh; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + place-items: center; + background: var(--wire-color-surface); +} +.wire-auth-split__form-inner { + width: min(100%, 30rem); +} +.wire-auth-form { + width: 100%; +} +.wire-auth-form > header { + display: flex; + margin-bottom: 1.5rem; + align-items: flex-start; + gap: 0.85rem; +} +.wire-auth-form__icon { + width: 2rem; + height: 2rem; + flex: none; + color: var(--wire-color-primary); +} +.wire-auth-form h2 { + margin: 0; + font-size: 1.5rem; + font-weight: 650; + letter-spacing: -0.025em; +} +.wire-auth-form header p { + margin: 0.35rem 0 0; + color: var(--wire-color-muted); + font-size: 0.75rem; + line-height: 1.5; +} +.wire-auth-form form { + display: grid; + gap: 1rem; +} +.wire-auth-form form > .wire-next--field, +.wire-auth-form form > .wire-field { + width: 100%; + max-width: none; +} +.wire-auth-form__options { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + font-size: 0.7rem; +} +.wire-auth-form__options a { + color: var(--wire-color-primary); + text-decoration: none; +} +.wire-auth-form__submit { + width: 100%; + justify-content: center; +} +.wire-auth-form__submit > .wire-btn { + width: 100%; + min-height: 3rem; + border-radius: 0.75rem; + box-shadow: 0 0.8rem 1.8rem color-mix(in srgb, var(--wire-btn-color) 20%, transparent); +} +@media (max-width: 800px) { + .wire-auth-split { + height: auto; + min-height: 100dvh; + overflow: visible; + grid-template-columns: 1fr; + } + .wire-auth-split__content { + height: auto; + min-height: auto; + overflow: visible; + padding-bottom: 2rem; + border-right: 0; + border-bottom: 1px solid var(--wire-color-border); + } + .wire-auth-split__message { + margin-block: 2rem 0; + } + .wire-auth-split__message h1 { + font-size: clamp(1.75rem, 8vw, 2.5rem); + } + .wire-auth-split__features, + .wire-auth-split__legal { + display: none; + } + .wire-auth-split__form { + height: auto; + min-height: auto; + overflow: visible; + padding-block: 2.5rem; + place-items: start center; + } +} .wire-next--field :where(input, textarea, select):focus-visible { border-color: var(--wire-color-primary); box-shadow: 0 0 0 3px color-mix(in srgb, var(--wire-color-primary) 16%, transparent); @@ -4953,7 +6451,7 @@ width: min(100%, 34rem); min-width: 0; color: var(--wire-color-text); - font-size: 0.875rem; + font-size: 0.8125rem; font-weight: 500; } @@ -4977,7 +6475,7 @@ .wire-next__field-heading label { color: var(--wire-color-text); - font-weight: 700; + font-weight: 600; } .wire-next__field-hint, @@ -4997,6 +6495,7 @@ border-color: var(--wire-color-border); border-radius: var(--wire-radius-sm); background: var(--wire-color-surface); + font-size: 0.8125rem; } .wire-next__field-control[data-icon-position="start"] > :is(input, textarea, select) { @@ -5172,6 +6671,8 @@ .wire-next__choice-field { display: grid; + width: 100%; + max-width: none; gap: 0.4rem; } @@ -5181,7 +6682,40 @@ align-items: center; gap: 0.65rem; color: var(--wire-color-text); - font-weight: 650; + width: 100%; + font-size: 0.75rem; + font-weight: 500; +} + +.wire-next__checkbox-content { + display: grid; + width: 100%; + min-width: 0; + align-items: start; + grid-template-columns: minmax(0, 1fr); +} + +.wire-next__checkbox-content > .wire-next__choice { + width: 100%; + min-width: 0; + align-items: flex-start; +} + +.wire-next__checkbox-slot { + display: block; + min-width: 0; + flex: 1 1 auto; + color: var(--wire-color-muted); + font-size: 0.75rem; + line-height: 1.45; +} + +.wire-next__checkbox-slot:empty { + display: none; +} + +.wire-next__checkbox-slot :is(a, button) { + color: var(--wire-field-color); } .wire-next__choice input { @@ -5773,8 +7307,10 @@ .wire-next--toggle-password { display: grid; - width: min(100%, 28rem); + width: 100%; min-width: 0; + max-width: none; + box-sizing: border-box; gap: 0.45rem; padding: 0; border: 0; @@ -5782,8 +7318,8 @@ .wire-next--toggle-password > label { color: var(--wire-color-text); - font-size: 0.82em; - font-weight: 750; + font-size: 0.75rem; + font-weight: 600; } .wire-next__password-fields { @@ -5807,6 +7343,7 @@ display: flex; min-width: 0; align-items: center; + width: 100%; } .wire-next__password-control > input { @@ -5820,6 +7357,7 @@ color: var(--wire-color-text); background: var(--wire-color-bg); font: inherit; + font-size: 0.8125rem; transition: border-color var(--wire-motion-base) var(--wire-ease-standard), box-shadow var(--wire-motion-base) var(--wire-ease-standard); diff --git a/packages/uploader/package.json b/packages/uploader/package.json index 14912c94..7e06c6f2 100644 --- a/packages/uploader/package.json +++ b/packages/uploader/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/uploader", - "version": "0.5.1", + "version": "0.5.10", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/validation/package.json b/packages/validation/package.json index 4338c40e..d318c2d1 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/validation", - "version": "0.5.1", + "version": "0.5.10", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/validation/src/runtime.ts b/packages/validation/src/runtime.ts index 798de1b6..3d91c8ec 100644 --- a/packages/validation/src/runtime.ts +++ b/packages/validation/src/runtime.ts @@ -194,6 +194,14 @@ export const VALIDATE_RUNTIME = String.raw` .then(function () { btns.forEach(function (b) { b.disabled = false; }); }); } + function runSubmitGuards(form, event) { + var guards = form.__wireSubmitGuards || []; + for (var index = 0; index < guards.length; index += 1) { + if (guards[index](event) === false) return false; + } + return true; + } + function bind(form) { if (form.__wireValidateBound) return; var name = form.getAttribute("data-schema"); @@ -219,6 +227,7 @@ export const VALIDATE_RUNTIME = String.raw` clearErrors(form, schema); hideSuccess(form); if (validateForm(form, schema) > 0) return; // client-invalid: stay put, errors shown + if (!runSubmitGuards(form, e)) return; submitForm(form, schema); }); function validateTarget(e) { diff --git a/packages/validation/test/validation.test.ts b/packages/validation/test/validation.test.ts index 9fd52dad..d96db958 100644 --- a/packages/validation/test/validation.test.ts +++ b/packages/validation/test/validation.test.ts @@ -305,3 +305,46 @@ test("schema-backed forms validate on input, change, and blur and synchronize fi delete (globalThis as Record).document; } }); + +test("submit guards run only after schema fields are valid", () => { + const win = new Window() as unknown as Window & Record; + win.document.body.innerHTML = ` +
+ + +
`; + win.__wireSchemas = { + guarded: v.object({ email: v.string().required("Enter an email") }).describe(), + }; + (globalThis as Record).window = win; + (globalThis as Record).document = win.document; + + try { + (0, eval)(VALIDATE_RUNTIME); + const form = win.document.querySelector("form")! as unknown as HTMLFormElement & { + __wireSubmitGuards?: Array<() => boolean>; + }; + let guardCalls = 0; + form.__wireSubmitGuards = [ + () => { + guardCalls += 1; + return false; + }, + ]; + + form.dispatchEvent( + new win.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event, + ); + expect(guardCalls).toBe(0); + expect(win.document.querySelector("[data-error=email]")!.textContent).toBe("Enter an email"); + + (form.elements.namedItem("email") as HTMLInputElement).value = "person@example.com"; + form.dispatchEvent( + new win.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event, + ); + expect(guardCalls).toBe(1); + } finally { + delete (globalThis as Record).window; + delete (globalThis as Record).document; + } +}); diff --git a/scripts/publish-packages.ts b/scripts/publish-packages.ts index c4872d5c..9dec78d5 100644 --- a/scripts/publish-packages.ts +++ b/scripts/publish-packages.ts @@ -50,6 +50,20 @@ const ASSETS: Record = { ], }; +function packageAssets(m: Record): string[] { + const declared = Array.isArray(m.files) + ? m.files.filter( + (entry: unknown): entry is string => + typeof entry === "string" && + entry !== "src" && + !entry.startsWith("src/") && + entry !== "dist" && + entry !== "README.md", + ) + : []; + return [...new Set([...(ASSETS[m.name] ?? []), ...declared])]; +} + interface Pkg { dir: string; name: string; @@ -104,6 +118,17 @@ function entriesOf(dir: string, m: Record): Record add(m.main); if (m.bin) Object.values(m.bin).forEach(add); if (m.exports) for (const v of Object.values(m.exports)) add(v); + if (m.wrnexus?.plugin) { + const routesDir = join(dir, "src", "routes"); + if (existsSync(routesDir)) { + for (const file of readdirSync(routesDir, { recursive: true }) as string[]) { + if (typeof file !== "string" || !file.endsWith(".ts")) continue; + const source = join(routesDir, file); + const outName = join("routes", file).replace(/\\/g, "/").replace(/\.ts$/, ""); + map[outName] = source.replace(/\\/g, "/"); + } + } + } return map; } @@ -130,7 +155,11 @@ function removeDirectoryWithRetry(path: string): void { } /** Rewrite the manifest for publishing. */ -function publishManifest(m: Record, version: string): Record { +function publishManifest( + m: Record, + version: string, + workspaceVersions: Map, +): Record { const js = (ts: string) => "./dist/" + ts.replace(/^\.?\/?src\//, "").replace(/\.ts$/, ".js"); const dts = (ts: string) => "./dist/" + ts.replace(/^\.?\/?src\//, "").replace(/\.ts$/, ".d.ts"); @@ -170,12 +199,22 @@ function publishManifest(m: Record, version: string): Record, version: string): Record [pkg.name, String(pkg.manifest.version)]), + ); + const selected = filter.length + ? allPackages.filter( + (pkg) => filter.includes(pkg.dir.split(/[\\/]/).pop()!) || filter.includes(pkg.name), + ) + : allPackages; + const pkgs = topoSort(selected); if (!pkgs.length) { console.error("No @wrnexus packages matched."); process.exit(1); @@ -225,11 +273,19 @@ async function main() { const stage = join(stageRoot, p.name.replace("@wrnexus/", "")); mkdirSync(stage, { recursive: true }); cpSync(join(p.dir, "dist"), join(stage, "dist"), { recursive: true }); - for (const a of ASSETS[p.name] ?? []) { + for (const a of packageAssets(p.manifest)) { if (existsSync(join(p.dir, a))) cpSync(join(p.dir, a), join(stage, a), { recursive: true }); } - const manifest = publishManifest(p.manifest, p.manifest.version); + const manifest = publishManifest(p.manifest, p.manifest.version, workspaceVersions); writeFileSync(join(stage, "package.json"), JSON.stringify(manifest, null, 2) + "\n"); + if (p.manifest.wrnexus && !manifest.wrnexus?.plugin?.plugin) { + throw new Error(`${p.name} lost its wrnexus.plugin metadata while staging.`); + } + for (const asset of packageAssets(p.manifest)) { + if (!existsSync(join(stage, asset))) { + throw new Error(`${p.name} did not stage declared runtime asset ${asset}.`); + } + } // Ship the package's own README.md if it has one; else a minimal placeholder. const srcReadme = join(p.dir, "README.md"); if (existsSync(srcReadme)) { diff --git a/update-package-versions.mjs b/update-package-versions.mjs index 264de1d7..fcff1723 100644 --- a/update-package-versions.mjs +++ b/update-package-versions.mjs @@ -1,7 +1,7 @@ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -const version = "0.4.0"; +const version = "0.5.10"; const dependencyGroups = [ "dependencies",