From cc98bccd6cbfb524ccae0fb03c3659ff6b38a6bd Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Fri, 7 Aug 2026 14:57:17 +0530 Subject: [PATCH] @ feat(ui): add DataTable and Toaster, drop the legacy Table, fix overlay dialogs DataTable replaces the 20-line Table scaffold entirely: columns, sorting, filtering, pagination, selection, bulk actions, comparison layout, sticky first column, custom HTML cells, and a remote source driven by a `request` output rather than a function prop (props travel as HTML attributes, so a function arrives as its own source text). Toaster replaces the hand-rolled status div: tone icons, actions, hover pause/resume and a progress bar. Overlays audit -- Modal and Drawer declared aria-modal="true" but nothing ever moved focus into the panel, so the @keydown handler on their root never ran and closeOnEscape did nothing. Focus, focus restore, a Tab trap and a body scroll lock now live in the reactive runtime, shared by both. ContextMenu placed pointer menus by subtracting a guessed 340x420 from the viewport, which pushed every menu that was not that size away from the pointer; it now positions at the pointer and lets the anchored clamp pull it back once it can be measured. The reactive runtime size budget moves 150k -> 175k to cover anchored overlays, dialog behaviour, the toaster and the DataTable client half. Co-Authored-By: Claude Opus 5 @ --- docs/WRNexusJS-Improvement-Plan.md | 500 +++++ docs/public-api-0.8.json | 3 +- docs/ui-visual-contract-0.8.json | 22 +- editors/vscode/src/compiler.cjs | 131 +- editors/vscode/src/extension.bundle.cjs | 61 +- editors/vscode/src/language-server.cjs | 26 +- examples/basic-app/app/api/accounts.ts | 90 + examples/basic-app/app/api/invite.ts | 26 + examples/basic-app/app/components/Modal.wrn | 121 -- examples/basic-app/app/pages/modal.wrn | 440 ++++- examples/basic-app/app/pages/table.wrn | 156 ++ examples/basic-app/app/routes.gen.ts | 3 + examples/basic-app/app/schemas/invite.ts | 11 + .../app/layouts/showcase.wrn | 6 +- .../app/pages/components/accordion.wrn | 334 ++-- .../pages/components/advanced-date-picker.wrn | 182 +- .../components/advanced-range-slider.wrn | 178 +- .../app/pages/components/advanced-select.wrn | 724 ++++---- .../app/pages/components/alert.wrn | 330 ++-- .../app/pages/components/announcement-bar.wrn | 6 +- .../app/pages/components/auth-form.wrn | 22 +- .../pages/components/auth-split-layout.wrn | 48 +- .../app/pages/components/avatar-group.wrn | 166 +- .../app/pages/components/avatar.wrn | 14 +- .../app/pages/components/badge.wrn | 6 +- .../app/pages/components/breadcrumb.wrn | 82 +- .../app/pages/components/button-group.wrn | 174 +- .../app/pages/components/button.wrn | 14 +- .../app/pages/components/card.wrn | 506 ++--- .../app/pages/components/carousel.wrn | 202 +- .../app/pages/components/chart.wrn | 174 +- .../app/pages/components/chat-bubble.wrn | 178 +- .../app/pages/components/checkbox.wrn | 342 ++-- .../app/pages/components/clipboard.wrn | 174 +- .../app/pages/components/collapse.wrn | 334 ++-- .../app/pages/components/color-picker.wrn | 18 +- .../app/pages/components/combo-box.wrn | 394 ++-- .../app/pages/components/confetti.wrn | 170 +- .../app/pages/components/context-menu.wrn | 106 +- .../app/pages/components/copy-markup.wrn | 14 +- .../app/pages/components/ctasection.wrn | 160 +- .../app/pages/components/custom-scrollbar.wrn | 6 +- .../app/pages/components/data-map.wrn | 172 +- .../app/pages/components/data-table.wrn | 1578 +++++++--------- .../app/pages/components/date-picker.wrn | 32 +- .../app/pages/components/device-frame.wrn | 170 +- .../app/pages/components/drag-and-drop.wrn | 188 +- .../app/pages/components/drawer.wrn | 31 +- .../app/pages/components/dropdown.wrn | 110 +- .../app/pages/components/feature-grid.wrn | 80 +- .../app/pages/components/file-input.wrn | 30 +- .../pages/components/file-upload-progress.wrn | 14 +- .../app/pages/components/file-upload.wrn | 190 +- .../app/pages/components/footer.wrn | 218 +-- .../app/pages/components/hero-actions.wrn | 68 +- .../app/pages/components/hero.wrn | 400 ++-- .../app/pages/components/input-group.wrn | 26 +- .../app/pages/components/input-number.wrn | 18 +- .../app/pages/components/input.wrn | 30 +- .../app/pages/components/layout-splitter.wrn | 14 +- .../app/pages/components/legend-indicator.wrn | 170 +- .../app/pages/components/list-group.wrn | 170 +- .../app/pages/components/list.wrn | 72 +- .../app/pages/components/map.wrn | 64 +- .../app/pages/components/marquee.wrn | 84 +- .../app/pages/components/mega-menu.wrn | 174 +- .../app/pages/components/metric-card.wrn | 10 +- .../app/pages/components/metric-grid.wrn | 90 +- .../app/pages/components/modal.wrn | 29 +- .../app/pages/components/nav.wrn | 170 +- .../app/pages/components/navbar.wrn | 424 ++--- .../app/pages/components/page-header.wrn | 132 +- .../app/pages/components/pagination.wrn | 174 +- .../app/pages/components/pin-input.wrn | 26 +- .../app/pages/components/popover.wrn | 18 +- .../app/pages/components/portal-dashboard.wrn | 650 +++---- .../pages/components/preference-switcher.wrn | 16 +- .../app/pages/components/radio.wrn | 182 +- .../app/pages/components/range-slider.wrn | 178 +- .../app/pages/components/rating.wrn | 170 +- .../app/pages/components/scrollspy.wrn | 166 +- .../app/pages/components/select.wrn | 350 ++-- .../app/pages/components/sidebar.wrn | 178 +- .../app/pages/components/split-hero.wrn | 292 +-- .../app/pages/components/stats-bar.wrn | 84 +- .../app/pages/components/stepper.wrn | 178 +- .../app/pages/components/strong-password.wrn | 16 +- .../app/pages/components/styled-icon.wrn | 160 +- .../app/pages/components/switch.wrn | 18 +- .../app/pages/components/table.wrn | 1219 ------------ .../app/pages/components/tabs.wrn | 76 +- .../app/pages/components/text-link.wrn | 14 +- .../app/pages/components/textarea.wrn | 22 +- .../app/pages/components/time-picker.wrn | 30 +- .../app/pages/components/timeline.wrn | 76 +- .../pages/components/toast-notifications.wrn | 178 +- .../app/pages/components/toast.wrn | 170 +- .../app/pages/components/toaster.wrn | 109 ++ .../app/pages/components/toggle-count.wrn | 170 +- .../app/pages/components/toggle-password.wrn | 24 +- .../app/pages/components/tooltip.wrn | 14 +- .../app/pages/components/tree-view.wrn | 178 +- .../app/pages/components/wysiwyg-editor.wrn | 178 +- .../component-showcase/app/pages/core.wrn | 16 +- .../component-showcase/app/pages/index.wrn | 4 +- .../app/pages/integrations.wrn | 16 +- .../component-showcase/app/pages/overlays.wrn | 6 +- .../component-showcase/app/pages/tables.wrn | 8 +- examples/component-showcase/app/routes.gen.ts | 346 +++- .../component-showcase/app/styles/global.css | 84 + .../scripts/generate-showcase.mjs | 26 +- .../scripts/showcase-profiles.mjs | 196 ++ .../component-showcase/showcase-manifest.json | 51 +- packages/cli/src/build.ts | 2 +- packages/cli/src/eject.ts | 2 +- packages/cli/src/inspect.ts | 2 +- .../cli/test/authz-prod-coldstart.test.ts | 54 +- packages/compiler/src/client-codegen.ts | 29 +- packages/compiler/src/codegen.ts | 76 +- .../__snapshots__/resilience.test.ts.snap | 5 +- packages/compiler/test/compiler.test.ts | 72 + packages/csr/src/reactive-runtime.ts | 1493 ++++++++++++--- packages/csr/test/reactive.test.ts | 321 ++++ packages/dev-server/src/index.ts | 2 +- packages/dev-server/src/partial-build.ts | 2 +- packages/dev-server/src/runtime.ts | 48 +- packages/styles/src/theme.ts | 28 +- packages/syntax/src/tokenizer.ts | 37 + packages/syntax/test/syntax.test.ts | 28 + packages/ui/COMPONENTS.md | 32 +- packages/ui/component-catalog.json | 14 +- packages/ui/component-reference.json | 441 ++++- packages/ui/components/ContextMenu.wrn | 9 +- packages/ui/components/DataTable.wrn | 1628 ++++++++++++++++- packages/ui/components/Drawer.wrn | 84 +- packages/ui/components/Dropdown.wrn | 1 + packages/ui/components/FeatureCard.wrn | 6 + packages/ui/components/Modal.wrn | 215 ++- packages/ui/components/PageHeader.wrn | 6 + packages/ui/components/Popover.wrn | 9 +- packages/ui/components/Toaster.wrn | 962 ++++++++++ packages/ui/components/table.wrn | 20 - packages/ui/components/tooltip.wrn | 3 +- packages/ui/package.json | 1 + packages/ui/src/index.ts | 93 +- packages/ui/src/metadata.ts | 2 +- packages/ui/src/registry.ts | 93 + packages/ui/test/ui.test.ts | 7 +- packages/ui/ui.css | 22 - scripts/reset-ui-components.mjs | 5 +- scripts/security-performance-audit.mjs | 6 +- 151 files changed, 14350 insertions(+), 9189 deletions(-) create mode 100644 docs/WRNexusJS-Improvement-Plan.md create mode 100644 examples/basic-app/app/api/accounts.ts create mode 100644 examples/basic-app/app/api/invite.ts delete mode 100644 examples/basic-app/app/components/Modal.wrn create mode 100644 examples/basic-app/app/pages/table.wrn create mode 100644 examples/basic-app/app/schemas/invite.ts delete mode 100644 examples/component-showcase/app/pages/components/table.wrn create mode 100644 examples/component-showcase/app/pages/components/toaster.wrn create mode 100644 packages/ui/components/Toaster.wrn delete mode 100644 packages/ui/components/table.wrn create mode 100644 packages/ui/src/registry.ts diff --git a/docs/WRNexusJS-Improvement-Plan.md b/docs/WRNexusJS-Improvement-Plan.md new file mode 100644 index 00000000..9c5f2954 --- /dev/null +++ b/docs/WRNexusJS-Improvement-Plan.md @@ -0,0 +1,500 @@ +# WRNexusJS — Security & Power Improvement Plan + +Prepared for WorkRoot · covers `E:\WireJS` (framework v0.8.4) + +This plan is scoped and sequenced the way the repo's own roadmap docs are (`ROADMAP-V1.md`, +`IMPLEMENTATION-ROADMAP-0.8.md`): version-gated phases, one package/file set per item, with +implementation, tests, and doc updates called out per item so each phase can ship as a real release +with a `bun run validate:0.X` gate, like 0.7 and 0.8 did. + +Every proposed API below follows conventions already in the repo (workspace `package.json` shape, +`Middleware`/`Context` typing from `@wrnexus/core`, ASVS row format in `docs/SECURITY-ASVS-5.md`, +audit-sink pattern from `packages/authz/src/audit.ts`) rather than inventing new patterns. + +--- + +## Phasing overview + +| Phase | Version | Theme | New packages | Est. effort | +| ----- | ------------- | ----------------------------------------------- | ---------------------------------------------- | ----------- | +| 1 | 0.8.5 (patch) | Security default fixes, no breaking changes | none | 1–2 weeks | +| 2 | 0.9 | Distributed rate limiting + live security audit | `@wrnexus/ratelimit-redis` | 2–3 weeks | +| 3 | 0.10 | Product-critical DX packages | `@wrnexus/mail`, `@wrnexus/flags` | 4–6 weeks | +| 4 | 0.11 | Search + AI pairing | `@wrnexus/search` | 3–4 weeks | +| 5 | 0.12 | Enterprise/government readiness | SAML in `@wrnexus/auth`, `@wrnexus/compliance` | 5–7 weeks | +| 6 | 1.0 | Monetization + ecosystem | `@wrnexus/billing`, public release strategy | 6–10 weeks | + +Total: roughly 6–8 months at a small-team pace, phased so each release is independently shippable +and dogfoodable on `workroot.in` / `wrnexusjs.dev` / the WRNexus SaaS itself before the next phase +starts. + +--- + +## Phase 1 — v0.8.5: Security default fixes + +No new packages. Pure hardening of existing code, all changes are config-default flips, so they are +non-breaking for anyone who already sets these fields explicitly and only change behavior for people +relying on the current default. + +### 1.1 Trusted Types default allowlist + +**File:** `packages/core/src/headers.ts`, function `applyTrustedTypesDirectives` + +**Problem:** `policyNames` defaults to `["*"]` in production, so any script — including an injected +one — can register a Trusted Types policy. This defeats the XSS mitigation Trusted Types exists for. + +**Change:** + +```ts +// Before +const policyNames = + typeof trustedTypes === "object" && trustedTypes.policyNames?.length + ? trustedTypes.policyNames + : ["*"]; + +// After +const policyNames = + typeof trustedTypes === "object" && trustedTypes.policyNames?.length + ? trustedTypes.policyNames + : ["wrnexus", "default"]; +``` + +Update the `TrustedTypesConfig.policyNames` doc comment to explain the new default and how to opt +back into `["*"]` for apps with third-party extensions that need it. + +**Tests:** update `packages/core/test/headers.test.ts` assertions that currently expect `*`. + +**Docs:** update the "Production configuration baseline" block in `docs/SECURITY-PERFORMANCE-0.7.md` +and add a migration note to `docs/UPGRADE-0.8.3.md`-style upgrade doc for 0.8.5. + +### 1.2 HSTS `preload` default + +**File:** `packages/core/src/headers.ts`, function `serializeHsts` + +**Problem:** `preload: true` is on by default whenever `mode === "production"`. Preload-list +submission is a long-lived commitment (removal takes months across browsers); defaulting it on for +every production build is a footgun for teams not ready to guarantee HTTPS on every subdomain +permanently. + +**Change:** + +```ts +function serializeHsts(config: HstsConfig): string { + const parts = [`max-age=${config.maxAge ?? 31536000}`]; + if (config.includeSubDomains !== false) parts.push("includeSubDomains"); + if (config.preload === true) parts.push("preload"); // was: !== false + return parts.join("; "); +} +``` + +`includeSubDomains` can stay default-on (safe, reversible); only `preload` flips to opt-in. + +**Tests:** update `packages/core/test/headers.test.ts`. + +**Docs:** update `SECURITY-ASVS-5.md` row for `v5.0.0-3.4.1` evidence note and the production config +baseline example (explicitly show `hsts: { preload: true }` as something apps opt into, with a +one-line warning comment). + +### 1.3 Live security-header verification (`--url` mode) + +**File:** `packages/cli/src/security-command.ts` + +**Problem:** `securityAudit()` already exists and is solid — it loads the local app config, builds a +synthetic request/response, and runs `withSecurityHeaders` to check what headers _would_ be emitted. +It never checks what a _deployed_ site is actually serving, so a misconfigured reverse proxy, +missing env var, or config drift between local and production is invisible to `wrnexus security +audit` today. + +**Change:** add a second code path that takes a URL instead of an app root: + +```ts +export interface SecurityAuditOptions { + appRoot?: string; + /** Fetch a live deployment and audit its actual response headers instead of a local config. */ + url?: string; +} + +export async function securityAudit(options: SecurityAuditOptions): Promise { + const headers = options.url + ? await fetchLiveHeaders(options.url) + : await securityHeaders(resolve(options.appRoot ?? ".")); + // same checks[] logic runs against either header source + ... +} + +async function fetchLiveHeaders(url: string): Promise> { + const res = await fetch(url, { method: "HEAD", redirect: "manual" }); + return Object.fromEntries(res.headers.entries()); +} +``` + +CLI surface: `wrnexus security audit --url=https://workroot.in` — same `SecurityAuditCheck[]` table +output as the local mode, so it's a drop-in mental model for anyone who's already used the local +version. + +**Tests:** `packages/cli/test/security-command.test.ts` — mock `fetch`, assert the same check IDs run +against a header map built from a fake `Response`. + +**Docs:** update `SECURITY-ASVS-5.md`'s intro line ("Run `bun run security:asvs` ... `wrnexus +security audit`") to mention the `--url` mode explicitly. + +--- + +## Phase 2 — v0.9: Distributed rate limiting + +### 2.1 `@wrnexus/ratelimit-redis` + +**Problem:** `packages/core/src/ratelimit.ts` is honestly documented as process-local by default, +with a clean `RateLimitStore` interface for swapping in a shared store — but no first-party +implementation ships. Every team running more than one instance has to write their own Redis +`INCR`/`PEXPIRE` bucket store before rate limiting actually works in production. + +**New package layout** (mirrors `packages/captcha`'s `stores/redis.ts` pattern, which already +exists for CAPTCHA — this is literally copying a pattern you've already built once): + +``` +packages/ratelimit-redis/ + package.json + src/ + index.ts # createRedisRateLimitStore() + client.ts # thin ioredis/bun-redis wrapper, injectable client + test/ + store.test.ts # against a real or mocked Redis + README.md +``` + +**API:** + +```ts +import { createRedisRateLimitStore } from "@wrnexus/ratelimit-redis"; +import { rateLimit } from "@wrnexus/core"; + +const store = createRedisRateLimitStore({ url: process.env.REDIS_URL! }); +app.use(rateLimit({ store, max: 100, windowMs: 60_000 })); +``` + +Implementation: one atomic Lua script (`INCR` + conditional `PEXPIRE`) to avoid a race between the +increment and the expiry set — same correctness bar as the in-memory store's atomicity within a +single process. + +**package.json:** + +```json +{ + "name": "@wrnexus/ratelimit-redis", + "version": "0.9.0", + "type": "module", + "main": "./src/index.ts", + "exports": { ".": "./src/index.ts" }, + "dependencies": { "@wrnexus/core": "workspace:*" } +} +``` + +**Tests:** `store.test.ts` covering window rollover, concurrent-hit correctness (fire N parallel +`hit()` calls, assert exact count), and store failure fallback behavior (Redis down → fail open with +a warning log, documented explicitly so nobody is surprised). + +**Docs:** add a row to `SECURITY-SUPPORT-MATRIX.md` under a new "Rate limiting" area, and link it from +the `RateLimitStore` doc comment in `packages/core/src/ratelimit.ts`. + +--- + +## Phase 3 — v0.10: Product-critical DX packages + +### 3.1 `@wrnexus/mail` + +**Problem:** there's no first-party way to actually send email. The queue example +(`app/queues/welcome-email.ts`) shows _scheduling_ an email job but nothing implements delivery. + +**Package layout:** + +``` +packages/mail/ + package.json + src/ + index.ts + send.ts # sendMail(), core envelope type + providers/ + resend.ts + ses.ts + postmark.ts + smtp.ts + dev-inbox.ts # captures mail in dev instead of sending; wrnexus dev shows it in DevToolbar + components/ # optional .wrn email-template partials, reusing the compiler + test/ + send.test.ts + dev-inbox.test.ts + README.md + SECURITY.md +``` + +**API (mirrors the `SafeUrlPolicy`/provider-adapter shape from `@wrnexus/captcha`'s providers):** + +```ts +export interface MailProvider { + send(message: MailMessage): Promise; +} + +export interface MailMessage { + to: string | string[]; + from: string; + subject: string; + html?: string; + text?: string; + replyTo?: string; + headers?: Record; +} + +export function createMailer(provider: MailProvider): { send(m: MailMessage): Promise }; + +// providers/resend.ts +export function resendProvider(opts: { apiKey: string }): MailProvider; +``` + +**Dev-mode behavior:** when `mode !== "production"`, `createMailer` wraps any provider with +`devInboxProvider()`, which stores messages in memory and surfaces them in the DevToolbar (new panel, +same pattern as the existing SQL/queue/realtime DevToolbar providers listed in +`SECURITY-PERFORMANCE-0.7.md` §18) instead of actually sending — this is the single highest-value DX +win in this package, since it removes the "did my email actually work" debugging loop entirely. + +**Security notes for `SECURITY-SUPPORT-MATRIX.md`:** framework guarantees provider-secret handling +stays server-side and never serializes into hydration payloads (reuse the existing serialization +redaction from `@wrnexus/security/serialization.ts`); app/operator responsibility covers SPF/DKIM/DMARC +DNS records and provider account reputation. + +**Effort:** ~2–3 weeks for `resend` + `smtp` providers, dev inbox, and DevToolbar panel; `ses` and +`postmark` can follow as a fast-follow since they share the same `MailProvider` interface. + +### 3.2 `@wrnexus/flags` + +**Problem:** no feature-flag primitive. Every team building past MVP eventually needs gradual +rollout, and right now they'd bolt on a third-party SDK with no integration into WRNexusJS's +`.wrn` reactivity or SSR model. + +**API:** + +```ts +export interface FlagsStore { + get(key: string, ctx: FlagContext): Promise; +} + +export function createFlags(store: FlagsStore): { + isEnabled(key: string, ctx: FlagContext): Promise; + variant(key: string, ctx: FlagContext): Promise; +}; + +// stores/memory.ts, stores/db.ts (reuses @wrnexus/db), stores/percentage.ts (deterministic hash rollout) +``` + +`.wrn` integration: expose `load server { const enabled = await flags.isEnabled("new-dashboard", +ctx) }` so flags flow into `props`/`state` the same way `load server` results already do — no new +compiler syntax needed, just a helper package. + +**Effort:** ~1–2 weeks; the DB-backed store reuses `@wrnexus/db` migration patterns already in the +repo, so most of the work is the percentage-rollout hashing and the DevToolbar panel. + +--- + +## Phase 4 — v0.11: Search, paired with `@wrnexus/ai` + +### 4.1 `@wrnexus/search` + +**Problem:** no first-party search story, despite already having `@wrnexus/ai` for embeddings/LLM +calls — search and RAG are the two things most SaaS apps need `@wrnexus/ai` _for_, so this is the +natural pairing package. + +**Package layout:** + +``` +packages/search/ + src/ + index.ts + adapters/ + postgres-fts.ts # tsvector + GIN index helpers, generated migration + sqlite-fts5.ts + pgvector.ts # embeddings via @wrnexus/ai, cosine-distance query helper + typesense.ts # optional hosted adapter + test/ +``` + +**API:** + +```ts +export function createSearchIndex(adapter: SearchAdapter, table: string, columns: string[]); +const results = await search.query("customer onboarding", { limit: 10 }); + +// pairs directly with @wrnexus/ai +import { embed } from "@wrnexus/ai"; +const vector = await embed(text); +await search.upsertVector(id, vector); +``` + +**Effort:** ~3–4 weeks — Postgres FTS and SQLite FTS5 adapters first (no new infra dependency, +reuses `@wrnexus/db`'s existing driver abstraction), pgvector and Typesense as fast-follows. + +--- + +## Phase 5 — v0.12: Enterprise & government readiness + +This phase is prioritized specifically because `workroot.in` markets to "enterprises & governments" — +these two items are the actual procurement blockers for that buyer, more than any generic framework +feature would be. + +### 5.1 SAML support in `@wrnexus/auth` + +**Problem:** OAuth/OIDC is implemented (per `SECURITY-SUPPORT-MATRIX.md`), but large-enterprise and +government IT departments frequently mandate SAML 2.0 specifically for SSO procurement, regardless of +OIDC's technical merits. + +**Location:** `packages/auth/src/saml/` — new subdirectory alongside the existing OAuth/OIDC code, +same `AuthProvider` interface shape so it plugs into the existing account/session engine +(`packages/auth/src/engine.ts`) without a parallel auth system. + +**Scope:** SP-initiated SSO, signed assertion validation, configurable IdP metadata (Okta, Azure AD, +Google Workspace, ADFS as the four IdPs to certify against first — that covers the large majority of +enterprise/government IT estates). + +**Security notes:** reuse the existing `AUTH_SECURITY_EVENT_TYPES` vocabulary for SAML-specific +events (assertion replay attempt, signature validation failure, clock-skew rejection) so they flow +into the same audit pipeline as every other auth event — no parallel logging system. + +**Effort:** ~4–5 weeks; SAML assertion validation is fiddly (XML canonicalization, signature +wrapping attacks) and deserves a dedicated security review pass before release, not just unit tests. + +### 5.2 `@wrnexus/compliance` + +**Problem:** `packages/authz/src/audit.ts` already has a well-built `AuthzAuditSink` interface with +log-injection-safe formatting (`logSafe()`) and memory/console sinks — but no durable, exportable +store. For SOC 2-style evidence or India's DPDP Act data-processing records, teams need retained, +queryable, exportable audit trails, not console lines. + +**Package layout:** + +``` +packages/compliance/ + src/ + index.ts + sinks/ + db.ts # durable AuthzAuditSink + auth security-event sink, reusing @wrnexus/db + export.ts # CSV/JSON export with retention-window filtering + retention.ts # configurable retention policy + scheduled purge + test/ +``` + +**API:** + +```ts +import { dbAuditSink } from "@wrnexus/compliance"; +authz.configure({ auditSink: dbAuditSink({ retentionDays: 365 }) }); + +const report = await compliance.exportAuditTrail({ from, to, format: "csv" }); +``` + +This is the package I'd actually build _first_ internally for the WRNexus SaaS itself, since any +enterprise/government customer of WRNexus will ask WorkRoot for exactly this evidence during their +own procurement review — dogfooding it validates the design before it ships to other developers. + +**Effort:** ~2 weeks on top of the existing audit-sink groundwork, since most of the hard part +(safe event formatting, sink interface) is already done. + +--- + +## Phase 6 — v1.0: Monetization and ecosystem + +### 6.1 `@wrnexus/billing` + +**Problem:** no payments package, despite WRNexus itself being a billed SaaS product — this is the +package where dogfooding value is highest. + +**API:** + +```ts +export interface BillingProvider { + createCheckoutSession(params): Promise<{ url: string }>; + verifyWebhook(req: Request): Promise; + getSubscription(customerId: string): Promise; +} + +// providers/stripe.ts, providers/razorpay.ts (India-relevant) +``` + +Webhook verification reuses `@wrnexus/security`'s constant-time comparison helpers (same primitive +already used in `packages/core/src/csrf.ts`'s `timingSafeEqual`) for signature checks. Usage metering +hooks into `@wrnexus/observability`'s existing counters/gauges rather than a new metrics system. + +**Effort:** ~5–6 weeks for Stripe + Razorpay, subscription lifecycle, and webhook handling with +proper idempotency-key handling (a real source of billing bugs if skipped). + +### 6.2 Public release strategy — open-core + +**Problem:** `@wrnexus/*` is currently private, so nobody outside WorkRoot can `bun install` any of +it. This is the actual ceiling on "developer power," not any single missing feature. + +**Recommended split:** + +| Tier | Packages | License | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------ | +| Open (public npm) | `core`, `ssr`, `compiler`, `syntax`, `router`, `store`, `reactive`, `security`, `ui`, `cli`, `dev-server`, `dev-toolbar` | MIT or Apache-2.0 | +| Paid/enterprise | `billing`, `compliance`, SAML in `auth`, `authz` advanced policy engine | Commercial license, distributed via the private registry you already run | + +**Rollout steps:** + +1. Audit the `open` tier packages for any WorkRoot-specific secrets/config baked in (`scripts/` + already has `generate-sbom.mjs` and `check-public-api.mjs` — extend `check-public-api.mjs` to also + flag internal-only references before a package is promoted to the public tier). +2. Publish under the `@wrnexus` npm org with the existing `PUBLISHING.md` process, starting with + `core` + `cli` (the minimum to `bunx @wrnexus/cli create my-app` publicly). +3. Public GitHub repo for the open tier only (mirrored from the monorepo via the existing + `stage:packages` script's publish pipeline, not a manual copy). +4. Keep `docs/ROADMAP.md`-style public roadmap visible so early external adopters see what's coming. + +**Effort:** ~4–6 weeks of packaging/licensing/CI work, separate from any new feature work above — this +can run in parallel with Phase 5. + +### 6.3 Lean into `@wrnexus/ai` + `@wrnexus/mcp` + +**Problem:** these packages already exist and are ahead of most frameworks, but aren't positioned as +a headline feature anywhere in the marketing (`wrnexusjs.dev` homepage doesn't mention AI/MCP at all +per the live screenshot taken earlier). + +**Recommendation:** ship an official MCP server (`packages/mcp/src/index.ts` already has a `stdio.ts` +transport — check whether it currently exposes framework introspection, e.g. route listing, `.wrn` +component schema, or `wrnexus doctor` output as MCP tools) so agentic coding tools (Claude Code, +Cursor, etc.) can scaffold and modify WRNexusJS apps with structured tool calls instead of guessing at +the `.wrn` syntax from grepped examples. This is a low-cost, high-differentiation move given how much +of the ecosystem is moving toward agent-built apps. + +**Effort:** ~2–3 weeks to wrap existing CLI commands (`inspect`, `doctor`, `routes`, `generate`) as +MCP tools, since the underlying logic already exists in `packages/cli/src`. + +--- + +## Cross-cutting requirements for every phase + +- Every new/changed package needs a `SECURITY-SUPPORT-MATRIX.md` row (framework guarantee vs. + app/operator responsibility) before release, matching the existing table format. +- Every new package needs an ASVS evidence row in `SECURITY-ASVS-5.md` if it touches auth, secrets, + network requests, or user input — same two-column (implementation evidence / verification evidence) + format already used. +- `bun run validate:0.X` release gate (`scripts/validate-0.8.mjs`-style) should get a new + `validate-0.9.mjs` etc. per phase, following the existing per-minor-version validation script + pattern rather than one growing script. +- `CHANGELOG.md` entries per release, same format as the 0.8.3/0.8.0 entries already there. +- Each phase should ship an example page/route in `examples/basic-app` (mirroring + `auth-showcase`, `captcha-showcase`, `i18n-showcase`) — e.g. `examples/mail-showcase`, + `examples/billing-showcase` — so the roadmap's own "executable examples" discipline + (`ROADMAP-COMPLETION-REPORT.md`) continues. + +--- + +## Suggested sequencing rationale + +Phase 1 ships first because it's the only phase with zero new surface area — pure default fixes that +improve every existing deployment (including `workroot.in` and `wrnexusjs.dev` themselves) without +anyone changing their code. Phases 2–4 build developer-facing power in the order teams actually hit +the wall (rate limiting under real traffic → needing email → needing flags → needing search once an +app has enough data to search). Phase 5 is prioritized ahead of Phase 6 despite being harder, because +it directly unblocks revenue-relevant deals in WorkRoot's actual pipeline (enterprise/government +procurement). Phase 6's public release is last on purpose — it should launch once there's a stronger +package lineup behind it, so the first public impression of `@wrnexus/*` is "batteries-included," not +"promising but thin." diff --git a/docs/public-api-0.8.json b/docs/public-api-0.8.json index a043b7ed..2efd18f1 100644 --- a/docs/public-api-0.8.json +++ b/docs/public-api-0.8.json @@ -2858,7 +2858,8 @@ ] }, "@wrnexus/ui": { - ".": [ + ".": [], + "./registry": [ "UiComponentMetadata", "UiComponentReference", "auditUiComponents", diff --git a/docs/ui-visual-contract-0.8.json b/docs/ui-visual-contract-0.8.json index 06cdce87..74ef2f49 100644 --- a/docs/ui-visual-contract-0.8.json +++ b/docs/ui-visual-contract-0.8.json @@ -27,18 +27,18 @@ "packages/ui/components/Combobox.wrn": "62a7d861b0e536ceb088cea3552c59acbafaea5184101e3651dc8f4976b99dbc", "packages/ui/components/Confetti.wrn": "22d687beefd5047055f65e806b4e39f81e18887532ec18d439ac364a044b230a", "packages/ui/components/Container.wrn": "be8fce140043eced8b78c8dd14ff85ace4ed2d91671251641ef83e0fe4da8f2a", - "packages/ui/components/ContextMenu.wrn": "2b5153f3acec3b11e5f20d7ea219f4e7b92569340fac09279e6a51bff0e7b235", + "packages/ui/components/ContextMenu.wrn": "2e011e9cb1c09a3a2344ed3fa29dcc74331cd39f838535d8aee4b249dceef0ce", "packages/ui/components/CopyMarkup.wrn": "8d57a7d72e02c126f855a181fc36e1b969edb90f683dce6b6ffe546d49d2b29b", "packages/ui/components/CustomScrollbar.wrn": "4c1f7758b9cd47e20ecf922403e12b5b15b8127a2744383c6bd280ee20e5fdd1", "packages/ui/components/DataMap.wrn": "65552d73ecd1a148427dbb10d611f9352ca36bd490f595ab1e1a47e173445ba1", - "packages/ui/components/DataTable.wrn": "a147aef9840ac1ed9e97921b8ff81b4f8a5afe66dd6ccb6d26947b8f7ffc760f", + "packages/ui/components/DataTable.wrn": "5a76e50e8559c724aaf01ec954958ac640c99758d44f1eef8e6eb1cb6dc4f862", "packages/ui/components/DatePicker.wrn": "18b07e59c27c2720bf960e9c04ad70d9e422ddcf0817fede74e71899811bb387", "packages/ui/components/DeviceFrame.wrn": "a881cf2cc1f859b43cc3c7a1e1ca0fb40cea7a9e530ab5bdcb985aa9592d11c0", "packages/ui/components/Divider.wrn": "e377f8005249cf4f6c51dd4d04ff9c6a70a3f4e3a79d2c02a8ab601754c8553a", "packages/ui/components/DragAndDrop.wrn": "9a403ce9ed20d36211d8916911871875c9c7d0952a7de1a21a433eaa0b3c3324", - "packages/ui/components/Drawer.wrn": "7ecfc54494402e8758eb3f648418df70b6cde598f38836a620a1fa179d1bb1bd", - "packages/ui/components/Dropdown.wrn": "703c354dfb304ad640a600398317b0a5eefeea326df8a1fc51c87e0ed3c26ebc", - "packages/ui/components/FeatureCard.wrn": "cf122df37d5de72a9556fea501b13b8bfad49ee4a5e88daec2a32d204ee9e784", + "packages/ui/components/Drawer.wrn": "04a77a69edfad6793b5ab60981a719f11061984c087d0d61663566e322649fef", + "packages/ui/components/Dropdown.wrn": "c61b46ae6e54f6aacac9f4ed422f6c659deea9a637fd944645acd37bf270b01d", + "packages/ui/components/FeatureCard.wrn": "a6afd6d4080917106b2c504ea3836b92ac3c18e85f1aafd74ffe49592782b5ec", "packages/ui/components/FeatureGrid.wrn": "9e3c0440d08c6982861d3e732eeb20f1023413a58243844dfb9d7033bac13d2b", "packages/ui/components/FeatureIconCard.wrn": "afe56213543dd78932b060fa19c4e645e558b211b380c42b6d4b0d18e9d543df", "packages/ui/components/FileInput.wrn": "8b63811deb90a03763620bedf0d5d3c7d05a20b8ae34292eaface9b756b32ecd", @@ -64,13 +64,13 @@ "packages/ui/components/MegaMenu.wrn": "4a084eaf6aae77bb9023d2f3589bc6b80119b9be63982a90a80f9d280cc9c0a5", "packages/ui/components/MetricCard.wrn": "6451182739298691908f68258c0250cce2a78b0dc27c97115579ece30d7d9f92", "packages/ui/components/MetricGrid.wrn": "6018a98c10628ed240ed236ed916c0ff60994d192c3aadafd10bc876be0f9364", - "packages/ui/components/Modal.wrn": "1821428492e510403dd029c4766e71272899299f65141ae4fdaf09c4a26719e5", + "packages/ui/components/Modal.wrn": "59d4ae9d6dd2700692d9edecfe53ee868e9f864363a4885961d1813cb2bee51f", "packages/ui/components/Nav.wrn": "78f215c94caf68e0968449a23e23bd3409a689e0c52a6c1770aa8373c767d080", "packages/ui/components/Navbar.wrn": "e68f9d3643e500e43124e9c7a6d4c7f6722657377ea7313f3e3cf5093a81b360", - "packages/ui/components/PageHeader.wrn": "adb3bed81ce040044405e35a52d0304f49d5a0d162f923fbd54a4242a25c7362", + "packages/ui/components/PageHeader.wrn": "0761235a4924eec09877be40b292d27b70db22d210be7d8e989493165c20df86", "packages/ui/components/Pagination.wrn": "9169e724f89992dacd10e9492a95438c8016affb39c0fd17a4f6fe26bd21d8b4", "packages/ui/components/PinInput.wrn": "5196f584de8d548a5dfa03688c3c95da3c948cead2662b05e927386ccea74299", - "packages/ui/components/Popover.wrn": "fd9982f60e37e500586f788470f41a8510d6b96d3a85c80d2859fa01cac6731e", + "packages/ui/components/Popover.wrn": "167f6c476cf3114ac5062ecf7739bddd9b5a81b1d209396a3675067dad1577b2", "packages/ui/components/PortalDashboard.wrn": "037d4300b59c7d60543abc7d4aba5c738efc143e9b61abc48aad0ddfcfe6845b", "packages/ui/components/PreferenceSwitcher.wrn": "2cc186d4dcb6580b4b152e3a265d9ed5ad210d3fdc76330b9092db21894465f0", "packages/ui/components/PublicPageShell.wrn": "507baad0e83dc05c24db42b8af8bd45b18f4e32e418843d77ec906428c687199", @@ -96,9 +96,10 @@ "packages/ui/components/Timeline.wrn": "5708c656eefd12f31c93490075ee482bf527059028844bd58cdcddc88461e2b5", "packages/ui/components/Toast.wrn": "f37c584d1c1a66401deb53d915c8ee0c70aaf7baa1d3c297fa74c5bdb914dd1d", "packages/ui/components/ToastNotifications.wrn": "33ff76b2a0a129ff896baea8979b4be97f7ec4471a92a2da970403a5c46e8b03", + "packages/ui/components/Toaster.wrn": "7481ecddde9ed5bf1f448d45a78da7ee0009f84963814bf681735db4e5ab75f9", "packages/ui/components/ToggleCount.wrn": "70a75b2bdcc89103f8ca300ee6d21cd61baf8c6a4aeade9b68d1dc529f77064b", "packages/ui/components/TogglePassword.wrn": "405a85cbfa3d0ff0185b52d2805fba88497a5f25501f01b2438ed3a28591a38d", - "packages/ui/components/Tooltip.wrn": "fe663c153e5239f37a298273662b16a8a77928be9376a823cd6f4e62fbaf2ee5", + "packages/ui/components/Tooltip.wrn": "e6c8c14a75062d04a2e64245df1a40e67785240573c73bae4b0b32e76ef822bc", "packages/ui/components/TreeView.wrn": "1791a10135595b17b5c746af9e0476e8520d516ba46d64c79dc4efe0af2b500d", "packages/ui/components/Typography.wrn": "a667e800e11d23da74b00047bd9c560a65a5920e5394f58f66a6e0b32572ce8e", "packages/ui/components/WysiwygEditor.wrn": "636e60b9f9be7a5807cca7ad20e0b0f370ee1ec6c1ecba5d0a577726cc86bc98", @@ -109,7 +110,6 @@ "packages/ui/components/progress.wrn": "ba6f4dfcc00f04f34e9533be675bb4a6200c3cdcd7d03fc597377e48520fdec7", "packages/ui/components/skeleton.wrn": "ceec8af147b8be08155500e378393ac7c72d819da61b62191c076c80e943d5a9", "packages/ui/components/spinner.wrn": "abc4ee3ede2e019289257e9009eee012c880a85839cc220fac6dbe1b780caf52", - "packages/ui/components/table.wrn": "96126772e82fbd401787600aba7a095ef097282d9bdb4df22f3e06016c677210", - "packages/ui/ui.css": "a72e839e6cccbe01483507f9d9ba913962aca2b9f866bb6e136f023d6ea2013d" + "packages/ui/ui.css": "eee7da4d1088703a61db8e78c43eb6ff7f1b6a02fd8082d8d722beb65bf78382" } } diff --git a/editors/vscode/src/compiler.cjs b/editors/vscode/src/compiler.cjs index 494b59e1..c57f4db3 100644 --- a/editors/vscode/src/compiler.cjs +++ b/editors/vscode/src/compiler.cjs @@ -1,6 +1,6 @@ "use strict"; // Generated by scripts/build-editor-compiler.mjs. Do not edit directly. -// WRN editor compiler source hash: 1d2e0d1e1c38a4513b0fce3631bae003f7c28069920c5386ecc68cbe728bf57b +// WRN editor compiler source hash: 451706f47a7734fd5808cdeeb5786e1b97a183afec98db99fc041369eb16a205 // WRN editor compiler generator hash: c71e7fe4258c97b73b384ff14b321f0cf0b30cc2ed0322f5f84b04e757159b18 // Generated with TypeScript: 5.9.3 const __nodeRequire = require; @@ -560,24 +560,44 @@ function browserModuleRequired(ast) { } function functionEntry(ast, fn, availableFunctions) { const parameterNames = new Set(fn.parameters.map((parameter) => parameter.name)); + /* + * Names the function body declares for itself. + * + * Props and state are destructured into the SAME scope as the body, so a + * body that declares `var size` when `size` is also a prop produced + * "Identifier 'size' has already been declared" and the entire module + * failed to parse -- taking every function in the component down with it, + * with nothing to point at the one line responsible. Skipping the alias for + * a shadowed name is also what plain JavaScript does: inside that function + * the local wins. + */ + const declaredLocals = new Set(); + for (const match of fn.body.matchAll(/\b(?:var|let|const)\s+([A-Za-z_$][\w$]*)|\bfunction\s+([A-Za-z_$][\w$]*)/g)) { + const name = match[1] ?? match[2]; + if (name) + declaredLocals.add(name); + } const stateNames = ast.states .filter((state) => state.runtime !== "server" && safeIdentifier(state.name) && !RUNTIME_BINDINGS.has(state.name) && - !parameterNames.has(state.name)) + !parameterNames.has(state.name) && + !declaredLocals.has(state.name)) .map((state) => state.name); const stateSet = new Set(stateNames); const propNames = ast.props .filter((prop) => safeIdentifier(prop.name) && !RUNTIME_BINDINGS.has(prop.name) && !parameterNames.has(prop.name) && - !stateSet.has(prop.name)) + !stateSet.has(prop.name) && + !declaredLocals.has(prop.name)) .map((prop) => prop.name); const functionAliases = availableFunctions.filter((name) => safeIdentifier(name) && !RUNTIME_BINDINGS.has(name) && !parameterNames.has(name) && !stateSet.has(name) && - !propNames.includes(name)); + !propNames.includes(name) && + !declaredLocals.has(name)); const parameters = fn.parameters.map((parameter) => parameter.name).join(", "); const initialStateSnapshot = stateNames.length ? `const __wrnexusInitialState = { ${stateNames.map((name) => `${JSON.stringify(name)}: context.state.${name}`).join(", ")} };` @@ -805,6 +825,31 @@ function eventAttribute(name) { } return `data-on-${name}`; } +/** + * Event attribute for a handler written on a *component tag* + * (``). + * + * These need their own attribute name. The mount's attributes are forwarded + * into the component and land on its view root, i.e. inside the component's + * own `data-scope` -- but the statement (`save()`) belongs to the parent that + * wrote the tag. Emitting `data-on-confirm` makes the child's runtime bind it + * against the child's scope, where the parent's functions and state do not + * exist, so the handler silently does nothing. `data-wrn-out-*` is ignored by + * the child and claimed by the mounting scope instead. + * + * `window:`/`document:` (and the browser/mobile bridges) keep the plain + * `data-on-*` form: those bind to a global target rather than to the element, + * and the runtime has no component-output path for them. + */ +function componentEventAttribute(name) { + if (name.startsWith("window:") || + name.startsWith("document:") || + name.startsWith("browser-") || + name.startsWith("mobile-")) { + return eventAttribute(name); + } + return `data-wrn-out-${name}`; +} function reactiveAttrValue(raw, reactive) { let found = false; const value = raw.replace(/\{([^{}]+)\}/g, (whole, inner) => { @@ -962,7 +1007,11 @@ function renderLoopBody(node) { const attrs = node.attrs .filter((attr) => attr.name !== "data-component") .map((attr) => { - const name = attr.event ? eventAttribute(attr.name) : attr.name; + const name = attr.event + ? componentTag + ? componentEventAttribute(attr.name) + : eventAttribute(attr.name) + : attr.name; if (attr.boolean) { return escLit(` ${name}`); } @@ -1221,7 +1270,9 @@ function renderNestedComponentInvocation(node, ctx) { return `\${__wireSpreadAttrs(${ctx.resolveExpr(spread[1])})}`; } if (attr.event) { - return (escLit(` ${eventAttribute(attr.name)}="`) + escLit(attrEscape(attr.value)) + escLit(`"`)); + return (escLit(` ${componentEventAttribute(attr.name)}="`) + + escLit(attrEscape(attr.value)) + + escLit(`"`)); } if (attr.boolean) { return ` ${attr.name}`; @@ -2398,6 +2449,18 @@ function renderComponentNode(node, ctx) { const marker = referencesState || referencesLoopVariable || referencesServerLocal ? ` data-wrn-bind-${bindIndex++}="${escLit(attrEscape(JSON.stringify([a.name, a.value])))}"` : ""; + /* + * A boolean attribute whose expression names a loop variable cannot + * be resolved on the server: __wireBooleanAttr runs at render time, + * where `row` or `item` simply does not exist, and the emitted + * module blew up. Leave the attribute off the server output and let + * the client bind set it -- the runtime toggles boolean attributes + * rather than stringifying them, so `checked={isSelected(row)}` + * behaves correctly once hydrated. + */ + if (referencesLoopVariable) { + return ` data-wrn-bind-${bindIndex++}="${escLit(attrEscape(JSON.stringify([a.name, a.value])))}"`; + } return `\${__wireBooleanAttr(${JSON.stringify(a.name)}, ${elementContext.resolveExpr(expression)})}${marker}`; } if (a.value === "false") @@ -2406,6 +2469,20 @@ function renderComponentNode(node, ctx) { return ` ${a.name}`; } const wholeExpression = wholeAttributeExpression(a.value); + /* + * data-show carries an EXPRESSION, not a value. The client re-evaluates + * whatever string it finds in the attribute on every state change, so + * interpolating `{open || visible}` down to the literal "false" at + * render time froze the directive: the element could never be shown + * again, no matter what the state did. A data-wrn-bind marker did not + * save it either -- the bind rewrites the same attribute the directive + * reads, and the directive had already captured "false" as its + * expression. Emitting the expression verbatim (the form Modal uses, + * data-show="isOpen()") makes both authoring styles behave the same. + */ + if (a.name === "data-show" && wholeExpression) { + return ` data-show="${escLit(attrEscape(wholeExpression))}"`; + } const compiledValue = isExplicitComponentMount && wholeExpression ? `\${__wireProp(${elementContext.resolveExpr(wholeExpression)})}` : compileAttrValue(a.value, elementContext); @@ -2751,7 +2828,10 @@ function __wireSpreadAttrs(value: any): string { lowerName === "style" || lowerName === "slot" || lowerName === "data-component" || - lowerName.startsWith("data-wrn") + // Internal markers must not leak through a spread -- except the + // parent's output handlers, whose whole job is to ride from the mount + // onto the view root so the mounting scope can bind them there. + (lowerName.startsWith("data-wrn") && !lowerName.startsWith("data-wrn-out-")) ) { continue; } @@ -2867,7 +2947,7 @@ function __wireProp(v) { } function renderPageComponentAttr(attr, dynamicExpressions) { if (attr.event) { - return ` ${eventAttribute(attr.name)}="${attrEscape(attr.value)}"`; + return ` ${componentEventAttribute(attr.name)}="${attrEscape(attr.value)}"`; } if (attr.boolean) { return ` ${attr.name}`; @@ -6703,6 +6783,18 @@ class Lexer { * Read a `{ ... }` block and return its INNER text (no outer braces), with * brace counting that respects string and template literals so a `}` inside a * string doesn't end the block early. + * + * Comments are skipped as well. Without that, an apostrophe in ordinary + * prose — `/* the panel's color *\/`, `// the Input's slot` — opened a + * string that ran to the next apostrophe, swallowing every brace in between + * and failing the whole component with "Unbalanced braces" pointing at the + * block's opening line. Comments are where apostrophes actually occur, so + * that error was almost always a false alarm. + * + * A `//` line comment is only recognised at the start of a line (after + * whitespace), which is where every comment in a `.wrn` file is written. + * Recognising it mid-line would break the far more common case of a bare + * URL in view text, where `https://…` is not inside quotes. */ readBalancedBraces() { this.skipTrivia(); @@ -6714,6 +6806,8 @@ class Lexer { let depth = 0; let i = this.pos; let str = null; + /** True while only whitespace has been seen since the last newline. */ + let atLineStart = false; for (; i < src.length; i++) { const c = src[i]; if (str) { @@ -6725,6 +6819,27 @@ class Lexer { str = null; continue; } + if (c === "\n") { + atLineStart = true; + continue; + } + if (c === "/" && src[i + 1] === "*") { + const close = src.indexOf("*/", i + 2); + if (close === -1) + break; // unterminated: fall through to the error + i = close + 1; + atLineStart = false; + continue; + } + if (atLineStart && c === "/" && src[i + 1] === "/") { + const newline = src.indexOf("\n", i + 2); + if (newline === -1) + break; + i = newline - 1; // let the loop's own increment land on the newline + continue; + } + if (c !== " " && c !== "\t" && c !== "\r") + atLineStart = false; if (c === '"' || c === "'" || c === "`") { str = c; continue; diff --git a/editors/vscode/src/extension.bundle.cjs b/editors/vscode/src/extension.bundle.cjs index 54a65613..3d83da57 100644 --- a/editors/vscode/src/extension.bundle.cjs +++ b/editors/vscode/src/extension.bundle.cjs @@ -1,4 +1,4 @@ -// WRN editor extension source hash: 825c406dea4b196976b1b6e53b3c71c3263bd63f403acdaf2fe7e5ad174275da +// WRN editor extension source hash: 174fee91d6ec09c86f71f1204aeb46a20e5a563cc8af1484051d68dde01c8e10 // WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728 "use strict"; var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports); @@ -23204,10 +23204,16 @@ ${(0, codegen_ts_1.generate)(ast)}`, } function functionEntry(ast, fn, availableFunctions) { const parameterNames = new Set(fn.parameters.map((parameter) => parameter.name)); - const stateNames = ast.states.filter((state) => state.runtime !== "server" && safeIdentifier(state.name) && !RUNTIME_BINDINGS.has(state.name) && !parameterNames.has(state.name)).map((state) => state.name); + const declaredLocals = new Set; + for (const match of fn.body.matchAll(/\b(?:var|let|const)\s+([A-Za-z_$][\w$]*)|\bfunction\s+([A-Za-z_$][\w$]*)/g)) { + const name = match[1] ?? match[2]; + if (name) + declaredLocals.add(name); + } + const stateNames = ast.states.filter((state) => state.runtime !== "server" && safeIdentifier(state.name) && !RUNTIME_BINDINGS.has(state.name) && !parameterNames.has(state.name) && !declaredLocals.has(state.name)).map((state) => state.name); const stateSet = new Set(stateNames); - const propNames = ast.props.filter((prop) => safeIdentifier(prop.name) && !RUNTIME_BINDINGS.has(prop.name) && !parameterNames.has(prop.name) && !stateSet.has(prop.name)).map((prop) => prop.name); - const functionAliases = availableFunctions.filter((name) => safeIdentifier(name) && !RUNTIME_BINDINGS.has(name) && !parameterNames.has(name) && !stateSet.has(name) && !propNames.includes(name)); + const propNames = ast.props.filter((prop) => safeIdentifier(prop.name) && !RUNTIME_BINDINGS.has(prop.name) && !parameterNames.has(prop.name) && !stateSet.has(prop.name) && !declaredLocals.has(prop.name)).map((prop) => prop.name); + const functionAliases = availableFunctions.filter((name) => safeIdentifier(name) && !RUNTIME_BINDINGS.has(name) && !parameterNames.has(name) && !stateSet.has(name) && !propNames.includes(name) && !declaredLocals.has(name)); const parameters = fn.parameters.map((parameter) => parameter.name).join(", "); const initialStateSnapshot = stateNames.length ? `const __wrnexusInitialState = { ${stateNames.map((name) => `${JSON.stringify(name)}: context.state.${name}`).join(", ")} };` : ""; const stateAliases = stateNames.length ? `let { ${stateNames.join(", ")} } = context.state;` : ""; @@ -23404,6 +23410,12 @@ export function bindClientScope(context) { } return `data-on-${name}`; } + function componentEventAttribute(name) { + if (name.startsWith("window:") || name.startsWith("document:") || name.startsWith("browser-") || name.startsWith("mobile-")) { + return eventAttribute(name); + } + return `data-wrn-out-${name}`; + } function reactiveAttrValue(raw, reactive) { let found = false; const value = raw.replace(/\{([^{}]+)\}/g, (whole, inner) => { @@ -23527,7 +23539,7 @@ export function bindClientScope(context) { } const componentTag = isComponentTag(node.tag); const attrs = node.attrs.filter((attr) => attr.name !== "data-component").map((attr) => { - const name = attr.event ? eventAttribute(attr.name) : attr.name; + const name = attr.event ? componentTag ? componentEventAttribute(attr.name) : eventAttribute(attr.name) : attr.name; if (attr.boolean) { return escLit(` ${name}`); } @@ -23699,7 +23711,7 @@ export function bindClientScope(context) { return `\${__wireSpreadAttrs(${ctx.resolveExpr(spread[1])})}`; } if (attr.event) { - return escLit(` ${eventAttribute(attr.name)}="`) + escLit(attrEscape(attr.value)) + escLit(`"`); + return escLit(` ${componentEventAttribute(attr.name)}="`) + escLit(attrEscape(attr.value)) + escLit(`"`); } if (attr.boolean) { return ` ${attr.name}`; @@ -24751,6 +24763,9 @@ ${handlers.join(` const referencesLoopVariable2 = elementContext.loopVars ? exprRefsState(a.value, elementContext.loopVars) : false; const referencesServerLocal2 = ctx.serverLocals ? exprRefsState(a.value, ctx.serverLocals) : false; const marker2 = referencesState2 || referencesLoopVariable2 || referencesServerLocal2 ? ` data-wrn-bind-${bindIndex++}="${escLit(attrEscape(JSON.stringify([a.name, a.value])))}"` : ""; + if (referencesLoopVariable2) { + return ` data-wrn-bind-${bindIndex++}="${escLit(attrEscape(JSON.stringify([a.name, a.value])))}"`; + } return `\${__wireBooleanAttr(${JSON.stringify(a.name)}, ${elementContext.resolveExpr(expression)})}${marker2}`; } if (a.value === "false") @@ -24759,6 +24774,9 @@ ${handlers.join(` return ` ${a.name}`; } const wholeExpression = wholeAttributeExpression(a.value); + if (a.name === "data-show" && wholeExpression) { + return ` data-show="${escLit(attrEscape(wholeExpression))}"`; + } const compiledValue = isExplicitComponentMount && wholeExpression ? `\${__wireProp(${elementContext.resolveExpr(wholeExpression)})}` : compileAttrValue(a.value, elementContext); const rendered = ` ${a.name}="${compiledValue}"`; const referencesState = exprRefsComponentReactiveValue(a.value, ctx); @@ -25054,7 +25072,10 @@ function __wireSpreadAttrs(value: any): string { lowerName === "style" || lowerName === "slot" || lowerName === "data-component" || - lowerName.startsWith("data-wrn") + // Internal markers must not leak through a spread -- except the + // parent's output handlers, whose whole job is to ride from the mount + // onto the view root so the mounting scope can bind them there. + (lowerName.startsWith("data-wrn") && !lowerName.startsWith("data-wrn-out-")) ) { continue; } @@ -25173,7 +25194,7 @@ function __wireRaw(v: any): string { } function renderPageComponentAttr(attr, dynamicExpressions) { if (attr.event) { - return ` ${eventAttribute(attr.name)}="${attrEscape(attr.value)}"`; + return ` ${componentEventAttribute(attr.name)}="${attrEscape(attr.value)}"`; } if (attr.boolean) { return ` ${attr.name}`; @@ -28961,6 +28982,7 @@ ${serverFunctions} let depth = 0; let i = this.pos; let str = null; + let atLineStart = false; for (;i < src.length; i++) { const c = src[i]; if (str) { @@ -28972,6 +28994,29 @@ ${serverFunctions} str = null; continue; } + if (c === ` +`) { + atLineStart = true; + continue; + } + if (c === "/" && src[i + 1] === "*") { + const close = src.indexOf("*/", i + 2); + if (close === -1) + break; + i = close + 1; + atLineStart = false; + continue; + } + if (atLineStart && c === "/" && src[i + 1] === "/") { + const newline = src.indexOf(` +`, i + 2); + if (newline === -1) + break; + i = newline - 1; + continue; + } + if (c !== " " && c !== "\t" && c !== "\r") + atLineStart = false; if (c === '"' || c === "'" || c === "`") { str = c; continue; diff --git a/editors/vscode/src/language-server.cjs b/editors/vscode/src/language-server.cjs index 9e086258..2b22f8c4 100644 --- a/editors/vscode/src/language-server.cjs +++ b/editors/vscode/src/language-server.cjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// WRN editor language server source hash: 5397c7912894dd5ab330f6fafc0f4093adbb94d95c23366ba7883605a724a7ae +// WRN editor language server source hash: de46b5767d808fb99bb470ac9ccd0bc9504aa9ab40f0cf8ad9ec4e945303200d // WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72 // @bun @bun-cjs (function(exports, require, module, __filename, __dirname) {var __create = Object.create; @@ -169020,6 +169020,7 @@ class Lexer { let depth = 0; let i = this.pos; let str = null; + let atLineStart = false; for (;i < src.length; i++) { const c = src[i]; if (str) { @@ -169031,6 +169032,29 @@ class Lexer { str = null; continue; } + if (c === ` +`) { + atLineStart = true; + continue; + } + if (c === "/" && src[i + 1] === "*") { + const close = src.indexOf("*/", i + 2); + if (close === -1) + break; + i = close + 1; + atLineStart = false; + continue; + } + if (atLineStart && c === "/" && src[i + 1] === "/") { + const newline = src.indexOf(` +`, i + 2); + if (newline === -1) + break; + i = newline - 1; + continue; + } + if (c !== " " && c !== "\t" && c !== "\r") + atLineStart = false; if (c === '"' || c === "'" || c === "`") { str = c; continue; diff --git a/examples/basic-app/app/api/accounts.ts b/examples/basic-app/app/api/accounts.ts new file mode 100644 index 00000000..01274d83 --- /dev/null +++ b/examples/basic-app/app/api/accounts.ts @@ -0,0 +1,90 @@ +// API route: GET /api/accounts — a paged, sorted, filtered slice of a dataset. +// +// This is the server half of the DataTable `load` contract. The table sends +// page, pageSize, sortKey, sortDirection and query, and expects `rows` for +// that page plus `total` for the whole matching set. The total has to come +// from here: the table only ever sees one page and cannot count the rest. +import type { Context } from "@wrnexus/core"; + +interface Account { + id: number; + name: string; + plan: string; + owner: string; + seats: number; + renewsOn: string; +} + +const PLANS = ["Scale", "Team", "Enterprise", "Starter"]; +const OWNERS = ["A. Okafor", "R. Silva", "M. Chen", "J. Dubois", "P. Novak"]; +const NAMES = [ + "Northwind", + "Acme Industrial", + "Globex", + "Initech", + "Umbrella", + "Stark Labs", + "Wayne Foods", + "Soylent", + "Hooli", + "Vehement", + "Massive Dynamic", + "Cyberdyne", + "Tyrell", + "Aperture", + "Black Mesa", + "Oceanic", + "Weyland", + "Gringotts", + "Duff Brewing", + "Prestige Worldwide", + "Bluth Company", + "Pied Piper", +]; + +// Deterministic so paging is stable across requests. +const ACCOUNTS: Account[] = NAMES.map((name, index) => ({ + id: index + 1, + name, + plan: PLANS[index % PLANS.length]!, + owner: OWNERS[index % OWNERS.length]!, + seats: ((index * 13) % 240) + 4, + renewsOn: new Date(Date.UTC(2026, index % 12, ((index * 5) % 27) + 1)).toISOString().slice(0, 10), +})); + +export function GET(ctx: Context): Response { + const params = ctx.url.searchParams; + const page = Math.max(1, Number(params.get("page")) || 1); + const pageSize = Math.min(100, Math.max(1, Number(params.get("pageSize")) || 10)); + const query = (params.get("query") ?? "").trim().toLowerCase(); + const sortKey = params.get("sortKey") ?? ""; + const descending = params.get("sortDirection") === "desc"; + + let matched = ACCOUNTS; + if (query) { + matched = matched.filter((account) => + [account.name, account.plan, account.owner, account.renewsOn].some((field) => + field.toLowerCase().includes(query), + ), + ); + } + + if (sortKey) { + const direction = descending ? -1 : 1; + matched = [...matched].sort((left, right) => { + const a = left[sortKey as keyof Account]; + const b = right[sortKey as keyof Account]; + if (typeof a === "number" && typeof b === "number") return (a - b) * direction; + return String(a).localeCompare(String(b)) * direction; + }); + } + + const start = (page - 1) * pageSize; + + return Response.json({ + rows: matched.slice(start, start + pageSize), + total: matched.length, + page, + pageSize, + }); +} diff --git a/examples/basic-app/app/api/invite.ts b/examples/basic-app/app/api/invite.ts new file mode 100644 index 00000000..190c991d --- /dev/null +++ b/examples/basic-app/app/api/invite.ts @@ -0,0 +1,26 @@ +// API route: POST /api/invite. Validates the body with the SAME schema the +// modal's invite form uses in the browser, so a request that bypasses the +// client (curl, a replayed fetch, a stale page) is held to identical rules. +// +// There is no mail provider wired up in the example, so a successful parse +// just echoes the invite back. The point being demonstrated is the shared +// schema and the client/server round trip, not delivery. +import { verifyCsrf, type Context } from "@wrnexus/core"; +import { parseBody } from "@wrnexus/validation"; +import invite from "../schemas/invite.ts"; + +export async function POST(ctx: Context): Promise { + if (!verifyCsrf(ctx)) return new Response("Invalid CSRF token", { status: 403 }); + + const result = await parseBody(invite, ctx.req); + if (!result.ok) return result.response; // 400 { ok:false, errors } — rendered per field + + const { email, message } = result.value as { email: string; message?: string }; + + return Response.json({ + ok: true, + email, + message: message ?? "", + sentAt: new Date().toISOString(), + }); +} diff --git a/examples/basic-app/app/components/Modal.wrn b/examples/basic-app/app/components/Modal.wrn deleted file mode 100644 index e7829160..00000000 --- a/examples/basic-app/app/components/Modal.wrn +++ /dev/null @@ -1,121 +0,0 @@ -component Modal { - props { - @event onConfirmed = function - } - - state isOpen = false - - functions { - function confirmed() { - isOpen = false; - if(onConfirmed) { - onConfirmed() - } - } - } - - view { -
- - -
-
-
-
- -
-
- -
-
-
-

- Transparent Modal -

- -

- Welcome to WRNexusJS -

- -

- This modal uses a transparent glass background with blur, - soft borders, and theme-aware colors. -

-
- - -
- -
-

- You can place forms, confirmation messages, account details, - images, or any other component inside this modal. -

-
- -
- - - -
-
-
-
-
- } - - style { - } -} diff --git a/examples/basic-app/app/pages/modal.wrn b/examples/basic-app/app/pages/modal.wrn index cf70249c..1230dc08 100644 --- a/examples/basic-app/app/pages/modal.wrn +++ b/examples/basic-app/app/pages/modal.wrn @@ -1,14 +1,438 @@ +// Modal demo page. Route: /modal +// +// Five real-world use cases for the shared @wrnexus/ui Modal component — each +// one exercises a different combination of props (color, variant, size, +// scrollable, destructive, showFooter, confirmDisabled) instead of just +// showing the same modal five times. +// +// Notifications go through the shared mounted at the bottom of +// this page. Any client expression can raise one with the runtime global — +// toast("..."), toast.success(...), toast.error(...) — so pages never +// hand-roll a status banner and never reach for a blocking alert(). +// +// No import here on purpose: component tags resolve by directory scan, and +// @wrnexus/ui's index.ts intentionally has no runtime exports (registry.ts +// holds the server-only filesystem helpers instead) — an explicit +// `import { Modal } from "@wrnexus/ui"` fails to bundle since there's no +// matching export. The WRN-IMPORT-IMPLICIT warning this triggers is safe to +// ignore for component tags. page ModalPage { - functions { - function onConfirmed() { - alert("User Confirmed") - } + state noteText = "Remember to ship the release notes" + state deletedNote = "" + + functions { + function onSaveConfirmed() { + toast.success("Changes saved", { title: "Draft published" }) } - view { - + function onDeleteConfirmed() { + toast.error("Account deleted", { title: "Gone for good", duration: 6000 }) } + + // Toast actions and component state. + // + // An action callback runs LONG AFTER the function that created it + // returned, and a client function only flushes its state when its body + // ends -- so assigning noteText straight from the callback would write to + // a dead local and vanish. The callback dispatches an event instead, and + // the declarative @window binding on the actions row below handles it as + // a fresh invocation with live state. Anything that is not component + // state (calling toast(), fetch, navigation) works from the callback + // directly. + function onDeleteNote() { + deletedNote = noteText + noteText = "" + toast("Note deleted", { + title: "Deleted", + actionLabel: "Undo", + onAction: function () { + window.dispatchEvent(new CustomEvent("demo:restore-note")) + }, + duration: 8000 + }) + } + + function restoreNote() { + if (!deletedNote) { + return + } + noteText = deletedNote + deletedNote = "" + toast.success("Note restored") + } + + // Two actions. The second is styled destructive and keeps the toast open + // (dismiss: false) so the choice stays on screen until it is resolved. + function onConflict() { + toast.warning("This note changed in another tab", { + title: "Conflict", + actions: [ + { + label: "Keep mine", + onClick: function () { toast.success("Kept your version") } + }, + { + label: "Discard", + tone: "danger", + dismiss: false, + onClick: function () { + window.dispatchEvent(new CustomEvent("demo:discard-note")) + } + } + ], + duration: 0 + }) + } + + function discardNote() { + deletedNote = noteText + noteText = "" + toast.error("Your changes were discarded") + } + + // Tones carry their own icon and colour, but any toast can override it. + // The class lives in this page source, so the app Tailwind/iconify build + // sees it and emits the rule -- a class from inside @wrnexus/ui would not + // be scanned, which is why the packaged defaults are inline SVG. + function onTrialNotice() { + toast.warning("Your trial ends in 3 days", { + title: "Heads up", + icon: "icon-[lucide--hourglass]", + actionLabel: "Upgrade", + duration: 6000 + }) + } + + function onTermsConfirmed() { + toast.success("Thanks, you are all set") + } + + // The invite form is a real
, so + // @wrnexus/validation owns the rules: it validates on input/blur/submit + // against app/schemas/invite.ts, writes each message into the + // [data-error] slot the Input renders, and only then POSTs to /api/invite — which parses + // the very same schema server-side. These two handlers just react to the + // outcome events the validator emits. + function onInviteSent(event) { + toast.success("Invite sent to " + event.detail.email, { title: "Invitation" }) + // Close the modal the form lives in. The event bubbles from the form up + // to the Modal root, which listens for it -- so the page never needs a + // handle on the modal or a way to reach its internal state. + event.target.dispatchEvent( + new CustomEvent("wrnexus:modal:close", { bubbles: true }) + ) + } + + function onInviteFailed(event) { + toast.error(event.detail.message || "Could not send the invite", { + title: "Invite failed" + }) + } + } + + style { + .modal-demo-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1rem; + margin-top: 1.5rem; + } + + .modal-demo-card { + display: flex; + flex-direction: column; + gap: 0.6rem; + padding: 1.1rem; + background: var(--wire-color-surface-raised); + border: 1px solid var(--wire-color-border); + border-radius: 1rem; + } + + /* + * Direct children only. A modal mounts *inside* its card, and its panel is + * position: fixed but still a DOM descendant -- so a plain + * `.modal-demo-card p` also styles the copy inside the open modal, which + * repainted the destructive modal body muted grey on its red panel. The + * card is describing its own blurb here, not everything a component it + * hosts happens to render. + */ + .modal-demo-card > h2 { + margin: 0; + font-size: 0.95rem; + } + + .modal-demo-card > p { + margin: 0; + color: var(--wire-color-text-muted); + font-size: 0.82rem; + line-height: 1.5; + } + + .modal-demo-form { + display: grid; + gap: 0.9rem; + } + + .modal-demo-form-error { + margin: 0; + color: var(--wire-color-danger); + font-size: 0.8rem; + } + + .modal-demo-form-error:empty { + display: none; + } + + .modal-demo-submit { + appearance: none; + min-height: 2.55rem; + padding: 0.65rem 1rem; + color: var(--wire-color-secondary-contrast); + background: var(--wire-color-secondary); + border: 0; + border-radius: 0.78rem; + font: inherit; + font-size: 0.83rem; + font-weight: 650; + cursor: pointer; + } + + .modal-demo-toast-demos { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 1.75rem; + color: var(--wire-color-text-muted); + font-size: 0.82rem; + } + + .modal-demo-note { + color: var(--wire-color-text-muted); + } + + .modal-demo-note em { + color: var(--wire-color-text); + font-style: normal; + } + + .modal-demo-toast-demos button { + appearance: none; + padding: 0.4rem 0.7rem; + color: var(--wire-color-text); + background: var(--wire-color-surface-raised); + border: 1px solid var(--wire-color-border); + border-radius: 0.6rem; + font: inherit; + font-size: 0.78rem; + cursor: pointer; + } + + .modal-demo-submit:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + .modal-demo-terms { + max-height: 12rem; + overflow-y: auto; + padding-right: 0.5rem; + color: var(--wire-color-text-muted); + font-size: 0.85rem; + line-height: 1.6; + } + } + + view { +

Modal

+

+ Five common use cases for the shared @wrnexus/ui + Modal component — themed, accessible, and responsive out + of the box. +

+ +
+ + + + + + + + + } } diff --git a/examples/basic-app/app/pages/table.wrn b/examples/basic-app/app/pages/table.wrn new file mode 100644 index 00000000..007bf65e --- /dev/null +++ b/examples/basic-app/app/pages/table.wrn @@ -0,0 +1,156 @@ +// DataTable demo. Route: /table +// +// Two tables: one over a local array, one backed by /api/accounts. +// +// The remote table does not receive a fetch function. Component props travel +// as HTML attributes, so a function passed that way arrives as its own source +// text rather than something callable. Instead the table emits a `request` +// output and we answer it by dispatching the rows back with the same id. +page TablePage { + + state localRows = [ + { id: 1, name: "Northwind", plan: "Scale", seats: 42, renewsOn: "2026-02-09" }, + { id: 2, name: "Acme Industrial", plan: "Team", seats: 8, renewsOn: "2026-01-10" }, + { id: 3, name: "Globex", plan: "Enterprise", seats: 210, renewsOn: "2025-12-24" }, + { id: 4, name: "Initech", plan: "Starter", seats: 5, renewsOn: "2026-03-02" }, + { id: 5, name: "Umbrella", plan: "Scale", seats: 96, renewsOn: "2026-01-31" } + ] + + state localColumns = [ + { key: "name", label: "Account", sortable: true }, + { key: "plan", label: "Plan", sortable: true }, + { key: "seats", label: "Seats", align: "end", sortable: true, type: "number" }, + { key: "renewsOn", label: "Renews", align: "end", sortable: true, type: "date" } + ] + + state remoteColumns = [ + { key: "name", label: "Account", sortable: true }, + { key: "owner", label: "Owner", sortable: true }, + { key: "plan", label: "Plan", align: "center" }, + { key: "seats", label: "Seats", align: "end", sortable: true, type: "number" }, + { key: "renewsOn", label: "Renews", align: "end", sortable: true, type: "date" } + ] + + state tableActions = [] + state lastEvent = "none yet" + + functions { + // The table asks for rows through its `request` output; we answer by + // dispatching the result back with the same instanceId. It cannot simply + // call a function we hand it: component props travel as HTML attributes, + // so a function would arrive as its own source text. + function loadAccounts(payload) { + var id = payload.instanceId + var search = new URLSearchParams({ + page: String(payload.page), + pageSize: String(payload.pageSize), + query: payload.query || "", + sortKey: payload.sortKey || "", + sortDirection: payload.sortDirection || "asc" + }) + + fetch("/api/accounts?" + search.toString()) + .then(function (response) { + if (!response.ok) { + throw new Error("Request failed with status " + response.status) + } + return response.json() + }) + .then(function (data) { + window.dispatchEvent(new CustomEvent("wrnexus:datatable:rows", { + detail: { instanceId: id, rows: data.rows, total: data.total } + })) + }) + .catch(function (error) { + window.dispatchEvent(new CustomEvent("wrnexus:datatable:error", { + detail: { instanceId: id, message: error.message } + })) + }) + } + + function onTableChange(payload) { + lastEvent = + "page " + payload.page + " of " + Math.max(1, Math.ceil(payload.total / payload.pageSize)) + + " · " + payload.total + " records" + + (payload.sortKey ? " · sorted by " + payload.sortKey + " " + payload.sortDirection : "") + } + + function onTableAction(payload) { + toast.info(payload.id + " on " + payload.rows.length + " record(s)") + } + } + + style { + .table-demo-section { + display: grid; + gap: 0.75rem; + margin-top: 2rem; + } + + .table-demo-note { + margin: 0; + color: var(--wire-color-text-muted); + font-size: 0.82rem; + } + + .table-demo-note code { + color: var(--wire-color-text); + } + } + + view { +

Data table

+

+ Sorting, filtering, paging and selection over a local array, and the same + component driven by an API through its request output. +

+ +
+

Local array

+

+ Everything is derived in the browser. The Renews column declares + type: "date", so it sorts chronologically rather than + alphabetically. +

+ + +
+ +
+

Loaded from an API

+

+ With remote the table emits a request for each + view change and waits to be handed rows back. Sorting and searching are + done by /api/accounts — the table never sees the other pages. +

+ + + +

Last change event: {lastEvent}

+
+ + + } +} diff --git a/examples/basic-app/app/routes.gen.ts b/examples/basic-app/app/routes.gen.ts index 2d432bdd..96b764bf 100644 --- a/examples/basic-app/app/routes.gen.ts +++ b/examples/basic-app/app/routes.gen.ts @@ -16,6 +16,7 @@ export interface Routes { "/platform-showcase": Record; "/reactive": Record; "/server-actions": Record; + "/table": Record; "/test": Record; "/ui": Record; } @@ -35,6 +36,7 @@ export interface RouteNames { "platform.showcase": "/platform-showcase"; "reactive": "/reactive"; "server.actions": "/server-actions"; + "table": "/table"; "test": "/test"; "ui": "/ui"; } @@ -123,6 +125,7 @@ export function route( "platform.showcase": "/platform-showcase", "reactive": "/reactive", "server.actions": "/server-actions", + "table": "/table", "test": "/test", "ui": "/ui" } as Record; diff --git a/examples/basic-app/app/schemas/invite.ts b/examples/basic-app/app/schemas/invite.ts new file mode 100644 index 00000000..9ffd5cad --- /dev/null +++ b/examples/basic-app/app/schemas/invite.ts @@ -0,0 +1,11 @@ +// One schema, two consumers: the browser validates the invite form against +// the descriptor emitted from this file, and /api/invite parses the same +// schema server-side. Keeping a single definition is the point — a rule added +// here tightens both sides at once, and the client can never drift into +// accepting something the server rejects. +import { v } from "@wrnexus/validation"; + +export default v.object({ + email: v.string().trim().email("Enter a valid email address"), + message: v.string().trim().max(140, "Keep the note under 140 characters").optional(), +}); diff --git a/examples/component-showcase/app/layouts/showcase.wrn b/examples/component-showcase/app/layouts/showcase.wrn index 864cc4a5..a5898d4f 100644 --- a/examples/component-showcase/app/layouts/showcase.wrn +++ b/examples/component-showcase/app/layouts/showcase.wrn @@ -39,7 +39,7 @@ layout Showcase {
Core - +
Data @@ -51,7 +51,7 @@ layout Showcase {
Integrations - +
Layout @@ -71,7 +71,7 @@ layout Showcase {
Tables - +

No components found.

diff --git a/examples/component-showcase/app/pages/components/accordion.wrn b/examples/component-showcase/app/pages/components/accordion.wrn index 554964cd..d62c39eb 100644 --- a/examples/component-showcase/app/pages/components/accordion.wrn +++ b/examples/component-showcase/app/pages/components/accordion.wrn @@ -45,7 +45,7 @@ page AccordionDetail {
Component code
<Accordion
   items='[
-    {
+    {
       "id": "accordion-0-1",
       "key": "accordion-0-1",
       "value": "primary",
@@ -74,31 +74,31 @@ page AccordionDetail {
       "target": "_self",
       "ariaLabel": "Open primary workflow 1",
       "items": [
-        {
+        {
           "label": "Nested option A",
           "value": "nested-a"
-        },
-        {
+        },
+        {
           "label": "Nested option B",
           "value": "nested-b"
-        }
+        }
       ],
       "links": [
-        {
+        {
           "label": "Documentation",
           "href": "#documentation"
-        },
-        {
+        },
+        {
           "label": "API reference",
           "href": "#api-reference"
-        }
+        }
       ],
       "values": [
         "Included",
         "Standard"
       ]
-    },
-    {
+    },
+    {
       "id": "accordion-0-2",
       "key": "accordion-0-2",
       "value": "secondary",
@@ -127,33 +127,33 @@ page AccordionDetail {
       "target": "_self",
       "ariaLabel": "Open primary workflow 2",
       "items": [
-        {
+        {
           "label": "Nested option A",
           "value": "nested-a"
-        },
-        {
+        },
+        {
           "label": "Nested option B",
           "value": "nested-b"
-        }
+        }
       ],
       "links": [
-        {
+        {
           "label": "Documentation",
           "href": "#documentation"
-        },
-        {
+        },
+        {
           "label": "API reference",
           "href": "#api-reference"
-        }
+        }
       ],
       "values": [
         "Included",
         "Standard"
       ]
-    }
+    }
   ]'
   defaultOpen='[
-    {
+    {
       "id": "accordion-0-1",
       "key": "accordion-0-1",
       "value": "primary",
@@ -182,31 +182,31 @@ page AccordionDetail {
       "target": "_self",
       "ariaLabel": "Open primary workflow 1",
       "items": [
-        {
+        {
           "label": "Nested option A",
           "value": "nested-a"
-        },
-        {
+        },
+        {
           "label": "Nested option B",
           "value": "nested-b"
-        }
+        }
       ],
       "links": [
-        {
+        {
           "label": "Documentation",
           "href": "#documentation"
-        },
-        {
+        },
+        {
           "label": "API reference",
           "href": "#api-reference"
-        }
+        }
       ],
       "values": [
         "Included",
         "Standard"
       ]
-    },
-    {
+    },
+    {
       "id": "accordion-0-2",
       "key": "accordion-0-2",
       "value": "secondary",
@@ -235,30 +235,30 @@ page AccordionDetail {
       "target": "_self",
       "ariaLabel": "Open primary workflow 2",
       "items": [
-        {
+        {
           "label": "Nested option A",
           "value": "nested-a"
-        },
-        {
+        },
+        {
           "label": "Nested option B",
           "value": "nested-b"
-        }
+        }
       ],
       "links": [
-        {
+        {
           "label": "Documentation",
           "href": "#documentation"
-        },
-        {
+        },
+        {
           "label": "API reference",
           "href": "#api-reference"
-        }
+        }
       ],
       "values": [
         "Included",
         "Standard"
       ]
-    }
+    }
   ]'
 />
@@ -503,7 +503,7 @@ page AccordionDetail {
Component usage.wrn
<Accordion
   items='[
-    {
+    {
       "id": "accordion-0-1",
       "key": "accordion-0-1",
       "value": "primary",
@@ -532,31 +532,31 @@ page AccordionDetail {
       "target": "_self",
       "ariaLabel": "Open primary workflow 1",
       "items": [
-        {
+        {
           "label": "Nested option A",
           "value": "nested-a"
-        },
-        {
+        },
+        {
           "label": "Nested option B",
           "value": "nested-b"
-        }
+        }
       ],
       "links": [
-        {
+        {
           "label": "Documentation",
           "href": "#documentation"
-        },
-        {
+        },
+        {
           "label": "API reference",
           "href": "#api-reference"
-        }
+        }
       ],
       "values": [
         "Included",
         "Standard"
       ]
-    },
-    {
+    },
+    {
       "id": "accordion-0-2",
       "key": "accordion-0-2",
       "value": "secondary",
@@ -585,33 +585,33 @@ page AccordionDetail {
       "target": "_self",
       "ariaLabel": "Open primary workflow 2",
       "items": [
-        {
+        {
           "label": "Nested option A",
           "value": "nested-a"
-        },
-        {
+        },
+        {
           "label": "Nested option B",
           "value": "nested-b"
-        }
+        }
       ],
       "links": [
-        {
+        {
           "label": "Documentation",
           "href": "#documentation"
-        },
-        {
+        },
+        {
           "label": "API reference",
           "href": "#api-reference"
-        }
+        }
       ],
       "values": [
         "Included",
         "Standard"
       ]
-    }
+    }
   ]'
   defaultOpen='[
-    {
+    {
       "id": "accordion-0-1",
       "key": "accordion-0-1",
       "value": "primary",
@@ -640,31 +640,31 @@ page AccordionDetail {
       "target": "_self",
       "ariaLabel": "Open primary workflow 1",
       "items": [
-        {
+        {
           "label": "Nested option A",
           "value": "nested-a"
-        },
-        {
+        },
+        {
           "label": "Nested option B",
           "value": "nested-b"
-        }
+        }
       ],
       "links": [
-        {
+        {
           "label": "Documentation",
           "href": "#documentation"
-        },
-        {
+        },
+        {
           "label": "API reference",
           "href": "#api-reference"
-        }
+        }
       ],
       "values": [
         "Included",
         "Standard"
       ]
-    },
-    {
+    },
+    {
       "id": "accordion-0-2",
       "key": "accordion-0-2",
       "value": "secondary",
@@ -693,30 +693,30 @@ page AccordionDetail {
       "target": "_self",
       "ariaLabel": "Open primary workflow 2",
       "items": [
-        {
+        {
           "label": "Nested option A",
           "value": "nested-a"
-        },
-        {
+        },
+        {
           "label": "Nested option B",
           "value": "nested-b"
-        }
+        }
       ],
       "links": [
-        {
+        {
           "label": "Documentation",
           "href": "#documentation"
-        },
-        {
+        },
+        {
           "label": "API reference",
           "href": "#api-reference"
-        }
+        }
       ],
       "values": [
         "Included",
         "Standard"
       ]
-    }
+    }
   ]'
 />
@@ -739,7 +739,7 @@ page AccordionDetail { size="sm" variant="secondary" items='[ - { + { "id": "accordion-1-1", "key": "accordion-1-1", "value": "primary", @@ -768,31 +768,31 @@ page AccordionDetail { "target": "_self", "ariaLabel": "Open secondary workflow 1", "items": [ - { + { "label": "Nested option A", "value": "nested-a" - }, - { + }, + { "label": "Nested option B", "value": "nested-b" - } + } ], "links": [ - { + { "label": "Documentation", "href": "#documentation" - }, - { + }, + { "label": "API reference", "href": "#api-reference" - } + } ], "values": [ "Included", "Standard" ] - }, - { + }, + { "id": "accordion-1-2", "key": "accordion-1-2", "value": "secondary", @@ -821,33 +821,33 @@ page AccordionDetail { "target": "_self", "ariaLabel": "Open secondary workflow 2", "items": [ - { + { "label": "Nested option A", "value": "nested-a" - }, - { + }, + { "label": "Nested option B", "value": "nested-b" - } + } ], "links": [ - { + { "label": "Documentation", "href": "#documentation" - }, - { + }, + { "label": "API reference", "href": "#api-reference" - } + } ], "values": [ "Included", "Standard" ] - } + } ]' defaultOpen='[ - { + { "id": "accordion-1-1", "key": "accordion-1-1", "value": "primary", @@ -876,31 +876,31 @@ page AccordionDetail { "target": "_self", "ariaLabel": "Open secondary workflow 1", "items": [ - { + { "label": "Nested option A", "value": "nested-a" - }, - { + }, + { "label": "Nested option B", "value": "nested-b" - } + } ], "links": [ - { + { "label": "Documentation", "href": "#documentation" - }, - { + }, + { "label": "API reference", "href": "#api-reference" - } + } ], "values": [ "Included", "Standard" ] - }, - { + }, + { "id": "accordion-1-2", "key": "accordion-1-2", "value": "secondary", @@ -929,30 +929,30 @@ page AccordionDetail { "target": "_self", "ariaLabel": "Open secondary workflow 2", "items": [ - { + { "label": "Nested option A", "value": "nested-a" - }, - { + }, + { "label": "Nested option B", "value": "nested-b" - } + } ], "links": [ - { + { "label": "Documentation", "href": "#documentation" - }, - { + }, + { "label": "API reference", "href": "#api-reference" - } + } ], "values": [ "Included", "Standard" ] - } + } ]' /> @@ -975,7 +975,7 @@ page AccordionDetail { size="lg" variant="success" items='[ - { + { "id": "accordion-2-1", "key": "accordion-2-1", "value": "primary", @@ -1004,31 +1004,31 @@ page AccordionDetail { "target": "_self", "ariaLabel": "Open advanced workflow 1", "items": [ - { + { "label": "Nested option A", "value": "nested-a" - }, - { + }, + { "label": "Nested option B", "value": "nested-b" - } + } ], "links": [ - { + { "label": "Documentation", "href": "#documentation" - }, - { + }, + { "label": "API reference", "href": "#api-reference" - } + } ], "values": [ "Included", "Unlimited" ] - }, - { + }, + { "id": "accordion-2-2", "key": "accordion-2-2", "value": "secondary", @@ -1057,33 +1057,33 @@ page AccordionDetail { "target": "_self", "ariaLabel": "Open advanced workflow 2", "items": [ - { + { "label": "Nested option A", "value": "nested-a" - }, - { + }, + { "label": "Nested option B", "value": "nested-b" - } + } ], "links": [ - { + { "label": "Documentation", "href": "#documentation" - }, - { + }, + { "label": "API reference", "href": "#api-reference" - } + } ], "values": [ "Included", "Unlimited" ] - } + } ]' defaultOpen='[ - { + { "id": "accordion-2-1", "key": "accordion-2-1", "value": "primary", @@ -1112,31 +1112,31 @@ page AccordionDetail { "target": "_self", "ariaLabel": "Open advanced workflow 1", "items": [ - { + { "label": "Nested option A", "value": "nested-a" - }, - { + }, + { "label": "Nested option B", "value": "nested-b" - } + } ], "links": [ - { + { "label": "Documentation", "href": "#documentation" - }, - { + }, + { "label": "API reference", "href": "#api-reference" - } + } ], "values": [ "Included", "Unlimited" ] - }, - { + }, + { "id": "accordion-2-2", "key": "accordion-2-2", "value": "secondary", @@ -1165,30 +1165,30 @@ page AccordionDetail { "target": "_self", "ariaLabel": "Open advanced workflow 2", "items": [ - { + { "label": "Nested option A", "value": "nested-a" - }, - { + }, + { "label": "Nested option B", "value": "nested-b" - } + } ], "links": [ - { + { "label": "Documentation", "href": "#documentation" - }, - { + }, + { "label": "API reference", "href": "#api-reference" - } + } ], "values": [ "Included", "Unlimited" ] - } + } ]' /> @@ -1198,21 +1198,21 @@ page AccordionDetail { @change='console.log(payload)' @open='console.log(payload)' @close='console.log(payload)' -/>
Direct output handlers.js
import { registerOutputHandler } from "@wrnexus/csr/outputs"
+/>
Direct output handlers.js
import { registerOutputHandler } from "@wrnexus/csr/outputs"
 
 const component = document.querySelector("[data-ui-component=\"Accordion\"], [data-component=\"Accordion\"]")
 
-if (component) registerOutputHandler(component, "change", (payload) => {
+if (component) registerOutputHandler(component, "change", (payload) => {
   console.log("change", payload)
-})
+})
 
-if (component) registerOutputHandler(component, "open", (payload) => {
+if (component) registerOutputHandler(component, "open", (payload) => {
   console.log("open", payload)
-})
+})
 
-if (component) registerOutputHandler(component, "close", (payload) => {
+if (component) registerOutputHandler(component, "close", (payload) => {
   console.log("close", payload)
-})
+})
Component API

Props and configuration

All content and behavior shown above is supplied through these props and slots.

PropTypeDefaultRequired
sizestring"default"No
colorstring"primary"No
variantstring"default"No
classstring""No
idstring"accordion"No
itemsunknown[][]No
defaultOpenunknown[][]No
multiplebooleanfalseNo
alwaysOpenbooleanfalseNo
disabledbooleanfalseNo
indicatorstring"plus"No
indicatorPositionstring"start"No
showIndicatorbooleantrueNo
borderedbooleanfalseNo
separatedbooleanfalseNo
flushbooleanfalseNo
contentItalicbooleanfalseNo